設定画面 (#34) #397

マージ済み
みてるぞ が 31 個のコミットを feature/034 から main へマージ 2026-07-07 00:48:41 +09:00
11個のファイルの変更1137行の追加92行の削除
コミット ca92c62049 の変更だけを表示してゐます - すべてのコミットを表示
+383 -3
ファイルの表示
@@ -274,6 +274,30 @@ const value =
- Treat TSX formatting rules as hard constraints, not preferences. Before - Treat TSX formatting rules as hard constraints, not preferences. Before
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
placement and fix violations instead of relying on formatter defaults. placement and fix violations instead of relying on formatter defaults.
- After every TSX edit, perform a style-only self-review of the edited hunks
before running verification or reporting completion. The task is not complete
while any edited TSX hunk violates these local formatting rules.
- The TSX self-review must classify every edited leading or trailing `)`, `]`,
and `}` by syntax role before deciding whether it is valid. Do not apply a
rule by glyph alone. A closing `)` for a function parameter list is different
from a closing `)` for a function call. A closing `}` for a block is different
from a closing `}` for an object, type literal, import list, or destructuring
pattern.
- The TSX self-review must confirm there are no common Prettier-style React
component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm multi-line function declaration parameter
`)` placement follows the detailed parameter-list rules below.
- The TSX self-review must confirm call-expression `)` is never at the
beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not
at the beginning of a line.
- The TSX self-review must confirm multi-line function/lambda/callback/block
`}` is on its own line and never at the end of the previous line.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -282,9 +306,15 @@ const value =
character. character.
- A leading tab is exactly equivalent to 8 leading spaces. - A leading tab is exactly equivalent to 8 leading spaces.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, when the parameter list spans multiple lines, always function declarations, classify the parameter list before placing the closing
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. - If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
- If the only line break is inside a parameter's inline type, object type, or
destructuring shape, and the parameter list itself is not split as a separate
block, keep the closing parameter `)` on the same line as that parameter's
final `}`. In this case, moving `)` to a new line is wrong.
- Do not write React component declarations in the common Prettier form - Do not write React component declarations in the common Prettier form
`const Component = ({ ... }) => (` when the destructured parameter spans `const Component = ({ ... }) => (` when the destructured parameter spans
multiple lines. Use the project form with the opening `(` after `=`, the multiple lines. Use the project form with the opening `(` after `=`, the
@@ -325,6 +355,356 @@ const value =
- Do not use a leading semicolon for expression statements such as - Do not use a leading semicolon for expression statements such as
`;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards
explicitly, for example with `void`. explicitly, for example with `void`.
- Spell British English identifiers correctly when the feature name uses
British English. Use `Behaviour`, not `Behavior`, for new component and file
names unless editing an already established American-English API.
### Frontend delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<Props> = ({
open,
onOpenChange,
}) => {
return null
}
```
Good:
```tsx
const Component: FC<Props> = (
{ open,
onOpenChange },
) => {
return null
}
```
Rule: when a React component or helper takes a destructured object parameter
that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
return null
}
```
Bad:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}) => {
return null
}
```
Rule: this is not a separately split parameter-list block. The line break is
inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key]) => {
return null
}
```
Good:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key],
) => {
return null
}
```
Rule: when the parameter list itself is split across multiple parameter lines,
the closing parameter `)` goes at the beginning of its own line before `=>` or
the return type.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
#### JSX closing markers
Bad:
```tsx
<Button
type="button"
onClick={handleSave}
>
</Button>
)
```
Good:
```tsx
<Button
type="button"
onClick={handleSave}>
</Button>)
```
Bad:
```tsx
<Input
value={value}
onChange={handleChange}
/>
```
Good:
```tsx
<Input
value={value}
onChange={handleChange}/>
```
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<Component prop={ value }/>
```
Good:
```tsx
<Component prop={value}/>
```
Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces.
#### Final TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
Preferred: Preferred:
+383 -3
ファイルの表示
@@ -129,13 +129,43 @@ pass or the remaining failure is clearly blocked.
- Treat TSX formatting rules as hard constraints, not preferences. Before - Treat TSX formatting rules as hard constraints, not preferences. Before
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}`
placement and fix violations instead of relying on formatter defaults. placement and fix violations instead of relying on formatter defaults.
- After every TSX edit, perform a style-only self-review of the edited hunks
before running verification or reporting completion. The task is not complete
while any edited TSX hunk violates these local formatting rules.
- The TSX self-review must classify every edited leading or trailing `)`, `]`,
and `}` by syntax role before deciding whether it is valid. Do not apply a
rule by glyph alone. A closing `)` for a function parameter list is different
from a closing `)` for a function call. A closing `}` for a block is different
from a closing `}` for an object, type literal, import list, or destructuring
pattern.
- The TSX self-review must confirm there are no common Prettier-style React
component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm multi-line function declaration parameter
`)` placement follows the detailed parameter-list rules below.
- The TSX self-review must confirm call-expression `)` is never at the
beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not
at the beginning of a line.
- The TSX self-review must confirm multi-line function/lambda/callback/block
`}` is on its own line and never at the end of the previous line.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- For long Tailwind `className` strings, wrap across lines only when needed. - For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation - Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression. rule, using tabs only as leading 8-space compression.
- In TypeScript and TSX function declarations, including `const` arrow - In TypeScript and TSX function declarations, including `const` arrow
function declarations, when the parameter list spans multiple lines, always function declarations, classify the parameter list before placing the closing
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. - If the parameter list itself is given its own multi-line block after the
function's opening `(`, put the closing parameter `)` at the beginning of its
own line before the return type or `=>`.
- If the only line break is inside a parameter's inline type, object type, or
destructuring shape, and the parameter list itself is not split as a separate
block, keep the closing parameter `)` on the same line as that parameter's
final `}`. In this case, moving `)` to a new line is wrong.
- Do not write React component declarations in the common Prettier form - Do not write React component declarations in the common Prettier form
`const Component = ({ ... }) => (` when the destructured parameter spans `const Component = ({ ... }) => (` when the destructured parameter spans
multiple lines. Use the project form with the opening `(` after `=`, the multiple lines. Use the project form with the opening `(` after `=`, the
@@ -162,8 +192,358 @@ pass or the remaining failure is clearly blocked.
single physical line. single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans - Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement. two or more physical lines, even if it is one statement.
- Spell British English identifiers correctly when the feature name uses
British English. Use `Behaviour`, not `Behavior`, for new component and file
names unless editing an already established American-English API.
- Avoid reformatting unrelated JSX. - Avoid reformatting unrelated JSX.
### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit.
#### Import and export named bindings
Bad:
```ts
import {
Button,
Card,
} from '@/components/ui'
```
Good:
```ts
import { Button,
Card } from '@/components/ui'
```
Also acceptable when short:
```ts
import { Button, Card } from '@/components/ui'
```
Rule: named-binding `}` is associative-array-style syntax. It must not be alone
at the beginning of a line. Prefer keeping `{` with the first binding and `}`
with the final binding when this fits the line limit.
#### Type literals
Bad:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void
}
```
Good:
```ts
type Props = {
open: boolean
onOpenChange: (open: boolean) => void }
```
Rule: type-literal `}` is not a block close. It must stay on the same line as
the final property unless that would break the hard line limit.
#### Object literals
Bad:
```ts
const value = {
open,
activeScope,
}
```
Good:
```ts
const value = {
open,
activeScope }
```
Bad:
```ts
const value = useMemo (() => ({
open,
activeScope,
}), [open, activeScope])
```
Good:
```ts
const value = useMemo (() => ({
open,
activeScope }), [open, activeScope])
```
Rule: object-literal `}` is associative-array-style syntax. It must not be on a
line by itself. Keep it with the final property, and keep call `)` off the
beginning of a line.
#### Destructuring parameters
Bad:
```tsx
const Component: FC<Props> = ({
open,
onOpenChange,
}) => {
return null
}
```
Good:
```tsx
const Component: FC<Props> = (
{ open,
onOpenChange },
) => {
return null
}
```
Rule: when a React component or helper takes a destructured object parameter
that spans multiple lines, do not use the Prettier-style `= ({ ... }) =>`
shape. Put the function parameter list on its own lines. The destructuring `}`
stays with the final binding. The parameter-list `)` is then allowed and
required at the beginning of its own line.
#### Inline typed destructuring parameter
Good:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>> }) => {
return null
}
```
Bad:
```ts
const RouteTransitionWrapper = ({ user, setUser }: {
user: User | null
setUser: Dispatch<SetStateAction<User | null>>
}) => {
return null
}
```
Rule: this is not a separately split parameter-list block. The line break is
inside the inline type. Keep the type-literal `}` and parameter-list `)` on the
same line as the final type property.
#### Multi-line normal parameter list
Bad:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key]) => {
return null
}
```
Good:
```ts
const updateDraft = <Key extends keyof Settings,> (
key: Key,
value: Settings[Key],
) => {
return null
}
```
Rule: when the parameter list itself is split across multiple parameter lines,
the closing parameter `)` goes at the beginning of its own line before `=>` or
the return type.
#### Function and callback blocks
Bad:
```ts
const handleSave = () => { save ()
}
```
Bad:
```ts
const handleSave = () => {
save () }
```
Good:
```ts
const handleSave = () => {
save ()
}
```
Rule: block `}` closes executable code, not associative data. Multi-line
function, lambda, callback, `if`, `for`, `switch`, and similar block braces
belong on their own line.
#### Function and method calls
Bad:
```ts
const value = compute (
first,
second
)
```
Good:
```ts
const value = compute (
first,
second)
```
Rule: call-expression `)` must not be at the beginning of a line. The exception
for leading `)` applies only to function declaration parameter lists, never to
calls.
#### JSX closing markers
Bad:
```tsx
<Button
type="button"
onClick={handleSave}
>
</Button>
)
```
Good:
```tsx
<Button
type="button"
onClick={handleSave}>
</Button>)
```
Bad:
```tsx
<Input
value={value}
onChange={handleChange}
/>
```
Good:
```tsx
<Input
value={value}
onChange={handleChange}/>
```
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
the local compact form such as `</div>)`.
#### Arrays and tuples
Bad:
```ts
const items = [
first,
second,
]
```
Good:
```ts
const items = [
first,
second]
```
Good when short:
```ts
const items = [first, second]
```
Rule: array and tuple `]` must not be at the beginning of a line. Keep it with
the final element unless that would break the hard line limit.
#### Single-line braces
Good:
```ts
const next = { ...current, enabled: true }
const tag = { id, name }
```
Bad:
```tsx
<Component prop={ value }/>
```
Good:
```tsx
<Component prop={value}/>
```
Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces.
#### Final TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line:
1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line.
2. No call-expression `)` appears at the beginning of a line.
3. Any leading `)` is definitely closing a function declaration parameter list,
not a call.
4. Any parameter-list `)` at the end of a line is valid because the parameter
list itself was not split, only an inline type or destructuring shape was.
5. No array or tuple `]` appears at the beginning of a line.
6. Multi-line executable block `}` is on its own line.
7. JSX `>` and `/>` stay with the final prop unless nearby code proves
otherwise.
8. JSX closing parentheses keep the compact local style.
9. Leading indentation is 4-space logical indentation with tabs used only as
leading 8-space compression.
10. No line has trailing whitespace.
## Lint and build constraints ## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
+1 -13
ファイルの表示
@@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, KeyboardEvent, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
import type { To } from 'react-router-dom' import type { To } from 'react-router-dom'
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { type Props = AnchorHTMLAttributes<HTMLAnchorElement> & {
@@ -25,7 +25,6 @@ export default forwardRef<HTMLAnchorElement, Props> (({
state, state,
onMouseEnter, onMouseEnter,
onTouchStart, onTouchStart,
onKeyDown,
onClick, onClick,
cancelOnError = false, cancelOnError = false,
...rest }, ref) => { ...rest }, ref) => {
@@ -98,22 +97,11 @@ 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 ( return (
<a ref={ref} <a ref={ref}
href={typeof to === 'string' ? to : createPath (to)} href={typeof to === 'string' ? to : createPath (to)}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onKeyDown={handleKeyDown}
onClick={handleClick} onClick={handleClick}
className={cn ('cursor-pointer', className)} className={cn ('cursor-pointer', className)}
{...rest}/>) {...rest}/>)
+143
ファイルの表示
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from 'react'
import FormField from '@/components/common/FormField'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import {
getClientBehaviorSettings,
setClientBehaviorSettings,
} from '@/lib/settings'
import { inputClass } from '@/lib/utils'
import type {
ClientAnimationMode,
ClientBehaviorSettings,
ClientEmbedAutoLoadMode,
ClientThumbnailMode,
} from '@/lib/settings'
import type { FC } from 'react'
type Props = {
sectionClassName: string
}
const BehaviorSettingsSection: FC<Props> = ({ sectionClassName }) => {
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)
}
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>
<p className="text-sm text-muted-foreground">
</p>
<p className="text-sm text-muted-foreground">
調
</p>
{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"
disabled={!(hasUnsavedChanges)}
onClick={handleDiscard}>
</Button>
<Button
type="button"
disabled={!(hasUnsavedChanges)}
onClick={handleSave}>
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<FormField label="アニメーション">
{() => (
<select
className={inputClass (false)}
value={draftSettings.animation ?? 'normal'}
onChange={ev => updateDraft (
'animation',
ev.target.value as ClientAnimationMode,
)}>
<option value="normal"></option>
<option value="reduced"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="埋め込み自動読込">
{() => (
<select
className={inputClass (false)}
value={draftSettings.embedAutoLoad ?? 'auto'}
onChange={ev => updateDraft (
'embedAutoLoad',
ev.target.value as ClientEmbedAutoLoadMode,
)}>
<option value="auto"></option>
<option value="manual"></option>
<option value="off"></option>
</select>)}
</FormField>
<FormField label="サムネイル表示">
{() => (
<select
className={inputClass (false)}
value={draftSettings.thumbnailMode ?? 'normal'}
onChange={ev => updateDraft (
'thumbnailMode',
ev.target.value as ClientThumbnailMode,
)}>
<option value="normal"></option>
<option value="light"></option>
<option value="off"></option>
</select>)}
</FormField>
</div>
</section>)
}
export default BehaviorSettingsSection
+12 -2
ファイルの表示
@@ -24,14 +24,24 @@ type Props = {
activeScope: ShortcutScope activeScope: ShortcutScope
effectiveBindings: Record<ShortcutActionId, KeyBinding | null> effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId> conflictActionIds: Set<ShortcutActionId>
availableActionIds: Set<ShortcutActionId>
} }
const ShortcutHelpDialog: FC<Props> = ( const ShortcutHelpDialog: FC<Props> = (
{ open, onOpenChange, activeScope, effectiveBindings, conflictActionIds }, {
open,
onOpenChange,
activeScope,
effectiveBindings,
conflictActionIds,
availableActionIds,
},
) => { ) => {
const rows = SHORTCUT_DEFINITIONS.filter (definition => const rows = SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope) definition.scope === 'global' || definition.scope === activeScope)
.filter (definition => availableActionIds.has (definition.id))
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
+15
ファイルの表示
@@ -134,6 +134,21 @@
line-height: 1.25; line-height: 1.25;
} }
:root[data-animation='reduced'] *,
:root[data-animation='off'] *
{
scroll-behavior: auto !important;
}
:root[data-animation='off'] *,
:root[data-animation='off'] *::before,
:root[data-animation='off'] *::after
{
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
a a
{ {
font-weight: 500; font-weight: 500;
+9
ファイルの表示
@@ -19,6 +19,7 @@ export type ShortcutActionId =
| 'settings.account' | 'settings.account'
| 'settings.theme' | 'settings.theme'
| 'settings.keyboard' | 'settings.keyboard'
| 'settings.behavior'
export type KeyBinding = { export type KeyBinding = {
key: string key: string
@@ -112,6 +113,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: 'settings', scope: 'settings',
defaultBinding: { key: '3' }, defaultBinding: { key: '3' },
}, },
{
id: 'settings.behavior',
label: '設定: 動作',
scope: 'settings',
defaultBinding: { key: '4' },
},
] ]
export const SHORTCUT_DEFINITIONS_BY_ID = export const SHORTCUT_DEFINITIONS_BY_ID =
@@ -132,6 +139,7 @@ export const SHORTCUT_DEFINITIONS_BY_ID =
'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.theme': SHORTCUT_DEFINITIONS[9], 'settings.theme': SHORTCUT_DEFINITIONS[9],
'settings.keyboard': SHORTCUT_DEFINITIONS[10], 'settings.keyboard': SHORTCUT_DEFINITIONS[10],
'settings.behavior': SHORTCUT_DEFINITIONS[11],
}, },
) )
@@ -153,6 +161,7 @@ export const DEFAULT_KEY_BINDINGS =
'settings.account': null, 'settings.account': null,
'settings.theme': null, 'settings.theme': null,
'settings.keyboard': null, 'settings.keyboard': null,
'settings.behavior': null,
}, },
) )
+135 -1
ファイルの表示
@@ -51,6 +51,9 @@ export type CustomClientTheme =
export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
export type TheatreTagFlow = 'vertical' | 'horizontal' export type TheatreTagFlow = 'vertical' | 'horizontal'
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
export type ClientAnimationMode = 'normal' | 'reduced' | 'off'
export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off'
export type ClientThumbnailMode = 'normal' | 'light' | 'off'
export type ClientPaneBreakpoint = 'desktop' | 'tablet' export type ClientPaneBreakpoint = 'desktop' | 'tablet'
export type ClientListKey = 'postList' | 'postSearch' | 'tagList' export type ClientListKey = 'postList' | 'postSearch' | 'tagList'
export type ClientListLimit = 20 | 50 | 100 export type ClientListLimit = 20 | 50 | 100
@@ -75,6 +78,12 @@ export type ClientAppearanceSettings = {
theme?: UserSettings['theme'] theme?: UserSettings['theme']
tagColours?: Partial<ThemeTagColours> } tagColours?: Partial<ThemeTagColours> }
export type ClientBehaviorSettings = {
animation?: ClientAnimationMode
embedAutoLoad?: ClientEmbedAutoLoadMode
thumbnailMode?: ClientThumbnailMode
}
// DB-backed settings. At present only `theme` is surfaced in `/users/settings`. // DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
// The remaining fields are retained for existing backend work and are not wired // The remaining fields are retained for existing backend work and are not wired
// to the common settings UI yet. // to the common settings UI yet.
@@ -87,6 +96,7 @@ export const DEFAULT_USER_SETTINGS: UserSettings = {
// Browser-local settings only. These are device or screen specific. // Browser-local settings only. These are device or screen specific.
export type ClientSettings = { export type ClientSettings = {
appearance?: ClientAppearanceSettings appearance?: ClientAppearanceSettings
behavior?: ClientBehaviorSettings
keyboard?: ClientKeyboardSettings keyboard?: ClientKeyboardSettings
panes?: Record<string, ClientPaneSettings> panes?: Record<string, ClientPaneSettings>
lists?: Partial<Record<ClientListKey, ClientListSettings>> lists?: Partial<Record<ClientListKey, ClientListSettings>>
@@ -303,6 +313,25 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
value === 20 || value === 50 || value === 100 ? value : null value === 20 || value === 50 || value === 100 ? value : null
const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => {
if (value === 'normal' || value === 'reduced' || value === 'off')
return value
if (value === 'true')
return 'reduced'
if (value === 'false')
return 'normal'
return null
}
const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null =>
value === 'auto' || value === 'manual' || value === 'off' ? value : null
const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null =>
value === 'normal' || value === 'light' || value === 'off' ? value : null
const normaliseKeyboardBindings = ( const normaliseKeyboardBindings = (
bindings: unknown, bindings: unknown,
): Partial<Record<ShortcutActionId, KeyBinding | null>> => { ): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
@@ -341,6 +370,110 @@ export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
} }
const legacyAnimationMode = (): ClientAnimationMode | null =>
normaliseAnimationMode (loadClientSettings ().reducedMotion)
const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null =>
normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad)
const legacyThumbnailMode = (): ClientThumbnailMode | null =>
normaliseThumbnailMode (loadClientSettings ().thumbnailMode)
export const getClientAnimationMode = (): ClientAnimationMode =>
loadClientSettings ().behavior?.animation
?? legacyAnimationMode ()
?? 'normal'
export const applyClientAnimationMode = (mode: ClientAnimationMode): void => {
if (typeof document === 'undefined')
return
if (mode === 'normal')
{
delete document.documentElement.dataset.animation
return
}
document.documentElement.dataset.animation = mode
}
export const setClientAnimationMode = (
animation: ClientAnimationMode,
): ClientBehaviorSettings =>
setClientBehaviorSettings ({
...getClientBehaviorSettings (),
animation,
})
export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode =>
loadClientSettings ().behavior?.embedAutoLoad
?? legacyEmbedAutoLoadMode ()
?? 'auto'
export const setClientEmbedAutoLoadMode = (
embedAutoLoad: ClientEmbedAutoLoadMode,
): ClientBehaviorSettings =>
setClientBehaviorSettings ({
...getClientBehaviorSettings (),
embedAutoLoad,
})
export const getClientThumbnailMode = (): ClientThumbnailMode =>
loadClientSettings ().behavior?.thumbnailMode
?? legacyThumbnailMode ()
?? 'normal'
export const setClientThumbnailMode = (
thumbnailMode: ClientThumbnailMode,
): ClientBehaviorSettings =>
setClientBehaviorSettings ({
...getClientBehaviorSettings (),
thumbnailMode,
})
export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({
animation: getClientAnimationMode (),
embedAutoLoad: getClientEmbedAutoLoadMode (),
thumbnailMode: getClientThumbnailMode (),
})
export const setClientBehaviorSettings = (
behavior: ClientBehaviorSettings,
): ClientBehaviorSettings => {
const next: ClientBehaviorSettings = {
animation: (
normaliseAnimationMode (behavior.animation)
?? getClientAnimationMode ()
),
embedAutoLoad: (
normaliseEmbedAutoLoadMode (behavior.embedAutoLoad)
?? getClientEmbedAutoLoadMode ()
),
thumbnailMode: (
normaliseThumbnailMode (behavior.thumbnailMode)
?? getClientThumbnailMode ()
),
}
updateClientSettings (settings => ({
...settings,
behavior: next,
}))
applyClientAnimationMode (next.animation ?? 'normal')
return next
}
export const setClientKeyboardSettings = ( export const setClientKeyboardSettings = (
keyboard: ClientKeyboardSettings, keyboard: ClientKeyboardSettings,
): ClientKeyboardSettings => { ): ClientKeyboardSettings => {
@@ -921,7 +1054,8 @@ export const setClientGekanatorBackgroundMotion = (
): void => { ): void => {
updateClientSettings (settings => ({ updateClientSettings (settings => ({
...settings, ...settings,
gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) gekanator: { ...(settings.gekanator ?? { }), backgroundMotion },
}))
} }
+37 -61
ファイルの表示
@@ -23,9 +23,6 @@ import {
import { import {
getClientKeyboardSettings, getClientKeyboardSettings,
getEffectiveKeyBindings, getEffectiveKeyBindings,
resetAllClientKeyBindings,
resetClientKeyBinding,
setClientKeyBinding,
setClientKeyboardSettings, setClientKeyboardSettings,
} from '@/lib/settings' } from '@/lib/settings'
@@ -46,10 +43,6 @@ type KeyboardShortcutsContextValue = {
effectiveBindings: Record<ShortcutActionId, KeyBinding | null> effectiveBindings: Record<ShortcutActionId, KeyBinding | null>
conflictActionIds: Set<ShortcutActionId> conflictActionIds: Set<ShortcutActionId>
saveKeyboardSettings: (settings: ClientKeyboardSettings) => void 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 registerHandlers: (handlers: ShortcutHandlers) => () => void
} }
@@ -131,37 +124,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
[registeredHandlers], [registeredHandlers],
) )
const setEnabled = useCallback ((enabled: boolean) => {
const next = setClientKeyboardSettings ({
...keyboardSettings,
enabled,
})
setKeyboardSettingsState (next)
}, [keyboardSettings])
const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => { const saveKeyboardSettings = useCallback ((settings: ClientKeyboardSettings) => {
const next = setClientKeyboardSettings (settings) const next = setClientKeyboardSettings (settings)
setKeyboardSettingsState (next) 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 registerHandlers = useCallback ((handlers: ShortcutHandlers) => {
const registrationId = ++registrationIdRef.current const registrationId = ++registrationIdRef.current
@@ -179,6 +146,39 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
} }
}, []) }, [])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
'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'),
'settings.behavior': () => navigate ('/users/settings?tab=behavior'),
}),
[navigate],
)
const availableActionIds = useMemo<Set<ShortcutActionId>> (
() => new Set (
SHORTCUT_DEFINITIONS
.filter (definition =>
definition.scope === 'global' || definition.scope === activeScope)
.filter (definition =>
builtinHandlers[definition.id] != null
|| mergedRegisteredHandlers[definition.id] != null)
.map (definition => definition.id),
),
[activeScope, builtinHandlers, mergedRegisteredHandlers],
)
useEffect (() => { useEffect (() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if ( if (
@@ -219,24 +219,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
return return
const actionId = candidates[0].id const actionId = candidates[0].id
const builtinHandler = ( const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId]
{
'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) if (handler == null)
return return
@@ -251,9 +234,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
activeScope, activeScope,
conflictActionIds, conflictActionIds,
effectiveBindings, effectiveBindings,
builtinHandlers,
keyboardSettings.enabled, keyboardSettings.enabled,
mergedRegisteredHandlers, mergedRegisteredHandlers,
navigate,
shortcutHelpOpen, shortcutHelpOpen,
]) ])
@@ -262,11 +245,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
keyboardSettings, keyboardSettings,
effectiveBindings, effectiveBindings,
conflictActionIds, conflictActionIds,
setEnabled,
saveKeyboardSettings, saveKeyboardSettings,
setBinding,
resetBinding,
resetAllBindings,
registerHandlers, registerHandlers,
}), [ }), [
activeScope, activeScope,
@@ -274,11 +253,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
effectiveBindings, effectiveBindings,
keyboardSettings, keyboardSettings,
registerHandlers, registerHandlers,
resetAllBindings,
resetBinding,
saveKeyboardSettings, saveKeyboardSettings,
setBinding,
setEnabled,
]) ])
return ( return (
@@ -289,7 +264,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
onOpenChange={setShortcutHelpOpen} onOpenChange={setShortcutHelpOpen}
activeScope={activeScope} activeScope={activeScope}
effectiveBindings={effectiveBindings} effectiveBindings={effectiveBindings}
conflictActionIds={conflictActionIds}/> conflictActionIds={conflictActionIds}
availableActionIds={availableActionIds}/>
</KeyboardShortcutsContext.Provider>) </KeyboardShortcutsContext.Provider>)
} }
+3
ファイルの表示
@@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async'
import '@/index.css' import '@/index.css'
import App from '@/App' import App from '@/App'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
const helmetContext = { } const helmetContext = { }
@@ -13,6 +14,8 @@ const client = new QueryClient ({
gcTime: 30 * 60 * 1000, gcTime: 30 * 60 * 1000,
retry: 1 } } }) retry: 1 } } })
applyClientAnimationMode (getClientAnimationMode ())
createRoot (document.getElementById ('root')!).render ( createRoot (document.getElementById ('root')!).render (
<HelmetProvider context={helmetContext}> <HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
+10 -3
ファイルの表示
@@ -8,6 +8,7 @@ import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import BehaviorSettingsSection from '@/components/users/BehaviorSettingsSection'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue' import InheritDialogue from '@/components/users/InheritDialogue'
import UserCodeDialogue from '@/components/users/UserCodeDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue'
@@ -48,7 +49,7 @@ type Props = {
type UserFormField = 'name' type UserFormField = 'name'
type SettingsFormField = 'theme' type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'theme' | 'keyboard' type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior'
type TabSpec = { type TabSpec = {
id: SettingsTab id: SettingsTab
@@ -79,7 +80,8 @@ type SharedSectionProps = {
const tabs: TabSpec[] = [ const tabs: TabSpec[] = [
{ id: 'account', label: 'アカウント' }, { id: 'account', label: 'アカウント' },
{ id: 'theme', label: 'テーマ' }, { id: 'theme', label: 'テーマ' },
{ id: 'keyboard', label: 'キーボード' } ] { id: 'keyboard', label: 'キーボード' },
{ id: 'behavior', label: '動作' } ]
const sectionClassName = const sectionClassName =
'space-y-4 rounded-xl border border-border bg-background/80 p-4' 'space-y-4 rounded-xl border border-border bg-background/80 p-4'
@@ -379,7 +381,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
() => ( () => (
{ account: Boolean (userFieldErrors.name?.length), { account: Boolean (userFieldErrors.name?.length),
theme: Boolean (settingsFieldErrors.theme?.length), theme: Boolean (settingsFieldErrors.theme?.length),
keyboard: conflictActionIds.size > 0 }), keyboard: conflictActionIds.size > 0,
behavior: false }),
[conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])
const applyDraftThemeSelection = ( const applyDraftThemeSelection = (
@@ -689,6 +692,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>} {sectionProps && activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
</div>)} </div>)}
</div> </div>
@@ -736,6 +741,8 @@ const SettingPage: FC<Props> = ({ user, setUser }) => {
{activeTab === 'theme' && <ThemeSection {...sectionProps}/>} {activeTab === 'theme' && <ThemeSection {...sectionProps}/>}
{activeTab === 'keyboard' && ( {activeTab === 'keyboard' && (
<KeyboardSettingsSection sectionClassName={sectionClassName}/>)} <KeyboardSettingsSection sectionClassName={sectionClassName}/>)}
{activeTab === 'behavior' && (
<BehaviorSettingsSection sectionClassName={sectionClassName}/>)}
</>)} </>)}
</div>)} </div>)}
</div> </div>