設定画面 (#34) #397

マージ済み
みてるぞ が 31 個のコミットを feature/034 から main へマージ 2026-07-07 00:48:41 +09:00
5個のファイルの変更276行の追加84行の削除
コミット 4e3fb6bef7 の変更だけを表示してゐます - すべてのコミットを表示
+1
ファイルの表示
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<input type="text" <input type="text"
data-shortcut-focus-search="true"
placeholder="タグ検索..." placeholder="タグ検索..."
value={search} value={search}
onChange={whenChanged} onChange={whenChanged}
+99 -21
ファイルの表示
@@ -3,15 +3,18 @@ import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { import {
getConflictingShortcutActionIds,
formatKeyBinding, formatKeyBinding,
isSpaceBinding, isSpaceBinding,
keyBindingFromKeyboardEvent, keyBindingFromKeyboardEvent,
SHORTCUT_DEFINITIONS, SHORTCUT_DEFINITIONS,
SHORTCUT_SCOPE_LABELS, SHORTCUT_SCOPE_LABELS,
} from '@/lib/keyboardShortcuts' } from '@/lib/keyboardShortcuts'
import { getEffectiveKeyBindings } from '@/lib/settings'
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts' import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
import type { ShortcutActionId } from '@/lib/keyboardShortcuts' import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
import type { ClientKeyboardSettings } from '@/lib/settings'
import type { FC } from 'react' import type { FC } from 'react'
type Props = { type Props = {
@@ -21,16 +24,30 @@ type Props = {
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => { const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
const { const {
conflictActionIds,
effectiveBindings,
keyboardSettings, keyboardSettings,
resetAllBindings, saveKeyboardSettings,
resetBinding,
setBinding,
setEnabled,
} = useKeyboardShortcutSettings () } = useKeyboardShortcutSettings ()
const [capturingActionId, setCapturingActionId] = const [capturingActionId, setCapturingActionId] =
useState<ShortcutActionId | null> (null) useState<ShortcutActionId | null> (null)
const [draftKeyboardSettings, setDraftKeyboardSettings] =
useState<ClientKeyboardSettings> (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 (() => { useEffect (() => {
if (capturingActionId == null) if (capturingActionId == null)
@@ -51,7 +68,13 @@ const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
if (event.key === 'Backspace' || event.key === 'Delete') if (event.key === 'Backspace' || event.key === 'Delete')
{ {
setBinding (capturingActionId, null) setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: null,
},
}))
setCapturingActionId (null) setCapturingActionId (null)
return return
} }
@@ -66,46 +89,92 @@ const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
return return
} }
setBinding (capturingActionId, binding) setDraftKeyboardSettings (current => ({
...current,
bindings: {
...(current.bindings ?? { }),
[capturingActionId]: binding,
},
}))
setCapturingActionId (null) setCapturingActionId (null)
} }
document.addEventListener ('keydown', onKeyDown, true) document.addEventListener ('keydown', onKeyDown, true)
return () => document.removeEventListener ('keydown', onKeyDown, true) return () => document.removeEventListener ('keydown', onKeyDown, true)
}, [capturingActionId, setBinding]) }, [capturingActionId])
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({ const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
...definition, ...definition,
currentBinding: effectiveBindings[definition.id], currentBinding: draftEffectiveBindings[definition.id],
hasConflict: conflictActionIds.has (definition.id), hasConflict: draftConflictActionIds.has (definition.id),
hasCustomBinding: Object.prototype.hasOwnProperty.call ( hasCustomBinding: Object.prototype.hasOwnProperty.call (
keyboardSettings.bindings ?? { }, draftKeyboardSettings.bindings ?? { },
definition.id, definition.id,
), ),
})), [conflictActionIds, effectiveBindings, keyboardSettings.bindings]) })), [
draftConflictActionIds,
draftEffectiveBindings,
draftKeyboardSettings.bindings,
])
const handleSave = () => {
saveKeyboardSettings (draftKeyboardSettings)
toast ({ title: 'キーボード設定を保存しました' })
}
const handleDiscard = () => {
setDraftKeyboardSettings (keyboardSettings)
setCapturingActionId (null)
}
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-xl font-bold"></h2> <h2 className="text-xl font-bold"></h2>
<p className="text-sm text-muted-foreground"> {hasUnsavedChanges && (
<p className="text-sm text-amber-700 dark:text-amber-300">
dispatcher
</p> </p>)}
</div> </div>
<Button type="button" variant="outline" onClick={resetAllBindings}> <div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
setDraftKeyboardSettings (current => ({
...current,
bindings: { },
}))
}}>
</Button> </Button>
<Button
type="button"
variant="outline"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
onClick={handleSave}>
</Button>
</div>
</div> </div>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<input <input
type="checkbox" type="checkbox"
checked={keyboardSettings.enabled !== false} checked={draftKeyboardSettings.enabled !== false}
onChange={event => { onChange={event => {
setEnabled (event.target.checked) setDraftKeyboardSettings (current => ({
...current,
enabled: event.target.checked,
}))
}}/> }}/>
<span></span> <span></span>
</label> </label>
@@ -154,7 +223,16 @@ const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
variant="ghost" variant="ghost"
className="h-8" className="h-8"
disabled={!(row.hasCustomBinding)} disabled={!(row.hasCustomBinding)}
onClick={() => resetBinding (row.id)}> onClick={() => {
setDraftKeyboardSettings (current => {
const nextBindings = { ...(current.bindings ?? { }) }
delete nextBindings[row.id]
return {
...current,
bindings: nextBindings,
}
})
}}>
</Button> </Button>
</td> </td>
+77
ファイルの表示
@@ -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<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId>
}
const ShortcutHelpDialog: FC<Props> = (
{ open, onOpenChange, activeScope, effectiveBindings, conflictActionIds },
) => {
const rows = SHORTCUT_DEFINITIONS.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto">
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
<thead className="border-b border-border">
<tr>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{rows.map (row => (
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
<td className="p-2">{row.label}</td>
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
<td className="p-2 font-mono">
{formatKeyBinding (effectiveBindings[row.id])}
</td>
<td className="p-2">
{conflictActionIds.has (row.id)
? <span className="text-red-600 dark:text-red-400"></span>
: 'なし'}
</td>
</tr>))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>)
}
export default ShortcutHelpDialog
+63 -26
ファイルの表示
@@ -9,10 +9,9 @@ import {
} from 'react' } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from '@/components/ui/use-toast' import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import { import {
DEFAULT_KEY_BINDINGS, DEFAULT_KEY_BINDINGS,
formatKeyBinding,
getConflictingShortcutActionIds, getConflictingShortcutActionIds,
isEditableEventTarget, isEditableEventTarget,
keyBindingFromKeyboardEvent, keyBindingFromKeyboardEvent,
@@ -46,6 +45,7 @@ type KeyboardShortcutsContextValue = {
keyboardSettings: ClientKeyboardSettings keyboardSettings: ClientKeyboardSettings
effectiveBindings: Record<ShortcutActionId, KeyBinding | null> effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId> conflictActionIds: Set<ShortcutActionId>
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
setEnabled: (enabled: boolean) => void setEnabled: (enabled: boolean) => void
setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void
resetBinding: (actionId: ShortcutActionId) => void resetBinding: (actionId: ShortcutActionId) => void
@@ -57,17 +57,47 @@ const KeyboardShortcutsContext =
createContext<KeyboardShortcutsContextValue | null> (null) createContext<KeyboardShortcutsContextValue | null> (null)
const buildShortcutHelpText = ( const isFocusableShortcutTarget = (element: HTMLElement): boolean => {
activeScope: ShortcutScope, if (element.closest ('[hidden], [aria-hidden="true"]') != null)
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>, return false
): string =>
SHORTCUT_DEFINITIONS if (
.filter (definition => (element instanceof HTMLInputElement
definition.scope === 'global' || definition.scope === activeScope) || element instanceof HTMLButtonElement
.filter (definition => effectiveBindings[definition.id] != null) || element instanceof HTMLSelectElement
.map (definition => || element instanceof HTMLTextAreaElement)
`${ definition.label }: ${ formatKeyBinding (effectiveBindings[definition.id]) }`) && element.disabled
.join (' / ') )
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) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
@@ -78,6 +108,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
const [registeredHandlers, setRegisteredHandlers] = const [registeredHandlers, setRegisteredHandlers] =
useState<Record<number, ShortcutHandlers>> ({ }) useState<Record<number, ShortcutHandlers>> ({ })
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
const registrationIdRef = useRef (0) const registrationIdRef = useRef (0)
const activeScope = useMemo<ShortcutScope> ( const activeScope = useMemo<ShortcutScope> (
@@ -108,6 +139,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
setKeyboardSettingsState (next) setKeyboardSettingsState (next)
}, [keyboardSettings]) }, [keyboardSettings])
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
const next = setClientKeyboardSettings (settings)
setKeyboardSettingsState (next)
}, [])
const setBinding = useCallback (( const setBinding = useCallback ((
actionId: ShortcutActionId, actionId: ShortcutActionId,
binding: KeyBinding | null, binding: KeyBinding | null,
@@ -157,6 +193,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
if (isEditableEventTarget (event.target)) if (isEditableEventTarget (event.target))
return return
if (shortcutHelpOpen)
return
const binding = keyBindingFromKeyboardEvent (event) const binding = keyBindingFromKeyboardEvent (event)
if (binding == null) if (binding == null)
return return
@@ -183,21 +222,10 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const builtinHandler = ( const builtinHandler = (
{ {
'global.openShortcutHelp': () => { 'global.openShortcutHelp': () => {
const description = setShortcutHelpOpen (true)
buildShortcutHelpText (activeScope, effectiveBindings)
|| 'この画面で使えるショートカットはありません.'
toast ({
title: 'キーボード・ショートカット',
description,
})
}, },
'global.focusSearch': () => { 'global.focusSearch': () => {
const target = document.querySelector<HTMLElement> ( focusSearchTarget ()
'[data-shortcut-focus-search="true"]',
)
target?.focus ()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
target.select ()
}, },
'navigation.posts': () => navigate ('/posts'), 'navigation.posts': () => navigate ('/posts'),
'navigation.tags': () => navigate ('/tags'), 'navigation.tags': () => navigate ('/tags'),
@@ -226,6 +254,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
keyboardSettings.enabled, keyboardSettings.enabled,
mergedRegisteredHandlers, mergedRegisteredHandlers,
navigate, navigate,
shortcutHelpOpen,
]) ])
const value = useMemo<KeyboardShortcutsContextValue> (() => ({ const value = useMemo<KeyboardShortcutsContextValue> (() => ({
@@ -234,6 +263,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
effectiveBindings, effectiveBindings,
conflictActionIds, conflictActionIds,
setEnabled, setEnabled,
saveKeyboardSettings,
setBinding, setBinding,
resetBinding, resetBinding,
resetAllBindings, resetAllBindings,
@@ -246,6 +276,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
registerHandlers, registerHandlers,
resetAllBindings, resetAllBindings,
resetBinding, resetBinding,
saveKeyboardSettings,
setBinding, setBinding,
setEnabled, setEnabled,
]) ])
@@ -253,6 +284,12 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
return ( return (
<KeyboardShortcutsContext.Provider value={value}> <KeyboardShortcutsContext.Provider value={value}>
{children} {children}
<ShortcutHelpDialog
open={shortcutHelpOpen}
onOpenChange={setShortcutHelpOpen}
activeScope={activeScope}
effectiveBindings={effectiveBindings}
conflictActionIds={conflictActionIds}/>
</KeyboardShortcutsContext.Provider>) </KeyboardShortcutsContext.Provider>)
} }
+33 -34
ファイルの表示
@@ -254,28 +254,10 @@ const ThemeSection: FC<SharedSectionProps> = (
</select>)} </select>)}
</FormField> </FormField>
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
<p>
base
</p>
<p className="text-muted-foreground">
backend system / light / dark mode
browser-local
</p>
<p className="text-muted-foreground">
themes
</p>
</div>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div> <div>
<h3 className="font-medium"></h3> <h3 className="font-medium"></h3>
<p className="text-sm text-muted-foreground">
{selectedTheme.builtin || draftActiveThemeId === 'system'
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
: `現在の編輯対象: ${ selectedTheme.name }`}
</p>
</div> </div>
<Button <Button
type="button" type="button"
@@ -344,14 +326,6 @@ const ThemeSection: FC<SharedSectionProps> = (
</div> </div>
</div> </div>
<div className="space-y-2 rounded-lg border border-dashed border-border/70 p-3 text-sm">
<p className="font-medium"> backend </p>
<p>
auto_fetch_titleauto_fetch_thumbnailwiki_editor_mode backend
</p>
</div>
<Button type="button" onClick={onThemeSubmit}> <Button type="button" onClick={onThemeSubmit}>
</Button> </Button>
@@ -682,15 +656,40 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden"> <div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
<PageTitle></PageTitle> <PageTitle></PageTitle>
<FieldError messages={settingsError ? [settingsError] : []}/> <FieldError messages={settingsError ? [settingsError] : []}/>
<div
role="tablist"
aria-label="設定区分"
aria-orientation="horizontal"
className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
{tabs.map (tab => (
<button
key={tab.id}
id={tabButtonId (tab.id)}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
aria-controls={tabPanelId (tab.id)}
className={cn (
'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
(activeTab === tab.id
? ['border-slate-900 bg-slate-900 text-white',
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
: ['border-border bg-background text-foreground']))}
onClick={() => setActiveTab (tab.id)}>
{tab.label}
{settingsTabErrors[tab.id] ? ' !' : ''}
</button>))}
</div>
{loading ? 'Loading...' : ( {loading ? 'Loading...' : (
<> <div
{sectionProps && ( id={tabPanelId (activeTab)}
<> role="tabpanel"
<AccountSection {...sectionProps}/> aria-labelledby={tabButtonId (activeTab)}>
<ThemeSection {...sectionProps}/> {sectionProps && activeTab === 'account' && <AccountSection {...sectionProps}/>}
<KeyboardSettingsSection sectionClassName={sectionClassName}/> {sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
</>)} {activeTab === 'keyboard' && (
</>)} <KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
</div>)}
</div> </div>
<div className="hidden md:flex md:justify-center md:px-4"> <div className="hidden md:flex md:justify-center md:px-4">