f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
329 行
8.3 KiB
TypeScript
329 行
8.3 KiB
TypeScript
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<ShortcutScope, string> = {
|
|
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<Record<ShortcutActionId, ShortcutDefinition>> (
|
|
(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<Record<ShortcutActionId, KeyBinding | null>> (
|
|
(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<KeyBinding>
|
|
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<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
|
|
): 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<ShortcutActionId, KeyBinding | null>,
|
|
): Set<ShortcutActionId> => {
|
|
const grouped = new Map<string, ShortcutActionId[]> ()
|
|
|
|
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 === ' '
|