release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
+85
View File
@@ -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;