Protect editor sessions from unsaved link navigation and unload
This commit is contained in:
1 parent
88f2e456ef
commit
a1704877e8
4 files changed
+70
-1
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/block-editor",
|
"name": "@wrnexus/block-editor",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./state.ts";
|
export * from "./state.ts";
|
||||||
export * from "./runtime.ts";
|
export * from "./runtime.ts";
|
||||||
|
export * from "./unsaved.ts";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function mountUnsavedChangesGuard(
|
||||||
|
root: HTMLElement,
|
||||||
|
options: { hasUnsavedChanges(): boolean; confirmLeave(): boolean },
|
||||||
|
): { destroy(): void } {
|
||||||
|
const document = root.ownerDocument;
|
||||||
|
const view = document.defaultView!;
|
||||||
|
let destroyed = false;
|
||||||
|
const dirty = () => !destroyed && root.isConnected && options.hasUnsavedChanges();
|
||||||
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
|
if (!dirty()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
};
|
||||||
|
const click = (event: MouseEvent) => {
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||||
|
const target = event.target as Element | null;
|
||||||
|
const link = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||||
|
if (!link || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
|
||||||
|
const url = new URL(link.href, document.baseURI);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) return;
|
||||||
|
const current = new URL(view.location.href);
|
||||||
|
if (url.origin === current.origin && url.pathname === current.pathname && url.search === current.search && url.hash) return;
|
||||||
|
if (dirty() && !options.confirmLeave()) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const observer = new view.MutationObserver(() => { if (!root.isConnected) destroy(); });
|
||||||
|
function destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
view.removeEventListener("beforeunload", beforeUnload);
|
||||||
|
document.removeEventListener("click", click, true);
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
view.addEventListener("beforeunload", beforeUnload);
|
||||||
|
document.addEventListener("click", click, true);
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
return { destroy };
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { Window } from "happy-dom";
|
||||||
|
import { mountUnsavedChangesGuard } from "../src/unsaved.ts";
|
||||||
|
|
||||||
|
test("unsaved guard protects unload and ordinary navigation, permits safe links, and cleans up", () => {
|
||||||
|
const window = new Window({ url: "https://email.example.com/builder/one" });
|
||||||
|
const root = window.document.createElement("main");
|
||||||
|
window.document.body.append(root);
|
||||||
|
let dirty = true, allow = false, confirmations = 0;
|
||||||
|
const guard = mountUnsavedChangesGuard(root as unknown as HTMLElement, { hasUnsavedChanges: () => dirty, confirmLeave: () => { confirmations++; return allow; } });
|
||||||
|
const unload = () => { const event = new window.Event("beforeunload", { cancelable: true }); window.dispatchEvent(event); return event.defaultPrevented; };
|
||||||
|
const click = (href: string, target = "") => {
|
||||||
|
const link = window.document.createElement("a"); link.href = href; link.target = target; root.append(link);
|
||||||
|
const event = new window.MouseEvent("click", { bubbles: true, cancelable: true });
|
||||||
|
link.dispatchEvent(event); return event.defaultPrevented;
|
||||||
|
};
|
||||||
|
expect(unload()).toBe(true);
|
||||||
|
expect(click("/builder/two")).toBe(true);
|
||||||
|
expect(click("#content")).toBe(false);
|
||||||
|
expect(click("/builder/two", "_blank")).toBe(false);
|
||||||
|
expect(confirmations).toBe(1);
|
||||||
|
allow = true;
|
||||||
|
expect(click("/builder/two")).toBe(false);
|
||||||
|
dirty = false;
|
||||||
|
expect(unload()).toBe(false);
|
||||||
|
guard.destroy(); guard.destroy(); dirty = true;
|
||||||
|
expect(unload()).toBe(false);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user