Files
EpicNext-Cms/src/hooks/use-unsaved-changes.ts
T
Simo d5091d1a73
CI / check (push) Successful in 2m34s
CI / deploy (push) Successful in 19s
CI / publish-container (push) Successful in 1m24s
fix(hk): protect unsaved editors and await prefix save results
2026-09-11 10:48:34 +02:00

99 lines
2.9 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef } from "react";
interface Guard {
dirty: boolean;
message: string;
}
const guards = new Set<{ current: Guard }>();
/** Call before programmatic navigation that would replace an unsaved form. */
export function confirmUnsavedNavigation(): boolean {
const guard = [...guards].find((entry) => entry.current.dirty)?.current;
return !guard || window.confirm(guard.message);
}
function leavesCurrentPage(href: string) {
const target = new URL(href, location.href);
return (
(target.protocol === "http:" || target.protocol === "https:") &&
(target.origin !== location.origin ||
target.pathname !== location.pathname ||
target.search !== location.search)
);
}
function beforeUnload(event: BeforeUnloadEvent) {
if (![...guards].some((entry) => entry.current.dirty)) return;
event.preventDefault();
event.returnValue = "";
}
function click(event: MouseEvent) {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey ||
event.altKey
)
return;
const link =
event.target instanceof Element ? event.target.closest("a[href]") : null;
if (
!(link instanceof HTMLAnchorElement) ||
link.hasAttribute("download") ||
(link.target && link.target !== "_self") ||
!leavesCurrentPage(link.href)
)
return;
if (!confirmUnsavedNavigation()) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
interface HistoryNavigationEvent extends Event {
navigationType: string;
destination: { url: string };
}
function traverse(event: Event) {
const navigation = event as HistoryNavigationEvent;
if (
navigation.navigationType !== "traverse" ||
!event.cancelable ||
!leavesCurrentPage(navigation.destination.url)
)
return;
if (!confirmUnsavedNavigation()) event.preventDefault();
}
/** Protect reload, links and supported browser history traversals; expose explicit close protection. */
export function useUnsavedChanges(dirty: boolean, message: string) {
const guard = useRef({ dirty, message });
guard.current = { dirty, message };
useEffect(() => {
if (!dirty) return;
const entry = guard;
const navigation = (window as Window & { navigation?: EventTarget })
.navigation;
if (guards.size === 0) {
window.addEventListener("beforeunload", beforeUnload);
document.addEventListener("click", click, true);
navigation?.addEventListener("navigate", traverse);
}
guards.add(entry);
return () => {
guards.delete(entry);
if (guards.size === 0) {
window.removeEventListener("beforeunload", beforeUnload);
document.removeEventListener("click", click, true);
navigation?.removeEventListener("navigate", traverse);
}
};
}, [dirty]);
const confirmLeave = useCallback(
() => !guard.current.dirty || window.confirm(guard.current.message),
[],
);
const markSaved = useCallback(() => {
guard.current.dirty = false;
}, []);
return { confirmLeave, markSaved };
}