209 行
5.4 KiB
TypeScript
209 行
5.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useBlocker } from 'react-router-dom'
|
|
|
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
|
|
import type { FC, PropsWithChildren } from 'react'
|
|
|
|
type UnsavedChangesSource = {
|
|
dirty: boolean
|
|
discard: () => void | Promise<void> }
|
|
|
|
type UseUnsavedChangesGuardOptions = {
|
|
dirty: boolean
|
|
onDiscard?: () => void | Promise<void> }
|
|
|
|
type UnsavedChangesGuardContextValue = {
|
|
hasUnsavedChanges: boolean
|
|
registerUnsavedChangesSource: (
|
|
source: UnsavedChangesSource,
|
|
) => () => void
|
|
allowNextNavigation: () => void }
|
|
|
|
const UnsavedChangesGuardContext =
|
|
createContext<UnsavedChangesGuardContextValue | null> (null)
|
|
|
|
|
|
export const UnsavedChangesGuardProvider: FC<PropsWithChildren> = ({ children }) => {
|
|
const dialogue = useDialogue ()
|
|
const { label } = useUITranslation ('settings')
|
|
const sourcesRef = useRef (new Map<symbol, UnsavedChangesSource> ())
|
|
const bypassNextNavigationRef = useRef<symbol | null> (null)
|
|
const [revision, setRevision] = useState (0)
|
|
const dialogueOpenRef = useRef (false)
|
|
const handlingBlockedTransitionRef = useRef (false)
|
|
|
|
const registerUnsavedChangesSource = useCallback ((
|
|
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 (() => {
|
|
const token = Symbol ('allowed-navigation')
|
|
bypassNextNavigationRef.current = token
|
|
|
|
queueMicrotask (() => {
|
|
if (bypassNextNavigationRef.current === token)
|
|
bypassNextNavigationRef.current = null
|
|
})
|
|
}, [])
|
|
|
|
const sources = useMemo (
|
|
() => [...sourcesRef.current.values ()],
|
|
[revision],
|
|
)
|
|
const dirtySources = useMemo (
|
|
() => sources.filter (source => source.dirty),
|
|
[sources],
|
|
)
|
|
const hasUnsavedChanges = dirtySources.length > 0
|
|
const hasUnsavedChangesRef = useRef (hasUnsavedChanges)
|
|
hasUnsavedChangesRef.current = hasUnsavedChanges
|
|
const shouldBlock = useCallback (() => {
|
|
if (bypassNextNavigationRef.current != null)
|
|
{
|
|
bypassNextNavigationRef.current = null
|
|
return false
|
|
}
|
|
|
|
return hasUnsavedChangesRef.current
|
|
}, [])
|
|
const blocker = useBlocker (shouldBlock)
|
|
|
|
const confirmDiscardChanges = useCallback (async (): Promise<boolean> => {
|
|
if (!(hasUnsavedChanges))
|
|
return true
|
|
|
|
if (dialogueOpenRef.current)
|
|
return false
|
|
|
|
dialogueOpenRef.current = true
|
|
|
|
try
|
|
{
|
|
const confirmed = await dialogue.confirm ({
|
|
title: label ('unsaved.leavePage'),
|
|
confirmText: label ('unsaved.discardAndMove'),
|
|
cancelText: label ('actions.cancel'),
|
|
closeLabel: label ('actions.close'),
|
|
variant: 'danger' })
|
|
if (!(confirmed))
|
|
return false
|
|
|
|
return true
|
|
}
|
|
finally
|
|
{
|
|
dialogueOpenRef.current = false
|
|
}
|
|
}, [dialogue, hasUnsavedChanges, label])
|
|
|
|
useEffect (() => {
|
|
if (blocker.state !== 'blocked' || handlingBlockedTransitionRef.current)
|
|
return
|
|
|
|
handlingBlockedTransitionRef.current = true
|
|
void (async () => {
|
|
try
|
|
{
|
|
const confirmed = await confirmDiscardChanges ()
|
|
if (!(confirmed))
|
|
{
|
|
blocker.reset ()
|
|
return
|
|
}
|
|
|
|
let discardResults: Array<void | Promise<void>>
|
|
try
|
|
{
|
|
discardResults = dirtySources.map (source => source.discard ())
|
|
}
|
|
catch
|
|
{
|
|
blocker.reset ()
|
|
return
|
|
}
|
|
|
|
blocker.proceed ()
|
|
await Promise.allSettled (discardResults)
|
|
}
|
|
finally
|
|
{
|
|
handlingBlockedTransitionRef.current = false
|
|
}
|
|
}) ()
|
|
}, [blocker, confirmDiscardChanges, dirtySources])
|
|
|
|
useEffect (() => {
|
|
if (!(hasUnsavedChanges))
|
|
return
|
|
|
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
event.preventDefault ()
|
|
event.returnValue = ''
|
|
}
|
|
|
|
window.addEventListener ('beforeunload', handleBeforeUnload)
|
|
return () => {
|
|
window.removeEventListener ('beforeunload', handleBeforeUnload)
|
|
}
|
|
}, [hasUnsavedChanges])
|
|
|
|
const value = useMemo<UnsavedChangesGuardContextValue> (() => ({
|
|
hasUnsavedChanges,
|
|
registerUnsavedChangesSource,
|
|
allowNextNavigation,
|
|
}), [
|
|
allowNextNavigation,
|
|
hasUnsavedChanges,
|
|
registerUnsavedChangesSource,
|
|
])
|
|
|
|
return (
|
|
<UnsavedChangesGuardContext.Provider value={value}>
|
|
{children}
|
|
</UnsavedChangesGuardContext.Provider>)
|
|
}
|
|
|
|
|
|
export const useUnsavedChangesGuard = (
|
|
options?: UseUnsavedChangesGuardOptions,
|
|
): UnsavedChangesGuardContextValue => {
|
|
const context = useContext (UnsavedChangesGuardContext)
|
|
|
|
if (context == null)
|
|
throw new Error ('UnsavedChangesGuardProvider が必要です.')
|
|
|
|
const { registerUnsavedChangesSource } = context
|
|
|
|
useEffect (() => {
|
|
if (options == null)
|
|
return
|
|
|
|
return registerUnsavedChangesSource ({
|
|
dirty: options.dirty,
|
|
discard: options.onDiscard ?? (() => undefined) })
|
|
}, [options?.dirty, options?.onDiscard, registerUnsavedChangesSource])
|
|
|
|
return context
|
|
}
|