このコミットが含まれているのは:
@@ -1,4 +0,0 @@
|
||||
export * from '@/lib/postImportTypes'
|
||||
export * from '@/lib/postImportStorage'
|
||||
export * from '@/lib/postImportSourceValidation'
|
||||
export * from '@/lib/postImportRows'
|
||||
@@ -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> (
|
||||
() => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする