このコミットが含まれているのは:
2026-07-18 19:26:27 +09:00
コミット eae6c30064
9個のファイルの変更、155行の追加、105行の削除
-4
ファイルの表示
@@ -1,4 +0,0 @@
export * from '@/lib/postImportTypes'
export * from '@/lib/postImportStorage'
export * from '@/lib/postImportSourceValidation'
export * from '@/lib/postImportRows'
+2 -7
ファイルの表示
@@ -25,7 +25,6 @@ import {
getEffectiveKeyBindings,
setClientKeyboardSettings,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import type {
KeyBinding,
@@ -97,7 +96,6 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation ()
const navigate = useNavigate ()
const { confirmDiscardNavigation } = useUnsavedChangesGuard ()
const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
@@ -149,11 +147,8 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
}, [])
const guardedNavigate = useCallback ((path: string) => {
confirmDiscardNavigation ().then (confirmed => {
if (confirmed)
navigate (path)
})
}, [confirmDiscardNavigation, navigate])
navigate (path)
}, [navigate])
const builtinHandlers = useMemo<ShortcutHandlers> (
() => ({
+104 -36
ファイルの表示
@@ -7,7 +7,7 @@ import {
useRef,
useState,
} from 'react'
import { useLocation } from 'react-router-dom'
import { useBlocker, useLocation } from 'react-router-dom'
import { useDialogue } from '@/lib/dialogues/useDialogue'
@@ -22,12 +22,11 @@ type UseUnsavedChangesGuardOptions = {
onDiscard?: () => void | Promise<void> }
type UnsavedChangesGuardContextValue = {
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource | null,
) => void
confirmDiscardNavigation: () => Promise<boolean>
allowNextNavigation: () => void }
hasUnsavedChanges: boolean
registerUnsavedChangesSource: (
source: UnsavedChangesSource,
) => () => void
allowNextNavigation: () => void }
const UnsavedChangesGuardContext =
createContext<UnsavedChangesGuardContextValue | null> (null)
@@ -36,41 +35,116 @@ const UnsavedChangesGuardContext =
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
const dialogue = useDialogue ()
const location = useLocation ()
const [source, setSource] = useState<UnsavedChangesSource | null> (null)
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
const bypassNextNavigationRef = useRef (false)
const [revision, setRevision] = useState (0)
const dialogueOpenRef = useRef (false)
const registerUnsavedChangesSource = useCallback ((
nextSource: UnsavedChangesSource | null,
) => {
setSource (nextSource)
source: UnsavedChangesSource,
): (() => void) => {
const sourceId = Symbol ('unsaved-changes-source')
sourcesRef.current.set (sourceId, source)
setRevision (current => current + 1)
return () => {
if (!(sourcesRef.current.delete (sourceId)))
return
setRevision (current => current + 1)
}
}, [])
const allowNextNavigation = useCallback (() => {
bypassNextNavigationRef.current = true
}, [])
const confirmDiscardNavigation = useCallback (async (): Promise<boolean> => {
if (!(source?.dirty))
const sources = useMemo (
() => [...sourcesRef.current.values ()],
[revision],
)
const dirtySources = useMemo (
() => sources.filter (source => source.dirty),
[sources],
)
const hasUnsavedChanges = dirtySources.length > 0
const blocker = useBlocker (
useCallback (
() => hasUnsavedChanges && !(bypassNextNavigationRef.current),
[hasUnsavedChanges],
),
)
const discardDirtyChanges = useCallback (async (): Promise<boolean> => {
if (!(hasUnsavedChanges))
return true
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
if (dialogueOpenRef.current)
return false
bypassNextNavigationRef.current = true
await source.discard ()
return true
}, [dialogue, source])
dialogueOpenRef.current = true
try
{
const confirmed = await dialogue.confirm ({
title: '変更が破棄してページ移動しますか?',
confirmText: '変更を破棄して移動',
variant: 'danger' })
if (!(confirmed))
return false
bypassNextNavigationRef.current = true
try
{
for (const source of dirtySources)
await source.discard ()
}
catch
{
bypassNextNavigationRef.current = false
return false
}
return true
}
finally
{
dialogueOpenRef.current = false
}
}, [dialogue, dirtySources, hasUnsavedChanges])
useEffect (() => {
if (blocker.state !== 'blocked')
return
let active = true
void (async () => {
const confirmed = await discardDirtyChanges ()
if (!(active))
return
if (confirmed)
{
blocker.proceed ()
return
}
blocker.reset ()
}) ()
return () => {
active = false
}
}, [blocker, discardDirtyChanges])
useEffect (() => {
bypassNextNavigationRef.current = false
}, [location.hash, location.pathname, location.search])
useEffect (() => {
if (!(source?.dirty))
if (!(hasUnsavedChanges))
return
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -82,23 +156,21 @@ export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children })
return () => {
window.removeEventListener ('beforeunload', handleBeforeUnload)
}
}, [source?.dirty])
}, [hasUnsavedChanges])
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
hasUnsavedChanges: source?.dirty === true,
hasUnsavedChanges,
registerUnsavedChangesSource,
confirmDiscardNavigation,
allowNextNavigation,
}), [
allowNextNavigation,
confirmDiscardNavigation,
hasUnsavedChanges,
registerUnsavedChangesSource,
source?.dirty,
])
return (
<UnsavedChangesGuardContext.Provider value={value}>
{children}
{children}
</UnsavedChangesGuardContext.Provider>)
}
@@ -115,13 +187,9 @@ export const useUnsavedChangesGuard = (
if (options == null)
return
context.registerUnsavedChangesSource ({
dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) })
return () => {
context.registerUnsavedChangesSource (null)
}
return context.registerUnsavedChangesSource ({
dirty: options.dirty,
discard: options.onDiscard ?? (() => undefined) })
}, [context, options?.dirty, options?.onDiscard])
return context