diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index 4577e243..12a50dfb 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/block-editor", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "description": "Hydrated accessible editor runtime for WRNexus block documents.", "exports": { diff --git a/packages/block-editor/src/runtime.ts b/packages/block-editor/src/runtime.ts index 03c94919..5f9e194b 100644 --- a/packages/block-editor/src/runtime.ts +++ b/packages/block-editor/src/runtime.ts @@ -16,7 +16,11 @@ export interface MountEditorOptions { export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions): MountedEditor { let manager: DndManager | undefined; let cleanups: Array<() => void> = []; - const live = document.createElement("div"); + let destroyed = false; + let generation = 0; + const owner = root.ownerDocument; + const view = owner.defaultView!; + const live = owner.createElement("div"); live.setAttribute("role", "status"); live.setAttribute("aria-live", "polite"); live.setAttribute("aria-atomic", "true"); @@ -24,21 +28,65 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions) "position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"; root.append(live); const refresh = () => { + if (destroyed) return; + const currentGeneration = ++generation; cleanups.forEach((cleanup) => cleanup()); cleanups = []; manager?.destroy(); + let originalChildren = new Map(); + let gesture = 0; + const groups = new Map(); manager = createDndManager({ - direction: options.direction ?? (getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"), - reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches, + direction: + options.direction ?? (view.getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"), + reducedMotion: view.matchMedia("(prefers-reduced-motion: reduce)").matches, announce(announcement) { + if (destroyed || generation !== currentGeneration) return; + if (announcement.type === "pickedUp") { + gesture++; + originalChildren = new Map(); + for (const element of root.querySelectorAll( + "[data-block-id][data-block-parent][data-block-slot]", + )) { + const parent = element.parentNode; + if (parent && !originalChildren.has(parent)) + originalChildren.set(parent, [...parent.childNodes]); + } + } else if (announcement.type === "cancelled") { + // Let the backend finish its own cancellation work before reconciliation. + // Keep the actual nodes (and their listeners), not a stale innerHTML copy. + const snapshot = originalChildren; + const cancelledGesture = gesture; + view.setTimeout(() => { + if ( + destroyed || + generation !== currentGeneration || + gesture !== cancelledGesture || + !root.isConnected + ) + return; + for (const [parent, children] of snapshot) { + if (parent !== root && !root.contains(parent)) continue; + let anchor: Node | null = null; + for (const child of [...children].reverse()) { + // Never resurrect nodes removed by an application rerender. + if (!root.contains(child)) continue; + parent.insertBefore(child, anchor); + anchor = child; + } + } + refresh(); + }, 0); + } else if (announcement.type === "dropped") originalChildren.clear(); live.textContent = options.announce?.(announcement) ?? announcement.type; }, async onMoveIntent(intent) { - const [parentId, slot] = String(intent.to.group).split(":", 2); - if (!parentId || !slot) return false; + if (destroyed || generation !== currentGeneration) return false; + const destination = groups.get(String(intent.to.group)); + if (!destination) return false; return options.onMove({ nodeIds: intent.ids.map(String), - to: { parentId, slot, index: intent.to.index }, + to: { ...destination, index: intent.to.index }, }); }, }); @@ -48,8 +96,12 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions) const id = element.dataset.blockId!; const parent = element.dataset.blockParent!; const slot = element.dataset.blockSlot!; + const group = JSON.stringify([parent, slot]); + groups.set(group, { parentId: parent, slot }); const siblings = [ - ...root.querySelectorAll("[data-block-id][data-block-parent][data-block-slot]"), + ...root.querySelectorAll( + "[data-block-id][data-block-parent][data-block-slot]", + ), ].filter( (candidate) => candidate.dataset.blockParent === parent && candidate.dataset.blockSlot === slot, @@ -58,7 +110,7 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions) cleanups.push( manager.registerSortable({ id, - group: `${parent}:${slot}`, + group, index: siblings.indexOf(element), element, handle, @@ -71,7 +123,11 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions) return { refresh, destroy() { + if (destroyed) return; + destroyed = true; + generation++; cleanups.forEach((cleanup) => cleanup()); + cleanups = []; manager?.destroy(); live.remove(); }, diff --git a/packages/block-editor/test/runtime.test.ts b/packages/block-editor/test/runtime.test.ts new file mode 100644 index 00000000..4721bfd0 --- /dev/null +++ b/packages/block-editor/test/runtime.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; +import type { DndManagerOptions, SortableRegistration } from "@wrnexus/dnd"; +import type { MountedEditor, MountEditorOptions } from "../src/runtime.ts"; + +const code = new Bun.Transpiler({ loader: "ts" }) + .transformSync(readFileSync(new URL("../src/runtime.ts", import.meta.url), "utf8")) + .replace(/^import .*;\s*$/gm, "") + .replace("export function mountBlockEditor", "function mountBlockEditor"); + +function fixture(onMove: MountEditorOptions["onMove"] = () => false) { + const window = new Window(); + const document = window.document; + const root = document.createElement("div"); + document.body.append(root); + root.innerHTML = + '
Keep
'; + const backends: Array<{ + options: DndManagerOptions; + registrations: SortableRegistration[]; + destroyed: number; + }> = []; + const mount = new Function("createDndManager", code + ";return mountBlockEditor;")( + (options: DndManagerOptions) => { + const backend = { options, registrations: [] as SortableRegistration[], destroyed: 0 }; + backends.push(backend); + return { + registerSortable: (registration: SortableRegistration) => { + backend.registrations.push(registration); + return () => {}; + }, + destroy: () => backend.destroyed++, + }; + }, + ); + const editor: MountedEditor = mount(root, { onMove }); + const emit = (type: "pickedUp" | "cancelled" | "dropped") => + backends.at(-1)!.options.announce!({ type, ids: ["a"] }); + return { + root, + document, + editor, + backends, + emit, + flush: () => window.happyDOM.whenAsyncComplete(), + }; +} + +test("cancelled optimistic moves restore original DOM nodes across containers", async () => { + const f = fixture(); + const a = f.root.querySelector('[data-block-id="a"]')!; + let clicks = 0; + a.addEventListener("click", () => clicks++); + f.emit("pickedUp"); + f.root.querySelector("#right")!.append(a); + f.emit("cancelled"); + await f.flush(); + expect(f.root.querySelector("#left")!.textContent).toBe("AKeepB"); + expect(f.root.querySelector("#right")!.textContent).toBe("C"); + expect(f.root.querySelector('[data-block-id="a"]')).toBe(a); + (a as any).click(); + expect(clicks).toBe(1); + expect(f.backends).toHaveLength(2); + expect(f.backends[0]!.destroyed).toBe(1); + f.editor.destroy(); +}); + +test("slot and parent identifiers containing colons round-trip without collisions", async () => { + const moves: unknown[] = []; + const f = fixture((intent) => { + moves.push(intent); + return true; + }); + const backend = f.backends[0]!; + await backend.options.onMoveIntent({ + ids: ["b"], + from: [], + to: { group: backend.registrations[0]!.group, index: 0 }, + }); + expect(moves).toEqual([ + { nodeIds: ["b"], to: { parentId: "parent:one", slot: "content:main", index: 0 } }, + ]); + expect( + await backend.options.onMoveIntent({ + ids: ["b"], + from: [], + to: { group: "unknown", index: 0 }, + }), + ).toBe(false); + f.editor.destroy(); +}); + +test("pending rollback cannot resurrect removed nodes or run after destruction", async () => { + for (const destroyed of [false, true]) { + const f = fixture(); + f.emit("pickedUp"); + f.root.querySelector('[data-block-id="a"]')!.remove(); + f.emit("cancelled"); + if (destroyed) { + f.editor.destroy(); + f.editor.destroy(); + f.editor.refresh(); + } + await f.flush(); + expect(f.root.querySelector('[data-block-id="a"]')).toBeNull(); + if (destroyed) expect(f.backends).toHaveLength(1); + f.editor.destroy(); + expect(f.backends.every((backend) => backend.destroyed === 1)).toBe(true); + } +});