コミットを比較
5 コミット
f00e97e335
...
8ac56f74ce
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 8ac56f74ce | |||
| 59a87c0e11 | |||
| f66532a55d | |||
| c3d2d8e1d1 | |||
| f4dea9b91e |
@@ -362,6 +362,37 @@ 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`.
|
||||
- Do not insert `void` mechanically before ordinary event-handler or callback
|
||||
calls merely because the callee returns a `Promise`. If the surrounding code
|
||||
already uses a plain expression statement such as
|
||||
`onClick={() => requestActiveTab ('theme')}`, preserve that style unless a
|
||||
local rule or type requirement truly forces `void`. Adding unnecessary
|
||||
`void` is a medium-severity style violation.
|
||||
- When fixing formatting, never change operator binding, evaluation order,
|
||||
tuple/comma behaviour, or any other expression structure. A formatting pass
|
||||
that changes how `!==`, `??`, `?:`, `&&`, `||`, or `,` binds is a capital
|
||||
offence.
|
||||
- In TypeScript and TSX, do not mechanically verticalise short call tails,
|
||||
short object literals, or short callback bodies that already fit the local
|
||||
line-length and delimiter rules. Expanding compact local code without a
|
||||
readability need is a minor style violation.
|
||||
- In TypeScript and TSX, treat short object literals, short returned objects,
|
||||
short typed object shapes, and short function-call object arguments as
|
||||
compact associative syntax by default. If they fit locally, keep the opening
|
||||
`{` on the same line as the first property and keep the closing `}` on the
|
||||
same line as the final property. Do not explode them into vertical
|
||||
Prettier-style blocks without a concrete readability or line-length reason.
|
||||
- In TypeScript and TSX, when a compact associative form is broken across two
|
||||
lines, keep the opening `{` with the first property and the closing `}` with
|
||||
the final property. A shape like `{\n key: value,\n}` is wrong even when
|
||||
line length would permit it.
|
||||
- In TypeScript and TSX, when a short call such as `apiGet<...> (...)`,
|
||||
`apiPatch<...> (...)`, `setState (...)`, `updateClientSettings (...)`, or
|
||||
`window.dispatchEvent (...)` already fits within the local style, do not
|
||||
add extra vertical layers around the object argument or callback body.
|
||||
- In TypeScript and TSX, after editing a file, inspect the file tail and
|
||||
remove meaningless blank lines before `export default` or the end of file.
|
||||
A stray empty line before `export default` is a minor style offence.
|
||||
- Use correct British English spelling for new identifiers, filenames,
|
||||
component names, helper names, comments, and developer-facing prose unless
|
||||
editing an already established American-English API that must keep its
|
||||
@@ -380,6 +411,168 @@ const value =
|
||||
- For this repository, prefer names such as `BehaviourSettingsSection.tsx`, not
|
||||
`BehaviorSettingsSection.tsx`.
|
||||
|
||||
### Additional style offences and penalties
|
||||
|
||||
Use this section for recurrent failure modes that are easy to miss when
|
||||
applying the delimiter rules mechanically.
|
||||
|
||||
#### Unnecessary `void` before ordinary callbacks
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
onClick={() => void requestActiveTab ('theme')}
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
onClick={() => requestActiveTab ('theme')}
|
||||
```
|
||||
|
||||
Penalty: medium-severity offence. The code still works, but it pollutes the
|
||||
local style and usually signals that the edit was made mechanically rather than
|
||||
by reading the surrounding code.
|
||||
|
||||
#### Formatting that changes expression structure
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const hasUnsavedChanges =
|
||||
serialiseThemeDraft (draftThemeSlots)
|
||||
!== (serialiseThemeDraft (savedThemeSlots),
|
||||
draftActiveThemeMode !== savedThemeMode)
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const hasUnsavedChanges =
|
||||
serialiseThemeDraft (draftThemeSlots)
|
||||
!== serialiseThemeDraft (savedThemeSlots)
|
||||
|| draftThemeMode !== savedThemeMode
|
||||
```
|
||||
|
||||
Penalty: capital offence. Formatting must never corrupt the meaning of an
|
||||
expression.
|
||||
|
||||
#### Mechanical vertical expansion of compact local code
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const nextState = {
|
||||
open,
|
||||
activeTab }
|
||||
|
||||
setDirtyStates (
|
||||
current => ({
|
||||
current,
|
||||
key: value }),
|
||||
)
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const nextState = { open, activeTab }
|
||||
|
||||
setDirtyStates (
|
||||
current => ({
|
||||
current,
|
||||
key: value }))
|
||||
```
|
||||
|
||||
Penalty: minor offence when semantics stay intact. It is not necessarily
|
||||
broken, but it ignores the repository's compact associative style.
|
||||
|
||||
#### Prettier-style exploded object literals that should stay compact
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const next = {
|
||||
enabled: keyboard?.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard?.bindings),
|
||||
}
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const next = {
|
||||
enabled: keyboard?.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
|
||||
```
|
||||
|
||||
Also good when short enough:
|
||||
|
||||
```ts
|
||||
const next = { enabled: keyboard?.enabled !== false,
|
||||
bindings: normaliseKeyboardBindings (keyboard?.bindings) }
|
||||
```
|
||||
|
||||
Rule: for compact associative syntax, keep `{` with the first property and `}`
|
||||
with the final property unless line length truly forces a different shape.
|
||||
Do not mechanically apply the common Prettier block form.
|
||||
Penalty: medium-severity offence. The code still runs, but it violates a core
|
||||
repository formatting habit that must be preserved consistently.
|
||||
|
||||
#### Exploded object arguments inside short calls
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
updateClientSettings (settings => ({
|
||||
...settings,
|
||||
keyboard: next,
|
||||
}))
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
updateClientSettings (settings => ({
|
||||
...settings,
|
||||
keyboard: next }))
|
||||
```
|
||||
|
||||
Rule: a call-expression closing `)` must not drift onto its own line, and a
|
||||
short object argument should not be exploded more than necessary. Keep the
|
||||
callback's returned object compact when it fits.
|
||||
Penalty: medium-severity offence. This usually means the edit was formatted
|
||||
mechanically instead of by reading the local style.
|
||||
|
||||
#### Stray blank line before `export default`
|
||||
|
||||
Bad:
|
||||
|
||||
```ts
|
||||
const Component: FC = () => {
|
||||
return <div/>
|
||||
}
|
||||
|
||||
|
||||
export default Component
|
||||
```
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const Component: FC = () => {
|
||||
return <div/>
|
||||
}
|
||||
|
||||
export default Component
|
||||
```
|
||||
|
||||
Rule: do not leave an extra empty line between the last top-level declaration
|
||||
and `export default` unless some surrounding file structure genuinely requires
|
||||
visual separation.
|
||||
Penalty: minor offence. It is small, but it is easy to avoid and should not
|
||||
recur.
|
||||
|
||||
### Frontend delimiter decision table
|
||||
|
||||
Use this table before accepting any edited TypeScript or TSX hunk. The table is
|
||||
@@ -412,6 +605,7 @@ 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.
|
||||
Penalty: capital offence. This is one of the core associative-brace rules.
|
||||
|
||||
#### Type literals
|
||||
|
||||
@@ -434,6 +628,8 @@ type Props = {
|
||||
|
||||
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.
|
||||
Penalty: capital offence. Mistaking a type-literal `}` for a block `}` is one
|
||||
of the recurring death-class errors.
|
||||
|
||||
#### Object literals
|
||||
|
||||
@@ -474,6 +670,8 @@ const value = useMemo (() => ({
|
||||
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.
|
||||
Penalty: capital offence. Wrong object-literal `}` placement and wrong call
|
||||
`)` placement are both death-class violations here.
|
||||
|
||||
#### Destructuring parameters
|
||||
|
||||
@@ -504,6 +702,8 @@ 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.
|
||||
Penalty: capital offence. This pattern is a primary source of repeated TSX
|
||||
style regressions.
|
||||
|
||||
#### Inline typed destructuring parameter
|
||||
|
||||
@@ -531,6 +731,8 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
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.
|
||||
Penalty: capital offence. Misclassifying this case means the delimiter review
|
||||
has failed at the syntax-role level.
|
||||
|
||||
#### Multi-line normal parameter list
|
||||
|
||||
@@ -558,6 +760,8 @@ const updateDraft = <Key extends keyof Settings,> (
|
||||
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.
|
||||
Penalty: capital offence. This is the main allowed exception for a leading `)`,
|
||||
so getting it wrong destroys the whole rule set.
|
||||
|
||||
#### Function and callback blocks
|
||||
|
||||
@@ -586,6 +790,8 @@ const handleSave = () => {
|
||||
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.
|
||||
Penalty: capital offence. Confusing block `}` with associative `}` is a
|
||||
death-class delimiter error.
|
||||
|
||||
#### Function and method calls
|
||||
|
||||
@@ -609,6 +815,8 @@ const value = compute (
|
||||
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.
|
||||
Penalty: capital offence. A leading call `)` is one of the clearest death-class
|
||||
violations in this codebase.
|
||||
|
||||
#### JSX closing markers
|
||||
|
||||
@@ -653,6 +861,8 @@ Good:
|
||||
|
||||
Rule: keep `>` or `/>` with the final prop, and keep JSX closing parentheses in
|
||||
the local compact form such as `</div>)`.
|
||||
Penalty: medium-severity offence. It is usually not semantically broken, but it
|
||||
is a conspicuous TSX-style failure.
|
||||
|
||||
#### Arrays and tuples
|
||||
|
||||
@@ -681,6 +891,8 @@ 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.
|
||||
Penalty: capital offence. A leading `]` is forbidden by the same class of hard
|
||||
delimiter rule as leading associative `}` and leading call `)`.
|
||||
|
||||
#### Single-line braces
|
||||
|
||||
@@ -705,6 +917,8 @@ Good:
|
||||
|
||||
Rule: JavaScript object braces on one line get one inner space. JSX expression
|
||||
braces do not get inner spaces.
|
||||
Penalty: minor offence. This is not normally semantic breakage, but it still
|
||||
counts as a style miss.
|
||||
|
||||
#### Final TypeScript/TSX self-review checklist
|
||||
|
||||
|
||||
生成ファイル
-6
@@ -371,12 +371,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "theme", default: "system", null: false
|
||||
t.string "display_density", default: "comfortable", null: false
|
||||
t.string "font_size", default: "normal", null: false
|
||||
t.integer "post_list_limit", default: 50, null: false
|
||||
t.string "post_list_order", default: "created_at_desc", null: false
|
||||
t.string "viewed_post_display", default: "show", null: false
|
||||
t.boolean "tag_autocomplete_nico", default: true, null: false
|
||||
t.string "auto_fetch_title", default: "manual", null: false
|
||||
t.string "auto_fetch_thumbnail", default: "manual", null: false
|
||||
t.string "wiki_editor_mode", default: "split", null: false
|
||||
|
||||
+7
-4
@@ -21,6 +21,7 @@ import { applyClientAppearance,
|
||||
setCachedUserThemeSlots,
|
||||
seedClientThemeMode } from '@/lib/settings'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { UnsavedChangesGuardProvider } from '@/lib/useUnsavedChangesGuard'
|
||||
|
||||
import { KeyboardShortcutsProvider } from '@/lib/useKeyboardShortcuts'
|
||||
import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage'
|
||||
@@ -210,8 +211,9 @@ const App: FC = () => {
|
||||
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||
|
||||
<BrowserRouter>
|
||||
<KeyboardShortcutsProvider>
|
||||
<DialogueProvider>
|
||||
<DialogueProvider>
|
||||
<UnsavedChangesGuardProvider>
|
||||
<KeyboardShortcutsProvider>
|
||||
<MotionConfig
|
||||
reducedMotion={
|
||||
animationMode === 'normal'
|
||||
@@ -232,8 +234,9 @@ const App: FC = () => {
|
||||
</MotionConfig>
|
||||
|
||||
<Toaster/>
|
||||
</DialogueProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</KeyboardShortcutsProvider>
|
||||
</UnsavedChangesGuardProvider>
|
||||
</DialogueProvider>
|
||||
</BrowserRouter>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createPath, useNavigate } from 'react-router-dom'
|
||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||
import { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
|
||||
@@ -35,11 +36,19 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
const navigate = useNavigate ()
|
||||
const qc = useQueryClient ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
|
||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||
const path = useMemo (
|
||||
() => typeof to === 'string' ? to : createPath (to),
|
||||
[to],
|
||||
)
|
||||
const url = useMemo (() => {
|
||||
const path = (typeof to === 'string') ? to : createPath (to)
|
||||
return (new URL (path, location.origin)).toString ()
|
||||
}, [to])
|
||||
return (new URL (path, window.location.origin)).toString ()
|
||||
}, [path])
|
||||
const nextPathname = useMemo (
|
||||
() => (new URL (path, window.location.origin)).pathname,
|
||||
[path],
|
||||
)
|
||||
const setOverlay = useOverlayStore (s => s.setActive)
|
||||
|
||||
const doPrefetch = async () => {
|
||||
@@ -84,6 +93,13 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
if (nextPathname !== window.location.pathname)
|
||||
{
|
||||
const confirmed = await confirmDiscardNavigation ()
|
||||
if (!(confirmed))
|
||||
return
|
||||
}
|
||||
|
||||
flushSync (() => {
|
||||
setOverlay (true)
|
||||
})
|
||||
@@ -106,7 +122,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|
||||
return (
|
||||
<a ref={ref}
|
||||
href={typeof to === 'string' ? to : createPath (to)}
|
||||
href={path}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onTouchStart={handleTouchStart}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -158,7 +158,7 @@ const BehaviourSettingsSection: FC<Props> = (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
disabled={hasUnsavedChanges === false}
|
||||
onClick={handleDiscard}>
|
||||
変更を破棄
|
||||
|
||||
@@ -167,7 +167,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant="destructive"
|
||||
disabled={!(hasUnsavedChanges)}
|
||||
onClick={handleDiscard}>
|
||||
変更を破棄
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
applyClientAppearance,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
fetchUserSettings,
|
||||
} from '@/lib/settings'
|
||||
|
||||
import type { Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
import type { UserSettings } from '@/lib/settings'
|
||||
|
||||
type ContextValue = {
|
||||
loaded: boolean
|
||||
error: string | null
|
||||
settings: UserSettings
|
||||
setSettings: Dispatch<SetStateAction<UserSettings>> }
|
||||
|
||||
const UserSettingsContext = createContext<ContextValue | null> (null)
|
||||
|
||||
|
||||
export const UserSettingsProvider: FC<{
|
||||
children: ReactNode
|
||||
user: User | null }> = ({ children, user }) => {
|
||||
const [error, setError] = useState<string | null> (null)
|
||||
const [loaded, setLoaded] = useState (false)
|
||||
const [settings, setSettings] = useState<UserSettings> (DEFAULT_USER_SETTINGS)
|
||||
|
||||
useEffect (() => {
|
||||
let cancelled = false
|
||||
|
||||
if (!(user))
|
||||
{
|
||||
setError (null)
|
||||
setSettings (DEFAULT_USER_SETTINGS)
|
||||
setLoaded (true)
|
||||
return
|
||||
}
|
||||
|
||||
setLoaded (false)
|
||||
setError (null)
|
||||
|
||||
void (async () => {
|
||||
try
|
||||
{
|
||||
const next = await fetchUserSettings ()
|
||||
if (!(cancelled))
|
||||
{
|
||||
setError (null)
|
||||
setSettings (next)
|
||||
setLoaded (true)
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!(cancelled))
|
||||
{
|
||||
setError ('設定を読み込めませんでした.既定値で表示しています.')
|
||||
setSettings (DEFAULT_USER_SETTINGS)
|
||||
setLoaded (true)
|
||||
}
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect (() => {
|
||||
void settings.theme
|
||||
applyClientAppearance ()
|
||||
}, [settings.theme])
|
||||
|
||||
const value = useMemo<ContextValue> (() => ({
|
||||
error,
|
||||
loaded,
|
||||
settings,
|
||||
setSettings,
|
||||
}), [error, loaded, settings])
|
||||
|
||||
return (
|
||||
<UserSettingsContext.Provider value={value}>
|
||||
{children}
|
||||
</UserSettingsContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
export const useUserSettings = (): ContextValue => {
|
||||
const value = useContext (UserSettingsContext)
|
||||
|
||||
if (value == null)
|
||||
throw new Error ('UserSettingsProvider is missing')
|
||||
|
||||
return value
|
||||
}
|
||||
+4
-25
@@ -38,6 +38,7 @@
|
||||
--top-nav-root-bg-desktop: #fefce8;
|
||||
--top-nav-active-bg: #fef08a;
|
||||
--top-nav-submenu-bg: #fef08a;
|
||||
--top-nav-mobile-menu-bg: #fef08a;
|
||||
--top-nav-mobile-active-bg: #fefce8;
|
||||
--top-nav-brand-link: #db2777;
|
||||
--top-nav-menu-link: #1d4ed8;
|
||||
@@ -89,6 +90,7 @@
|
||||
--top-nav-root-bg-desktop: #230505;
|
||||
--top-nav-active-bg: #450a0a;
|
||||
--top-nav-submenu-bg: #450a0a;
|
||||
--top-nav-mobile-menu-bg: #230505;
|
||||
--top-nav-mobile-active-bg: #450a0a;
|
||||
--top-nav-brand-link: #f9a8d4;
|
||||
--top-nav-menu-link: #93c5fd;
|
||||
@@ -105,7 +107,7 @@
|
||||
}
|
||||
a:hover
|
||||
{
|
||||
opacity: .85;
|
||||
color: color-mix(in oklab, hsl(var(--theme-link)), white 18%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,29 +127,6 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root[data-font-size='small']
|
||||
{
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:root[data-font-size='normal']
|
||||
{
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:root[data-font-size='large']
|
||||
{
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:root[data-display-density='compact'] input,
|
||||
:root[data-display-density='compact'] textarea,
|
||||
:root[data-display-density='compact'] select,
|
||||
:root[data-display-density='compact'] button
|
||||
{
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
:root[data-animation='reduced'] *,
|
||||
:root[data-animation='off'] *
|
||||
{
|
||||
@@ -260,7 +239,7 @@ body
|
||||
|
||||
.top-nav-mobile-menu
|
||||
{
|
||||
background: var(--top-nav-root-bg-mobile);
|
||||
background: var(--top-nav-mobile-menu-bg);
|
||||
}
|
||||
|
||||
.top-nav-mobile-active
|
||||
|
||||
+436
-419
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -25,6 +25,7 @@ import {
|
||||
getEffectiveKeyBindings,
|
||||
setClientKeyboardSettings,
|
||||
} from '@/lib/settings'
|
||||
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
|
||||
|
||||
import type {
|
||||
KeyBinding,
|
||||
@@ -96,6 +97,7 @@ const focusSearchTarget = (): void => {
|
||||
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
|
||||
|
||||
const [keyboardSettings, setKeyboardSettingsState] =
|
||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||
@@ -146,6 +148,13 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const guardedNavigate = useCallback ((path: string) => {
|
||||
confirmDiscardNavigation ().then (confirmed => {
|
||||
if (confirmed)
|
||||
navigate (path)
|
||||
})
|
||||
}, [confirmDiscardNavigation, navigate])
|
||||
|
||||
const builtinHandlers = useMemo<ShortcutHandlers> (
|
||||
() => ({
|
||||
'global.openShortcutHelp': () => {
|
||||
@@ -154,16 +163,16 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
'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'),
|
||||
'navigation.posts': () => guardedNavigate ('/posts'),
|
||||
'navigation.tags': () => guardedNavigate ('/tags'),
|
||||
'navigation.materials': () => guardedNavigate ('/materials'),
|
||||
'navigation.wiki': () => guardedNavigate ('/wiki'),
|
||||
'settings.account': () => guardedNavigate ('/users/settings?tab=account'),
|
||||
'settings.theme': () => guardedNavigate ('/users/settings?tab=theme'),
|
||||
'settings.keyboard': () => guardedNavigate ('/users/settings?tab=keyboard'),
|
||||
'settings.behavior': () => guardedNavigate ('/users/settings?tab=behavior'),
|
||||
}),
|
||||
[navigate],
|
||||
[guardedNavigate],
|
||||
)
|
||||
|
||||
const availableActionIds = useMemo<Set<ShortcutActionId>> (
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||||
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
|
||||
type UnsavedChangesSource = {
|
||||
dirty: boolean
|
||||
discard: () => void | Promise<void> }
|
||||
|
||||
type UnsavedChangesGuardContextValue = {
|
||||
hasUnsavedChanges: boolean
|
||||
registerUnsavedChangesSource: (
|
||||
source: UnsavedChangesSource | null,
|
||||
) => void
|
||||
confirmDiscardNavigation: () => Promise<boolean> }
|
||||
|
||||
const UnsavedChangesGuardContext =
|
||||
createContext<UnsavedChangesGuardContextValue | null> (null)
|
||||
|
||||
|
||||
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const dialogue = useDialogue ()
|
||||
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
|
||||
|
||||
const registerUnsavedChangesSource = useCallback ((
|
||||
nextSource: UnsavedChangesSource | null,
|
||||
) => {
|
||||
setSource (nextSource)
|
||||
}, [])
|
||||
|
||||
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
|
||||
if (!(source?.dirty))
|
||||
return true
|
||||
|
||||
const confirmed = await dialogue.confirm ({
|
||||
title: '未保存の変更があります',
|
||||
description: 'このまま移動すると、保存していない変更は失われます。',
|
||||
cancelText: 'このページに残る',
|
||||
confirmText: '変更を破棄して移動',
|
||||
variant: 'danger' })
|
||||
if (!(confirmed))
|
||||
return false
|
||||
|
||||
await source.discard ()
|
||||
return true
|
||||
}, [dialogue, source])
|
||||
|
||||
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
||||
hasUnsavedChanges: source?.dirty === true,
|
||||
registerUnsavedChangesSource,
|
||||
confirmDiscardNavigation,
|
||||
}), [confirmDiscardNavigation, registerUnsavedChangesSource, source?.dirty])
|
||||
|
||||
return (
|
||||
<UnsavedChangesGuardContext.Provider value={value}>
|
||||
{children}
|
||||
</UnsavedChangesGuardContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
export const useUnsavedChangesGuard = (): UnsavedChangesGuardContextValue => {
|
||||
const context = useContext (UnsavedChangesGuardContext)
|
||||
|
||||
if (context == null)
|
||||
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
||||
|
||||
return context
|
||||
}
|
||||
ファイル差分が大きすぎるため省略します
差分を読込み
新しい課題から参照
ユーザをブロックする