134 lines
5.6 KiB
TypeScript
134 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useRef } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
|
|
|
const GUARD_KEY = '__detailUnsavedGuard'
|
|
|
|
/** One reusable history sentinel supports browsers without the Navigation API. */
|
|
export function useUnsavedDetailGuard(dirty: boolean) {
|
|
const router = useRouter()
|
|
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
|
const dirtyRef = useRef(dirty)
|
|
const pendingRef = useRef(false)
|
|
const allowedRef = useRef(false)
|
|
const armRef = useRef<() => void>(() => undefined)
|
|
const cleanRef = useRef<(after?: () => void) => void>(() => undefined)
|
|
const stateRef = useRef({ token: '', armed: false, url: '', phase: '' as '' | 'restore' | 'clean' | 'replay', steps: 0, targetUrl: '', afterClean: undefined as (() => void) | undefined })
|
|
dirtyRef.current = dirty
|
|
|
|
useEffect(() => {
|
|
let disposed = false
|
|
let resetTimer: ReturnType<typeof setTimeout> | undefined
|
|
const state = stateRef.current
|
|
const confirmLeave = () => confirmAction({ title: '离开此页面?', description: '当前修改尚未保存,离开后将丢失。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
|
|
const allowDeparture = () => {
|
|
allowedRef.current = true
|
|
clearTimeout(resetTimer)
|
|
resetTimer = setTimeout(() => { allowedRef.current = false }, 2000)
|
|
}
|
|
const arm = () => {
|
|
if (state.armed || state.phase || !dirtyRef.current || window.history.length < 2) return
|
|
state.token = `detail-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
state.url = window.location.href
|
|
state.armed = true
|
|
window.history.pushState({ ...window.history.state, [GUARD_KEY]: state.token }, '', state.url)
|
|
}
|
|
const clean = (after?: () => void) => {
|
|
if (!state.armed) { after?.(); return }
|
|
state.afterClean = after
|
|
state.phase = 'clean'
|
|
window.history.back()
|
|
}
|
|
armRef.current = arm
|
|
cleanRef.current = clean
|
|
|
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!dirtyRef.current || allowedRef.current) return
|
|
event.preventDefault()
|
|
event.returnValue = ''
|
|
}
|
|
const pop = (event: PopStateEvent) => {
|
|
if (state.phase === 'replay') {
|
|
state.phase = ''; state.armed = false
|
|
return
|
|
}
|
|
if (state.phase === 'clean') {
|
|
event.stopImmediatePropagation()
|
|
state.phase = ''; state.armed = false
|
|
const after = state.afterClean
|
|
state.afterClean = undefined
|
|
if (after) after()
|
|
else arm()
|
|
return
|
|
}
|
|
if (!state.armed) return
|
|
if (event.state?.[GUARD_KEY] === state.token && state.phase !== 'restore') return
|
|
event.stopImmediatePropagation()
|
|
if (state.phase !== 'restore') {
|
|
state.phase = 'restore'; state.steps = 0; state.targetUrl = window.location.href
|
|
pendingRef.current = true
|
|
// The guard was appended last; restoring toward it is always forward.
|
|
window.history.forward()
|
|
return
|
|
}
|
|
state.steps++
|
|
if (event.state?.[GUARD_KEY] !== state.token) { window.history.forward(); return }
|
|
state.phase = ''
|
|
void confirmLeave().then((result) => {
|
|
pendingRef.current = false
|
|
if (disposed) return
|
|
if (!result.confirmed) { if (!dirtyRef.current) clean(); return }
|
|
const target = new URL(state.targetUrl)
|
|
const source = new URL(state.url)
|
|
const samePage = target.pathname === source.pathname && target.search === source.search
|
|
allowDeparture()
|
|
state.phase = 'replay'
|
|
window.history.go(-(state.steps + (samePage ? 1 : 0)))
|
|
})
|
|
}
|
|
const click = (event: MouseEvent) => {
|
|
if ((!dirtyRef.current && state.phase !== 'clean') || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>('a[href]') : null
|
|
if (!anchor || anchor.hasAttribute('download') || (anchor.target && anchor.target !== '_self')) return
|
|
const destination = new URL(anchor.href, window.location.href)
|
|
const current = new URL(window.location.href)
|
|
if (destination.origin !== current.origin || destination.pathname.startsWith('/api/')) return
|
|
if (destination.pathname === current.pathname && destination.search === current.search) return
|
|
event.preventDefault()
|
|
event.stopImmediatePropagation()
|
|
const leave = () => { allowDeparture(); router.push(`${destination.pathname}${destination.search}${destination.hash}`) }
|
|
if (!dirtyRef.current) { state.afterClean = leave; return }
|
|
if (pendingRef.current) return
|
|
pendingRef.current = true
|
|
allowedRef.current = false
|
|
void confirmLeave().then((result) => {
|
|
pendingRef.current = false
|
|
if (!disposed && result.confirmed) clean(leave)
|
|
})
|
|
}
|
|
window.addEventListener('beforeunload', beforeUnload)
|
|
window.addEventListener('popstate', pop, true)
|
|
document.addEventListener('click', click, true)
|
|
return () => {
|
|
disposed = true
|
|
pendingRef.current = false
|
|
clearTimeout(resetTimer)
|
|
window.removeEventListener('beforeunload', beforeUnload)
|
|
window.removeEventListener('popstate', pop, true)
|
|
document.removeEventListener('click', click, true)
|
|
}
|
|
}, [router, confirmAction])
|
|
|
|
useEffect(() => {
|
|
if (dirty) armRef.current()
|
|
else {
|
|
allowedRef.current = false
|
|
if (stateRef.current.armed && !stateRef.current.phase && !pendingRef.current) cleanRef.current()
|
|
}
|
|
}, [dirty])
|
|
|
|
return confirmActionDialog
|
|
}
|