diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx
index 9447e1f..dfa519a 100644
--- a/frontend/src/components/TagSearch.tsx
+++ b/frontend/src/components/TagSearch.tsx
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return (
= ({ sectionClassName }) => {
const {
- conflictActionIds,
- effectiveBindings,
- keyboardSettings,
- resetAllBindings,
- resetBinding,
- setBinding,
- setEnabled,
+ keyboardSettings,
+ saveKeyboardSettings,
} = useKeyboardShortcutSettings ()
const [capturingActionId, setCapturingActionId] =
useState
(null)
+ const [draftKeyboardSettings, setDraftKeyboardSettings] =
+ useState (keyboardSettings)
+
+ useEffect (() => {
+ setDraftKeyboardSettings (keyboardSettings)
+ }, [keyboardSettings])
+
+ const draftEffectiveBindings = useMemo (
+ () => getEffectiveKeyBindings (draftKeyboardSettings.bindings),
+ [draftKeyboardSettings.bindings],
+ )
+ const draftConflictActionIds = useMemo (
+ () => getConflictingShortcutActionIds (draftEffectiveBindings),
+ [draftEffectiveBindings],
+ )
+ const hasUnsavedChanges = useMemo (
+ () => JSON.stringify (draftKeyboardSettings) !== JSON.stringify (keyboardSettings),
+ [draftKeyboardSettings, keyboardSettings],
+ )
useEffect (() => {
if (capturingActionId == null)
@@ -51,7 +68,13 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => {
if (event.key === 'Backspace' || event.key === 'Delete')
{
- setBinding (capturingActionId, null)
+ setDraftKeyboardSettings (current => ({
+ ...current,
+ bindings: {
+ ...(current.bindings ?? { }),
+ [capturingActionId]: null,
+ },
+ }))
setCapturingActionId (null)
return
}
@@ -66,46 +89,92 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => {
return
}
- setBinding (capturingActionId, binding)
+ setDraftKeyboardSettings (current => ({
+ ...current,
+ bindings: {
+ ...(current.bindings ?? { }),
+ [capturingActionId]: binding,
+ },
+ }))
setCapturingActionId (null)
}
document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true)
- }, [capturingActionId, setBinding])
+ }, [capturingActionId])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition,
- currentBinding: effectiveBindings[definition.id],
- hasConflict: conflictActionIds.has (definition.id),
+ currentBinding: draftEffectiveBindings[definition.id],
+ hasConflict: draftConflictActionIds.has (definition.id),
hasCustomBinding: Object.prototype.hasOwnProperty.call (
- keyboardSettings.bindings ?? { },
+ draftKeyboardSettings.bindings ?? { },
definition.id,
),
- })), [conflictActionIds, effectiveBindings, keyboardSettings.bindings])
+ })), [
+ draftConflictActionIds,
+ draftEffectiveBindings,
+ draftKeyboardSettings.bindings,
+ ])
+
+ const handleSave = () => {
+ saveKeyboardSettings (draftKeyboardSettings)
+ toast ({ title: 'キーボード設定を保存しました' })
+ }
+
+ const handleDiscard = () => {
+ setDraftKeyboardSettings (keyboardSettings)
+ setCapturingActionId (null)
+ }
return (
キーボード
-
- この設定は現在のブラウザに自動保存されます.競合した割当は保存されますが,
- dispatcher では発火しません.
-
+ {hasUnsavedChanges && (
+
+ 未保存の変更があります
+
)}
-
+
+
+
+
+
@@ -154,7 +223,16 @@ const KeyboardSettingsSection: FC = ({ sectionClassName }) => {
variant="ghost"
className="h-8"
disabled={!(row.hasCustomBinding)}
- onClick={() => resetBinding (row.id)}>
+ onClick={() => {
+ setDraftKeyboardSettings (current => {
+ const nextBindings = { ...(current.bindings ?? { }) }
+ delete nextBindings[row.id]
+ return {
+ ...current,
+ bindings: nextBindings,
+ }
+ })
+ }}>
既定に戻す
diff --git a/frontend/src/components/users/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx
new file mode 100644
index 0000000..0a7b1b5
--- /dev/null
+++ b/frontend/src/components/users/ShortcutHelpDialog.tsx
@@ -0,0 +1,77 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ formatKeyBinding,
+ SHORTCUT_DEFINITIONS,
+ SHORTCUT_SCOPE_LABELS,
+} from '@/lib/keyboardShortcuts'
+
+import type {
+ KeyBinding,
+ ShortcutActionId,
+ ShortcutScope,
+} from '@/lib/keyboardShortcuts'
+import type { FC } from 'react'
+
+type Props = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ activeScope: ShortcutScope
+ effectiveBindings: Record
+ conflictActionIds: Set
+}
+
+
+const ShortcutHelpDialog: FC = (
+ { open, onOpenChange, activeScope, effectiveBindings, conflictActionIds },
+) => {
+ const rows = SHORTCUT_DEFINITIONS.filter (definition =>
+ definition.scope === 'global' || definition.scope === activeScope)
+
+ return (
+ )
+}
+
+export default ShortcutHelpDialog
diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx
index 23509fb..47f05a2 100644
--- a/frontend/src/lib/useKeyboardShortcuts.tsx
+++ b/frontend/src/lib/useKeyboardShortcuts.tsx
@@ -9,10 +9,9 @@ import {
} from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
-import { toast } from '@/components/ui/use-toast'
+import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import {
DEFAULT_KEY_BINDINGS,
- formatKeyBinding,
getConflictingShortcutActionIds,
isEditableEventTarget,
keyBindingFromKeyboardEvent,
@@ -46,6 +45,7 @@ type KeyboardShortcutsContextValue = {
keyboardSettings: ClientKeyboardSettings
effectiveBindings: Record
conflictActionIds: Set
+ saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
setEnabled: (enabled: boolean) => void
setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void
resetBinding: (actionId: ShortcutActionId) => void
@@ -57,17 +57,47 @@ 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 (' / ')
+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 (
+ '[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) => {
@@ -78,6 +108,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
useState (() => getClientKeyboardSettings ())
const [registeredHandlers, setRegisteredHandlers] =
useState> ({ })
+ const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0)
const activeScope = useMemo (
@@ -108,6 +139,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
setKeyboardSettingsState (next)
}, [keyboardSettings])
+ const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
+ const next = setClientKeyboardSettings (settings)
+ setKeyboardSettingsState (next)
+ }, [])
+
const setBinding = useCallback ((
actionId: ShortcutActionId,
binding: KeyBinding | null,
@@ -157,6 +193,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
if (isEditableEventTarget (event.target))
return
+ if (shortcutHelpOpen)
+ return
+
const binding = keyBindingFromKeyboardEvent (event)
if (binding == null)
return
@@ -183,21 +222,10 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const builtinHandler = (
{
'global.openShortcutHelp': () => {
- const description =
- buildShortcutHelpText (activeScope, effectiveBindings)
- || 'この画面で使えるショートカットはありません.'
- toast ({
- title: 'キーボード・ショートカット',
- description,
- })
+ setShortcutHelpOpen (true)
},
'global.focusSearch': () => {
- const target = document.querySelector (
- '[data-shortcut-focus-search="true"]',
- )
- target?.focus ()
- if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
- target.select ()
+ focusSearchTarget ()
},
'navigation.posts': () => navigate ('/posts'),
'navigation.tags': () => navigate ('/tags'),
@@ -226,6 +254,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
keyboardSettings.enabled,
mergedRegisteredHandlers,
navigate,
+ shortcutHelpOpen,
])
const value = useMemo (() => ({
@@ -234,6 +263,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
effectiveBindings,
conflictActionIds,
setEnabled,
+ saveKeyboardSettings,
setBinding,
resetBinding,
resetAllBindings,
@@ -246,6 +276,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
registerHandlers,
resetAllBindings,
resetBinding,
+ saveKeyboardSettings,
setBinding,
setEnabled,
])
@@ -253,6 +284,12 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
return (
{children}
+
)
}
diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx
index 1650178..c818e00 100644
--- a/frontend/src/pages/users/SettingPage.tsx
+++ b/frontend/src/pages/users/SettingPage.tsx
@@ -254,28 +254,10 @@ const ThemeSection: FC = (
)}
-
-
- テーマの選択状態は現在のブラウザに保存し,背景・文字色はそのテーマの base に従います.
-
-
- backend には system / light / dark の mode だけ保存します.
- カスタムテーマ本体は今は browser-local です.
-
-
- 将来は themes テーブルなどへ移して,端末間同期できる構造に拡張できます.
-
-
-
タグカテゴリ色
-
- {selectedTheme.builtin || draftActiveThemeId === 'system'
- ? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
- : `現在の編輯対象: ${ selectedTheme.name }`}
-
-
-
既存 backend 設定について
-
- auto_fetch_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に
- 保持したままです.この共通設定画面にはまだ接続していません.
-
-
-
@@ -682,15 +656,40 @@ const SettingPage: FC = ({ user, setUser }) => {
設定
+
+ {tabs.map (tab => (
+ ))}
+
{loading ? 'Loading...' : (
- <>
- {sectionProps && (
- <>
-
-
-
- >)}
- >)}
+
+ {sectionProps && activeTab === 'account' &&
}
+ {sectionProps && activeTab === 'theme' &&
}
+ {activeTab === 'keyboard' && (
+
)}
+ )}