Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #397 でマージされました.
このコミットが含まれているのは:
@@ -0,0 +1,322 @@
|
||||
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,
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする