diff --git a/AGENTS.md b/AGENTS.md index 94f69a9..96c9d79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,30 @@ const value = - 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. +- 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. - Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward. @@ -282,9 +306,15 @@ const value = character. - A leading tab is exactly equivalent to 8 leading spaces. - 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. + function declarations, classify the parameter list before placing the closing + `)`. +- 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 `const Component = ({ ... }) => (` when the destructured parameter spans 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 `;([...]).forEach(...)`; rewrite the expression to avoid ASI hazards 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 = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { 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> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + 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: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + 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 + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `)`. + +#### 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 + +``` + +Good: + +```tsx + +``` + +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: diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index eb9c576..2c2727e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -129,13 +129,43 @@ pass or the remaining failure is clearly blocked. - 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. +- 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. - 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. + function declarations, classify the parameter list before placing the closing + `)`. +- 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 `const Component = ({ ... }) => (` when the destructured parameter spans 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. - Always add braces around `if`, `else`, or `for` bodies when the body spans 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. +### 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 = ({ + open, + onOpenChange, +}) => { + return null +} +``` + +Good: + +```tsx +const Component: FC = ( + { 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> }) => { + return null +} +``` + +Bad: + +```ts +const RouteTransitionWrapper = ({ user, setUser }: { + user: User | null + setUser: Dispatch> +}) => { + 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: Key, + value: Settings[Key]) => { + return null +} +``` + +Good: + +```ts +const updateDraft = ( + 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 + +) +``` + +Good: + +```tsx +) +``` + +Bad: + +```tsx + +``` + +Good: + +```tsx + +``` + +Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in +the local compact form such as `)`. + +#### 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 + +``` + +Good: + +```tsx + +``` + +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 - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 86fe9a5..18fc855 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -7,7 +7,7 @@ import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' 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' type Props = AnchorHTMLAttributes & { @@ -25,7 +25,6 @@ export default forwardRef (({ state, onMouseEnter, onTouchStart, - onKeyDown, onClick, cancelOnError = false, ...rest }, ref) => { @@ -98,22 +97,11 @@ export default forwardRef (({ } } - const handleKeyDown = (ev: KeyboardEvent) => { - onKeyDown?.(ev) - - if (ev.defaultPrevented) - return - - if (ev.key === ' ' || ev.key === 'Spacebar') - ev.preventDefault () - } - return ( ) diff --git a/frontend/src/components/users/BehaviorSettingsSection.tsx b/frontend/src/components/users/BehaviorSettingsSection.tsx new file mode 100644 index 0000000..9aa0eee --- /dev/null +++ b/frontend/src/components/users/BehaviorSettingsSection.tsx @@ -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 = ({ sectionClassName }) => { + const [savedSettings, setSavedSettings] = + useState (() => getClientBehaviorSettings ()) + const [draftSettings, setDraftSettings] = + useState (() => getClientBehaviorSettings ()) + + useEffect (() => { + const current = getClientBehaviorSettings () + setSavedSettings (current) + setDraftSettings (current) + }, []) + + const hasUnsavedChanges = useMemo ( + () => JSON.stringify (draftSettings) !== JSON.stringify (savedSettings), + [draftSettings, savedSettings], + ) + + const updateDraft = ( + 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 ( +
+
+
+

動作

+

+ この設定は現在のブラウザに保存されます。 +

+

+ 重い端末や通信量を抑えたい場合に調整します。 +

+ {hasUnsavedChanges && ( +

+ 未保存の変更があります +

)} +
+ +
+ + +
+
+ +
+ + {() => ( + )} + + + + {() => ( + )} + + + + {() => ( + )} + +
+
) +} + +export default BehaviorSettingsSection diff --git a/frontend/src/components/users/ShortcutHelpDialog.tsx b/frontend/src/components/users/ShortcutHelpDialog.tsx index 0a7b1b5..f00b072 100644 --- a/frontend/src/components/users/ShortcutHelpDialog.tsx +++ b/frontend/src/components/users/ShortcutHelpDialog.tsx @@ -24,14 +24,24 @@ type Props = { activeScope: ShortcutScope effectiveBindings: Record conflictActionIds: Set + availableActionIds: Set } const ShortcutHelpDialog: FC = ( - { open, onOpenChange, activeScope, effectiveBindings, conflictActionIds }, + { + open, + onOpenChange, + activeScope, + effectiveBindings, + conflictActionIds, + availableActionIds, + }, ) => { - const rows = SHORTCUT_DEFINITIONS.filter (definition => - definition.scope === 'global' || definition.scope === activeScope) + const rows = SHORTCUT_DEFINITIONS + .filter (definition => + definition.scope === 'global' || definition.scope === activeScope) + .filter (definition => availableActionIds.has (definition.id)) return ( diff --git a/frontend/src/index.css b/frontend/src/index.css index 05d2ee0..4aa7caa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -134,6 +134,21 @@ 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 { font-weight: 500; diff --git a/frontend/src/lib/keyboardShortcuts.ts b/frontend/src/lib/keyboardShortcuts.ts index 53990f3..1042a8c 100644 --- a/frontend/src/lib/keyboardShortcuts.ts +++ b/frontend/src/lib/keyboardShortcuts.ts @@ -19,6 +19,7 @@ export type ShortcutActionId = | 'settings.account' | 'settings.theme' | 'settings.keyboard' + | 'settings.behavior' export type KeyBinding = { key: string @@ -112,6 +113,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ scope: 'settings', defaultBinding: { key: '3' }, }, + { + id: 'settings.behavior', + label: '設定: 動作', + scope: 'settings', + defaultBinding: { key: '4' }, + }, ] export const SHORTCUT_DEFINITIONS_BY_ID = @@ -132,6 +139,7 @@ export const SHORTCUT_DEFINITIONS_BY_ID = 'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.theme': SHORTCUT_DEFINITIONS[9], 'settings.keyboard': SHORTCUT_DEFINITIONS[10], + 'settings.behavior': SHORTCUT_DEFINITIONS[11], }, ) @@ -153,6 +161,7 @@ export const DEFAULT_KEY_BINDINGS = 'settings.account': null, 'settings.theme': null, 'settings.keyboard': null, + 'settings.behavior': null, }, ) diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 53a9907..b747fa3 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -51,6 +51,9 @@ export type CustomClientTheme = export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' export type TheatreTagFlow = 'vertical' | 'horizontal' 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 ClientListKey = 'postList' | 'postSearch' | 'tagList' export type ClientListLimit = 20 | 50 | 100 @@ -75,6 +78,12 @@ export type ClientAppearanceSettings = { theme?: UserSettings['theme'] tagColours?: Partial } +export type ClientBehaviorSettings = { + animation?: ClientAnimationMode + embedAutoLoad?: ClientEmbedAutoLoadMode + thumbnailMode?: ClientThumbnailMode +} + // 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 // 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. export type ClientSettings = { appearance?: ClientAppearanceSettings + behavior?: ClientBehaviorSettings keyboard?: ClientKeyboardSettings panes?: Record lists?: Partial> @@ -303,6 +313,25 @@ const validListLimit = (value: unknown): ClientListLimit | 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 = ( bindings: unknown, ): Partial> => { @@ -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 = ( keyboard: ClientKeyboardSettings, ): ClientKeyboardSettings => { @@ -921,7 +1054,8 @@ export const setClientGekanatorBackgroundMotion = ( ): void => { updateClientSettings (settings => ({ ...settings, - gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } })) + gekanator: { ...(settings.gekanator ?? { }), backgroundMotion }, + })) } diff --git a/frontend/src/lib/useKeyboardShortcuts.tsx b/frontend/src/lib/useKeyboardShortcuts.tsx index 47f05a2..2b441f6 100644 --- a/frontend/src/lib/useKeyboardShortcuts.tsx +++ b/frontend/src/lib/useKeyboardShortcuts.tsx @@ -23,9 +23,6 @@ import { import { getClientKeyboardSettings, getEffectiveKeyBindings, - resetAllClientKeyBindings, - resetClientKeyBinding, - setClientKeyBinding, setClientKeyboardSettings, } from '@/lib/settings' @@ -41,16 +38,12 @@ type ShortcutHandler = () => void type ShortcutHandlers = Partial> type KeyboardShortcutsContextValue = { - activeScope: ShortcutScope - keyboardSettings: ClientKeyboardSettings - effectiveBindings: Record - conflictActionIds: Set + activeScope: ShortcutScope + keyboardSettings: ClientKeyboardSettings + effectiveBindings: Record + conflictActionIds: Set 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 } const KeyboardShortcutsContext = @@ -131,37 +124,11 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { [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 @@ -179,6 +146,39 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { } }, []) + const builtinHandlers = useMemo ( + () => ({ + '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> ( + () => 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 (() => { const onKeyDown = (event: KeyboardEvent) => { if ( @@ -219,24 +219,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { 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] + const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId] if (handler == null) return @@ -251,9 +234,9 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { activeScope, conflictActionIds, effectiveBindings, + builtinHandlers, keyboardSettings.enabled, mergedRegisteredHandlers, - navigate, shortcutHelpOpen, ]) @@ -262,11 +245,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { keyboardSettings, effectiveBindings, conflictActionIds, - setEnabled, saveKeyboardSettings, - setBinding, - resetBinding, - resetAllBindings, registerHandlers, }), [ activeScope, @@ -274,11 +253,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { effectiveBindings, keyboardSettings, registerHandlers, - resetAllBindings, - resetBinding, saveKeyboardSettings, - setBinding, - setEnabled, ]) return ( @@ -289,7 +264,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { onOpenChange={setShortcutHelpOpen} activeScope={activeScope} effectiveBindings={effectiveBindings} - conflictActionIds={conflictActionIds}/> + conflictActionIds={conflictActionIds} + availableActionIds={availableActionIds}/> ) } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 64630d2..e50c55f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { HelmetProvider } from 'react-helmet-async' import '@/index.css' import App from '@/App' +import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings' const helmetContext = { } @@ -13,6 +14,8 @@ const client = new QueryClient ({ gcTime: 30 * 60 * 1000, retry: 1 } } }) +applyClientAnimationMode (getClientAnimationMode ()) + createRoot (document.getElementById ('root')!).render ( diff --git a/frontend/src/pages/users/SettingPage.tsx b/frontend/src/pages/users/SettingPage.tsx index c818e00..aca8f40 100644 --- a/frontend/src/pages/users/SettingPage.tsx +++ b/frontend/src/pages/users/SettingPage.tsx @@ -8,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 BehaviorSettingsSection from '@/components/users/BehaviorSettingsSection' import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection' import InheritDialogue from '@/components/users/InheritDialogue' import UserCodeDialogue from '@/components/users/UserCodeDialogue' @@ -48,7 +49,7 @@ type Props = { type UserFormField = 'name' type SettingsFormField = 'theme' -type SettingsTab = 'account' | 'theme' | 'keyboard' +type SettingsTab = 'account' | 'theme' | 'keyboard' | 'behavior' type TabSpec = { id: SettingsTab @@ -79,7 +80,8 @@ type SharedSectionProps = { const tabs: TabSpec[] = [ { id: 'account', label: 'アカウント' }, { id: 'theme', label: 'テーマ' }, - { id: 'keyboard', label: 'キーボード' } ] + { id: 'keyboard', label: 'キーボード' }, + { id: 'behavior', label: '動作' } ] const sectionClassName = 'space-y-4 rounded-xl border border-border bg-background/80 p-4' @@ -379,7 +381,8 @@ const SettingPage: FC = ({ user, setUser }) => { () => ( { account: Boolean (userFieldErrors.name?.length), theme: Boolean (settingsFieldErrors.theme?.length), - keyboard: conflictActionIds.size > 0 }), + keyboard: conflictActionIds.size > 0, + behavior: false }), [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name]) const applyDraftThemeSelection = ( @@ -689,6 +692,8 @@ const SettingPage: FC = ({ user, setUser }) => { {sectionProps && activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} + {activeTab === 'behavior' && ( + )} )} @@ -736,6 +741,8 @@ const SettingPage: FC = ({ user, setUser }) => { {activeTab === 'theme' && } {activeTab === 'keyboard' && ( )} + {activeTab === 'behavior' && ( + )} )} )}