feat: keyboard shortcuts for primary actions (#2030)

This commit is contained in:
Mo
2022-11-18 09:01:48 -06:00
committed by GitHub
parent 0309912f98
commit f49ba6bd4d
67 changed files with 1296 additions and 555 deletions

View File

@@ -0,0 +1,12 @@
import { KeyboardCommand } from './KeyboardCommands'
export type KeyboardCommandHandler = {
command: KeyboardCommand
onKeyDown?: (event: KeyboardEvent) => boolean | void
onKeyUp?: (event: KeyboardEvent) => boolean | void
element?: HTMLElement
elements?: HTMLElement[]
notElement?: HTMLElement
notElementIds?: string[]
notTags?: string[]
}

View File

@@ -0,0 +1,26 @@
export type KeyboardCommand = symbol
function createKeyboardCommand(type: string): KeyboardCommand {
return Symbol(type)
}
export const TOGGLE_LIST_PANE_KEYBOARD_COMMAND = createKeyboardCommand('TOGGLE_LIST_PANE_KEYBOARD_COMMAND')
export const TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND = createKeyboardCommand('TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND')
export const CREATE_NEW_NOTE_KEYBOARD_COMMAND = createKeyboardCommand('CREATE_NEW_NOTE_KEYBOARD_COMMAND')
export const NEXT_LIST_ITEM_KEYBOARD_COMMAND = createKeyboardCommand('NEXT_LIST_ITEM_KEYBOARD_COMMAND')
export const PREVIOUS_LIST_ITEM_KEYBOARD_COMMAND = createKeyboardCommand('PREVIOUS_LIST_ITEM_KEYBOARD_COMMAND')
export const SEARCH_KEYBOARD_COMMAND = createKeyboardCommand('SEARCH_KEYBOARD_COMMAND')
export const CANCEL_SEARCH_COMMAND = createKeyboardCommand('CANCEL_SEARCH_COMMAND')
export const SELECT_ALL_ITEMS_KEYBOARD_COMMAND = createKeyboardCommand('SELECT_ALL_ITEMS_KEYBOARD_COMMAND')
export const SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND = createKeyboardCommand('SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND')
export const DELETE_NOTE_KEYBOARD_COMMAND = createKeyboardCommand('DELETE_NOTE_KEYBOARD_COMMAND')
export const TAB_COMMAND = createKeyboardCommand('PLAIN_EDITOR_INSERT_TAB_KEYBOARD_COMMAND')
export const ESCAPE_COMMAND = createKeyboardCommand('ESCAPE_COMMAND')
export const TOGGLE_FOCUS_MODE_COMMAND = createKeyboardCommand('TOGGLE_FOCUS_MODE_COMMAND')
export const CHANGE_EDITOR_COMMAND = createKeyboardCommand('CHANGE_EDITOR_COMMAND')
export const FOCUS_TAGS_INPUT_COMMAND = createKeyboardCommand('FOCUS_TAGS_INPUT_COMMAND')
export const CREATE_NEW_TAG_COMMAND = createKeyboardCommand('CREATE_NEW_TAG_COMMAND')
export const OPEN_NOTE_HISTORY_COMMAND = createKeyboardCommand('OPEN_NOTE_HISTORY_COMMAND')
export const CAPTURE_SAVE_COMMAND = createKeyboardCommand('CAPTURE_SAVE_COMMAND')
export const STAR_NOTE_COMMAND = createKeyboardCommand('STAR_NOTE_COMMAND')
export const PIN_NOTE_COMMAND = createKeyboardCommand('PIN_NOTE_COMMAND')

View File

@@ -0,0 +1,12 @@
export enum KeyboardKey {
Tab = 'Tab',
Backspace = 'Backspace',
Up = 'ArrowUp',
Down = 'ArrowDown',
Left = 'ArrowLeft',
Right = 'ArrowRight',
Enter = 'Enter',
Escape = 'Escape',
Home = 'Home',
End = 'End',
}

View File

@@ -0,0 +1,4 @@
export enum KeyboardKeyEvent {
Down = 'KeyEventDown',
Up = 'KeyEventUp',
}

View File

@@ -0,0 +1,7 @@
export enum KeyboardModifier {
Shift = 'Shift',
Ctrl = 'Control',
/** ⌘ key on Mac, ⊞ key on Windows */
Meta = 'Meta',
Alt = 'Alt',
}

View File

@@ -0,0 +1,203 @@
import { Environment, Platform } from '@standardnotes/snjs'
import { removeFromArray } from '@standardnotes/utils'
import { eventMatchesKeyAndModifiers } from './eventMatchesKeyAndModifiers'
import { KeyboardCommand } from './KeyboardCommands'
import { KeyboardKeyEvent } from './KeyboardKeyEvent'
import { KeyboardModifier } from './KeyboardModifier'
import { KeyboardCommandHandler } from './KeyboardCommandHandler'
import { KeyboardShortcut, PlatformedKeyboardShortcut } from './KeyboardShortcut'
import { getKeyboardShortcuts } from './getKeyboardShortcuts'
export class KeyboardService {
readonly activeModifiers = new Set<KeyboardModifier>()
private commandHandlers: KeyboardCommandHandler[] = []
private commandMap = new Map<KeyboardCommand, KeyboardShortcut>()
constructor(private platform: Platform, environment: Environment) {
window.addEventListener('keydown', this.handleKeyDown)
window.addEventListener('keyup', this.handleKeyUp)
window.addEventListener('blur', this.handleWindowBlur)
const shortcuts = getKeyboardShortcuts(platform, environment)
for (const shortcut of shortcuts) {
this.registerShortcut(shortcut)
}
}
get isMac() {
return this.platform === Platform.MacDesktop || this.platform === Platform.MacWeb
}
public deinit() {
this.commandHandlers.length = 0
window.removeEventListener('keydown', this.handleKeyDown)
window.removeEventListener('keyup', this.handleKeyUp)
window.removeEventListener('blur', this.handleWindowBlur)
;(this.handleKeyDown as unknown) = undefined
;(this.handleKeyUp as unknown) = undefined
;(this.handleWindowBlur as unknown) = undefined
}
private addActiveModifier = (modifier: KeyboardModifier | undefined): void => {
if (!modifier) {
return
}
switch (modifier) {
case KeyboardModifier.Meta: {
if (this.isMac) {
this.activeModifiers.add(modifier)
}
break
}
case KeyboardModifier.Ctrl: {
if (!this.isMac) {
this.activeModifiers.add(modifier)
}
break
}
default: {
this.activeModifiers.add(modifier)
break
}
}
}
private removeActiveModifier = (modifier: KeyboardModifier | undefined): void => {
if (!modifier) {
return
}
this.activeModifiers.delete(modifier)
}
public cancelAllKeyboardModifiers = (): void => {
this.activeModifiers.clear()
}
public handleComponentKeyDown = (modifier: KeyboardModifier | undefined): void => {
this.addActiveModifier(modifier)
}
public handleComponentKeyUp = (modifier: KeyboardModifier | undefined): void => {
this.removeActiveModifier(modifier)
}
private handleKeyDown = (event: KeyboardEvent): void => {
this.updateAllModifiersFromEvent(event)
this.handleKeyboardEvent(event, KeyboardKeyEvent.Down)
}
private handleKeyUp = (event: KeyboardEvent): void => {
this.updateAllModifiersFromEvent(event)
this.handleKeyboardEvent(event, KeyboardKeyEvent.Up)
}
private updateAllModifiersFromEvent(event: KeyboardEvent): void {
for (const modifier of Object.values(KeyboardModifier)) {
if (event.getModifierState(modifier)) {
this.addActiveModifier(modifier)
} else {
this.removeActiveModifier(modifier)
}
}
}
handleWindowBlur = (): void => {
for (const modifier of this.activeModifiers) {
this.activeModifiers.delete(modifier)
}
}
private handleKeyboardEvent(event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void {
for (const command of this.commandMap.keys()) {
const shortcut = this.commandMap.get(command)
if (!shortcut) {
continue
}
if (eventMatchesKeyAndModifiers(event, shortcut)) {
if (shortcut.preventDefault) {
event.preventDefault()
}
this.handleCommand(command, event, keyEvent)
}
}
}
private handleCommand(command: KeyboardCommand, event: KeyboardEvent, keyEvent: KeyboardKeyEvent): void {
const target = event.target as HTMLElement
for (const observer of this.commandHandlers) {
if (observer.command !== command) {
continue
}
if (observer.element && event.target !== observer.element) {
continue
}
if (observer.elements && !observer.elements.includes(target)) {
continue
}
if (observer.notElement && observer.notElement === event.target) {
continue
}
if (observer.notElementIds && observer.notElementIds.includes(target.id)) {
continue
}
if (observer.notTags && observer.notTags.includes(target.tagName)) {
continue
}
const callback = keyEvent === KeyboardKeyEvent.Down ? observer.onKeyDown : observer.onKeyUp
if (callback) {
const exclusive = callback(event)
if (exclusive) {
return
}
}
}
}
registerShortcut(shortcut: KeyboardShortcut): void {
this.commandMap.set(shortcut.command, shortcut)
}
addCommandHandler(observer: KeyboardCommandHandler): () => void {
this.commandHandlers.push(observer)
const thislessObservers = this.commandHandlers
return () => {
observer.onKeyDown = undefined
observer.onKeyDown = undefined
removeFromArray(thislessObservers, observer)
}
}
addCommandHandlers(handlers: KeyboardCommandHandler[]): () => void {
const disposers = handlers.map((handler) => this.addCommandHandler(handler))
return () => {
for (const disposer of disposers) {
disposer()
}
}
}
keyboardShortcutForCommand(command: KeyboardCommand): PlatformedKeyboardShortcut | undefined {
const shortcut = this.commandMap.get(command)
if (!shortcut) {
return undefined
}
return {
platform: this.platform,
...shortcut,
}
}
}

View File

@@ -0,0 +1,20 @@
import { Platform } from '@standardnotes/snjs'
import { KeyboardCommand } from './KeyboardCommands'
import { KeyboardKey } from './KeyboardKey'
import { KeyboardModifier } from './KeyboardModifier'
export type KeyboardShortcut = {
command: KeyboardCommand
modifiers?: KeyboardModifier[]
key?: KeyboardKey | string
/**
* Alternative to using key, if the key can be affected by alt + shift. For example, if you want alt + shift + n,
* use code 'KeyN' instead of key 'n', as the modifiers would turn n into '˜' on Mac.
*/
code?: string
preventDefault?: boolean
}
export type PlatformedKeyboardShortcut = KeyboardShortcut & {
platform: Platform
}

View File

@@ -0,0 +1,27 @@
import { KeyboardShortcut } from './KeyboardShortcut'
import { modifiersForEvent } from './modifiersForEvent'
export function eventMatchesKeyAndModifiers(event: KeyboardEvent, shortcut: KeyboardShortcut): boolean {
const eventModifiers = modifiersForEvent(event)
const shortcutModifiers = shortcut.modifiers ?? []
if (eventModifiers.length !== shortcutModifiers.length) {
return false
}
for (const modifier of shortcutModifiers) {
if (!eventModifiers.includes(modifier)) {
return false
}
}
if (!shortcut.key && !shortcut.code) {
return true
}
if (shortcut.key) {
return shortcut.key.toLowerCase() === event.key.toLowerCase()
} else {
return shortcut.code === event.code
}
}

View File

@@ -0,0 +1,136 @@
import { Environment, Platform } from '@standardnotes/snjs'
import { isMacPlatform } from './platformCheck'
import {
CREATE_NEW_NOTE_KEYBOARD_COMMAND,
TOGGLE_LIST_PANE_KEYBOARD_COMMAND,
TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND,
NEXT_LIST_ITEM_KEYBOARD_COMMAND,
PREVIOUS_LIST_ITEM_KEYBOARD_COMMAND,
SEARCH_KEYBOARD_COMMAND,
SELECT_ALL_ITEMS_KEYBOARD_COMMAND,
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
DELETE_NOTE_KEYBOARD_COMMAND,
TAB_COMMAND,
ESCAPE_COMMAND,
CANCEL_SEARCH_COMMAND,
TOGGLE_FOCUS_MODE_COMMAND,
CHANGE_EDITOR_COMMAND,
FOCUS_TAGS_INPUT_COMMAND,
CREATE_NEW_TAG_COMMAND,
OPEN_NOTE_HISTORY_COMMAND,
CAPTURE_SAVE_COMMAND,
STAR_NOTE_COMMAND,
PIN_NOTE_COMMAND,
} from './KeyboardCommands'
import { KeyboardKey } from './KeyboardKey'
import { KeyboardModifier } from './KeyboardModifier'
import { KeyboardShortcut } from './KeyboardShortcut'
export function getKeyboardShortcuts(platform: Platform, _environment: Environment): KeyboardShortcut[] {
const isMac = isMacPlatform(platform)
const primaryModifier = isMac ? KeyboardModifier.Meta : KeyboardModifier.Ctrl
return [
{
command: TOGGLE_LIST_PANE_KEYBOARD_COMMAND,
key: 'l',
modifiers: [primaryModifier, KeyboardModifier.Shift],
},
{
command: TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND,
key: 'e',
modifiers: [primaryModifier, KeyboardModifier.Shift],
},
{
command: CREATE_NEW_NOTE_KEYBOARD_COMMAND,
code: 'KeyN',
modifiers: [KeyboardModifier.Alt, KeyboardModifier.Shift],
},
{
command: NEXT_LIST_ITEM_KEYBOARD_COMMAND,
key: KeyboardKey.Down,
},
{
command: PREVIOUS_LIST_ITEM_KEYBOARD_COMMAND,
key: KeyboardKey.Up,
},
{
command: SEARCH_KEYBOARD_COMMAND,
code: 'KeyF',
modifiers: [KeyboardModifier.Alt, KeyboardModifier.Shift],
},
{
command: CANCEL_SEARCH_COMMAND,
key: KeyboardKey.Escape,
},
{
command: SELECT_ALL_ITEMS_KEYBOARD_COMMAND,
key: 'a',
modifiers: [primaryModifier],
},
{
command: SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
modifiers: [KeyboardModifier.Alt],
},
{
command: DELETE_NOTE_KEYBOARD_COMMAND,
key: KeyboardKey.Backspace,
modifiers: [primaryModifier],
},
{
command: TAB_COMMAND,
key: KeyboardKey.Tab,
},
{
command: ESCAPE_COMMAND,
key: KeyboardKey.Escape,
},
{
command: TOGGLE_FOCUS_MODE_COMMAND,
key: 'f',
modifiers: [primaryModifier, KeyboardModifier.Shift],
},
{
command: CHANGE_EDITOR_COMMAND,
key: '/',
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
{
command: FOCUS_TAGS_INPUT_COMMAND,
code: 'KeyT',
modifiers: [primaryModifier, KeyboardModifier.Alt],
preventDefault: true,
},
{
command: CREATE_NEW_TAG_COMMAND,
code: 'KeyN',
modifiers: [primaryModifier, KeyboardModifier.Alt],
},
{
command: OPEN_NOTE_HISTORY_COMMAND,
key: 'h',
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
{
command: CAPTURE_SAVE_COMMAND,
key: 's',
modifiers: [primaryModifier],
preventDefault: true,
},
{
command: STAR_NOTE_COMMAND,
key: 's',
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
{
command: PIN_NOTE_COMMAND,
key: 'p',
modifiers: [primaryModifier, KeyboardModifier.Shift],
preventDefault: true,
},
]
}

View File

@@ -0,0 +1,22 @@
import { Platform } from '@standardnotes/snjs'
import { KeyboardModifier } from './KeyboardModifier'
function isMacPlatform(platform: Platform) {
return platform === Platform.MacDesktop || platform === Platform.MacWeb
}
export function keyboardCharacterForModifier(modifier: KeyboardModifier, platform: Platform) {
const isMac = isMacPlatform(platform)
if (modifier === KeyboardModifier.Meta) {
return isMac ? '⌘' : '⊞'
} else if (modifier === KeyboardModifier.Ctrl) {
return isMac ? '⌃' : 'Ctrl'
} else if (modifier === KeyboardModifier.Alt) {
return isMac ? '⌥' : 'Alt'
} else if (modifier === KeyboardModifier.Shift) {
return isMac ? '⇧' : 'Shift'
} else {
return KeyboardModifier[modifier]
}
}

View File

@@ -0,0 +1,29 @@
import { isMacPlatform } from '@standardnotes/ui-services'
import { keyboardCharacterForModifier } from './keyboardCharacterForModifier'
import { PlatformedKeyboardShortcut } from './KeyboardShortcut'
function stringForCode(code = ''): string {
return code.replace('Key', '').replace('Digit', '')
}
export function keyboardStringForShortcut(shortcut: PlatformedKeyboardShortcut | undefined) {
if (!shortcut) {
return ''
}
const key = shortcut.key?.toUpperCase() || stringForCode(shortcut.code)
if (!shortcut.modifiers || shortcut.modifiers.length === 0) {
return key
}
const modifierCharacters = shortcut.modifiers.map((modifier) =>
keyboardCharacterForModifier(modifier, shortcut.platform),
)
if (isMacPlatform(shortcut.platform)) {
return `${modifierCharacters.join('')}${key}`
} else {
return `${modifierCharacters.join('+')}+${key}`
}
}

View File

@@ -0,0 +1,18 @@
import { KeyboardModifier } from './KeyboardModifier'
export function modifiersForEvent(event: KeyboardEvent): KeyboardModifier[] {
const allModifiers = Object.values(KeyboardModifier)
const eventModifiers = allModifiers.filter((modifier) => {
// For a modifier like ctrlKey, must check both event.ctrlKey and event.key.
// That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true.
const matches =
((event.ctrlKey || event.key === KeyboardModifier.Ctrl) && modifier === KeyboardModifier.Ctrl) ||
((event.metaKey || event.key === KeyboardModifier.Meta) && modifier === KeyboardModifier.Meta) ||
((event.altKey || event.key === KeyboardModifier.Alt) && modifier === KeyboardModifier.Alt) ||
((event.shiftKey || event.key === KeyboardModifier.Shift) && modifier === KeyboardModifier.Shift)
return matches
})
return eventModifiers
}

View File

@@ -0,0 +1,9 @@
import { Platform } from '@standardnotes/snjs'
export function isMacPlatform(platform: Platform) {
return platform === Platform.MacDesktop || platform === Platform.MacWeb
}
export function isWindowsPlatform(platform: Platform) {
return platform === Platform.WindowsDesktop || platform === Platform.WindowsWeb
}