gongxiangp2/components/detail-generator/useUnsavedDetailGuard.tsx

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
}