f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
323 行
9.0 KiB
TypeScript
323 行
9.0 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useLocation, useNavigate } from 'react-router-dom'
|
|
|
|
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
|
|
import {
|
|
DEFAULT_KEY_BINDINGS,
|
|
getConflictingShortcutActionIds,
|
|
isEditableEventTarget,
|
|
keyBindingFromKeyboardEvent,
|
|
resolveShortcutScope,
|
|
serialiseKeyBinding,
|
|
SHORTCUT_DEFINITIONS,
|
|
SHORTCUT_SCOPE_LABELS,
|
|
} from '@/lib/keyboardShortcuts'
|
|
import {
|
|
getClientKeyboardSettings,
|
|
getEffectiveKeyBindings,
|
|
setClientKeyboardSettings,
|
|
} from '@/lib/settings'
|
|
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
|
|
|
import type {
|
|
KeyBinding,
|
|
ShortcutActionId,
|
|
ShortcutScope,
|
|
} from '@/lib/keyboardShortcuts'
|
|
import type { ClientKeyboardSettings } from '@/lib/settings'
|
|
import type { PropsWithChildren } from 'react'
|
|
|
|
type ShortcutHandler = () => void
|
|
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
|
|
|
|
type KeyboardShortcutsContextValue = {
|
|
activeScope: ShortcutScope
|
|
keyboardSettings: ClientKeyboardSettings
|
|
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
|
|
conflictActionIds: Set<ShortcutActionId>
|
|
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
|
|
registerHandlers: (handlers: ShortcutHandlers) => () => void
|
|
}
|
|
|
|
const KeyboardShortcutsContext =
|
|
createContext<KeyboardShortcutsContextValue | null> (null)
|
|
|
|
|
|
const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
|
|
if (element.closest ('[hidden], [aria-hidden="true"]') != null)
|
|
return false
|
|
|
|
if (
|
|
(element instanceof HTMLInputElement
|
|
|| element instanceof HTMLButtonElement
|
|
|| element instanceof HTMLSelectElement
|
|
|| element instanceof HTMLTextAreaElement)
|
|
&& element.disabled
|
|
)
|
|
return false
|
|
|
|
const style = window.getComputedStyle (element)
|
|
if (
|
|
style.display === 'none'
|
|
|| style.visibility === 'hidden'
|
|
|| style.pointerEvents === 'none'
|
|
)
|
|
return false
|
|
|
|
return element.getClientRects ().length > 0
|
|
}
|
|
|
|
|
|
const focusSearchTarget = (): void => {
|
|
const targets = [
|
|
...document.querySelectorAll<HTMLElement> (
|
|
'[data-shortcut-focus-search="true"]',
|
|
),
|
|
]
|
|
const target = targets.find (isFocusableShortcutTarget)
|
|
|
|
if (target == null)
|
|
return
|
|
|
|
target.focus ()
|
|
|
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
target.select ()
|
|
}
|
|
|
|
|
|
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
|
const location = useLocation ()
|
|
const navigate = useNavigate ()
|
|
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
|
|
|
|
const [keyboardSettings, setKeyboardSettingsState] =
|
|
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
|
const [registeredHandlers, setRegisteredHandlers] =
|
|
useState<Record<number, ShortcutHandlers>> ({ })
|
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
|
|
const registrationIdRef = useRef (0)
|
|
|
|
const activeScope = useMemo<ShortcutScope> (
|
|
() => resolveShortcutScope (location.pathname),
|
|
[location.pathname],
|
|
)
|
|
const effectiveBindings = useMemo (
|
|
() => getEffectiveKeyBindings (keyboardSettings.bindings),
|
|
[keyboardSettings.bindings],
|
|
)
|
|
const conflictActionIds = useMemo (
|
|
() => getConflictingShortcutActionIds (effectiveBindings),
|
|
[effectiveBindings],
|
|
)
|
|
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
|
|
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
|
|
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
|
|
{ },
|
|
),
|
|
[registeredHandlers],
|
|
)
|
|
|
|
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
|
|
const next = setClientKeyboardSettings (settings)
|
|
setKeyboardSettingsState (next)
|
|
}, [])
|
|
|
|
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
|
|
const registrationId = ++registrationIdRef.current
|
|
|
|
setRegisteredHandlers (current => ({
|
|
...current,
|
|
[registrationId]: handlers,
|
|
}))
|
|
|
|
return () => {
|
|
setRegisteredHandlers (current => {
|
|
const next = { ...current }
|
|
delete next[registrationId]
|
|
return next
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
const guardedNavigate = useCallback ((path: string) => {
|
|
confirmDiscardNavigation ().then (confirmed => {
|
|
if (confirmed)
|
|
navigate (path)
|
|
})
|
|
}, [confirmDiscardNavigation, navigate])
|
|
|
|
const builtinHandlers = useMemo<ShortcutHandlers> (
|
|
() => ({
|
|
'global.openShortcutHelp': () => {
|
|
setShortcutHelpOpen (true)
|
|
},
|
|
'global.focusSearch': () => {
|
|
focusSearchTarget ()
|
|
},
|
|
'navigation.posts': () => guardedNavigate ('/posts'),
|
|
'navigation.tags': () => guardedNavigate ('/tags'),
|
|
'navigation.materials': () => guardedNavigate ('/materials'),
|
|
'navigation.wiki': () => guardedNavigate ('/wiki'),
|
|
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
|
|
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
|
|
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
|
|
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
|
|
}),
|
|
[guardedNavigate],
|
|
)
|
|
|
|
const availableActionIds = useMemo<Set<ShortcutActionId>> (
|
|
() => new Set (
|
|
SHORTCUT_DEFINITIONS
|
|
.filter (definition =>
|
|
definition.scope === 'global' || definition.scope === activeScope)
|
|
.filter (definition =>
|
|
builtinHandlers[definition.id] != null
|
|
|| mergedRegisteredHandlers[definition.id] != null)
|
|
.map (definition => definition.id),
|
|
),
|
|
[activeScope, builtinHandlers, mergedRegisteredHandlers],
|
|
)
|
|
|
|
useEffect (() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
keyboardSettings.enabled === false
|
|
|| event.defaultPrevented
|
|
|| event.isComposing
|
|
|| event.key === 'Process'
|
|
|| event.repeat
|
|
)
|
|
return
|
|
|
|
if (isEditableEventTarget (event.target))
|
|
return
|
|
|
|
if (shortcutHelpOpen)
|
|
return
|
|
|
|
const binding = keyBindingFromKeyboardEvent (event)
|
|
if (binding == null)
|
|
return
|
|
|
|
const serialisedBinding = serialiseKeyBinding (binding)
|
|
if (serialisedBinding == null)
|
|
return
|
|
|
|
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
|
|
definition.scope === 'global'
|
|
|| definition.scope === activeScope
|
|
)).filter (definition => {
|
|
const currentBinding = effectiveBindings[definition.id]
|
|
return (
|
|
currentBinding != null
|
|
&& serialiseKeyBinding (currentBinding) === serialisedBinding
|
|
)
|
|
}).filter (definition => !(conflictActionIds.has (definition.id)))
|
|
|
|
if (candidates.length !== 1)
|
|
return
|
|
|
|
const actionId = candidates[0].id
|
|
const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
|
|
|
|
if (handler == null)
|
|
return
|
|
|
|
event.preventDefault ()
|
|
handler ()
|
|
}
|
|
|
|
document.addEventListener ('keydown', onKeyDown)
|
|
return () => document.removeEventListener ('keydown', onKeyDown)
|
|
}, [
|
|
activeScope,
|
|
conflictActionIds,
|
|
effectiveBindings,
|
|
builtinHandlers,
|
|
keyboardSettings.enabled,
|
|
mergedRegisteredHandlers,
|
|
shortcutHelpOpen,
|
|
])
|
|
|
|
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
|
|
activeScope,
|
|
keyboardSettings,
|
|
effectiveBindings,
|
|
conflictActionIds,
|
|
saveKeyboardSettings,
|
|
registerHandlers,
|
|
}), [
|
|
activeScope,
|
|
conflictActionIds,
|
|
effectiveBindings,
|
|
keyboardSettings,
|
|
registerHandlers,
|
|
saveKeyboardSettings,
|
|
])
|
|
|
|
return (
|
|
<KeyboardShortcutsContext.Provider value={value}>
|
|
{children}
|
|
<ShortcutHelpDialog
|
|
open={shortcutHelpOpen}
|
|
onOpenChange={setShortcutHelpOpen}
|
|
activeScope={activeScope}
|
|
effectiveBindings={effectiveBindings}
|
|
conflictActionIds={conflictActionIds}
|
|
availableActionIds={availableActionIds}/>
|
|
</KeyboardShortcutsContext.Provider>)
|
|
}
|
|
|
|
|
|
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
|
|
const context = useContext (KeyboardShortcutsContext)
|
|
|
|
if (context == null)
|
|
throw new Error ('KeyboardShortcutsProvider が必要です.')
|
|
|
|
return context
|
|
}
|
|
|
|
|
|
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
|
|
const { registerHandlers } = useKeyboardShortcutSettings ()
|
|
const handlersRef = useRef (handlers)
|
|
const actionIdsKey = Object.keys (handlers).sort ().join ('|')
|
|
|
|
useEffect (() => {
|
|
handlersRef.current = handlers
|
|
}, [handlers])
|
|
|
|
const stableHandlers = useMemo<ShortcutHandlers> (() => {
|
|
return actionIdsKey.split ('|').filter (actionId => actionId !== '')
|
|
.reduce<ShortcutHandlers> ((nextHandlers, actionId) => {
|
|
const key = actionId as ShortcutActionId
|
|
nextHandlers[key] = () => {
|
|
handlersRef.current[key]?.()
|
|
}
|
|
return nextHandlers
|
|
}, { })
|
|
}, [actionIdsKey])
|
|
|
|
useEffect (
|
|
() => registerHandlers (stableHandlers),
|
|
[registerHandlers, stableHandlers],
|
|
)
|
|
}
|
|
|
|
export {
|
|
DEFAULT_KEY_BINDINGS,
|
|
SHORTCUT_DEFINITIONS,
|
|
SHORTCUT_SCOPE_LABELS,
|
|
}
|