このコミットが含まれているのは:
@@ -0,0 +1,319 @@
|
||||
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.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: 's', alt: true },
|
||||
},
|
||||
{
|
||||
id: 'navigation.posts',
|
||||
label: '広場へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'p', alt: true, shift: true },
|
||||
},
|
||||
{
|
||||
id: 'navigation.tags',
|
||||
label: 'タグへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 't', alt: true, shift: true },
|
||||
},
|
||||
{
|
||||
id: 'navigation.materials',
|
||||
label: '素材へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'm', alt: true, shift: true },
|
||||
},
|
||||
{
|
||||
id: 'navigation.wiki',
|
||||
label: 'Wiki へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'w', alt: true, shift: true },
|
||||
},
|
||||
{
|
||||
id: 'pagination.next',
|
||||
label: '次のページへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowRight', shift: true },
|
||||
},
|
||||
{
|
||||
id: 'pagination.previous',
|
||||
label: '前のページへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowLeft', shift: true },
|
||||
},
|
||||
{
|
||||
id: 'settings.account',
|
||||
label: '設定: アカウント',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '1', alt: true },
|
||||
},
|
||||
{
|
||||
id: 'settings.theme',
|
||||
label: '設定: テーマ',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '2', alt: true },
|
||||
},
|
||||
{
|
||||
id: 'settings.keyboard',
|
||||
label: '設定: キーボード',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '3', alt: true },
|
||||
},
|
||||
]
|
||||
|
||||
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.theme': SHORTCUT_DEFINITIONS[9],
|
||||
'settings.keyboard': SHORTCUT_DEFINITIONS[10],
|
||||
},
|
||||
)
|
||||
|
||||
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.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 === ' '
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import { apiGet, apiPatch } from '@/lib/api'
|
||||
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
|
||||
|
||||
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
||||
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||
|
||||
export type UserSettings = {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
@@ -61,6 +63,11 @@ type ClientListSettings = {
|
||||
limit?: ClientListLimit
|
||||
order?: string }
|
||||
|
||||
export type ClientKeyboardSettings = {
|
||||
enabled?: boolean
|
||||
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||
}
|
||||
|
||||
export type ClientAppearanceSettings = {
|
||||
activeThemeId?: ActiveThemeId
|
||||
customThemes?: Record<string, CustomClientTheme>
|
||||
@@ -80,6 +87,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = {
|
||||
// Browser-local settings only. These are device or screen specific.
|
||||
export type ClientSettings = {
|
||||
appearance?: ClientAppearanceSettings
|
||||
keyboard?: ClientKeyboardSettings
|
||||
panes?: Record<string, ClientPaneSettings>
|
||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||
theatre?: {
|
||||
@@ -295,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
|
||||
value === 20 || value === 50 || value === 100 ? value : null
|
||||
|
||||
|
||||
const normaliseKeyboardBindings = (
|
||||
bindings: unknown,
|
||||
): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
|
||||
if (!(bindings) || typeof bindings !== 'object')
|
||||
return { }
|
||||
|
||||
return Object.keys (DEFAULT_KEY_BINDINGS).reduce<
|
||||
Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||
> (
|
||||
(normalised, actionId) => {
|
||||
const value = (bindings as Record<string, unknown>)[actionId]
|
||||
if (value === null)
|
||||
{
|
||||
normalised[actionId as ShortcutActionId] = null
|
||||
return normalised
|
||||
}
|
||||
|
||||
const binding = normaliseKeyBinding (value)
|
||||
if (binding != null)
|
||||
normalised[actionId as ShortcutActionId] = binding
|
||||
|
||||
return normalised
|
||||
},
|
||||
{ },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
|
||||
const keyboard = loadClientSettings ().keyboard
|
||||
|
||||
return {
|
||||
enabled: keyboard?.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard?.bindings),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const setClientKeyboardSettings = (
|
||||
keyboard: ClientKeyboardSettings,
|
||||
): ClientKeyboardSettings => {
|
||||
const next: ClientKeyboardSettings = {
|
||||
enabled: keyboard.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard.bindings),
|
||||
}
|
||||
|
||||
updateClientSettings (settings => ({
|
||||
...settings,
|
||||
keyboard: next,
|
||||
}))
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
export const getEffectiveKeyBindings = (
|
||||
bindings: Partial<Record<ShortcutActionId, KeyBinding | null>> =
|
||||
getClientKeyboardSettings ().bindings ?? { },
|
||||
): Record<ShortcutActionId, KeyBinding | null> =>
|
||||
Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
||||
(effectiveBindings, actionId) => {
|
||||
const key = actionId as ShortcutActionId
|
||||
effectiveBindings[key] =
|
||||
Object.prototype.hasOwnProperty.call (bindings, key)
|
||||
? bindings[key] ?? null
|
||||
: DEFAULT_KEY_BINDINGS[key]
|
||||
return effectiveBindings
|
||||
},
|
||||
{ ...DEFAULT_KEY_BINDINGS },
|
||||
)
|
||||
|
||||
|
||||
export const setClientKeyBinding = (
|
||||
actionId: ShortcutActionId,
|
||||
binding: KeyBinding | null,
|
||||
): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: {
|
||||
...(current.bindings ?? { }),
|
||||
[actionId]: binding,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const resetClientKeyBinding = (
|
||||
actionId: ShortcutActionId,
|
||||
): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
const nextBindings = { ...(current.bindings ?? { }) }
|
||||
delete nextBindings[actionId]
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: nextBindings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const resetAllClientKeyBindings = (): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: { },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const normaliseHexColour = (value: string): string | null =>
|
||||
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
|
||||
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
DEFAULT_KEY_BINDINGS,
|
||||
formatKeyBinding,
|
||||
getConflictingShortcutActionIds,
|
||||
isEditableEventTarget,
|
||||
keyBindingFromKeyboardEvent,
|
||||
resolveShortcutScope,
|
||||
serialiseKeyBinding,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
SHORTCUT_SCOPE_LABELS,
|
||||
} from '@/lib/keyboardShortcuts'
|
||||
import {
|
||||
getClientKeyboardSettings,
|
||||
getEffectiveKeyBindings,
|
||||
resetAllClientKeyBindings,
|
||||
resetClientKeyBinding,
|
||||
setClientKeyBinding,
|
||||
setClientKeyboardSettings,
|
||||
} from '@/lib/settings'
|
||||
|
||||
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>
|
||||
setEnabled: (enabled: boolean) => void
|
||||
setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void
|
||||
resetBinding: (actionId: ShortcutActionId) => void
|
||||
resetAllBindings: () => void
|
||||
registerHandlers: (handlers: ShortcutHandlers) => () => void
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext =
|
||||
createContext<KeyboardShortcutsContextValue | null> (null)
|
||||
|
||||
|
||||
const buildShortcutHelpText = (
|
||||
activeScope: ShortcutScope,
|
||||
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>,
|
||||
): string =>
|
||||
SHORTCUT_DEFINITIONS
|
||||
.filter (definition =>
|
||||
definition.scope === 'global' || definition.scope === activeScope)
|
||||
.filter (definition => effectiveBindings[definition.id] != null)
|
||||
.map (definition =>
|
||||
`${ definition.label }: ${ formatKeyBinding (effectiveBindings[definition.id]) }`)
|
||||
.join (' / ')
|
||||
|
||||
|
||||
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [keyboardSettings, setKeyboardSettingsState] =
|
||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||
const [registeredHandlers, setRegisteredHandlers] =
|
||||
useState<Record<number, ShortcutHandlers>> ({ })
|
||||
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 setEnabled = useCallback ((enabled: boolean) => {
|
||||
const next = setClientKeyboardSettings ({
|
||||
...keyboardSettings,
|
||||
enabled,
|
||||
})
|
||||
setKeyboardSettingsState (next)
|
||||
}, [keyboardSettings])
|
||||
|
||||
const setBinding = useCallback ((
|
||||
actionId: ShortcutActionId,
|
||||
binding: KeyBinding | null,
|
||||
) => {
|
||||
const next = setClientKeyBinding (actionId, binding)
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const resetBinding = useCallback ((actionId: ShortcutActionId) => {
|
||||
const next = resetClientKeyBinding (actionId)
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const resetAllBindings = useCallback (() => {
|
||||
const next = resetAllClientKeyBindings ()
|
||||
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
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
keyboardSettings.enabled === false
|
||||
|| event.defaultPrevented
|
||||
|| event.isComposing
|
||||
|| event.key === 'Process'
|
||||
|| event.repeat
|
||||
)
|
||||
return
|
||||
|
||||
if (isEditableEventTarget (event.target))
|
||||
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 builtinHandler = (
|
||||
{
|
||||
'global.openShortcutHelp': () => {
|
||||
const description =
|
||||
buildShortcutHelpText (activeScope, effectiveBindings)
|
||||
|| 'この画面で使えるショートカットはありません.'
|
||||
toast ({
|
||||
title: 'キーボード・ショートカット',
|
||||
description,
|
||||
})
|
||||
},
|
||||
'global.focusSearch': () => {
|
||||
const target = document.querySelector<HTMLElement> (
|
||||
'[data-shortcut-focus-search="true"]',
|
||||
)
|
||||
target?.focus ()
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
||||
target.select ()
|
||||
},
|
||||
'navigation.posts': () => navigate ('/posts'),
|
||||
'navigation.tags': () => navigate ('/tags'),
|
||||
'navigation.materials': () => navigate ('/materials'),
|
||||
'navigation.wiki': () => navigate ('/wiki'),
|
||||
'settings.account': () => navigate ('/users/settings?tab=account'),
|
||||
'settings.theme': () => navigate ('/users/settings?tab=theme'),
|
||||
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
|
||||
} as ShortcutHandlers
|
||||
)[actionId]
|
||||
const handler = builtinHandler ?? mergedRegisteredHandlers[actionId]
|
||||
|
||||
if (handler == null)
|
||||
return
|
||||
|
||||
event.preventDefault ()
|
||||
handler ()
|
||||
}
|
||||
|
||||
document.addEventListener ('keydown', onKeyDown)
|
||||
return () => document.removeEventListener ('keydown', onKeyDown)
|
||||
}, [
|
||||
activeScope,
|
||||
conflictActionIds,
|
||||
effectiveBindings,
|
||||
keyboardSettings.enabled,
|
||||
mergedRegisteredHandlers,
|
||||
navigate,
|
||||
])
|
||||
|
||||
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
|
||||
activeScope,
|
||||
keyboardSettings,
|
||||
effectiveBindings,
|
||||
conflictActionIds,
|
||||
setEnabled,
|
||||
setBinding,
|
||||
resetBinding,
|
||||
resetAllBindings,
|
||||
registerHandlers,
|
||||
}), [
|
||||
activeScope,
|
||||
conflictActionIds,
|
||||
effectiveBindings,
|
||||
keyboardSettings,
|
||||
registerHandlers,
|
||||
resetAllBindings,
|
||||
resetBinding,
|
||||
setBinding,
|
||||
setEnabled,
|
||||
])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={value}>
|
||||
{children}
|
||||
</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 ()
|
||||
|
||||
useEffect (
|
||||
() => registerHandlers (handlers),
|
||||
[handlers, registerHandlers],
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_KEY_BINDINGS,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
SHORTCUT_SCOPE_LABELS,
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする