export type ShortcutScope = | 'global' | 'posts' | 'postSearch' | 'tags' | 'settings' | 'wiki' | 'materials' export type ShortcutActionId = | 'global.openShortcutHelp' | 'global.focusSearch' | 'navigation.posts' | 'navigation.tags' | 'navigation.materials' | 'navigation.wiki' | 'pagination.next' | 'pagination.previous' | 'settings.account' | 'settings.behavior' | 'settings.theme' | 'settings.keyboard' export type KeyBinding = { key: string ctrl?: boolean shift?: boolean alt?: boolean meta?: boolean } export type ShortcutDefinition = { id: ShortcutActionId label: string scope: ShortcutScope defaultBinding: KeyBinding | null } export const SHORTCUT_SCOPE_LABELS: Record = { global: '全画面', posts: '広場', postSearch: '広場検索', tags: 'タグ', settings: '設定', wiki: 'Wiki', materials: '素材', } export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: 'global.openShortcutHelp', label: 'ショートカット一覧を開く', scope: 'global', defaultBinding: { key: '?', shift: true }, }, { id: 'global.focusSearch', label: '検索欄へ移動', scope: 'global', defaultBinding: { key: '/' }, }, { id: 'navigation.posts', label: '広場へ移動', scope: 'global', defaultBinding: { key: 'p' }, }, { id: 'navigation.tags', label: 'タグへ移動', scope: 'global', defaultBinding: { key: 't' }, }, { id: 'navigation.materials', label: '素材へ移動', scope: 'global', defaultBinding: { key: 'm' }, }, { id: 'navigation.wiki', label: 'Wiki へ移動', scope: 'global', defaultBinding: { key: 'w' }, }, { id: 'pagination.next', label: '次のページへ移動', scope: 'global', defaultBinding: { key: 'ArrowRight' }, }, { id: 'pagination.previous', label: '前のページへ移動', scope: 'global', defaultBinding: { key: 'ArrowLeft' }, }, { id: 'settings.account', label: '設定: アカウント', scope: 'settings', defaultBinding: { key: '1' }, }, { id: 'settings.behavior', label: '設定: 動作', scope: 'settings', defaultBinding: { key: '2' }, }, { id: 'settings.theme', label: '設定: テーマ', scope: 'settings', defaultBinding: { key: '3' }, }, { id: 'settings.keyboard', label: '設定: キーボード', scope: 'settings', defaultBinding: { key: '4' }, }, ] export const SHORTCUT_DEFINITIONS_BY_ID = SHORTCUT_DEFINITIONS.reduce> ( (definitions, definition) => { definitions[definition.id] = definition return definitions }, { 'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0], 'global.focusSearch': SHORTCUT_DEFINITIONS[1], 'navigation.posts': SHORTCUT_DEFINITIONS[2], 'navigation.tags': SHORTCUT_DEFINITIONS[3], 'navigation.materials': SHORTCUT_DEFINITIONS[4], 'navigation.wiki': SHORTCUT_DEFINITIONS[5], 'pagination.next': SHORTCUT_DEFINITIONS[6], 'pagination.previous': SHORTCUT_DEFINITIONS[7], 'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.behavior': SHORTCUT_DEFINITIONS[9], 'settings.theme': SHORTCUT_DEFINITIONS[10], 'settings.keyboard': SHORTCUT_DEFINITIONS[11], }, ) export const DEFAULT_KEY_BINDINGS = SHORTCUT_DEFINITIONS.reduce> ( (bindings, definition) => { bindings[definition.id] = definition.defaultBinding return bindings }, { 'global.openShortcutHelp': null, 'global.focusSearch': null, 'navigation.posts': null, 'navigation.tags': null, 'navigation.materials': null, 'navigation.wiki': null, 'pagination.next': null, 'pagination.previous': null, 'settings.account': null, 'settings.behavior': null, 'settings.theme': null, 'settings.keyboard': null, }, ) const normaliseKey = (key: string): string => { if (key === 'Spacebar') return ' ' if (key.length === 1 && key !== ' ') return key.toLowerCase () return key } const isModifierKey = (key: string): boolean => key === 'Shift' || key === 'Control' || key === 'Alt' || key === 'Meta' export const normaliseKeyBinding = (value: unknown): KeyBinding | null => { if (!(value) || typeof value !== 'object') return null const binding = value as Partial if (typeof binding.key !== 'string') return null const key = normaliseKey (binding.key) if (key.length === 0 || isModifierKey (key)) return null return { key, ...(binding.ctrl ? { ctrl: true } : { }), ...(binding.shift ? { shift: true } : { }), ...(binding.alt ? { alt: true } : { }), ...(binding.meta ? { meta: true } : { }), } } export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => { if (binding == null) return null const parts = [ binding.ctrl ? 'Ctrl' : null, binding.shift ? 'Shift' : null, binding.alt ? 'Alt' : null, binding.meta ? 'Meta' : null, binding.key, ].filter ((part): part is string => part != null) return parts.join ('+') } const keyLabel = (key: string): string => { switch (key) { case ' ': return 'Space' case 'ArrowLeft': return 'ArrowLeft' case 'ArrowRight': return 'ArrowRight' case 'ArrowUp': return 'ArrowUp' case 'ArrowDown': return 'ArrowDown' case 'Escape': return 'Escape' case 'Backspace': return 'Backspace' case 'Delete': return 'Delete' default: return key.length === 1 ? key.toUpperCase () : key } } export const formatKeyBinding = (binding: KeyBinding | null): string => { if (binding == null) return '未割当' return [ binding.ctrl ? 'Ctrl' : null, binding.shift ? 'Shift' : null, binding.alt ? 'Alt' : null, binding.meta ? 'Meta' : null, keyLabel (binding.key), ].filter ((part): part is string => part != null) .join (' + ') } export const keyBindingFromKeyboardEvent = ( event: Pick, ): KeyBinding | null => { const key = normaliseKey (event.key) if (key.length === 0 || isModifierKey (key)) return null return { key, ...(event.ctrlKey ? { ctrl: true } : { }), ...(event.shiftKey ? { shift: true } : { }), ...(event.altKey ? { alt: true } : { }), ...(event.metaKey ? { meta: true } : { }), } } export const isEditableEventTarget = (target: EventTarget | null): boolean => { if (!(target instanceof Element)) return false return target.closest ( 'input, textarea, select, [contenteditable="true"], [role="textbox"]', ) != null } export const getConflictingShortcutActionIds = ( bindings: Record, ): Set => { const grouped = new Map () Object.entries (bindings).forEach (([actionId, binding]) => { const serialised = serialiseKeyBinding (binding) if (serialised == null) return const ids = grouped.get (serialised) ?? [] ids.push (actionId as ShortcutActionId) grouped.set (serialised, ids) }) return new Set ( [...grouped.values ()] .filter (actionIds => actionIds.length > 1) .flat (), ) } export const resolveShortcutScope = (pathname: string): ShortcutScope => { if (pathname.startsWith ('/posts/search')) return 'postSearch' if (pathname.startsWith ('/posts')) return 'posts' if (pathname.startsWith ('/tags')) return 'tags' if (pathname.startsWith ('/users/settings')) return 'settings' if (pathname.startsWith ('/wiki')) return 'wiki' if (pathname.startsWith ('/materials')) return 'materials' return 'global' } export const isSpaceBinding = (binding: KeyBinding | null): boolean => binding?.key === ' '