diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index b53feaad..6b3824c1 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/block-editor", - "version": "0.1.3", + "version": "0.1.4", "type": "module", "description": "Hydrated accessible editor runtime for WRNexus block documents.", "exports": { diff --git a/packages/block-editor/src/index.ts b/packages/block-editor/src/index.ts index 8789eccf..0395521a 100644 --- a/packages/block-editor/src/index.ts +++ b/packages/block-editor/src/index.ts @@ -1,2 +1,3 @@ export * from "./state.ts"; export * from "./runtime.ts"; +export * from "./unsaved.ts"; diff --git a/packages/block-editor/src/unsaved.ts b/packages/block-editor/src/unsaved.ts new file mode 100644 index 00000000..09dba011 --- /dev/null +++ b/packages/block-editor/src/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 }; +} diff --git a/packages/block-editor/test/unsaved.test.ts b/packages/block-editor/test/unsaved.test.ts new file mode 100644 index 00000000..2b457ce1 --- /dev/null +++ b/packages/block-editor/test/unsaved.test.ts @@ -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); +});