Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #397 でマージされました.
このコミットが含まれているのは:
@@ -0,0 +1,34 @@
|
||||
import type { Transition } from 'framer-motion'
|
||||
|
||||
import type { ClientAnimationMode } from '@/lib/settings'
|
||||
|
||||
type ClientAnimationTransitionOptions = {
|
||||
normal: Transition
|
||||
reduced?: Transition
|
||||
off?: Transition }
|
||||
|
||||
|
||||
export const clientAnimationsOff = (
|
||||
animationMode: ClientAnimationMode,
|
||||
): boolean =>
|
||||
animationMode === 'off'
|
||||
|
||||
|
||||
export const clientAnimationTransition = (
|
||||
animationMode: ClientAnimationMode,
|
||||
{ normal, reduced, off }: ClientAnimationTransitionOptions,
|
||||
): Transition => {
|
||||
if (animationMode === 'off')
|
||||
return off ?? { duration: 0 }
|
||||
|
||||
if (animationMode === 'reduced')
|
||||
return reduced ?? { duration: .08, ease: 'linear' as const }
|
||||
|
||||
return normal
|
||||
}
|
||||
|
||||
|
||||
export const clientScrollBehaviour = (
|
||||
animationMode: ClientAnimationMode,
|
||||
): ScrollBehavior =>
|
||||
animationMode === 'off' ? 'auto' : 'smooth'
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS,
|
||||
formatKeyBinding,
|
||||
getConflictingShortcutActionIds,
|
||||
isEditableEventTarget,
|
||||
keyBindingFromKeyboardEvent,
|
||||
resolveShortcutScope,
|
||||
SHORTCUT_DEFINITIONS_BY_ID } from '@/lib/keyboardShortcuts'
|
||||
|
||||
describe ('keyboardShortcuts', () => {
|
||||
it ('defines the default key bindings used by the settings screen', () => {
|
||||
expect (SHORTCUT_DEFINITIONS_BY_ID['global.openShortcutHelp']).toMatchObject ({
|
||||
label: 'ショートカット一覧を開く',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: '?', shift: true } })
|
||||
expect (DEFAULT_KEY_BINDINGS['global.focusSearch']).toEqual ({ key: '/' })
|
||||
expect (DEFAULT_KEY_BINDINGS['navigation.posts']).toEqual ({ key: 'p' })
|
||||
expect (DEFAULT_KEY_BINDINGS['pagination.next']).toEqual ({ key: 'ArrowRight' })
|
||||
expect (DEFAULT_KEY_BINDINGS['settings.theme']).toEqual ({ key: '3' })
|
||||
})
|
||||
|
||||
it ('formats key bindings for display', () => {
|
||||
expect (formatKeyBinding ({ key: '?', shift: true })).toBe ('Shift + ?')
|
||||
expect (formatKeyBinding ({ key: '/' })).toBe ('/')
|
||||
expect (formatKeyBinding ({ key: 'p' })).toBe ('P')
|
||||
expect (formatKeyBinding ({ key: 'ArrowRight' })).toBe ('ArrowRight')
|
||||
expect (formatKeyBinding (null)).toBe ('未割当')
|
||||
})
|
||||
|
||||
it ('normalises keyboard events without assigning space by default', () => {
|
||||
const event = { key: 'P',
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false } as KeyboardEvent
|
||||
|
||||
expect (keyBindingFromKeyboardEvent (event)).toEqual ({ key: 'p' })
|
||||
expect (DEFAULT_KEY_BINDINGS['pagination.next']).not.toEqual ({ key: ' ' })
|
||||
})
|
||||
|
||||
it ('detects conflicting action bindings', () => {
|
||||
const conflicts = getConflictingShortcutActionIds ({
|
||||
...DEFAULT_KEY_BINDINGS,
|
||||
'navigation.posts': { key: 'p' },
|
||||
'navigation.tags': { key: 'p' } })
|
||||
|
||||
expect (conflicts.has ('navigation.posts')).toBe (true)
|
||||
expect (conflicts.has ('navigation.tags')).toBe (true)
|
||||
expect (conflicts.has ('navigation.wiki')).toBe (false)
|
||||
})
|
||||
|
||||
it ('resolves route scopes and editable event targets', () => {
|
||||
const input = document.createElement ('input')
|
||||
const div = document.createElement ('div')
|
||||
div.setAttribute ('role', 'textbox')
|
||||
|
||||
expect (resolveShortcutScope ('/posts/search')).toBe ('postSearch')
|
||||
expect (resolveShortcutScope ('/users/settings')).toBe ('settings')
|
||||
expect (resolveShortcutScope ('/materials/1')).toBe ('materials')
|
||||
expect (isEditableEventTarget (input)).toBe (true)
|
||||
expect (isEditableEventTarget (div)).toBe (true)
|
||||
expect (isEditableEventTarget (document.createElement ('button'))).toBe (false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,328 @@
|
||||
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 === ' '
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyClientAnimationMode,
|
||||
applyThemeTokens,
|
||||
buildThemeTokens,
|
||||
CLIENT_SETTINGS_STORAGE_KEY,
|
||||
DARK_THEME_TOKENS,
|
||||
DEFAULT_POST_LIST_ORDER,
|
||||
getClientListLimit,
|
||||
getClientListOrder,
|
||||
getClientThemeMode,
|
||||
LIGHT_THEME_TOKENS,
|
||||
loadClientSettings,
|
||||
setClientListSettings,
|
||||
setClientThemeAppearance } from '@/lib/settings'
|
||||
|
||||
import type { FetchPostsOrder } from '@/types'
|
||||
|
||||
const installMatchMedia = (matches: boolean) => {
|
||||
Object.defineProperty (window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn ().mockImplementation (() => ({
|
||||
matches,
|
||||
addEventListener: vi.fn (),
|
||||
removeEventListener: vi.fn () })) })
|
||||
}
|
||||
|
||||
describe ('settings', () => {
|
||||
beforeEach (() => {
|
||||
localStorage.clear ()
|
||||
document.documentElement.removeAttribute ('data-animation')
|
||||
document.documentElement.removeAttribute ('style')
|
||||
document.documentElement.className = ''
|
||||
installMatchMedia (false)
|
||||
})
|
||||
|
||||
it ('stores list settings under the shared client settings key', () => {
|
||||
setClientListSettings ('postSearch', {
|
||||
limit: 50,
|
||||
order: 'title:asc' })
|
||||
|
||||
expect (getClientListLimit ('postSearch')).toBe (50)
|
||||
expect (getClientListOrder<FetchPostsOrder> ('postSearch')).toBe ('title:asc')
|
||||
expect (JSON.parse (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY) ?? '{}'))
|
||||
.toMatchObject ({ lists: { postSearch: { limit: 50, order: 'title:asc' } } })
|
||||
})
|
||||
|
||||
it ('ignores invalid list limits and keeps stored order strings', () => {
|
||||
localStorage.setItem (
|
||||
CLIENT_SETTINGS_STORAGE_KEY,
|
||||
JSON.stringify ({ lists: { postList: { limit: 30, order: DEFAULT_POST_LIST_ORDER } } }))
|
||||
|
||||
expect (getClientListLimit ('postList')).toBeNull ()
|
||||
expect (getClientListOrder<FetchPostsOrder> ('postList')).toBe (
|
||||
DEFAULT_POST_LIST_ORDER)
|
||||
})
|
||||
|
||||
it ('stores theme mode and light/dark slot selections in client settings', () => {
|
||||
setClientThemeAppearance ({
|
||||
themeMode: 'dark',
|
||||
activeLightThemeSlotNo: 2,
|
||||
activeDarkThemeSlotNo: 3 })
|
||||
|
||||
expect (getClientThemeMode ()).toBe ('dark')
|
||||
expect (loadClientSettings ().appearance).toMatchObject ({
|
||||
themeMode: 'dark',
|
||||
activeLightThemeSlotNo: 2,
|
||||
activeDarkThemeSlotNo: 3 })
|
||||
})
|
||||
|
||||
it ('applies animation mode to the document dataset', () => {
|
||||
applyClientAnimationMode ('off')
|
||||
expect (document.documentElement.dataset.animation).toBe ('off')
|
||||
|
||||
applyClientAnimationMode ('reduced')
|
||||
expect (document.documentElement.dataset.animation).toBe ('reduced')
|
||||
|
||||
applyClientAnimationMode ('normal')
|
||||
expect (document.documentElement.dataset.animation).toBeUndefined ()
|
||||
})
|
||||
|
||||
it ('normalises TopNav menu link tokens to complete CSS colours', () => {
|
||||
const tokens = buildThemeTokens ('light', {
|
||||
link: '221.2 83.2% 53.3%',
|
||||
topNavMenuLink: '221.2 83.2% 53.3%' })
|
||||
|
||||
expect (tokens.link).toBe ('221.2 83.2% 53.3%')
|
||||
expect (tokens.topNavMenuLink).toBe ('hsl(221.2 83.2% 53.3%)')
|
||||
})
|
||||
|
||||
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
|
||||
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
|
||||
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
|
||||
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
|
||||
DARK_THEME_TOKENS.topNavActiveBackground)
|
||||
})
|
||||
|
||||
it ('sets shadcn tokens and TopNav tokens with their different value formats', () => {
|
||||
const tokens = buildThemeTokens ('dark', { link: '213.1 93.9% 67.8%' })
|
||||
|
||||
applyThemeTokens (tokens)
|
||||
|
||||
expect (document.documentElement.style.getPropertyValue ('--theme-link')).toBe (
|
||||
'213.1 93.9% 67.8%')
|
||||
expect (document.documentElement.style.getPropertyValue ('--top-nav-menu-link')).toBe (
|
||||
'hsl(213.1 93.9% 67.8%)')
|
||||
})
|
||||
})
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
CLIENT_SETTINGS_EVENT,
|
||||
getClientBehaviorSettings,
|
||||
} from '@/lib/settings'
|
||||
|
||||
import type { ClientBehaviorSettings } from '@/lib/settings'
|
||||
|
||||
|
||||
export const useClientBehaviourSettings = (): ClientBehaviorSettings => {
|
||||
const [settings, setSettings] =
|
||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
||||
|
||||
useEffect (() => {
|
||||
const handleClientSettingsChange = () => {
|
||||
setSettings (getClientBehaviorSettings ())
|
||||
}
|
||||
|
||||
window.addEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||
return () =>
|
||||
window.removeEventListener (CLIENT_SETTINGS_EVENT, handleClientSettingsChange)
|
||||
}, [])
|
||||
|
||||
return settings
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
|
||||
type UnsavedChangesSource = {
|
||||
dirty: boolean
|
||||
discard: () => void | Promise<void> }
|
||||
|
||||
type UnsavedChangesGuardContextValue = {
|
||||
hasUnsavedChanges: boolean
|
||||
registerUnsavedChangesSource: (
|
||||
source: UnsavedChangesSource | null,
|
||||
) => void
|
||||
confirmDiscardNavigation: () => Promise<boolean> }
|
||||
|
||||
const UnsavedChangesGuardContext =
|
||||
createContext<UnsavedChangesGuardContextValue | null> (null)
|
||||
|
||||
|
||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const dialogue = useDialogue ()
|
||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
nextSource: UnsavedChangesSource | null,
|
||||
) => {
|
||||
setSource (nextSource)
|
||||
}, [])
|
||||
|
||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||
if (!(source?.dirty))
|
||||
return true
|
||||
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '未保存の変更があります',
|
||||
description: 'このまま移動すると、保存していない変更は失われます。',
|
||||
cancelText: 'このページに残る',
|
||||
confirmText: '変更を破棄して移動',
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
|
||||
await source.discard ()
|
||||
return true
|
||||
}, [dialogue, source])
|
||||
|
||||
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
||||
hasUnsavedChanges: source?.dirty === true,
|
||||
registerUnsavedChangesSource,
|
||||
confirmDiscardNavigation,
|
||||
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuardContext.Provider value={value}>
|
||||
{children}
|
||||
</UnsavedChangesGuardContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
|
||||
const context = useContext (UnsavedChangesGuardContext)
|
||||
|
||||
if (context == null)
|
||||
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
||||
|
||||
return context
|
||||
}
|
||||
新しい課題から参照
ユーザをブロックする