86 lines
3.6 KiB
TypeScript
86 lines
3.6 KiB
TypeScript
import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { Dialog } from "tdesign-react";
|
|
import { useBlocker } from "react-router-dom";
|
|
import { UnsavedContext } from "./UnsavedChangesContext";
|
|
|
|
// Keep navigation protection state local to the mounted route tree.
|
|
// The context identity itself is kept in the dedicated registry module.
|
|
|
|
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
|
const [warning, setWarning] = useState<string | null>(null);
|
|
const [pendingAction, setPendingAction] = useState<(() => void) | null>(null);
|
|
const warnings = useRef(new Map<string, string>());
|
|
const warningRef = useRef<string | null>(null);
|
|
const blocker = useBlocker(Boolean(warning));
|
|
|
|
useEffect(() => {
|
|
if (!warning) return;
|
|
const preventUnload = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = ""; };
|
|
window.addEventListener("beforeunload", preventUnload);
|
|
return () => window.removeEventListener("beforeunload", preventUnload);
|
|
}, [warning]);
|
|
|
|
const registerWarning = useCallback((id: string, message: string | null) => {
|
|
if (message) warnings.current.set(id, message); else warnings.current.delete(id);
|
|
const nextWarning = warnings.current.values().next().value ?? null;
|
|
warningRef.current = nextWarning;
|
|
if (!nextWarning) setPendingAction(null);
|
|
setWarning(nextWarning);
|
|
}, []);
|
|
const clearWarnings = useCallback(() => {
|
|
warnings.current.clear();
|
|
warningRef.current = null;
|
|
setWarning(null);
|
|
}, []);
|
|
const requestDiscard = useCallback((action: () => void) => {
|
|
if (!warningRef.current) { action(); return; }
|
|
setPendingAction(() => action);
|
|
}, []);
|
|
|
|
const value = useMemo(() => ({ registerWarning, requestDiscard }), [registerWarning, requestDiscard]);
|
|
const visible = blocker.state === "blocked" || Boolean(pendingAction);
|
|
useEffect(() => {
|
|
// A dirty form can disappear for reasons unrelated to navigation (for
|
|
// example session expiry). Do not leave its stale close callback attached
|
|
// to the confirmation dialog that belongs to the new screen.
|
|
if (!warning && pendingAction) setPendingAction(null);
|
|
}, [pendingAction, warning]);
|
|
const confirm = () => {
|
|
const action = pendingAction;
|
|
setPendingAction(null);
|
|
if (blocker.state === "blocked") {
|
|
blocker.proceed();
|
|
} else {
|
|
clearWarnings();
|
|
action?.();
|
|
}
|
|
};
|
|
const cancel = () => {
|
|
setPendingAction(null);
|
|
if (blocker.state === "blocked") blocker.reset();
|
|
};
|
|
|
|
return <UnsavedContext.Provider value={value}>
|
|
{children}
|
|
<Dialog visible={visible} header="放弃未保存内容?" confirmBtn={{ content: "放弃并离开", theme: "danger" }} cancelBtn="继续编辑" onClose={cancel} onConfirm={confirm} onCancel={cancel}>
|
|
{warning || "当前页面有未保存的内容,离开后将无法恢复。"}
|
|
</Dialog>
|
|
</UnsavedContext.Provider>;
|
|
}
|
|
|
|
export function useUnsavedChanges(active: boolean, message = "当前表单有未保存的内容,离开后这些内容会丢失。") {
|
|
const context = useContext(UnsavedContext);
|
|
if (!context) throw new Error("useUnsavedChanges must be used inside UnsavedChangesProvider");
|
|
const id = useId();
|
|
useEffect(() => {
|
|
context.registerWarning(id, active ? message : null);
|
|
return () => context.registerWarning(id, null);
|
|
}, [active, context, id, message]);
|
|
}
|
|
|
|
export function useUnsavedActions() {
|
|
const context = useContext(UnsavedContext);
|
|
if (!context) throw new Error("useUnsavedActions must be used inside UnsavedChangesProvider");
|
|
return context;
|
|
}
|