Add shared canvas annotation and drag handle runtime
This commit is contained in:
1 parent
0f8d10eb91
commit
88207cdd48
4 files changed
+90
-1
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/block-editor",
|
"name": "@wrnexus/block-editor",
|
||||||
"version": "0.1.5",
|
"version": "0.1.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -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<HTMLElement>("[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()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./state.ts";
|
export * from "./state.ts";
|
||||||
export * from "./runtime.ts";
|
export * from "./runtime.ts";
|
||||||
export * from "./unsaved.ts";
|
export * from "./unsaved.ts";
|
||||||
|
export * from "./canvas.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 = '<table data-block-id="root"><tbody><tr><td data-block-id="column" draggable="true"><table></table></td></tr></tbody></table>';
|
||||||
|
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 = '<div data-block-id="root"><div data-block-id="child">Content</div></div>';
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user