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 ( + + + + キーボード・ショートカット + + この画面で利用できるショートカットを表示します。 + + + +
+ + + + + + + + + + + {rows.map (row => ( + + + + + + ))} + +
操作適用範囲キー競合
{row.label}{SHORTCUT_SCOPE_LABELS[row.scope]} + {formatKeyBinding (effectiveBindings[row.id])} + + {conflictActionIds.has (row.id) + ? 競合あり + : 'なし'} +
+
+
+
) +} + +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' && ( + )} +
)}