コミットを比較
5 コミット
fecbb4b354
...
4e3fb6bef7
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 4e3fb6bef7 | |||
| 3a9ac8af32 | |||
| 7bc9ee1e68 | |||
| 95b4db1fc2 | |||
| 4117bdd31e |
@@ -271,6 +271,9 @@ const value =
|
||||
- Preserve the local TSX formatting style.
|
||||
- Do not normalize TSX to common Prettier-style React formatting unless
|
||||
explicitly asked.
|
||||
- Treat TSX formatting rules as hard constraints, not preferences. Before
|
||||
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
|
||||
placement and fix violations instead of relying on formatter defaults.
|
||||
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
|
||||
- Put two blank lines before and after top-level `const` function
|
||||
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||
@@ -281,14 +284,29 @@ const value =
|
||||
- In TypeScript and TSX function declarations, including `const` arrow
|
||||
function declarations, when the parameter list spans multiple lines, always
|
||||
put the closing parenthesis at the beginning of its own line before the return
|
||||
type or `=>`.
|
||||
type or `=>`. Do not put that closing `)` at the end of the previous line.
|
||||
- Do not write React component declarations in the common Prettier form
|
||||
`const Component = ({ ... }) => (` when the destructured parameter spans
|
||||
multiple lines. Use the project form with the opening `(` after `=`, the
|
||||
destructured object as the argument, and the closing parameter `)` on its own
|
||||
line before `=>`.
|
||||
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
|
||||
a line except for a multi-line function declaration parameter list.
|
||||
- Never place a closing square bracket at the beginning of a line.
|
||||
- For object literals and other associative-array-style braces, do not place
|
||||
the closing brace at the beginning of a line. Function, lambda, callback, and
|
||||
block closing braces are exempt and should stay on their own line when that
|
||||
fits the local style.
|
||||
- For object literals, type literals, import/export named bindings, destructuring
|
||||
patterns, and other associative-array-style braces, do not place the closing
|
||||
brace at the beginning of a line. Keep `}` on the same line as the final
|
||||
property, binding, or specifier unless that would violate the line limit.
|
||||
Function, lambda, callback, and block closing braces are exempt and should
|
||||
stay on their own line when that fits the local style. When a function,
|
||||
lambda, callback, or block body spans multiple lines, do not put its closing
|
||||
`}` at the end of the previous line.
|
||||
- For arrays and tuple-like lists, do not place the closing `]` at the
|
||||
beginning of a line. Keep `]` on the same line as the final element unless
|
||||
that would violate the line limit.
|
||||
- For function and method calls, do not place the closing `)` at the beginning
|
||||
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
|
||||
multi-line function declaration.
|
||||
- When writing braces on a single line in TypeScript or TSX JavaScript
|
||||
context, put exactly one space inside the braces, as in `{ value }` or
|
||||
`{ key: value }`.
|
||||
|
||||
@@ -126,9 +126,38 @@ pass or the remaining failure is clearly blocked.
|
||||
|
||||
- Preserve compact TSX expression shapes such as inline ternary branches and
|
||||
closing `</div>)` forms when nearby code uses them.
|
||||
- Treat TSX formatting rules as hard constraints, not preferences. Before
|
||||
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
|
||||
placement and fix violations instead of relying on formatter defaults.
|
||||
- For long Tailwind `className` strings, wrap across lines only when needed.
|
||||
- Keep continuation indentation aligned with the 4-space logical indentation
|
||||
rule, using tabs only as leading 8-space compression.
|
||||
- In TypeScript and TSX function declarations, including `const` arrow
|
||||
function declarations, when the parameter list spans multiple lines, always
|
||||
put the closing parenthesis at the beginning of its own line before the return
|
||||
type or `=>`. Do not put that closing `)` at the end of the previous line.
|
||||
- Do not write React component declarations in the common Prettier form
|
||||
`const Component = ({ ... }) => (` when the destructured parameter spans
|
||||
multiple lines. Use the project form with the opening `(` after `=`, the
|
||||
destructured object as the argument, and the closing parameter `)` on its own
|
||||
line before `=>`.
|
||||
- In TypeScript and TSX, never place a closing parenthesis at the beginning of
|
||||
a line except for a multi-line function declaration parameter list.
|
||||
- Never place a closing square bracket at the beginning of a line.
|
||||
- For object literals, type literals, import/export named bindings, destructuring
|
||||
patterns, and other associative-array-style braces, do not place the closing
|
||||
brace at the beginning of a line. Keep `}` on the same line as the final
|
||||
property, binding, or specifier unless that would violate the line limit.
|
||||
Function, lambda, callback, and block closing braces are exempt and should
|
||||
stay on their own line when that fits the local style. When a function,
|
||||
lambda, callback, or block body spans multiple lines, do not put its closing
|
||||
`}` at the end of the previous line.
|
||||
- For arrays and tuple-like lists, do not place the closing `]` at the
|
||||
beginning of a line. Keep `]` on the same line as the final element unless
|
||||
that would violate the line limit.
|
||||
- For function and method calls, do not place the closing `)` at the beginning
|
||||
of a line. The only TypeScript/TSX exception is the closing parameter `)` of a
|
||||
multi-line function declaration.
|
||||
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
|
||||
single physical line.
|
||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||
|
||||
+15
-12
@@ -18,6 +18,7 @@ import {
|
||||
getClientThemeMode,
|
||||
seedClientThemeMode,
|
||||
} from '@/lib/settings'
|
||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
||||
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
||||
@@ -185,19 +186,21 @@ const App: FC = () => {
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
|
||||
<BrowserRouter>
|
||||
<DialogueProvider>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
<KeyboardShortcutsProvider>
|
||||
<DialogueProvider>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
|
||||
<Toaster/>
|
||||
</DialogueProvider>
|
||||
<Toaster/>
|
||||
</DialogueProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</BrowserRouter>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||
import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react'
|
||||
import type { To } from 'react-router-dom'
|
||||
|
||||
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
@@ -25,6 +25,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
state,
|
||||
onMouseEnter,
|
||||
onTouchStart,
|
||||
onKeyDown,
|
||||
onClick,
|
||||
cancelOnError = false,
|
||||
...rest }, ref) => {
|
||||
@@ -97,11 +98,22 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (ev: KeyboardEvent<HTMLAnchorElement>) => {
|
||||
onKeyDown?.(ev)
|
||||
|
||||
if (ev.defaultPrevented)
|
||||
return
|
||||
|
||||
if (ev.key === ' ' || ev.key === 'Spacebar')
|
||||
ev.preventDefault ()
|
||||
}
|
||||
|
||||
return (
|
||||
<a ref={ref}
|
||||
href={typeof to === 'string' ? to : createPath (to)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onTouchStart={handleTouchStart}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={cn ('cursor-pointer', className)}
|
||||
{...rest}/>)
|
||||
|
||||
@@ -105,6 +105,7 @@ const TagSearch: FC = () => {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<input type="text"
|
||||
data-shortcut-focus-search="true"
|
||||
placeholder="タグ検索..."
|
||||
value={search}
|
||||
onChange={whenChanged}
|
||||
|
||||
@@ -249,16 +249,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
|
||||
<TopNavUser user={user}/>
|
||||
|
||||
<a href="#"
|
||||
className="md:hidden ml-auto pr-4
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden ml-auto border-0 bg-transparent pr-4
|
||||
text-pink-600 hover:text-pink-400
|
||||
dark:text-pink-300 dark:hover:text-pink-100"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : 'Menu'}
|
||||
</a>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
|
||||
@@ -28,15 +28,19 @@ const TabGroup: FC<Props> = ({ children, rightAddon }) => {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{tabs.map ((tab, i) => (
|
||||
<a key={i}
|
||||
href="#"
|
||||
className={cn (i === current && 'font-bold')}
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn (
|
||||
'bg-transparent p-0 text-left text-[hsl(var(--theme-link))] hover:opacity-85',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
i === current && 'font-bold',
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrent (i)
|
||||
}}>
|
||||
{tab.props.name}
|
||||
</a>))}
|
||||
</button>))}
|
||||
</div>
|
||||
{rightAddon && (
|
||||
<div className="shrink-0">
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
|
||||
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)
|
||||
}
|
||||
|
||||
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>
|
||||
{hasUnsavedChanges && (
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
未保存の変更があります
|
||||
</p>)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraftKeyboardSettings (current => ({
|
||||
...current,
|
||||
bindings: { },
|
||||
}))
|
||||
}}>
|
||||
すべて既定に戻す
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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,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
|
||||
@@ -0,0 +1,319 @@
|
||||
export type ShortcutScope =
|
||||
| 'global'
|
||||
| 'posts'
|
||||
| 'postSearch'
|
||||
| 'tags'
|
||||
| 'settings'
|
||||
| 'wiki'
|
||||
| 'materials'
|
||||
|
||||
export type ShortcutActionId =
|
||||
| 'global.openShortcutHelp'
|
||||
| 'global.focusSearch'
|
||||
| 'navigation.posts'
|
||||
| 'navigation.tags'
|
||||
| 'navigation.materials'
|
||||
| 'navigation.wiki'
|
||||
| 'pagination.next'
|
||||
| 'pagination.previous'
|
||||
| 'settings.account'
|
||||
| 'settings.theme'
|
||||
| 'settings.keyboard'
|
||||
|
||||
export type KeyBinding = {
|
||||
key: string
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
alt?: boolean
|
||||
meta?: boolean
|
||||
}
|
||||
|
||||
export type ShortcutDefinition = {
|
||||
id: ShortcutActionId
|
||||
label: string
|
||||
scope: ShortcutScope
|
||||
defaultBinding: KeyBinding | null
|
||||
}
|
||||
|
||||
export const SHORTCUT_SCOPE_LABELS: Record<ShortcutScope, string> = {
|
||||
global: '全画面',
|
||||
posts: '広場',
|
||||
postSearch: '広場検索',
|
||||
tags: 'タグ',
|
||||
settings: '設定',
|
||||
wiki: 'Wiki',
|
||||
materials: '素材',
|
||||
}
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
|
||||
{
|
||||
id: 'global.openShortcutHelp',
|
||||
label: 'ショートカット一覧を開く',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: '?', shift: true },
|
||||
},
|
||||
{
|
||||
id: 'global.focusSearch',
|
||||
label: '検索欄へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: '/' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.posts',
|
||||
label: '広場へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'p' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.tags',
|
||||
label: 'タグへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 't' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.materials',
|
||||
label: '素材へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'm' },
|
||||
},
|
||||
{
|
||||
id: 'navigation.wiki',
|
||||
label: 'Wiki へ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'w' },
|
||||
},
|
||||
{
|
||||
id: 'pagination.next',
|
||||
label: '次のページへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowRight' },
|
||||
},
|
||||
{
|
||||
id: 'pagination.previous',
|
||||
label: '前のページへ移動',
|
||||
scope: 'global',
|
||||
defaultBinding: { key: 'ArrowLeft' },
|
||||
},
|
||||
{
|
||||
id: 'settings.account',
|
||||
label: '設定: アカウント',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '1' },
|
||||
},
|
||||
{
|
||||
id: 'settings.theme',
|
||||
label: '設定: テーマ',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '2' },
|
||||
},
|
||||
{
|
||||
id: 'settings.keyboard',
|
||||
label: '設定: キーボード',
|
||||
scope: 'settings',
|
||||
defaultBinding: { key: '3' },
|
||||
},
|
||||
]
|
||||
|
||||
export const SHORTCUT_DEFINITIONS_BY_ID =
|
||||
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, ShortcutDefinition>> (
|
||||
(definitions, definition) => {
|
||||
definitions[definition.id] = definition
|
||||
return definitions
|
||||
},
|
||||
{
|
||||
'global.openShortcutHelp': SHORTCUT_DEFINITIONS[0],
|
||||
'global.focusSearch': SHORTCUT_DEFINITIONS[1],
|
||||
'navigation.posts': SHORTCUT_DEFINITIONS[2],
|
||||
'navigation.tags': SHORTCUT_DEFINITIONS[3],
|
||||
'navigation.materials': SHORTCUT_DEFINITIONS[4],
|
||||
'navigation.wiki': SHORTCUT_DEFINITIONS[5],
|
||||
'pagination.next': SHORTCUT_DEFINITIONS[6],
|
||||
'pagination.previous': SHORTCUT_DEFINITIONS[7],
|
||||
'settings.account': SHORTCUT_DEFINITIONS[8],
|
||||
'settings.theme': SHORTCUT_DEFINITIONS[9],
|
||||
'settings.keyboard': SHORTCUT_DEFINITIONS[10],
|
||||
},
|
||||
)
|
||||
|
||||
export const DEFAULT_KEY_BINDINGS =
|
||||
SHORTCUT_DEFINITIONS.reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
||||
(bindings, definition) => {
|
||||
bindings[definition.id] = definition.defaultBinding
|
||||
return bindings
|
||||
},
|
||||
{
|
||||
'global.openShortcutHelp': null,
|
||||
'global.focusSearch': null,
|
||||
'navigation.posts': null,
|
||||
'navigation.tags': null,
|
||||
'navigation.materials': null,
|
||||
'navigation.wiki': null,
|
||||
'pagination.next': null,
|
||||
'pagination.previous': null,
|
||||
'settings.account': null,
|
||||
'settings.theme': null,
|
||||
'settings.keyboard': null,
|
||||
},
|
||||
)
|
||||
|
||||
const normaliseKey = (key: string): string => {
|
||||
if (key === 'Spacebar')
|
||||
return ' '
|
||||
if (key.length === 1 && key !== ' ')
|
||||
return key.toLowerCase ()
|
||||
return key
|
||||
}
|
||||
|
||||
const isModifierKey = (key: string): boolean =>
|
||||
key === 'Shift'
|
||||
|| key === 'Control'
|
||||
|| key === 'Alt'
|
||||
|| key === 'Meta'
|
||||
|
||||
export const normaliseKeyBinding = (value: unknown): KeyBinding | null => {
|
||||
if (!(value) || typeof value !== 'object')
|
||||
return null
|
||||
|
||||
const binding = value as Partial<KeyBinding>
|
||||
if (typeof binding.key !== 'string')
|
||||
return null
|
||||
|
||||
const key = normaliseKey (binding.key)
|
||||
if (key.length === 0 || isModifierKey (key))
|
||||
return null
|
||||
|
||||
return {
|
||||
key,
|
||||
...(binding.ctrl ? { ctrl: true } : { }),
|
||||
...(binding.shift ? { shift: true } : { }),
|
||||
...(binding.alt ? { alt: true } : { }),
|
||||
...(binding.meta ? { meta: true } : { }),
|
||||
}
|
||||
}
|
||||
|
||||
export const serialiseKeyBinding = (binding: KeyBinding | null): string | null => {
|
||||
if (binding == null)
|
||||
return null
|
||||
|
||||
const parts = [
|
||||
binding.ctrl ? 'Ctrl' : null,
|
||||
binding.shift ? 'Shift' : null,
|
||||
binding.alt ? 'Alt' : null,
|
||||
binding.meta ? 'Meta' : null,
|
||||
binding.key,
|
||||
].filter ((part): part is string => part != null)
|
||||
|
||||
return parts.join ('+')
|
||||
}
|
||||
|
||||
const keyLabel = (key: string): string => {
|
||||
switch (key)
|
||||
{
|
||||
case ' ':
|
||||
return 'Space'
|
||||
|
||||
case 'ArrowLeft':
|
||||
return 'ArrowLeft'
|
||||
|
||||
case 'ArrowRight':
|
||||
return 'ArrowRight'
|
||||
|
||||
case 'ArrowUp':
|
||||
return 'ArrowUp'
|
||||
|
||||
case 'ArrowDown':
|
||||
return 'ArrowDown'
|
||||
|
||||
case 'Escape':
|
||||
return 'Escape'
|
||||
|
||||
case 'Backspace':
|
||||
return 'Backspace'
|
||||
|
||||
case 'Delete':
|
||||
return 'Delete'
|
||||
|
||||
default:
|
||||
return key.length === 1 ? key.toUpperCase () : key
|
||||
}
|
||||
}
|
||||
|
||||
export const formatKeyBinding = (binding: KeyBinding | null): string => {
|
||||
if (binding == null)
|
||||
return '未割当'
|
||||
|
||||
return [
|
||||
binding.ctrl ? 'Ctrl' : null,
|
||||
binding.shift ? 'Shift' : null,
|
||||
binding.alt ? 'Alt' : null,
|
||||
binding.meta ? 'Meta' : null,
|
||||
keyLabel (binding.key),
|
||||
].filter ((part): part is string => part != null)
|
||||
.join (' + ')
|
||||
}
|
||||
|
||||
export const keyBindingFromKeyboardEvent = (
|
||||
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'shiftKey' | 'altKey' | 'metaKey'>,
|
||||
): KeyBinding | null => {
|
||||
const key = normaliseKey (event.key)
|
||||
if (key.length === 0 || isModifierKey (key))
|
||||
return null
|
||||
|
||||
return {
|
||||
key,
|
||||
...(event.ctrlKey ? { ctrl: true } : { }),
|
||||
...(event.shiftKey ? { shift: true } : { }),
|
||||
...(event.altKey ? { alt: true } : { }),
|
||||
...(event.metaKey ? { meta: true } : { }),
|
||||
}
|
||||
}
|
||||
|
||||
export const isEditableEventTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element))
|
||||
return false
|
||||
|
||||
return target.closest (
|
||||
'input, textarea, select, [contenteditable="true"], [role="textbox"]',
|
||||
) != null
|
||||
}
|
||||
|
||||
export const getConflictingShortcutActionIds = (
|
||||
bindings: Record<ShortcutActionId, KeyBinding | null>,
|
||||
): Set<ShortcutActionId> => {
|
||||
const grouped = new Map<string, ShortcutActionId[]> ()
|
||||
|
||||
Object.entries (bindings).forEach (([actionId, binding]) => {
|
||||
const serialised = serialiseKeyBinding (binding)
|
||||
if (serialised == null)
|
||||
return
|
||||
|
||||
const ids = grouped.get (serialised) ?? []
|
||||
ids.push (actionId as ShortcutActionId)
|
||||
grouped.set (serialised, ids)
|
||||
})
|
||||
|
||||
return new Set (
|
||||
[...grouped.values ()]
|
||||
.filter (actionIds => actionIds.length > 1)
|
||||
.flat (),
|
||||
)
|
||||
}
|
||||
|
||||
export const resolveShortcutScope = (pathname: string): ShortcutScope => {
|
||||
if (pathname.startsWith ('/posts/search'))
|
||||
return 'postSearch'
|
||||
if (pathname.startsWith ('/posts'))
|
||||
return 'posts'
|
||||
if (pathname.startsWith ('/tags'))
|
||||
return 'tags'
|
||||
if (pathname.startsWith ('/users/settings'))
|
||||
return 'settings'
|
||||
if (pathname.startsWith ('/wiki'))
|
||||
return 'wiki'
|
||||
if (pathname.startsWith ('/materials'))
|
||||
return 'materials'
|
||||
return 'global'
|
||||
}
|
||||
|
||||
export const isSpaceBinding = (binding: KeyBinding | null): boolean =>
|
||||
binding?.key === ' '
|
||||
+130
-1
@@ -1,7 +1,9 @@
|
||||
import { CATEGORIES } from '@/consts'
|
||||
import { apiGet, apiPatch } from '@/lib/api'
|
||||
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
|
||||
|
||||
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
|
||||
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||
|
||||
export type UserSettings = {
|
||||
theme: 'system' | 'light' | 'dark'
|
||||
@@ -61,6 +63,11 @@ type ClientListSettings = {
|
||||
limit?: ClientListLimit
|
||||
order?: string }
|
||||
|
||||
export type ClientKeyboardSettings = {
|
||||
enabled?: boolean
|
||||
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||
}
|
||||
|
||||
export type ClientAppearanceSettings = {
|
||||
activeThemeId?: ActiveThemeId
|
||||
customThemes?: Record<string, CustomClientTheme>
|
||||
@@ -80,6 +87,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = {
|
||||
// Browser-local settings only. These are device or screen specific.
|
||||
export type ClientSettings = {
|
||||
appearance?: ClientAppearanceSettings
|
||||
keyboard?: ClientKeyboardSettings
|
||||
panes?: Record<string, ClientPaneSettings>
|
||||
lists?: Partial<Record<ClientListKey, ClientListSettings>>
|
||||
theatre?: {
|
||||
@@ -212,6 +220,15 @@ const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY =
|
||||
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'
|
||||
|
||||
|
||||
const createCustomThemeId = (): `custom:${ string }` => {
|
||||
const randomPart =
|
||||
globalThis.crypto?.randomUUID?.()
|
||||
?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }`
|
||||
|
||||
return `custom:${ randomPart }`
|
||||
}
|
||||
|
||||
|
||||
export const fetchUserSettings = async (): Promise<UserSettings> => {
|
||||
const raw = await apiGet<{
|
||||
theme: UserSettings['theme']
|
||||
@@ -286,6 +303,118 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
|
||||
value === 20 || value === 50 || value === 100 ? value : null
|
||||
|
||||
|
||||
const normaliseKeyboardBindings = (
|
||||
bindings: unknown,
|
||||
): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
|
||||
if (!(bindings) || typeof bindings !== 'object')
|
||||
return { }
|
||||
|
||||
return Object.keys (DEFAULT_KEY_BINDINGS).reduce<
|
||||
Partial<Record<ShortcutActionId, KeyBinding | null>>
|
||||
> (
|
||||
(normalised, actionId) => {
|
||||
const value = (bindings as Record<string, unknown>)[actionId]
|
||||
if (value === null)
|
||||
{
|
||||
normalised[actionId as ShortcutActionId] = null
|
||||
return normalised
|
||||
}
|
||||
|
||||
const binding = normaliseKeyBinding (value)
|
||||
if (binding != null)
|
||||
normalised[actionId as ShortcutActionId] = binding
|
||||
|
||||
return normalised
|
||||
},
|
||||
{ },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
|
||||
const keyboard = loadClientSettings ().keyboard
|
||||
|
||||
return {
|
||||
enabled: keyboard?.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard?.bindings),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const setClientKeyboardSettings = (
|
||||
keyboard: ClientKeyboardSettings,
|
||||
): ClientKeyboardSettings => {
|
||||
const next: ClientKeyboardSettings = {
|
||||
enabled: keyboard.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard.bindings),
|
||||
}
|
||||
|
||||
updateClientSettings (settings => ({
|
||||
...settings,
|
||||
keyboard: next,
|
||||
}))
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
export const getEffectiveKeyBindings = (
|
||||
bindings: Partial<Record<ShortcutActionId, KeyBinding | null>> =
|
||||
getClientKeyboardSettings ().bindings ?? { },
|
||||
): Record<ShortcutActionId, KeyBinding | null> =>
|
||||
Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
|
||||
(effectiveBindings, actionId) => {
|
||||
const key = actionId as ShortcutActionId
|
||||
effectiveBindings[key] =
|
||||
Object.prototype.hasOwnProperty.call (bindings, key)
|
||||
? bindings[key] ?? null
|
||||
: DEFAULT_KEY_BINDINGS[key]
|
||||
return effectiveBindings
|
||||
},
|
||||
{ ...DEFAULT_KEY_BINDINGS },
|
||||
)
|
||||
|
||||
|
||||
export const setClientKeyBinding = (
|
||||
actionId: ShortcutActionId,
|
||||
binding: KeyBinding | null,
|
||||
): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: {
|
||||
...(current.bindings ?? { }),
|
||||
[actionId]: binding,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const resetClientKeyBinding = (
|
||||
actionId: ShortcutActionId,
|
||||
): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
const nextBindings = { ...(current.bindings ?? { }) }
|
||||
delete nextBindings[actionId]
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: nextBindings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const resetAllClientKeyBindings = (): ClientKeyboardSettings => {
|
||||
const current = getClientKeyboardSettings ()
|
||||
|
||||
return setClientKeyboardSettings ({
|
||||
...current,
|
||||
bindings: { },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const normaliseHexColour = (value: string): string | null =>
|
||||
/^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null
|
||||
|
||||
@@ -567,7 +696,7 @@ export const createCustomThemeFromBase = (
|
||||
baseTheme: BuiltinThemeId,
|
||||
tokens?: ThemeTokens,
|
||||
): CustomClientTheme => {
|
||||
const id = `custom:${ crypto.randomUUID () }` as ThemeId
|
||||
const id = createCustomThemeId ()
|
||||
return {
|
||||
id,
|
||||
name: themeCopyName (baseTheme),
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
|
||||
import {
|
||||
DEFAULT_KEY_BINDINGS,
|
||||
getConflictingShortcutActionIds,
|
||||
isEditableEventTarget,
|
||||
keyBindingFromKeyboardEvent,
|
||||
resolveShortcutScope,
|
||||
serialiseKeyBinding,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
SHORTCUT_SCOPE_LABELS,
|
||||
} from '@/lib/keyboardShortcuts'
|
||||
import {
|
||||
getClientKeyboardSettings,
|
||||
getEffectiveKeyBindings,
|
||||
resetAllClientKeyBindings,
|
||||
resetClientKeyBinding,
|
||||
setClientKeyBinding,
|
||||
setClientKeyboardSettings,
|
||||
} from '@/lib/settings'
|
||||
|
||||
import type {
|
||||
KeyBinding,
|
||||
ShortcutActionId,
|
||||
ShortcutScope,
|
||||
} from '@/lib/keyboardShortcuts'
|
||||
import type { ClientKeyboardSettings } from '@/lib/settings'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
|
||||
type ShortcutHandler = () => void
|
||||
type ShortcutHandlers = Partial<Record<ShortcutActionId, ShortcutHandler>>
|
||||
|
||||
type KeyboardShortcutsContextValue = {
|
||||
activeScope: ShortcutScope
|
||||
keyboardSettings: ClientKeyboardSettings
|
||||
effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
|
||||
conflictActionIds: Set<ShortcutActionId>
|
||||
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void
|
||||
setEnabled: (enabled: boolean) => void
|
||||
setBinding: (actionId: ShortcutActionId, binding: KeyBinding | null) => void
|
||||
resetBinding: (actionId: ShortcutActionId) => void
|
||||
resetAllBindings: () => void
|
||||
registerHandlers: (handlers: ShortcutHandlers) => () => void
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext =
|
||||
createContext<KeyboardShortcutsContextValue | null> (null)
|
||||
|
||||
|
||||
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<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) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
|
||||
const [keyboardSettings, setKeyboardSettingsState] =
|
||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||
const [registeredHandlers, setRegisteredHandlers] =
|
||||
useState<Record<number, ShortcutHandlers>> ({ })
|
||||
const [shortcutHelpOpen, setShortcutHelpOpen] = useState (false)
|
||||
const registrationIdRef = useRef (0)
|
||||
|
||||
const activeScope = useMemo<ShortcutScope> (
|
||||
() => resolveShortcutScope (location.pathname),
|
||||
[location.pathname],
|
||||
)
|
||||
const effectiveBindings = useMemo (
|
||||
() => getEffectiveKeyBindings (keyboardSettings.bindings),
|
||||
[keyboardSettings.bindings],
|
||||
)
|
||||
const conflictActionIds = useMemo (
|
||||
() => getConflictingShortcutActionIds (effectiveBindings),
|
||||
[effectiveBindings],
|
||||
)
|
||||
const mergedRegisteredHandlers = useMemo<ShortcutHandlers> (
|
||||
() => Object.values (registeredHandlers).reduce<ShortcutHandlers> (
|
||||
(handlers, currentHandlers) => ({ ...handlers, ...currentHandlers }),
|
||||
{ },
|
||||
),
|
||||
[registeredHandlers],
|
||||
)
|
||||
|
||||
const setEnabled = useCallback ((enabled: boolean) => {
|
||||
const next = setClientKeyboardSettings ({
|
||||
...keyboardSettings,
|
||||
enabled,
|
||||
})
|
||||
setKeyboardSettingsState (next)
|
||||
}, [keyboardSettings])
|
||||
|
||||
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
|
||||
const next = setClientKeyboardSettings (settings)
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const setBinding = useCallback ((
|
||||
actionId: ShortcutActionId,
|
||||
binding: KeyBinding | null,
|
||||
) => {
|
||||
const next = setClientKeyBinding (actionId, binding)
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const resetBinding = useCallback ((actionId: ShortcutActionId) => {
|
||||
const next = resetClientKeyBinding (actionId)
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const resetAllBindings = useCallback (() => {
|
||||
const next = resetAllClientKeyBindings ()
|
||||
setKeyboardSettingsState (next)
|
||||
}, [])
|
||||
|
||||
const registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
|
||||
const registrationId = ++registrationIdRef.current
|
||||
|
||||
setRegisteredHandlers (current => ({
|
||||
...current,
|
||||
[registrationId]: handlers,
|
||||
}))
|
||||
|
||||
return () => {
|
||||
setRegisteredHandlers (current => {
|
||||
const next = { ...current }
|
||||
delete next[registrationId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect (() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
keyboardSettings.enabled === false
|
||||
|| event.defaultPrevented
|
||||
|| event.isComposing
|
||||
|| event.key === 'Process'
|
||||
|| event.repeat
|
||||
)
|
||||
return
|
||||
|
||||
if (isEditableEventTarget (event.target))
|
||||
return
|
||||
|
||||
if (shortcutHelpOpen)
|
||||
return
|
||||
|
||||
const binding = keyBindingFromKeyboardEvent (event)
|
||||
if (binding == null)
|
||||
return
|
||||
|
||||
const serialisedBinding = serialiseKeyBinding (binding)
|
||||
if (serialisedBinding == null)
|
||||
return
|
||||
|
||||
const candidates = SHORTCUT_DEFINITIONS.filter (definition => (
|
||||
definition.scope === 'global'
|
||||
|| definition.scope === activeScope
|
||||
)).filter (definition => {
|
||||
const currentBinding = effectiveBindings[definition.id]
|
||||
return (
|
||||
currentBinding != null
|
||||
&& serialiseKeyBinding (currentBinding) === serialisedBinding
|
||||
)
|
||||
}).filter (definition => !(conflictActionIds.has (definition.id)))
|
||||
|
||||
if (candidates.length !== 1)
|
||||
return
|
||||
|
||||
const actionId = candidates[0].id
|
||||
const builtinHandler = (
|
||||
{
|
||||
'global.openShortcutHelp': () => {
|
||||
setShortcutHelpOpen (true)
|
||||
},
|
||||
'global.focusSearch': () => {
|
||||
focusSearchTarget ()
|
||||
},
|
||||
'navigation.posts': () => navigate ('/posts'),
|
||||
'navigation.tags': () => navigate ('/tags'),
|
||||
'navigation.materials': () => navigate ('/materials'),
|
||||
'navigation.wiki': () => navigate ('/wiki'),
|
||||
'settings.account': () => navigate ('/users/settings?tab=account'),
|
||||
'settings.theme': () => navigate ('/users/settings?tab=theme'),
|
||||
'settings.keyboard': () => navigate ('/users/settings?tab=keyboard'),
|
||||
} as ShortcutHandlers
|
||||
)[actionId]
|
||||
const handler = builtinHandler ?? mergedRegisteredHandlers[actionId]
|
||||
|
||||
if (handler == null)
|
||||
return
|
||||
|
||||
event.preventDefault ()
|
||||
handler ()
|
||||
}
|
||||
|
||||
document.addEventListener ('keydown', onKeyDown)
|
||||
return () => document.removeEventListener ('keydown', onKeyDown)
|
||||
}, [
|
||||
activeScope,
|
||||
conflictActionIds,
|
||||
effectiveBindings,
|
||||
keyboardSettings.enabled,
|
||||
mergedRegisteredHandlers,
|
||||
navigate,
|
||||
shortcutHelpOpen,
|
||||
])
|
||||
|
||||
const value = useMemo<KeyboardShortcutsContextValue> (() => ({
|
||||
activeScope,
|
||||
keyboardSettings,
|
||||
effectiveBindings,
|
||||
conflictActionIds,
|
||||
setEnabled,
|
||||
saveKeyboardSettings,
|
||||
setBinding,
|
||||
resetBinding,
|
||||
resetAllBindings,
|
||||
registerHandlers,
|
||||
}), [
|
||||
activeScope,
|
||||
conflictActionIds,
|
||||
effectiveBindings,
|
||||
keyboardSettings,
|
||||
registerHandlers,
|
||||
resetAllBindings,
|
||||
resetBinding,
|
||||
saveKeyboardSettings,
|
||||
setBinding,
|
||||
setEnabled,
|
||||
])
|
||||
|
||||
return (
|
||||
<KeyboardShortcutsContext.Provider value={value}>
|
||||
{children}
|
||||
<ShortcutHelpDialog
|
||||
open={shortcutHelpOpen}
|
||||
onOpenChange={setShortcutHelpOpen}
|
||||
activeScope={activeScope}
|
||||
effectiveBindings={effectiveBindings}
|
||||
conflictActionIds={conflictActionIds}/>
|
||||
</KeyboardShortcutsContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
export const useKeyboardShortcutSettings = (): KeyboardShortcutsContextValue => {
|
||||
const context = useContext (KeyboardShortcutsContext)
|
||||
|
||||
if (context == null)
|
||||
throw new Error ('KeyboardShortcutsProvider が必要です.')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
export const useKeyboardShortcuts = (handlers: ShortcutHandlers): void => {
|
||||
const { registerHandlers } = useKeyboardShortcutSettings ()
|
||||
|
||||
useEffect (
|
||||
() => registerHandlers (handlers),
|
||||
[handlers, registerHandlers],
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_KEY_BINDINGS,
|
||||
SHORTCUT_DEFINITIONS,
|
||||
SHORTCUT_SCOPE_LABELS,
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
LIST_LIMIT_OPTIONS,
|
||||
setClientListSettings,
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { fetchWikiPageByTitle } from '@/lib/wiki'
|
||||
|
||||
import type { FC } from 'react'
|
||||
@@ -68,6 +69,12 @@ const PostListPage: FC = () => {
|
||||
setClientListSettings ('postList', { limit })
|
||||
}, [limit])
|
||||
|
||||
const updatePage = (nextPage: number) => {
|
||||
const params = new URLSearchParams (location.search)
|
||||
params.set ('page', String (Math.max (1, nextPage)))
|
||||
navigate (`${ location.pathname }?${ params.toString () }`)
|
||||
}
|
||||
|
||||
const updateListQuery = (next: { limit?: 20 | 50 | 100 }) => {
|
||||
const params = new URLSearchParams (location.search)
|
||||
params.set ('limit', String (next.limit ?? limit))
|
||||
@@ -75,6 +82,23 @@ const PostListPage: FC = () => {
|
||||
navigate (`${ location.pathname }?${ params.toString () }`)
|
||||
}
|
||||
|
||||
useKeyboardShortcuts (useMemo (() => ({
|
||||
...(page > 1
|
||||
? {
|
||||
'pagination.previous': () => {
|
||||
updatePage (page - 1)
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
...(totalPages > 0 && page < totalPages
|
||||
? {
|
||||
'pagination.next': () => {
|
||||
updatePage (Math.min (page + 1, totalPages))
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
}), [page, totalPages, location.search]))
|
||||
|
||||
useLayoutEffect (() => {
|
||||
scroll (0, 0)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
LIST_LIMIT_OPTIONS,
|
||||
setClientListSettings,
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { dateString, inputClass, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC, FormEvent } from 'react'
|
||||
@@ -171,11 +172,34 @@ const PostSearchPage: FC = () => {
|
||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||
}
|
||||
|
||||
const updatePage = (nextPage: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
qs.set ('page', String (Math.max (1, nextPage)))
|
||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||
}
|
||||
|
||||
const handleSearch = (e: FormEvent) => {
|
||||
e.preventDefault ()
|
||||
search ()
|
||||
}
|
||||
|
||||
useKeyboardShortcuts (useMemo (() => ({
|
||||
...(page > 1
|
||||
? {
|
||||
'pagination.previous': () => {
|
||||
updatePage (page - 1)
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
...(totalPages > 0 && page < totalPages
|
||||
? {
|
||||
'pagination.next': () => {
|
||||
updatePage (Math.min (page + 1, totalPages))
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
}), [page, totalPages, location.search]))
|
||||
|
||||
const defaultDirection = { title: 'asc',
|
||||
url: 'asc',
|
||||
original_created_at: 'desc',
|
||||
@@ -214,6 +238,7 @@ const PostSearchPage: FC = () => {
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
data-shortcut-focus-search="true"
|
||||
value={title}
|
||||
onChange={e => setTitle (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
LIST_LIMIT_OPTIONS,
|
||||
setClientListSettings,
|
||||
} from '@/lib/settings'
|
||||
import { useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
|
||||
import { fetchTags } from '@/lib/tags'
|
||||
import { dateString, inputClass } from '@/lib/utils'
|
||||
|
||||
@@ -180,12 +181,35 @@ const TagListPage: FC = () => {
|
||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||
}
|
||||
|
||||
const updatePage = (nextPage: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
qs.set ('page', String (Math.max (1, nextPage)))
|
||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||
}
|
||||
|
||||
const defaultDirection = { name: 'asc',
|
||||
category: 'asc',
|
||||
post_count: 'desc',
|
||||
created_at: 'desc',
|
||||
updated_at: 'desc' } as const
|
||||
|
||||
useKeyboardShortcuts (useMemo (() => ({
|
||||
...(page > 1
|
||||
? {
|
||||
'pagination.previous': () => {
|
||||
updatePage (page - 1)
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
...(totalPages > 0 && page < totalPages
|
||||
? {
|
||||
'pagination.next': () => {
|
||||
updatePage (Math.min (page + 1, totalPages))
|
||||
},
|
||||
}
|
||||
: { }),
|
||||
}), [page, totalPages, location.search]))
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
@@ -218,6 +242,7 @@ const TagListPage: FC = () => {
|
||||
{({ invalid }) => (
|
||||
<input
|
||||
type="text"
|
||||
data-shortcut-focus-search="true"
|
||||
value={name}
|
||||
onChange={e => setName (e.target.value)}
|
||||
className={inputClass (invalid)}/>)}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
@@ -10,6 +8,7 @@ import Label from '@/components/common/Label'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
|
||||
import InheritDialogue from '@/components/users/InheritDialogue'
|
||||
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
|
||||
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
|
||||
@@ -17,37 +16,35 @@ import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import {
|
||||
applyThemeSelection,
|
||||
BUILTIN_THEMES,
|
||||
createCustomThemeFromBase,
|
||||
defaultThemeTagColoursFor,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
deriveUserThemeModeFromSelection,
|
||||
fetchUserSettings,
|
||||
hasStoredClientThemeSelection,
|
||||
getClientActiveThemeId,
|
||||
getClientCustomThemes,
|
||||
getResolvedThemeFromSelection,
|
||||
setClientAppearanceThemes,
|
||||
updateUserSettings,
|
||||
} from '@/lib/settings'
|
||||
import { applyThemeSelection,
|
||||
BUILTIN_THEMES,
|
||||
createCustomThemeFromBase,
|
||||
defaultThemeTagColoursFor,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
deriveUserThemeModeFromSelection,
|
||||
fetchUserSettings,
|
||||
hasStoredClientThemeSelection,
|
||||
getClientActiveThemeId,
|
||||
getClientCustomThemes,
|
||||
getResolvedThemeFromSelection,
|
||||
setClientAppearanceThemes,
|
||||
updateUserSettings } from '@/lib/settings'
|
||||
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||
|
||||
import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'
|
||||
|
||||
import type { Category, Tag, User } from '@/types'
|
||||
import type {
|
||||
ActiveThemeId,
|
||||
ClientTheme,
|
||||
CustomClientTheme,
|
||||
ThemeTagColours,
|
||||
UserSettings,
|
||||
} from '@/lib/settings'
|
||||
import type { ActiveThemeId,
|
||||
ClientTheme,
|
||||
CustomClientTheme,
|
||||
ThemeTagColours,
|
||||
UserSettings } from '@/lib/settings'
|
||||
|
||||
type Props = {
|
||||
user: User | null
|
||||
setUser: Dispatch<SetStateAction<User | null>>
|
||||
}
|
||||
setUser: Dispatch<SetStateAction<User | null>> }
|
||||
|
||||
type UserFormField = 'name'
|
||||
type SettingsFormField = 'theme'
|
||||
@@ -71,19 +68,18 @@ type SharedSectionProps = {
|
||||
selectedTheme: ClientTheme
|
||||
draftTagColours: ThemeTagColours
|
||||
onThemeChange: (themeId: ActiveThemeId) => void
|
||||
onEnsureEditableTheme: () => void
|
||||
onTagColourChange: (category: Category, colour: string) => void
|
||||
onResetAllTagColours: () => void
|
||||
onResetTagColour: (category: Category) => void
|
||||
onThemeSubmit: () => void
|
||||
settingsBaseErrors: string[]
|
||||
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>>
|
||||
}
|
||||
settingsFieldErrors: Partial<Record<SettingsFormField, string[]>> }
|
||||
|
||||
const tabs: TabSpec[] = [
|
||||
{ id: 'account', label: 'アカウント' },
|
||||
{ id: 'theme', label: 'テーマ' },
|
||||
{ id: 'keyboard', label: 'キーボード' },
|
||||
]
|
||||
{ id: 'keyboard', label: 'キーボード' } ]
|
||||
|
||||
const sectionClassName =
|
||||
'space-y-4 rounded-xl border border-border bg-background/80 p-4'
|
||||
@@ -91,34 +87,12 @@ const sectionClassName =
|
||||
const themeLabel: Record<UserSettings['theme'], string> = {
|
||||
system: 'システム設定に従う',
|
||||
light: 'ライト・モード',
|
||||
dark: 'ダーク・モード',
|
||||
}
|
||||
dark: 'ダーク・モード' }
|
||||
|
||||
const builtinThemeLabel: Record<'light' | 'dark', string> = {
|
||||
light: 'ライト・モード',
|
||||
dark: 'ダーク・モード',
|
||||
}
|
||||
dark: 'ダーク・モード' }
|
||||
|
||||
const keyboardShortcutRows = [
|
||||
{
|
||||
action: '投稿詳細を開く',
|
||||
key: 'Enter',
|
||||
scope: '一覧・検索結果',
|
||||
conflict: 'なし',
|
||||
},
|
||||
{
|
||||
action: '候補タグを選ぶ',
|
||||
key: '↑ / ↓ / Enter',
|
||||
scope: 'タグ入力',
|
||||
conflict: 'なし',
|
||||
},
|
||||
{
|
||||
action: '候補タグを閉じる',
|
||||
key: 'Escape',
|
||||
scope: 'タグ入力',
|
||||
conflict: 'なし',
|
||||
},
|
||||
]
|
||||
|
||||
const previewTag = (
|
||||
id: number,
|
||||
@@ -140,8 +114,7 @@ const previewTag = (
|
||||
hasWiki,
|
||||
materialId: null,
|
||||
hasDeerjikists: options?.hasDeerjikists ?? false,
|
||||
children: [],
|
||||
})
|
||||
children: [] })
|
||||
|
||||
const previewTags: Record<Category, Tag> = {
|
||||
deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
|
||||
@@ -150,15 +123,14 @@ const previewTags: Record<Category, Tag> = {
|
||||
general: previewTag (4, '一般', 'general', 78, true),
|
||||
material: previewTag (5, '素材', 'material', 90, false),
|
||||
meta: previewTag (6, 'メタ', 'meta', 21, true),
|
||||
nico: previewTag (7, 'nico:sm9', 'nico', 43, false),
|
||||
}
|
||||
nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }
|
||||
|
||||
|
||||
const parseTab = (search: string): SettingsTab => {
|
||||
const tab = new URLSearchParams (search).get ('tab')
|
||||
return tabs.some (candidate => candidate.id === tab)
|
||||
? tab as SettingsTab
|
||||
: 'account'
|
||||
return (tabs.some (candidate => candidate.id === tab)
|
||||
? (tab as SettingsTab)
|
||||
: 'account')
|
||||
}
|
||||
|
||||
|
||||
@@ -170,9 +142,7 @@ const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
|
||||
Object.fromEntries (
|
||||
CATEGORIES.flatMap (category => [
|
||||
[`--tag-colour-${ category }`, tagColours[category]],
|
||||
[`--tag-colour-${ category }-hover`, tagColours[category]],
|
||||
]),
|
||||
) as CSSProperties
|
||||
[`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties
|
||||
|
||||
|
||||
const themeLabelById = (
|
||||
@@ -187,16 +157,16 @@ const themeLabelById = (
|
||||
}
|
||||
|
||||
|
||||
const AccountSection: FC<SharedSectionProps> = ({
|
||||
name,
|
||||
onInheritOpen,
|
||||
onNameChange,
|
||||
onUserCodeOpen,
|
||||
onUserSubmit,
|
||||
user,
|
||||
userBaseErrors,
|
||||
userFieldErrors,
|
||||
}) => (
|
||||
const AccountSection: FC<SharedSectionProps> = (
|
||||
{ name,
|
||||
onInheritOpen,
|
||||
onNameChange,
|
||||
onUserCodeOpen,
|
||||
onUserSubmit,
|
||||
user,
|
||||
userBaseErrors,
|
||||
userFieldErrors },
|
||||
) => (
|
||||
<section className={sectionClassName}>
|
||||
<h2 className="text-xl font-bold">アカウント</h2>
|
||||
<FieldError messages={userBaseErrors}/>
|
||||
@@ -247,19 +217,20 @@ const AccountSection: FC<SharedSectionProps> = ({
|
||||
</section>)
|
||||
|
||||
|
||||
const ThemeSection: FC<SharedSectionProps> = ({
|
||||
draftActiveThemeId,
|
||||
draftCustomThemes,
|
||||
selectedTheme,
|
||||
draftTagColours,
|
||||
onResetAllTagColours,
|
||||
onResetTagColour,
|
||||
onTagColourChange,
|
||||
onThemeChange,
|
||||
onThemeSubmit,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
}) => (
|
||||
const ThemeSection: FC<SharedSectionProps> = (
|
||||
{ draftActiveThemeId,
|
||||
draftCustomThemes,
|
||||
selectedTheme,
|
||||
draftTagColours,
|
||||
onEnsureEditableTheme,
|
||||
onResetAllTagColours,
|
||||
onResetTagColour,
|
||||
onTagColourChange,
|
||||
onThemeChange,
|
||||
onThemeSubmit,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors },
|
||||
) => (
|
||||
<section className={sectionClassName}>
|
||||
<h2 className="text-xl font-bold">テーマ</h2>
|
||||
<FieldError messages={settingsBaseErrors}/>
|
||||
@@ -283,28 +254,10 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
||||
</select>)}
|
||||
</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="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-medium">タグカテゴリ色</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedTheme.builtin || draftActiveThemeId === 'system'
|
||||
? '組み込みテーマは直接変更されません.色を変更するとカスタムテーマを作成します.'
|
||||
: `現在の編輯対象: ${ selectedTheme.name }`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -331,6 +284,8 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
||||
<input
|
||||
type="color"
|
||||
value={draftTagColours[category]}
|
||||
onPointerDown={onEnsureEditableTheme}
|
||||
onFocus={onEnsureEditableTheme}
|
||||
onChange={ev => onTagColourChange (category, ev.target.value)}
|
||||
className="h-10 w-16 cursor-pointer rounded border border-border
|
||||
bg-transparent p-1"/>
|
||||
@@ -371,53 +326,12 @@ const ThemeSection: FC<SharedSectionProps> = ({
|
||||
</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_title、auto_fetch_thumbnail、wiki_editor_mode は backend 側に
|
||||
保持したままです.この共通設定画面にはまだ接続していません.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onThemeSubmit}>
|
||||
テーマを保存
|
||||
</Button>
|
||||
</section>)
|
||||
|
||||
|
||||
const KeyboardSection: FC = () => (
|
||||
<section className={sectionClassName}>
|
||||
<h2 className="text-xl font-bold">キーボード</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
現段階では一覧表示のみです.将来は action / key / scope / conflict を持つ
|
||||
key bind 設定へ拡張できる形にしています.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[28rem] 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>
|
||||
{keyboardShortcutRows.map (row => (
|
||||
<tr key={row.action} className="border-b border-border/60 last:border-b-0">
|
||||
<td className="p-2">{row.action}</td>
|
||||
<td className="p-2 font-mono">{row.key}</td>
|
||||
<td className="p-2">{row.scope}</td>
|
||||
<td className="p-2">{row.conflict}</td>
|
||||
</tr>))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>)
|
||||
|
||||
|
||||
const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
@@ -436,24 +350,21 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
const [draftCustomThemes, setDraftCustomThemes] =
|
||||
useState<Record<string, CustomClientTheme>> (getClientCustomThemes ())
|
||||
const [userCodeVsbl, setUserCodeVsbl] = useState (false)
|
||||
const {
|
||||
baseErrors: userBaseErrors,
|
||||
fieldErrors: userFieldErrors,
|
||||
clearValidationErrors: clearUserValidationErrors,
|
||||
applyValidationError: applyUserValidationError,
|
||||
} = useValidationErrors<UserFormField> ()
|
||||
const {
|
||||
baseErrors: settingsBaseErrors,
|
||||
fieldErrors: settingsFieldErrors,
|
||||
clearValidationErrors: clearSettingsValidationErrors,
|
||||
applyValidationError: applySettingsValidationError,
|
||||
} = useValidationErrors<SettingsFormField> ()
|
||||
const { conflictActionIds } = useKeyboardShortcutSettings ()
|
||||
const { baseErrors: userBaseErrors,
|
||||
fieldErrors: userFieldErrors,
|
||||
clearValidationErrors: clearUserValidationErrors,
|
||||
applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
|
||||
const { baseErrors: settingsBaseErrors,
|
||||
fieldErrors: settingsFieldErrors,
|
||||
clearValidationErrors: clearSettingsValidationErrors,
|
||||
applyValidationError: applySettingsValidationError } =
|
||||
useValidationErrors<SettingsFormField> ()
|
||||
|
||||
const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
|
||||
const selectedTheme = useMemo<ClientTheme> (
|
||||
() => getResolvedThemeFromSelection (draftActiveThemeId, draftCustomThemes),
|
||||
[draftActiveThemeId, draftCustomThemes],
|
||||
)
|
||||
[draftActiveThemeId, draftCustomThemes])
|
||||
const draftTagColours = selectedTheme.tokens.tagColours
|
||||
|
||||
const setActiveTab = (tab: SettingsTab) => {
|
||||
@@ -461,15 +372,15 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
params.set ('tab', tab)
|
||||
navigate (
|
||||
`${ location.pathname }?${ params.toString () }`,
|
||||
{ replace: true },
|
||||
)
|
||||
{ replace: true })
|
||||
}
|
||||
|
||||
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (() => ({
|
||||
account: Boolean (userFieldErrors.name?.length),
|
||||
theme: Boolean (settingsFieldErrors.theme?.length),
|
||||
keyboard: false,
|
||||
}), [settingsFieldErrors.theme, userFieldErrors.name])
|
||||
const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
|
||||
() => (
|
||||
{ account: Boolean (userFieldErrors.name?.length),
|
||||
theme: Boolean (settingsFieldErrors.theme?.length),
|
||||
keyboard: conflictActionIds.size > 0 }),
|
||||
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
|
||||
|
||||
const applyDraftThemeSelection = (
|
||||
activeThemeId: ActiveThemeId,
|
||||
@@ -479,39 +390,42 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
setDraftCustomThemes (customThemes)
|
||||
setDraftSettings (current => ({
|
||||
...current,
|
||||
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes),
|
||||
}))
|
||||
theme: deriveUserThemeModeFromSelection (activeThemeId, customThemes) }))
|
||||
applyThemeSelection (activeThemeId, customThemes)
|
||||
}
|
||||
|
||||
const ensureEditableCustomTheme = (): {
|
||||
activeThemeId: ActiveThemeId
|
||||
customThemes: Record<string, CustomClientTheme>
|
||||
} => {
|
||||
customThemes: Record<string, CustomClientTheme> } => {
|
||||
if (draftActiveThemeId !== 'system' && draftCustomThemes[draftActiveThemeId] != null)
|
||||
{
|
||||
return {
|
||||
activeThemeId: draftActiveThemeId,
|
||||
customThemes: draftCustomThemes,
|
||||
}
|
||||
return { activeThemeId: draftActiveThemeId,
|
||||
customThemes: draftCustomThemes }
|
||||
}
|
||||
|
||||
const customTheme = createCustomThemeFromBase (
|
||||
selectedTheme.baseTheme,
|
||||
{
|
||||
...selectedTheme.tokens,
|
||||
tagColours: { ...selectedTheme.tokens.tagColours },
|
||||
},
|
||||
)
|
||||
{ ...selectedTheme.tokens,
|
||||
tagColours: { ...selectedTheme.tokens.tagColours } })
|
||||
const nextCustomThemes = {
|
||||
...draftCustomThemes,
|
||||
[customTheme.id]: customTheme,
|
||||
}
|
||||
[customTheme.id]: customTheme }
|
||||
|
||||
return {
|
||||
activeThemeId: customTheme.id,
|
||||
customThemes: nextCustomThemes,
|
||||
}
|
||||
customThemes: nextCustomThemes }
|
||||
}
|
||||
|
||||
const handleEnsureEditableTheme = (): {
|
||||
activeThemeId: ActiveThemeId
|
||||
customThemes: Record<string, CustomClientTheme> } => {
|
||||
const editable = ensureEditableCustomTheme ()
|
||||
|
||||
if (editable.activeThemeId !== draftActiveThemeId
|
||||
|| editable.customThemes !== draftCustomThemes)
|
||||
applyDraftThemeSelection (editable.activeThemeId, editable.customThemes)
|
||||
|
||||
return editable
|
||||
}
|
||||
|
||||
const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
@@ -612,9 +526,9 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
return
|
||||
|
||||
const customThemes = getClientCustomThemes ()
|
||||
const activeThemeId = hasStoredClientThemeSelection ()
|
||||
? getClientActiveThemeId ()
|
||||
: data.theme
|
||||
const activeThemeId = (hasStoredClientThemeSelection ()
|
||||
? getClientActiveThemeId ()
|
||||
: data.theme)
|
||||
const themeMode = deriveUserThemeModeFromSelection (activeThemeId, customThemes)
|
||||
|
||||
setDraftSettings ({ ...data, theme: themeMode })
|
||||
@@ -661,68 +575,62 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
}
|
||||
}, [draftActiveThemeId, draftCustomThemes, savedActiveThemeId, savedCustomThemes])
|
||||
|
||||
const sectionProps: SharedSectionProps | null = user ? {
|
||||
user,
|
||||
name,
|
||||
onNameChange: setName,
|
||||
onInheritOpen: () => setInheritVsbl (true),
|
||||
onUserCodeOpen: () => setUserCodeVsbl (true),
|
||||
onUserSubmit: handleUserSubmit,
|
||||
userBaseErrors,
|
||||
userFieldErrors,
|
||||
draftActiveThemeId,
|
||||
draftCustomThemes,
|
||||
selectedTheme,
|
||||
draftTagColours,
|
||||
onThemeChange: themeId => {
|
||||
applyDraftThemeSelection (themeId, draftCustomThemes)
|
||||
},
|
||||
onTagColourChange: (category, colour) => {
|
||||
const editable = ensureEditableCustomTheme ()
|
||||
const editableTheme = editable.customThemes[editable.activeThemeId]
|
||||
const nextCustomThemes = {
|
||||
...editable.customThemes,
|
||||
[editable.activeThemeId]: {
|
||||
...editableTheme,
|
||||
tokens: {
|
||||
...editableTheme.tokens,
|
||||
tagColours: {
|
||||
...editableTheme.tokens.tagColours,
|
||||
[category]: colour,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
|
||||
},
|
||||
onResetAllTagColours: () => {
|
||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||
return
|
||||
const sectionProps: SharedSectionProps | null =
|
||||
user
|
||||
? { user,
|
||||
name,
|
||||
onNameChange: setName,
|
||||
onInheritOpen: () => setInheritVsbl (true),
|
||||
onUserCodeOpen: () => setUserCodeVsbl (true),
|
||||
onUserSubmit: handleUserSubmit,
|
||||
userBaseErrors,
|
||||
userFieldErrors,
|
||||
draftActiveThemeId,
|
||||
draftCustomThemes,
|
||||
selectedTheme,
|
||||
draftTagColours,
|
||||
onThemeChange: themeId => {
|
||||
applyDraftThemeSelection (themeId, draftCustomThemes)
|
||||
},
|
||||
onEnsureEditableTheme: () => {
|
||||
handleEnsureEditableTheme ()
|
||||
},
|
||||
onTagColourChange: (category, colour) => {
|
||||
const editable = handleEnsureEditableTheme ()
|
||||
const editableTheme = editable.customThemes[editable.activeThemeId]
|
||||
const nextCustomThemes = {
|
||||
...editable.customThemes,
|
||||
[editable.activeThemeId]: {
|
||||
...editableTheme,
|
||||
tokens: {
|
||||
...editableTheme.tokens,
|
||||
tagColours: {
|
||||
...editableTheme.tokens.tagColours,
|
||||
[category]: colour } } } }
|
||||
applyDraftThemeSelection (editable.activeThemeId, nextCustomThemes)
|
||||
},
|
||||
onResetAllTagColours: () => {
|
||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||
return
|
||||
|
||||
applyDraftThemeSelection (
|
||||
draftActiveThemeId,
|
||||
{
|
||||
...draftCustomThemes,
|
||||
[draftActiveThemeId]: {
|
||||
...draftCustomThemes[draftActiveThemeId],
|
||||
tokens: {
|
||||
...draftCustomThemes[draftActiveThemeId].tokens,
|
||||
tagColours: {
|
||||
...defaultThemeTagColoursFor (selectedTheme.baseTheme),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onResetTagColour: category => {
|
||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||
return
|
||||
applyDraftThemeSelection (
|
||||
draftActiveThemeId,
|
||||
{
|
||||
...draftCustomThemes,
|
||||
[draftActiveThemeId]: {
|
||||
...draftCustomThemes[draftActiveThemeId],
|
||||
tokens: {
|
||||
...draftCustomThemes[draftActiveThemeId].tokens,
|
||||
tagColours: {
|
||||
...defaultThemeTagColoursFor (selectedTheme.baseTheme) } } } })
|
||||
},
|
||||
onResetTagColour: category => {
|
||||
if (selectedTheme.builtin || draftActiveThemeId === 'system')
|
||||
return
|
||||
|
||||
applyDraftThemeSelection (
|
||||
draftActiveThemeId,
|
||||
{
|
||||
...draftCustomThemes,
|
||||
applyDraftThemeSelection (
|
||||
draftActiveThemeId,
|
||||
{ ...draftCustomThemes,
|
||||
[draftActiveThemeId]: {
|
||||
...draftCustomThemes[draftActiveThemeId],
|
||||
tokens: {
|
||||
@@ -730,17 +638,13 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
tagColours: {
|
||||
...draftCustomThemes[draftActiveThemeId].tokens.tagColours,
|
||||
[category]:
|
||||
defaultThemeTagColoursFor (selectedTheme.baseTheme)[category],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
onThemeSubmit: handleThemeSubmit,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors,
|
||||
} : null
|
||||
defaultThemeTagColoursFor (
|
||||
selectedTheme.baseTheme)[category] } } } })
|
||||
},
|
||||
onThemeSubmit: handleThemeSubmit,
|
||||
settingsBaseErrors,
|
||||
settingsFieldErrors }
|
||||
: null
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
@@ -752,15 +656,40 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
|
||||
<PageTitle>設定</PageTitle>
|
||||
<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...' : (
|
||||
<>
|
||||
{sectionProps && (
|
||||
<>
|
||||
<AccountSection {...sectionProps}/>
|
||||
<ThemeSection {...sectionProps}/>
|
||||
<KeyboardSection/>
|
||||
</>)}
|
||||
</>)}
|
||||
<div
|
||||
id={tabPanelId (activeTab)}
|
||||
role="tabpanel"
|
||||
aria-labelledby={tabButtonId (activeTab)}>
|
||||
{sectionProps && activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||||
{sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||||
{activeTab === 'keyboard' && (
|
||||
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex md:justify-center md:px-4">
|
||||
@@ -781,12 +710,11 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||
className={cn (
|
||||
'rounded-xl border px-4 py-3 text-left 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-slate-300 bg-white text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100'],
|
||||
)}
|
||||
(activeTab === tab.id
|
||||
? ['border-slate-900 bg-slate-900 text-white',
|
||||
'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
|
||||
: ['border-slate-300 bg-white text-slate-700',
|
||||
'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']))}
|
||||
onClick={() => setActiveTab (tab.id)}
|
||||
onKeyDown={handleTabKeyDown}>
|
||||
{tab.label}
|
||||
@@ -806,7 +734,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
<>
|
||||
{activeTab === 'account' && <AccountSection {...sectionProps}/>}
|
||||
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
|
||||
{activeTab === 'keyboard' && <KeyboardSection/>}
|
||||
{activeTab === 'keyboard' && (
|
||||
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
|
||||
</>)}
|
||||
</div>)}
|
||||
</div>
|
||||
@@ -826,4 +755,5 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
|
||||
export default SettingPage
|
||||
|
||||
新しい課題から参照
ユーザをブロックする