From 95b4db1fc28496afb14d75ac9ada712d410b4339 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 02:59:48 +0900 Subject: [PATCH] #34 --- frontend/src/App.tsx | 27 +- frontend/src/components/PrefetchLink.tsx | 14 +- frontend/src/components/TopNav.tsx | 10 +- frontend/src/components/common/TabGroup.tsx | 12 +- .../users/KeyboardSettingsSection.tsx | 173 ++++++++++ frontend/src/lib/keyboardShortcuts.ts | 319 ++++++++++++++++++ frontend/src/lib/settings.ts | 120 +++++++ frontend/src/lib/useKeyboardShortcuts.tsx | 283 ++++++++++++++++ frontend/src/pages/posts/PostListPage.tsx | 24 ++ frontend/src/pages/posts/PostSearchPage.tsx | 25 ++ frontend/src/pages/tags/TagListPage.tsx | 25 ++ frontend/src/pages/users/SettingPage.tsx | 67 +--- 12 files changed, 1017 insertions(+), 82 deletions(-) create mode 100644 frontend/src/components/users/KeyboardSettingsSection.tsx create mode 100644 frontend/src/lib/keyboardShortcuts.ts create mode 100644 frontend/src/lib/useKeyboardShortcuts.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d90891c..14b1bb2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { getClientThemeMode, seedClientThemeMode, } from '@/lib/settings' +import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts' import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' import MaterialBasePage from '@/pages/materials/MaterialBasePage' import MaterialDetailPage from '@/pages/materials/MaterialDetailPage' @@ -185,19 +186,21 @@ const App: FC = () => { {import.meta.env.DEV && } - - - - - - - + + + + + + + + - - + + + ) } diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 18fc855..86fe9a5 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' import { cn } from '@/lib/utils' -import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' +import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react' import type { To } from 'react-router-dom' type Props = AnchorHTMLAttributes & { @@ -25,6 +25,7 @@ export default forwardRef (({ state, onMouseEnter, onTouchStart, + onKeyDown, onClick, cancelOnError = false, ...rest }, ref) => { @@ -97,11 +98,22 @@ export default forwardRef (({ } } + const handleKeyDown = (ev: KeyboardEvent) => { + onKeyDown?.(ev) + + if (ev.defaultPrevented) + return + + if (ev.key === ' ' || ev.key === 'Spacebar') + ev.preventDefault () + } + return ( ) diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 22068fc..875ee3d 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -249,16 +249,16 @@ const TopNav: FC = ({ user }) => { - { - ev.preventDefault () + onClick={() => { setMenuOpen (!(menuOpen)) }}> {menuOpen ? '×' : 'Menu'} - + diff --git a/frontend/src/components/common/TabGroup.tsx b/frontend/src/components/common/TabGroup.tsx index 0a0974a..7a5627c 100644 --- a/frontend/src/components/common/TabGroup.tsx +++ b/frontend/src/components/common/TabGroup.tsx @@ -28,15 +28,15 @@ const TabGroup: FC = ({ children, rightAddon }) => {
{rightAddon && (
diff --git a/frontend/src/components/users/KeyboardSettingsSection.tsx b/frontend/src/components/users/KeyboardSettingsSection.tsx new file mode 100644 index 0000000..580dca3 --- /dev/null +++ b/frontend/src/components/users/KeyboardSettingsSection.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { + formatKeyBinding, + isSpaceBinding, + keyBindingFromKeyboardEvent, + SHORTCUT_DEFINITIONS, + SHORTCUT_SCOPE_LABELS, +} from '@/lib/keyboardShortcuts' +import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' + +import type { ShortcutActionId } from '@/lib/keyboardShortcuts' +import type { FC } from 'react' + +type Props = { + sectionClassName: string +} + + +const KeyboardSettingsSection: FC = ({ sectionClassName }) => { + const { + conflictActionIds, + effectiveBindings, + keyboardSettings, + resetAllBindings, + resetBinding, + setBinding, + setEnabled, + } = useKeyboardShortcutSettings () + const [capturingActionId, setCapturingActionId] = + useState (null) + + useEffect (() => { + if (capturingActionId == null) + return + + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing || event.key === 'Process') + return + + event.preventDefault () + event.stopPropagation () + + if (event.key === 'Escape') + { + setCapturingActionId (null) + return + } + + if (event.key === 'Backspace' || event.key === 'Delete') + { + setBinding (capturingActionId, null) + setCapturingActionId (null) + return + } + + const binding = keyBindingFromKeyboardEvent (event) + if (binding == null) + return + + if (isSpaceBinding (binding)) + { + toast ({ title: 'Space は今は割り当てられません.' }) + return + } + + setBinding (capturingActionId, binding) + setCapturingActionId (null) + } + + document.addEventListener ('keydown', onKeyDown, true) + return () => document.removeEventListener ('keydown', onKeyDown, true) + }, [capturingActionId, setBinding]) + + const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ + ...definition, + currentBinding: effectiveBindings[definition.id], + hasConflict: conflictActionIds.has (definition.id), + hasCustomBinding: Object.prototype.hasOwnProperty.call ( + keyboardSettings.bindings ?? { }, + definition.id, + ), + })), [conflictActionIds, effectiveBindings, keyboardSettings.bindings]) + + return ( +
+
+
+

キーボード

+

+ この設定は現在のブラウザに自動保存されます.競合した割当は保存されますが, + dispatcher では発火しません. +

+
+ + +
+ + + + {capturingActionId != null && ( +

+ キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます. +

)} + +
+ + + + + + + + + + + + + {rows.map (row => ( + + + + + + + + ))} + +
操作適用範囲現在のキー変更既定に戻す競合状態
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (row.currentBinding)} + + + + + + {row.hasConflict + ? 競合あり + : 'なし'} +
+
+
) +} + +export default KeyboardSettingsSection diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts new file mode 100644 index 0000000..7948a26 --- /dev/null +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -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 = { + 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> ( + (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> ( + (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 + 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, +): 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, +): Set => { + const grouped = new Map () + + 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 === ' ' diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index c33a3c4..53a9907 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -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> +} + export type ClientAppearanceSettings = { activeThemeId?: ActiveThemeId customThemes?: Record @@ -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 lists?: Partial> theatre?: { @@ -295,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | null => value === 20 || value === 50 || value === 100 ? value : null +const normaliseKeyboardBindings = ( + bindings: unknown, +): Partial> => { + if (!(bindings) || typeof bindings !== 'object') + return { } + + return Object.keys (DEFAULT_KEY_BINDINGS).reduce< + Partial> + > ( + (normalised, actionId) => { + const value = (bindings as Record)[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> = + getClientKeyboardSettings ().bindings ?? { }, +): Record => + Object.keys (DEFAULT_KEY_BINDINGS).reduce> ( + (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 diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx new file mode 100644 index 0000000..23509fb --- /dev/null +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -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> + +type KeyboardShortcutsContextValue = { + activeScope: ShortcutScope + keyboardSettings: ClientKeyboardSettings + effectiveBindings: Record + conflictActionIds: Set + setEnabled: (enabled: boolean) => void + setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void + resetBinding: (actionId: ShortcutActionId) => void + resetAllBindings: () => void + registerHandlers: (handlers: ShortcutHandlers) => () => void +} + +const KeyboardShortcutsContext = + createContext (null) + + +const buildShortcutHelpText = ( + activeScope: ShortcutScope, + effectiveBindings: Record, +): 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 (() => getClientKeyboardSettings ()) + const [registeredHandlers, setRegisteredHandlers] = + useState> ({ }) + const registrationIdRef = useRef (0) + + const activeScope = useMemo ( + () => resolveShortcutScope (location.pathname), + [location.pathname], + ) + const effectiveBindings = useMemo ( + () => getEffectiveKeyBindings (keyboardSettings.bindings), + [keyboardSettings.bindings], + ) + const conflictActionIds = useMemo ( + () => getConflictingShortcutActionIds (effectiveBindings), + [effectiveBindings], + ) + const mergedRegisteredHandlers = useMemo ( + () => Object.values (registeredHandlers).reduce ( + (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 ( + '[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 (() => ({ + activeScope, + keyboardSettings, + effectiveBindings, + conflictActionIds, + setEnabled, + setBinding, + resetBinding, + resetAllBindings, + registerHandlers, + }), [ + activeScope, + conflictActionIds, + effectiveBindings, + keyboardSettings, + registerHandlers, + resetAllBindings, + resetBinding, + setBinding, + setEnabled, + ]) + + return ( + + {children} + ) +} + + +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, +} diff --git a/frontend/src/pages/posts/PostListPage.tsx b/frontend/src/pages/posts/PostListPage.tsx index ab5330d..48be9ff 100644 --- a/frontend/src/pages/posts/PostListPage.tsx +++ b/frontend/src/pages/posts/PostListPage.tsx @@ -19,6 +19,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchWikiPageByTitle } from '@/lib/wiki' import type { FC } from 'react' @@ -68,6 +69,12 @@ const PostListPage: FC = () => { setClientListSettings ('postList', { limit }) }, [limit]) + const updatePage = (nextPage: number) => { + const params = new URLSearchParams (location.search) + params.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ params.toString () }`) + } + const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => { const params = new URLSearchParams (location.search) params.set ('limit', String (next.limit ?? limit)) @@ -75,6 +82,23 @@ const PostListPage: FC = () => { navigate (`${ location.pathname }?${ params.toString () }`) } + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + useLayoutEffect (() => { scroll (0, 0) diff --git a/frontend/src/pages/posts/PostSearchPage.tsx b/frontend/src/pages/posts/PostSearchPage.tsx index 4ef0ba1..282f7c0 100644 --- a/frontend/src/pages/posts/PostSearchPage.tsx +++ b/frontend/src/pages/posts/PostSearchPage.tsx @@ -24,6 +24,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils' import type { FC, FormEvent } from 'react' @@ -171,11 +172,34 @@ const PostSearchPage: FC = () => { navigate (`${ location.pathname }?${ qs.toString () }`) } + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const handleSearch = (e: FormEvent) => { e.preventDefault () search () } + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + const defaultDirection = { title: 'asc', url: 'asc', original_created_at: 'desc', @@ -214,6 +238,7 @@ const PostSearchPage: FC = () => { {({ invalid }) => ( setTitle (e.target.value)} className={inputClass (invalid)}/>)} diff --git a/frontend/src/pages/tags/TagListPage.tsx b/frontend/src/pages/tags/TagListPage.tsx index b01350e..f6c9fc6 100644 --- a/frontend/src/pages/tags/TagListPage.tsx +++ b/frontend/src/pages/tags/TagListPage.tsx @@ -22,6 +22,7 @@ import { LIST_LIMIT_OPTIONS, setClientListSettings, } from '@/lib/settings' +import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts' import { fetchTags } from '@/lib/tags' import { dateString, inputClass } from '@/lib/utils' @@ -180,12 +181,35 @@ const TagListPage: FC = () => { navigate (`${ location.pathname }?${ qs.toString () }`) } + const updatePage = (nextPage: number) => { + const qs = new URLSearchParams (location.search) + qs.set ('page', String (Math.max (1, nextPage))) + navigate (`${ location.pathname }?${ qs.toString () }`) + } + const defaultDirection = { name: 'asc', category: 'asc', post_count: 'desc', created_at: 'desc', updated_at: 'desc' } as const + useKeyboardShortcuts (useMemo (() => ({ + ...(page > 1 + ? { + 'pagination.previous': () => { + updatePage (page - 1) + }, + } + : { }), + ...(totalPages > 0 && page < totalPages + ? { + 'pagination.next': () => { + updatePage (Math.min (page + 1, totalPages)) + }, + } + : { }), + }), [page, totalPages, location.search])) + return ( @@ -218,6 +242,7 @@ const TagListPage: FC = () => { {({ invalid }) => ( setName (e.target.value)} className={inputClass (invalid)}/>)} diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index 7e83cc7..49294d9 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -10,6 +10,7 @@ import Label from '@/components/common/Label' import PageTitle from '@/components/common/PageTitle' import MainArea from '@/components/layout/MainArea' import TagLink from '@/components/TagLink' +import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' import { CATEGORY_NAMES, CATEGORIES } from '@/consts' @@ -32,6 +33,7 @@ import { setClientAppearanceThemes, updateUserSettings, } from '@/lib/settings' +import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { cn, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' @@ -100,27 +102,6 @@ const builtinThemeLabel: Record<'light' | 'dark', string> = { dark: 'ダーク・モード', } -const keyboardShortcutRows = [ - { - action: '投稿詳細を開く', - key: 'Enter', - scope: '一覧・検索結果', - conflict: 'なし', - }, - { - action: '候補タグを選ぶ', - key: '↑ / ↓ / Enter', - scope: 'タグ入力', - conflict: 'なし', - }, - { - action: '候補タグを閉じる', - key: 'Escape', - scope: 'タグ入力', - conflict: 'なし', - }, -] - const previewTag = ( id: number, name: string, @@ -389,39 +370,6 @@ const ThemeSection: FC = ({ ) -const KeyboardSection: FC = () => ( -
-

キーボード

- -

- 現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ - key bind 設定へ拡張できる形にしています. -

- -
- - - - - - - - - - - {keyboardShortcutRows.map (row => ( - - - - - - ))} - -
操作キー適用範囲競合
{row.action}{row.key}{row.scope}{row.conflict}
-
-
) - - const SettingPage: FC = ({ user, setUser }) => { const location = useLocation () const navigate = useNavigate () @@ -440,6 +388,7 @@ const SettingPage: FC = ({ user, setUser }) => { const [draftCustomThemes, setDraftCustomThemes] = useState> (getClientCustomThemes ()) const [userCodeVsbl, setUserCodeVsbl] = useState (false) + const { conflictActionIds } = useKeyboardShortcutSettings () const { baseErrors: userBaseErrors, fieldErrors: userFieldErrors, @@ -472,8 +421,8 @@ const SettingPage: FC = ({ user, setUser }) => { const settingsTabErrors = useMemo> (() => ({ account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), - keyboard: false, - }), [settingsFieldErrors.theme, userFieldErrors.name]) + keyboard: conflictActionIds.size > 0, + }), [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( activeThemeId: ActiveThemeId, @@ -780,7 +729,7 @@ const SettingPage: FC = ({ user, setUser }) => { <> - + )} )}
@@ -828,7 +777,9 @@ const SettingPage: FC = ({ user, setUser }) => { <> {activeTab === 'account' && } {activeTab === 'theme' && } - {activeTab === 'keyboard' && } + {activeTab === 'keyboard' && ( + + )} )}
)}