Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/397 Co-authored-by: miteruzo <miteruzo@naver.com> Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはプルリクエスト #397 でマージされました。
このコミットが含まれているのは:
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
import type { ClientAnimationMode,
|
||||
ClientBehaviorSettings,
|
||||
ClientEmbedAutoLoadMode,
|
||||
ClientLinkPreloadMode,
|
||||
ClientTagRelationDisplayMode } from '@/lib/settings'
|
||||
|
||||
type Props = {
|
||||
sectionClassName: string
|
||||
onDirtyStateChange?: (
|
||||
dirty: boolean,
|
||||
discard: () => void,
|
||||
) => void }
|
||||
|
||||
type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string }
|
||||
|
||||
type SegmentedControlProps<T extends string> = {
|
||||
value: T
|
||||
options: SegmentedOption<T>[]
|
||||
onChange: (value: T) => void }
|
||||
|
||||
type SettingBlockProps = {
|
||||
title: string
|
||||
description?: string
|
||||
children: ReactNode }
|
||||
|
||||
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
|
||||
{ value: 'off', label: 'なし' },
|
||||
{ value: 'reduced', label: '控えめ' },
|
||||
{ value: 'normal', label: '通常' }]
|
||||
|
||||
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
|
||||
{ value: 'off', label: '読み込まない' },
|
||||
{ value: 'manual', label: 'クリック時' },
|
||||
{ value: 'auto', label: '自動' }]
|
||||
|
||||
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
|
||||
{ value: 'off', label: 'しない' },
|
||||
{ value: 'intent', label: 'する' }]
|
||||
|
||||
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
|
||||
{ value: 'flat', label: 'まとめない' },
|
||||
{ value: 'grouped', label: 'まとめる' }]
|
||||
|
||||
const SegmentedControl = <T extends string,> (
|
||||
{ value, options, onChange }: SegmentedControlProps<T>,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
className="flex flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
|
||||
{options.map (option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === option.value}
|
||||
className={cn (
|
||||
'min-w-0 flex-1 rounded-lg px-3 py-2 text-sm font-medium',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||
'focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
value === option.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
|
||||
: 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
||||
)}
|
||||
onClick={() => onChange (option.value)}>
|
||||
{option.label}
|
||||
</button>))}
|
||||
</div>)
|
||||
}
|
||||
|
||||
|
||||
const SettingBlock: FC<SettingBlockProps> = (
|
||||
{ title, description, children },
|
||||
) => (
|
||||
<div className="space-y-3 rounded-xl px-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>)
|
||||
|
||||
|
||||
const BehaviourSettingsSection: FC<Props> = (
|
||||
{ sectionClassName,
|
||||
onDirtyStateChange },
|
||||
) => {
|
||||
const [savedSettings, setSavedSettings] =
|
||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
||||
const [draftSettings, setDraftSettings] =
|
||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
||||
|
||||
useEffect (() => {
|
||||
const current = getClientBehaviorSettings ()
|
||||
setSavedSettings (current)
|
||||
setDraftSettings (current)
|
||||
}, [])
|
||||
|
||||
const hasUnsavedChanges = useMemo (
|
||||
() => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings),
|
||||
[draftSettings, savedSettings])
|
||||
|
||||
const updateDraft = <Key extends keyof ClientBehaviorSettings,> (
|
||||
key: Key,
|
||||
value: ClientBehaviorSettings[Key],
|
||||
) => {
|
||||
setDraftSettings (current => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const next = setClientBehaviorSettings (draftSettings)
|
||||
setSavedSettings (next)
|
||||
setDraftSettings (next)
|
||||
toast ({ title: '動作設定を保存しました' })
|
||||
}
|
||||
|
||||
const handleDiscard = () => {
|
||||
setDraftSettings (savedSettings)
|
||||
}
|
||||
|
||||
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="destructive"
|
||||
disabled={hasUnsavedChanges === false}
|
||||
onClick={handleDiscard}>
|
||||
変更を破棄
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={hasUnsavedChanges === false}
|
||||
onClick={handleSave}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SettingBlock title="アニメーション">
|
||||
<SegmentedControl
|
||||
value={draftSettings.animation ?? 'normal'}
|
||||
options={animationOptions}
|
||||
onChange={value => updateDraft ('animation', value)}/>
|
||||
</SettingBlock>
|
||||
|
||||
<SettingBlock
|
||||
title="リンク先の先読み"
|
||||
description="マウスを置いた時点でリンク先のデータを取得します。">
|
||||
<SegmentedControl
|
||||
value={draftSettings.linkPreload ?? 'intent'}
|
||||
options={linkPreloadOptions}
|
||||
onChange={value => updateDraft ('linkPreload', value)}/>
|
||||
</SettingBlock>
|
||||
|
||||
<SettingBlock title="タグの親子関係表示">
|
||||
<SegmentedControl
|
||||
value={draftSettings.tagRelationDisplay ?? 'grouped'}
|
||||
options={tagRelationDisplayOptions}
|
||||
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
|
||||
</SettingBlock>
|
||||
|
||||
<SettingBlock title="埋め込み自動読込">
|
||||
<SegmentedControl
|
||||
value={draftSettings.embedAutoLoad ?? 'auto'}
|
||||
options={embedAutoLoadOptions}
|
||||
onChange={value => updateDraft ('embedAutoLoad', value)}/>
|
||||
</SettingBlock>
|
||||
</div>
|
||||
</section>)
|
||||
}
|
||||
|
||||
export default BehaviourSettingsSection
|
||||
@@ -0,0 +1,262 @@
|
||||
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
|
||||
@@ -0,0 +1,87 @@
|
||||
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>
|
||||
availableActionIds: Set<ShortcutActionId>
|
||||
}
|
||||
|
||||
|
||||
const ShortcutHelpDialog: FC<Props> = (
|
||||
{
|
||||
open,
|
||||
onOpenChange,
|
||||
activeScope,
|
||||
effectiveBindings,
|
||||
conflictActionIds,
|
||||
availableActionIds,
|
||||
},
|
||||
) => {
|
||||
const rows = SHORTCUT_DEFINITIONS
|
||||
.filter (definition =>
|
||||
definition.scope === 'global' || definition.scope === activeScope)
|
||||
.filter (definition => availableActionIds.has (definition.id))
|
||||
|
||||
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
|
||||
新しいイシューから参照
ユーザーをブロックする