diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index fe223019..1447852b 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/block-editor", - "version": "0.1.5", + "version": "0.1.6", "type": "module", "description": "Hydrated accessible editor runtime for WRNexus block documents.", "exports": { diff --git a/packages/block-editor/src/canvas.ts b/packages/block-editor/src/canvas.ts new file mode 100644 index 00000000..64540284 --- /dev/null +++ b/packages/block-editor/src/canvas.ts @@ -0,0 +1,51 @@ +import type { BlockNode } from "@wrnexus/blocks"; +import { mountBlockEditor, type MountedEditor, type MountEditorOptions } from "./runtime.ts"; + +/** Annotates an editor-only rendered document; never changes the canonical tree. */ +export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: MountEditorOptions): MountedEditor { + const elements = new Map([...root.querySelectorAll("[data-block-id]")].map(element => [element.dataset.blockId!, element])); + const cleanup: Array<() => void> = []; + const visit = (node: BlockNode, parent?: BlockNode, slot?: string) => { + const element = elements.get(node.id); + if (element) { + const old = ["data-block-parent", "data-block-slot", "data-block-type", "draggable"].map(name => [name, element.getAttribute(name)] as const); + cleanup.push(() => { for (const [name, value] of old) value === null ? element.removeAttribute(name) : element.setAttribute(name, value); }); + element.setAttribute("draggable", "false"); + if (parent && slot !== undefined) { + element.dataset.blockParent = parent.id; + element.dataset.blockSlot = slot; + element.dataset.blockType = node.type; + const handle = root.ownerDocument.createElement("button"); + handle.type = "button"; + handle.dataset.blockHandle = ""; + handle.className = "wrn-canvas-drag-handle"; + handle.setAttribute("aria-label", `Move ${node.type}`); + handle.style.touchAction = "none"; + handle.textContent = "⋮⋮"; + element.prepend(handle); + cleanup.push(() => handle.remove()); + } + for (const [name, children] of Object.entries(node.slots)) { + if (children.length) continue; + const target = root.ownerDocument.createElement("div"); + target.dataset.blockParent = node.id; + target.dataset.blockSlotTarget = name; + target.className = "wrn-canvas-empty-slot"; + target.textContent = `Drop blocks in ${name}`; + // Root is a presentation table; keep editor decorations in a valid cell. + const container = element.tagName === "TABLE" ? element.querySelector("td") : element; + if (container) { container.append(target); cleanup.push(() => target.remove()); } + } + } + for (const [name, children] of Object.entries(node.slots)) for (const child of children) visit(child, node, name); + }; + visit(tree); + let mounted: MountedEditor; + try { mounted = mountBlockEditor(root, options); } + catch (error) { cleanup.reverse().forEach(fn => fn()); throw error; } + let destroyed = false; + return { + refresh: () => { if (!destroyed) mounted.refresh(); }, + destroy() { if (destroyed) return; destroyed = true; mounted.destroy(); cleanup.reverse().forEach(fn => fn()); }, + }; +} diff --git a/packages/block-editor/src/index.ts b/packages/block-editor/src/index.ts index 0395521a..5b490bea 100644 --- a/packages/block-editor/src/index.ts +++ b/packages/block-editor/src/index.ts @@ -1,3 +1,4 @@ export * from "./state.ts"; export * from "./runtime.ts"; export * from "./unsaved.ts"; +export * from "./canvas.ts"; diff --git a/packages/block-editor/test/canvas.test.ts b/packages/block-editor/test/canvas.test.ts new file mode 100644 index 00000000..6dbddcb1 --- /dev/null +++ b/packages/block-editor/test/canvas.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { Window } from "happy-dom"; + +const code = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/canvas.ts", import.meta.url), "utf8")) + .replace(/^import .*;\s*$/gm, "").replace("export function mountBlockCanvas", "function mountBlockCanvas"); + +test("canvas annotates exact node locations and cleans editor decorations without changing content", () => { + const window = new Window(); + const root = window.document.body; + root.innerHTML = '
'; + const original = root.innerHTML; + const tree = { id: "root", type: "layout.email-document", slots: { content: [{ id: "column", type: "layout.column", slots: { content: [] } }] } }; + const before = JSON.stringify(tree); + let destroyed = 0; + const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => ({ destroy: () => destroyed++, refresh() {} })); + const mounted = mount(root, tree, {}); + const column = root.querySelector('[data-block-id="column"]')!; + expect(column.getAttribute("data-block-parent")).toBe("root"); + expect(column.getAttribute("data-block-slot")).toBe("content"); + expect(column.getAttribute("draggable")).toBe("false"); + expect(root.querySelectorAll("[data-block-handle]")).toHaveLength(1); + expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe("column"); + expect(JSON.stringify(tree)).toBe(before); + mounted.destroy(); mounted.destroy(); + expect(destroyed).toBe(1); + expect(root.innerHTML).toBe(original); +}); + +test("failed canvas mounting rolls back annotations", () => { + const root = new Window().document.body; + root.innerHTML = '
Content
'; + const before = root.innerHTML; + const mount = new Function("mountBlockEditor", code + ";return mountBlockCanvas;")(() => { throw new Error("backend unavailable"); }); + expect(() => mount(root, { id: "root", type: "root", slots: { content: [{ id: "child", type: "text", slots: {} }] } }, {})).toThrow("backend unavailable"); + expect(root.innerHTML).toBe(before); +});