f5b632ed89
Reviewed-on: #397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
263 行
7.0 KiB
TypeScript
263 行
7.0 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
|
||
import { Button } from '@/components/ui/button'
|
||
import { toast } from '@/components/ui/use-toast'
|
||
import {
|
||
getConflictingShortcutActionIds,
|
||
formatKeyBinding,
|
||
isSpaceBinding,
|
||
keyBindingFromKeyboardEvent,
|
||
SHORTCUT_DEFINITIONS,
|
||
SHORTCUT_SCOPE_LABELS,
|
||
} from '@/lib/keyboardShortcuts'
|
||
import { getEffectiveKeyBindings } from '@/lib/settings'
|
||
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||
|
||
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||
import type { ClientKeyboardSettings } from '@/lib/settings'
|
||
import type { FC } from 'react'
|
||
|
||
type Props = {
|
||
sectionClassName: string
|
||
onDirtyStateChange?: (
|
||
dirty: boolean,
|
||
discard: () => void,
|
||
) => void
|
||
}
|
||
|
||
|
||
const KeyboardSettingsSection: FC<Props> = (
|
||
{ sectionClassName,
|
||
onDirtyStateChange },
|
||
) => {
|
||
const {
|
||
keyboardSettings,
|
||
saveKeyboardSettings,
|
||
} = useKeyboardShortcutSettings ()
|
||
const [capturingActionId, setCapturingActionId] =
|
||
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 (() => {
|
||
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')
|
||
{
|
||
setDraftKeyboardSettings (current => ({
|
||
...current,
|
||
bindings: {
|
||
...(current.bindings ?? { }),
|
||
[capturingActionId]: null,
|
||
},
|
||
}))
|
||
setCapturingActionId (null)
|
||
return
|
||
}
|
||
|
||
const binding = keyBindingFromKeyboardEvent (event)
|
||
if (binding == null)
|
||
return
|
||
|
||
if (isSpaceBinding (binding))
|
||
{
|
||
toast ({ title: 'Space は今は割り当てられません.' })
|
||
return
|
||
}
|
||
|
||
setDraftKeyboardSettings (current => ({
|
||
...current,
|
||
bindings: {
|
||
...(current.bindings ?? { }),
|
||
[capturingActionId]: binding,
|
||
},
|
||
}))
|
||
setCapturingActionId (null)
|
||
}
|
||
|
||
document.addEventListener ('keydown', onKeyDown, true)
|
||
return () => document.removeEventListener ('keydown', onKeyDown, true)
|
||
}, [capturingActionId])
|
||
|
||
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
|
||
...definition,
|
||
currentBinding: draftEffectiveBindings[definition.id],
|
||
hasConflict: draftConflictActionIds.has (definition.id),
|
||
hasCustomBinding: Object.prototype.hasOwnProperty.call (
|
||
draftKeyboardSettings.bindings ?? { },
|
||
definition.id,
|
||
),
|
||
})), [
|
||
draftConflictActionIds,
|
||
draftEffectiveBindings,
|
||
draftKeyboardSettings.bindings,
|
||
])
|
||
|
||
const handleSave = () => {
|
||
saveKeyboardSettings (draftKeyboardSettings)
|
||
toast ({ title: 'キーボード設定を保存しました' })
|
||
}
|
||
|
||
const handleDiscard = () => {
|
||
setDraftKeyboardSettings (keyboardSettings)
|
||
setCapturingActionId (null)
|
||
}
|
||
|
||
useEffect (() => {
|
||
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
|
||
|
||
return () => {
|
||
onDirtyStateChange?.(false, () => { })
|
||
}
|
||
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
|
||
|
||
return (
|
||
<section className={sectionClassName}>
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="space-y-1">
|
||
<h2 className="text-xl font-bold">キーボード</h2>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => {
|
||
setDraftKeyboardSettings (current => ({
|
||
...current,
|
||
bindings: { },
|
||
}))
|
||
}}>
|
||
すべて既定に戻す
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
variant="destructive"
|
||
disabled={!(hasUnsavedChanges)}
|
||
onClick={handleDiscard}>
|
||
変更を破棄
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
|
||
onClick={handleSave}>
|
||
保存
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={draftKeyboardSettings.enabled !== false}
|
||
onChange={event => {
|
||
setDraftKeyboardSettings (current => ({
|
||
...current,
|
||
enabled: event.target.checked,
|
||
}))
|
||
}}/>
|
||
<span>キーボード・ショートカットを有効にする</span>
|
||
</label>
|
||
|
||
{capturingActionId != null && (
|
||
<p className="text-sm text-muted-foreground">
|
||
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
|
||
</p>)}
|
||
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[52rem] 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>
|
||
<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 (row.currentBinding)}
|
||
</td>
|
||
<td className="p-2">
|
||
<Button
|
||
type="button"
|
||
variant={capturingActionId === row.id ? 'default' : 'outline'}
|
||
className="h-8"
|
||
onClick={() => {
|
||
setCapturingActionId (
|
||
capturingActionId === row.id ? null : row.id,
|
||
)
|
||
}}>
|
||
{capturingActionId === row.id ? '入力待ち…' : '変更'}
|
||
</Button>
|
||
</td>
|
||
<td className="p-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="h-8"
|
||
disabled={!(row.hasCustomBinding)}
|
||
onClick={() => {
|
||
setDraftKeyboardSettings (current => {
|
||
const nextBindings = { ...(current.bindings ?? { }) }
|
||
delete nextBindings[row.id]
|
||
return {
|
||
...current,
|
||
bindings: nextBindings,
|
||
}
|
||
})
|
||
}}>
|
||
既定に戻す
|
||
</Button>
|
||
</td>
|
||
<td className="p-2">
|
||
{row.hasConflict
|
||
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
||
: 'なし'}
|
||
</td>
|
||
</tr>))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>)
|
||
}
|
||
|
||
export default KeyboardSettingsSection
|