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 }) => {
<>
-
+
>)}
>)}