This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext, type Context } from "react";
|
||||
|
||||
export type UnsavedContextValue = {
|
||||
registerWarning: (id: string, message: string | null) => void;
|
||||
requestDiscard: (action: () => void) => void;
|
||||
};
|
||||
|
||||
/*
|
||||
* Vite Fast Refresh can re-evaluate a module while an existing route tree is
|
||||
* still mounted. Keep the context identity on the browser window so the old
|
||||
* provider and the refreshed consumers continue to refer to the same object.
|
||||
*/
|
||||
type ContextRegistry = Window & {
|
||||
__tallynoteUnsavedContext?: Context<UnsavedContextValue | null>;
|
||||
};
|
||||
|
||||
const registry = typeof window === "undefined" ? null : window as ContextRegistry;
|
||||
export const UnsavedContext: Context<UnsavedContextValue | null> = registry?.__tallynoteUnsavedContext ?? createContext<UnsavedContextValue | null>(null);
|
||||
if (registry && !registry.__tallynoteUnsavedContext) registry.__tallynoteUnsavedContext = UnsavedContext;
|
||||
Reference in New Issue
Block a user