feat: release design system engine, studio and component contracts

This commit is contained in:
Clintchiz committed 2026-10-19 13:09:03 +05:30
1 parent ff1065fb27
commit 3943b946d6
693 files changed
+175546 -3894

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/block-editor",
"version": "0.1.20",
"version": "0.1.21",
"type": "module",
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
"exports": {
+45 -11
View File
@@ -2,14 +2,30 @@ 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]));
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); });
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) {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
}
});
element.setAttribute("draggable", "false");
if (parent && slot !== undefined) {
element.dataset.blockParent = parent.id;
@@ -25,7 +41,10 @@ export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: Mo
// Clicks must not bubble into selection or inline-edit handlers.
// Pointer/keyboard sensor events remain available to the DnD backend.
for (const name of ["click", "dblclick"])
handle.addEventListener(name, event => { event.preventDefault(); event.stopPropagation(); });
handle.addEventListener(name, (event) => {
event.preventDefault();
event.stopPropagation();
});
element.prepend(handle);
cleanup.push(() => handle.remove());
}
@@ -38,18 +57,33 @@ export function mountBlockCanvas(root: HTMLElement, tree: BlockNode, options: Mo
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()); }
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);
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; }
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()); },
refresh: () => {
if (!destroyed) mounted.refresh();
},
destroy() {
if (destroyed) return;
destroyed = true;
mounted.destroy();
cleanup.reverse().forEach((fn) => fn());
},
};
}
+14 -3
View File
@@ -1,7 +1,12 @@
const downloads = new WeakMap<HTMLElement, () => void>();
/** Keep a direct user-gesture fallback until replaced or the editor is removed. */
export function offerBuilderDownload(host: HTMLElement, content: string, type: string, filename: string): void {
export function offerBuilderDownload(
host: HTMLElement,
content: string,
type: string,
filename: string,
): void {
const outlet = host.querySelector<HTMLElement>("[data-builder-download]");
if (!outlet) throw new Error("Download outlet is unavailable");
const url = URL.createObjectURL(new Blob([content], { type }));
@@ -10,7 +15,9 @@ export function offerBuilderDownload(host: HTMLElement, content: string, type: s
link.download = filename;
link.textContent = `Download prepared file: ${filename}`;
let disposed = false;
const observer = new MutationObserver(() => { if (!host.isConnected) dispose(); });
const observer = new MutationObserver(() => {
if (!host.isConnected) dispose();
});
const dispose = () => {
if (disposed) return;
disposed = true;
@@ -29,5 +36,9 @@ export function offerBuilderDownload(host: HTMLElement, content: string, type: s
throw error;
}
// A blocked automatic click must not destroy the direct-click fallback.
try { link.click(); } catch { /* The visible link remains available. */ }
try {
link.click();
} catch {
/* The visible link remains available. */
}
}
+10 -5
View File
@@ -11,13 +11,18 @@ export function showImageFallbacks(canvas: Document, enabled: boolean): void {
fallback.dataset.imageFallback = "";
const alt = image.getAttribute("alt") ?? "";
fallback.textContent = alt;
if (alt) { fallback.setAttribute("role", "img"); fallback.setAttribute("aria-label", alt); }
else fallback.setAttribute("aria-hidden", "true");
const width = Number(image.getAttribute("width")), height = Number(image.getAttribute("height"));
fallback.style.cssText = "display:flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #64748b;color:#334155;background:#f1f5f9;font:14px Arial;padding:8px;overflow-wrap:anywhere;overflow:hidden;width:100%;min-height:32px;";
if (alt) {
fallback.setAttribute("role", "img");
fallback.setAttribute("aria-label", alt);
} else fallback.setAttribute("aria-hidden", "true");
const width = Number(image.getAttribute("width")),
height = Number(image.getAttribute("height"));
fallback.style.cssText =
"display:flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #64748b;color:#334155;background:#f1f5f9;font:14px Arial;padding:8px;overflow-wrap:anywhere;overflow:hidden;width:100%;min-height:32px;";
if (Number.isFinite(width) && width > 0 && width <= 4000) {
fallback.style.maxWidth = `${width}px`;
if (Number.isFinite(height) && height > 0 && height <= 4000) fallback.style.aspectRatio = `${width}/${height}`;
if (Number.isFinite(height) && height > 0 && height <= 4000)
fallback.style.aspectRatio = `${width}/${height}`;
}
image.after(fallback);
created.push(fallback);
+38 -13
View File
@@ -1,6 +1,13 @@
import { createRegistry, type BlockDefinition, type BlockNode, type LocalizedLabel } from "@wrnexus/blocks";
import {
createRegistry,
type BlockDefinition,
type BlockNode,
type LocalizedLabel,
} from "@wrnexus/blocks";
export interface BuilderCreateContext { id(): string }
export interface BuilderCreateContext {
id(): string;
}
export interface BuilderPreset {
id: string;
name: LocalizedLabel;
@@ -23,36 +30,54 @@ export function createBuilderKit(options: {
}
const validateProps = (type: string, props: Record<string, unknown>): void => {
const definition = registry.get(type);
if (!definition) throw new Error("Unavailable block: " + type + ". Existing data must be preserved.");
if (!definition)
throw new Error("Unavailable block: " + type + ". Existing data must be preserved.");
const parsed = definition.manifest.props.parse(props, "props");
if (!parsed.ok) throw new Error(parsed.errors.map(error => (error.path ?? "props") + ": " + error.message).join("\n"));
if (!parsed.ok)
throw new Error(
parsed.errors.map((error) => (error.path ?? "props") + ": " + error.message).join("\n"),
);
};
const validateCreated = (root: BlockNode): BlockNode => {
const ids = new Set<string>();
const visit = (node: BlockNode, depth: number) => {
if (depth > 30 || ids.size >= 500) throw new Error("Created tree exceeds builder limits");
if (!node.id || ids.has(node.id)) throw new Error("Created node IDs must be nonempty and unique");
if (!node.id || ids.has(node.id))
throw new Error("Created node IDs must be nonempty and unique");
ids.add(node.id);
const definition = registry.get(node.type);
if (!definition || node.version !== definition.manifest.version) throw new Error("Unsupported created block version: " + node.type);
if (!definition || node.version !== definition.manifest.version)
throw new Error("Unsupported created block version: " + node.type);
validateProps(node.type, node.props);
const styles = definition.manifest.styles.parse(node.styles, "styles");
if (!styles.ok) throw new Error(styles.errors.map(error => error.message).join("\n"));
if (!styles.ok) throw new Error(styles.errors.map((error) => error.message).join("\n"));
for (const [name, children] of Object.entries(node.slots)) {
const slot = definition.manifest.slots[name];
if (!slot || !Array.isArray(children)) throw new Error("Unknown or invalid slot: " + name);
if (children.length < (slot.minChildren ?? 0) || children.length > (slot.maxChildren ?? 500)) throw new Error("Invalid slot size: " + name);
if (
children.length < (slot.minChildren ?? 0) ||
children.length > (slot.maxChildren ?? 500)
)
throw new Error("Invalid slot size: " + name);
for (const child of children) {
const childDefinition = registry.get(child.type);
if (slot.accepts?.types && !slot.accepts.types.includes(child.type)) throw new Error("Slot rejects block type: " + child.type);
if (slot.accepts?.capabilities && !slot.accepts.capabilities.every(value => childDefinition?.manifest.capabilities?.includes(value))) throw new Error("Slot rejects block capabilities: " + child.type);
if (slot.accepts?.types && !slot.accepts.types.includes(child.type))
throw new Error("Slot rejects block type: " + child.type);
if (
slot.accepts?.capabilities &&
!slot.accepts.capabilities.every((value) =>
childDefinition?.manifest.capabilities?.includes(value),
)
)
throw new Error("Slot rejects block capabilities: " + child.type);
visit(child, depth + 1);
}
}
for (const [name, slot] of Object.entries(definition.manifest.slots))
if ((slot.minChildren ?? 0) > 0 && !node.slots[name]) throw new Error("Required slot missing: " + name);
const errors = definition.validate?.(node)?.filter(item => item.severity === "error");
if (errors?.length) throw new Error(errors.map(item => item.message).join("\n"));
if ((slot.minChildren ?? 0) > 0 && !node.slots[name])
throw new Error("Required slot missing: " + name);
const errors = definition.validate?.(node)?.filter((item) => item.severity === "error");
if (errors?.length) throw new Error(errors.map((item) => item.message).join("\n"));
};
const copy = structuredClone(root);
visit(copy, 0);
+78 -22
View File
@@ -3,36 +3,58 @@ import { builderPreviewHTML } from "./preview-html.ts";
export function updateBuilderCanvas(frame: HTMLIFrameElement, html: string): void {
const canvas = frame.contentDocument;
if (!canvas?.body) return;
const parsed = new DOMParser().parseFromString(builderPreviewHTML(html, frame.ownerDocument), "text/html");
const parsed = new DOMParser().parseFromString(
builderPreviewHTML(html, frame.ownerDocument),
"text/html",
);
const scrolling = canvas.scrollingElement;
const top = scrolling?.scrollTop ?? 0, left = scrolling?.scrollLeft ?? 0;
canvas.head.replaceChildren(...Array.from(parsed.head.childNodes).map(node => canvas.importNode(node, true)));
const top = scrolling?.scrollTop ?? 0,
left = scrolling?.scrollLeft ?? 0;
canvas.head.replaceChildren(
...Array.from(parsed.head.childNodes).map((node) => canvas.importNode(node, true)),
);
for (const attr of Array.from(canvas.body.attributes)) canvas.body.removeAttribute(attr.name);
for (const attr of Array.from(parsed.body.attributes)) canvas.body.setAttribute(attr.name, attr.value);
canvas.body.replaceChildren(...Array.from(parsed.body.childNodes).map(node => canvas.importNode(node, true)));
if (scrolling) { scrolling.scrollTop = top; scrolling.scrollLeft = left; }
for (const attr of Array.from(parsed.body.attributes))
canvas.body.setAttribute(attr.name, attr.value);
canvas.body.replaceChildren(
...Array.from(parsed.body.childNodes).map((node) => canvas.importNode(node, true)),
);
if (scrolling) {
scrolling.scrollTop = top;
scrolling.scrollLeft = left;
}
}
/** Selection controls are editor decorations, never part of saved/exported HTML. */
export function showBuilderBlockActions(canvas: Document, id: string, label: string, actions: Record<string, () => void>): void {
export function showBuilderBlockActions(
canvas: Document,
id: string,
label: string,
actions: Record<string, () => void>,
): void {
canvas.querySelector("[data-builder-block-actions]")?.remove();
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(
(item) => item.dataset.blockId === id,
);
if (!block) return;
if (!canvas.querySelector('[data-builder-selection-style]')) {
const style = canvas.createElement('style');
style.dataset.builderSelectionStyle = '';
style.textContent = '.wrn-builder-selected[data-block-type="content.button"]{outline:none!important}.wrn-builder-selected[data-block-type="content.button"] a{outline:2px solid var(--wrn-builder-accent);outline-offset:3px}';
if (!canvas.querySelector("[data-builder-selection-style]")) {
const style = canvas.createElement("style");
style.dataset.builderSelectionStyle = "";
style.textContent =
'.wrn-builder-selected[data-block-type="content.button"]{outline:none!important}.wrn-builder-selected[data-block-type="content.button"] a{outline:2px solid var(--wrn-builder-accent);outline-offset:3px}';
canvas.head.append(style);
}
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", label + " actions");
toolbar.style.cssText = "position:absolute;z-index:100;display:flex;gap:2px;align-items:center;padding:3px;box-sizing:border-box;max-width:100%;border-radius:6px;background:var(--wrn-builder-accent,var(--wrn-color-primary,#2563eb));color:white;font:11px Arial;box-shadow:0 3px 12px #0003";
toolbar.style.cssText =
"position:absolute;z-index:100;display:flex;gap:2px;align-items:center;padding:3px;box-sizing:border-box;max-width:100%;border-radius:6px;background:var(--wrn-builder-accent,var(--wrn-color-primary,#2563eb));color:white;font:11px Arial;box-shadow:0 3px 12px #0003";
const name = canvas.createElement("strong");
name.textContent = label.split(" — ")[0];
name.title = label;
name.style.cssText = "padding:0 6px;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
name.style.cssText =
"padding:0 6px;max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
toolbar.append(name);
for (const [name, action] of Object.entries(actions)) {
const button = canvas.createElement("button");
@@ -48,28 +70,62 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
};
if (paths[name]) {
const icon = canvas.createElementNS("http://www.w3.org/2000/svg", "svg");
for (const [key, value] of Object.entries({ width: "15", height: "15", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "1.7", "stroke-linecap": "round", "stroke-linejoin": "round", "aria-hidden": "true" })) icon.setAttribute(key, value);
for (const [key, value] of Object.entries({
width: "15",
height: "15",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "1.7",
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
}))
icon.setAttribute(key, value);
const path = canvas.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", paths[name]);
icon.append(path);
button.append(icon);
} else button.textContent = name;
button.style.cssText = "border:0;border-radius:3px;background:transparent;color:white;min-width:28px;height:28px;display:grid;place-items:center;cursor:pointer";
button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); action(); });
button.style.cssText =
"border:0;border-radius:3px;background:transparent;color:white;min-width:28px;height:28px;display:grid;place-items:center;cursor:pointer";
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
action();
});
toolbar.append(button);
}
// Keep controls outside presentation tables so browser table repair cannot move them.
canvas.body.prepend(toolbar);
const surface = block.dataset.blockType === 'content.button' ? block.querySelector('a') ?? block : block;
const surface =
block.dataset.blockType === "content.button" ? (block.querySelector("a") ?? block) : block;
const bounds = surface.getBoundingClientRect();
toolbar.style.top = Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px";
toolbar.style.left = Math.max(0, Math.min(bounds.right - toolbar.offsetWidth, canvas.documentElement.clientWidth - toolbar.offsetWidth)) + "px";
toolbar.style.top =
Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px";
toolbar.style.left =
Math.max(
0,
Math.min(
bounds.right - toolbar.offsetWidth,
canvas.documentElement.clientWidth - toolbar.offsetWidth,
),
) + "px";
block.setAttribute("aria-label", label + " selected");
}
/** Inline formatting is returned as semantic runs, never raw HTML. */
export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): boolean {
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
export function editBuilderText(
canvas: Document,
id: string,
value: string,
update: (text: string, runs?: BuilderTextRun[]) => void,
finish: () => void,
runs: BuilderTextRun[] = [],
): boolean {
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(
(item) => item.dataset.blockId === id,
);
const element = block?.querySelector<HTMLElement>("h1,h2,h3,h4,p");
if (!element) return false;
mountInlineRichText(canvas, element, value, update, finish, runs);
+12 -6
View File
@@ -7,11 +7,17 @@ export function builderOutlineLabel(
): string {
const name = definition?.name?.fallback || node.type;
const props = node.props ?? {};
const candidate = [props.text, props.label, props.title, props.question, props.alt]
.find(value => typeof value === "string" && value.trim());
const detail = typeof candidate === "string"
? candidate.replace(/\s+/g, " ").trim()
: node.type === "layout.column" && typeof props.width === "string" ? props.width : "";
const candidate = [props.text, props.label, props.title, props.question, props.alt].find(
(value) => typeof value === "string" && value.trim(),
);
const detail =
typeof candidate === "string"
? candidate.replace(/\s+/g, " ").trim()
: node.type === "layout.column" && typeof props.width === "string"
? props.width
: "";
const bounded = Array.from(detail);
return detail ? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}` : name;
return detail
? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}`
: name;
}
+176 -40
View File
@@ -1,4 +1,9 @@
export interface BuilderTextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
export interface BuilderTextRun {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
}
/** Read only allowlisted inline semantics; pasted HTML and executable content are never stored. */
export function readBuilderText(element: HTMLElement): BuilderTextRun[] {
@@ -6,33 +11,63 @@ export function readBuilderText(element: HTMLElement): BuilderTextRun[] {
const append = (text: string, marks: Omit<BuilderTextRun, "text">) => {
if (!text) return;
const last = runs.at(-1);
if (last && !!last.bold === !!marks.bold && !!last.italic === !!marks.italic && !!last.underline === !!marks.underline) last.text += text;
else runs.push({text, ...marks});
if (
last &&
!!last.bold === !!marks.bold &&
!!last.italic === !!marks.italic &&
!!last.underline === !!marks.underline
)
last.text += text;
else runs.push({ text, ...marks });
};
const walk = (node: Node, marks: Omit<BuilderTextRun, "text">) => {
if (node.nodeType === 3) { append(node.textContent ?? "", marks); return; }
if (node.nodeType === 3) {
append(node.textContent ?? "", marks);
return;
}
if (node.nodeType !== 1) return;
const el = node as HTMLElement, tag = el.tagName;
const el = node as HTMLElement,
tag = el.tagName;
if (["SCRIPT", "STYLE", "IFRAME", "OBJECT"].includes(tag)) return;
if (tag === "BR") { append("\n", marks); return; }
if (["DIV", "P"].includes(tag) && runs.length && !runs.at(-1)!.text.endsWith("\n")) append("\n", marks);
const next = {...marks};
if (["B", "STRONG"].includes(tag) || ["bold", "700"].includes(el.style.fontWeight)) next.bold = true;
if (tag === "BR") {
append("\n", marks);
return;
}
if (["DIV", "P"].includes(tag) && runs.length && !runs.at(-1)!.text.endsWith("\n"))
append("\n", marks);
const next = { ...marks };
if (["B", "STRONG"].includes(tag) || ["bold", "700"].includes(el.style.fontWeight))
next.bold = true;
if (["I", "EM"].includes(tag) || el.style.fontStyle === "italic") next.italic = true;
if (tag === "U" || el.style.textDecoration.includes("underline")) next.underline = true;
el.childNodes.forEach(child => walk(child, next));
el.childNodes.forEach((child) => walk(child, next));
};
element.childNodes.forEach(node => walk(node, {}));
element.childNodes.forEach((node) => walk(node, {}));
return runs;
}
export function mountInlineRichText(canvas: Document, element: HTMLElement, value: string, update: (text: string, runs?: BuilderTextRun[]) => void, finish: () => void, runs: BuilderTextRun[] = []): void {
export function mountInlineRichText(
canvas: Document,
element: HTMLElement,
value: string,
update: (text: string, runs?: BuilderTextRun[]) => void,
finish: () => void,
runs: BuilderTextRun[] = [],
): void {
element.replaceChildren();
const initial = runs.map(run => run.text).join("") === value ? runs : [{text: value}];
const initial = runs.map((run) => run.text).join("") === value ? runs : [{ text: value }];
for (const run of initial) {
let child: Node = canvas.createTextNode(run.text);
for (const [mark, tag] of [[run.underline, "u"], [run.italic, "em"], [run.bold, "strong"]] as const) {
if (mark) { const wrapper = canvas.createElement(tag); wrapper.append(child); child = wrapper; }
for (const [mark, tag] of [
[run.underline, "u"],
[run.italic, "em"],
[run.bold, "strong"],
] as const) {
if (mark) {
const wrapper = canvas.createElement(tag);
wrapper.append(child);
child = wrapper;
}
}
element.append(child);
}
@@ -41,43 +76,144 @@ export function mountInlineRichText(canvas: Document, element: HTMLElement, valu
element.setAttribute("aria-label", "Edit rich text");
element.setAttribute("aria-multiline", "true");
element.style.whiteSpace = "pre-wrap";
const block = element.closest<HTMLElement>('[data-block-id]');
block?.classList.add('wrn-inline-editing');
const editingStyle = canvas.createElement('style');
editingStyle.textContent = '.wrn-inline-editing{outline:none!important}.wrn-inline-editing>[data-block-handle]{visibility:hidden!important}.wrn-inline-editing [contenteditable="true"]{outline:2px solid var(--wrn-builder-accent)!important;outline-offset:4px;border-radius:2px;cursor:text}';
const block = element.closest<HTMLElement>("[data-block-id]");
block?.classList.add("wrn-inline-editing");
const editingStyle = canvas.createElement("style");
editingStyle.textContent =
'.wrn-inline-editing{outline:none!important}.wrn-inline-editing>[data-block-handle]{visibility:hidden!important}.wrn-inline-editing [contenteditable="true"]{outline:2px solid var(--wrn-builder-accent)!important;outline-offset:4px;border-radius:2px;cursor:text}';
canvas.head.append(editingStyle);
const toolbar = canvas.createElement("div");
toolbar.dataset.builderBlockActions = "rich-text";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "Text formatting");
toolbar.style.cssText = "position:fixed;z-index:1000;display:flex;gap:4px;padding:6px;border-radius:8px;background:var(--wrn-builder-accent,#2563eb);color:white;box-shadow:0 4px 16px #0003;font:14px Arial";
canvas.querySelectorAll('[data-builder-block-actions]').forEach(item => item.remove());
toolbar.style.cssText =
"position:fixed;z-index:1000;display:flex;gap:4px;padding:6px;border-radius:8px;background:var(--wrn-builder-accent,#2563eb);color:white;box-shadow:0 4px 16px #0003;font:14px Arial";
canvas.querySelectorAll("[data-builder-block-actions]").forEach((item) => item.remove());
canvas.body.append(toolbar);
const position = () => { const box = element.getBoundingClientRect(); toolbar.style.top = Math.max(4, box.top - 44) + "px"; toolbar.style.left = Math.max(4, Math.min(box.left, canvas.documentElement.clientWidth - toolbar.offsetWidth - 4)) + "px"; };
const emit = () => { const result = readBuilderText(element); update(result.map(run => run.text).join(""), result); };
const position = () => {
const box = element.getBoundingClientRect();
toolbar.style.top = Math.max(4, box.top - 44) + "px";
toolbar.style.left =
Math.max(
4,
Math.min(box.left, canvas.documentElement.clientWidth - toolbar.offsetWidth - 4),
) + "px";
};
const emit = () => {
const result = readBuilderText(element);
update(result.map((run) => run.text).join(""), result);
};
let selection: Range | null = null;
const remember = () => { const current = canvas.getSelection(); if (current?.rangeCount && element.contains(current.anchorNode) && element.contains(current.focusNode)) selection = current.getRangeAt(0).cloneRange(); };
const restore = () => { element.focus({preventScroll:true}); if (selection) {const current = canvas.getSelection(); current?.removeAllRanges(); current?.addRange(selection);} };
const abort = new (canvas.defaultView!.AbortController)();
const options = {signal: abort.signal};
const remember = () => {
const current = canvas.getSelection();
if (
current?.rangeCount &&
element.contains(current.anchorNode) &&
element.contains(current.focusNode)
)
selection = current.getRangeAt(0).cloneRange();
};
const restore = () => {
element.focus({ preventScroll: true });
if (selection) {
const current = canvas.getSelection();
current?.removeAllRanges();
current?.addRange(selection);
}
};
const abort = new canvas.defaultView!.AbortController();
const options = { signal: abort.signal };
let done = false;
const close = () => { if (done) return; done = true; abort.abort(); toolbar.remove(); editingStyle.remove(); block?.classList.remove('wrn-inline-editing'); element.contentEditable = "false"; finish(); };
for (const [label, command] of [["Bold", "bold"], ["Italic", "italic"], ["Underline", "underline"], ["Clear formatting", "removeFormat"], ["Done", "done"]]) {
const close = () => {
if (done) return;
done = true;
abort.abort();
toolbar.remove();
editingStyle.remove();
block?.classList.remove("wrn-inline-editing");
element.contentEditable = "false";
finish();
};
for (const [label, command] of [
["Bold", "bold"],
["Italic", "italic"],
["Underline", "underline"],
["Clear formatting", "removeFormat"],
["Done", "done"],
]) {
const button = canvas.createElement("button");
button.type = "button"; button.textContent = label; button.setAttribute("aria-label", label!);
button.style.cssText = "background:transparent;color:inherit;border:0;border-radius:4px;padding:6px;cursor:pointer;font:inherit";
button.addEventListener("mousedown", event => event.preventDefault(), options);
button.addEventListener("click", event => {event.stopPropagation(); if (command === "done") { close(); return; } restore(); canvas.execCommand(command!); remember(); emit();}, options);
button.type = "button";
button.textContent = label;
button.setAttribute("aria-label", label!);
button.style.cssText =
"background:transparent;color:inherit;border:0;border-radius:4px;padding:6px;cursor:pointer;font:inherit";
button.addEventListener("mousedown", (event) => event.preventDefault(), options);
button.addEventListener(
"click",
(event) => {
event.stopPropagation();
if (command === "done") {
close();
return;
}
restore();
canvas.execCommand(command!);
remember();
emit();
},
options,
);
toolbar.append(button);
}
element.addEventListener("input", emit, options);
element.addEventListener("paste", event => {event.preventDefault(); canvas.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? ""); emit();}, options);
element.addEventListener("drop", event => event.preventDefault(), options);
element.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") {event.preventDefault(); close();}}, options);
toolbar.addEventListener("keydown", event => {event.stopPropagation(); if(event.key === "Escape") close();}, options);
element.addEventListener(
"paste",
(event) => {
event.preventDefault();
canvas.execCommand("insertText", false, event.clipboardData?.getData("text/plain") ?? "");
emit();
},
options,
);
element.addEventListener("drop", (event) => event.preventDefault(), options);
element.addEventListener(
"keydown",
(event) => {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
close();
}
},
options,
);
toolbar.addEventListener(
"keydown",
(event) => {
event.stopPropagation();
if (event.key === "Escape") close();
},
options,
);
canvas.addEventListener("selectionchange", remember, options);
canvas.addEventListener("focusin", event => {if (!element.contains(event.target as Node) && !toolbar.contains(event.target as Node)) close();}, options);
element.addEventListener("blur", () => {setTimeout(() => {if (!toolbar.contains(canvas.activeElement) && canvas.activeElement !== element) close();}, 0);}, options);
canvas.addEventListener(
"focusin",
(event) => {
if (!element.contains(event.target as Node) && !toolbar.contains(event.target as Node))
close();
},
options,
);
element.addEventListener(
"blur",
() => {
setTimeout(() => {
if (!toolbar.contains(canvas.activeElement) && canvas.activeElement !== element) close();
}, 0);
},
options,
);
canvas.defaultView?.addEventListener("scroll", position, options);
element.focus({preventScroll:true}); position();
element.focus({ preventScroll: true });
position();
}
+125 -110
View File
@@ -39,123 +39,138 @@ export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions)
cleanups.forEach((cleanup) => cleanup());
} finally {
cleanups = [];
try { manager?.destroy(); } finally { live.remove(); }
try {
manager?.destroy();
} finally {
live.remove();
}
}
};
const refresh = () => {
if (destroyed) return;
try {
const currentGeneration = ++generation;
cleanups.forEach((cleanup) => cleanup());
cleanups = [];
manager?.destroy();
let originalChildren = new Map<Node, Node[]>();
let gesture = 0;
const groups = new Map<string, { parentId: string; slot: string }>();
manager = createDndManager({
accept(ids, location) {
const destination = groups.get(String(location.group));
return (
!destroyed &&
generation === currentGeneration &&
!!destination &&
(options.canMove?.({
nodeIds: ids.map(String),
to: { ...destination, index: location.index },
}) ??
true)
);
},
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;
}
const currentGeneration = ++generation;
cleanups.forEach((cleanup) => cleanup());
cleanups = [];
manager?.destroy();
let originalChildren = new Map<Node, Node[]>();
let gesture = 0;
const groups = new Map<string, { parentId: string; slot: string }>();
manager = createDndManager({
accept(ids, location) {
const destination = groups.get(String(location.group));
return (
!destroyed &&
generation === currentGeneration &&
!!destination &&
(options.canMove?.({
nodeIds: ids.map(String),
to: { ...destination, index: location.index },
}) ??
true)
);
},
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]);
}
refresh();
}, 0);
} else if (announcement.type === "dropped") originalChildren.clear();
live.textContent = options.announce?.(announcement) ?? announcement.type;
},
async onMoveIntent(intent) {
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: { ...destination, index: intent.to.index },
});
},
});
const elements = [...root.querySelectorAll<HTMLElement>(
"[data-block-id][data-block-parent][data-block-slot]",
)];
const groupSizes = new Map<string, number>();
const registrations = elements.map(element => {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlot!;
const group = JSON.stringify([parentId, slot]);
const index = groupSizes.get(group) ?? 0;
groupSizes.set(group, index + 1);
groups.set(group, { parentId, slot });
return { element, group, index };
});
for (const element of root.querySelectorAll<HTMLElement>("[data-block-slot-target][data-block-parent]")) {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlotTarget!;
const group = JSON.stringify([parentId, slot]);
if (groupSizes.has(group)) continue;
groups.set(group, { parentId, slot });
cleanups.push(manager.registerDropZone({ id: `wrn-slot:${group}`, group, index: 0, element }));
}
for (const { element, group, index } of registrations) {
const id = element.dataset.blockId!;
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
cleanups.push(
manager.registerSortable({
id,
group,
index,
element,
handle,
type: element.dataset.blockType,
}),
);
}
} 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) {
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: { ...destination, index: intent.to.index },
});
},
});
const elements = [
...root.querySelectorAll<HTMLElement>(
"[data-block-id][data-block-parent][data-block-slot]",
),
];
const groupSizes = new Map<string, number>();
const registrations = elements.map((element) => {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlot!;
const group = JSON.stringify([parentId, slot]);
const index = groupSizes.get(group) ?? 0;
groupSizes.set(group, index + 1);
groups.set(group, { parentId, slot });
return { element, group, index };
});
for (const element of root.querySelectorAll<HTMLElement>(
"[data-block-slot-target][data-block-parent]",
)) {
const parentId = element.dataset.blockParent!;
const slot = element.dataset.blockSlotTarget!;
const group = JSON.stringify([parentId, slot]);
if (groupSizes.has(group)) continue;
groups.set(group, { parentId, slot });
cleanups.push(
manager.registerDropZone({ id: `wrn-slot:${group}`, group, index: 0, element }),
);
}
for (const { element, group, index } of registrations) {
const id = element.dataset.blockId!;
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
cleanups.push(
manager.registerSortable({
id,
group,
index,
element,
handle,
type: element.dataset.blockType,
}),
);
}
} catch (error) {
try { destroy(); } finally { throw error; }
try {
destroy();
} catch {
/* Preserve the original registration failure. */
}
throw error;
}
};
refresh();
+19 -3
View File
@@ -12,20 +12,36 @@ export function mountUnsavedChangesGuard(
event.returnValue = "";
};
const click = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
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 (
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(); });
const observer = new view.MutationObserver(() => {
if (!root.isConnected) destroy();
});
function destroy() {
if (destroyed) return;
destroyed = true;
+32 -10
View File
@@ -2,18 +2,28 @@ 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");
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>';
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 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 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");
@@ -21,7 +31,8 @@ test("canvas annotates exact node locations and cleans editor decorations withou
expect(column.getAttribute("draggable")).toBe("false");
expect(root.querySelectorAll("[data-block-handle]")).toHaveLength(1);
const handle = root.querySelector("[data-block-handle]")!;
let clicks = 0, pointers = 0;
let clicks = 0,
pointers = 0;
column.addEventListener("click", () => clicks++);
column.addEventListener("dblclick", () => clicks++);
column.addEventListener("pointerdown", () => pointers++);
@@ -30,9 +41,12 @@ test("canvas annotates exact node locations and cleans editor decorations withou
handle.dispatchEvent(new window.PointerEvent("pointerdown", { bubbles: true }));
expect(clicks).toBe(0);
expect(pointers).toBe(1);
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe("column");
expect(root.querySelector("[data-block-slot-target]")?.getAttribute("data-block-parent")).toBe(
"column",
);
expect(JSON.stringify(tree)).toBe(before);
mounted.destroy(); mounted.destroy();
mounted.destroy();
mounted.destroy();
expect(destroyed).toBe(1);
expect(root.innerHTML).toBe(original);
});
@@ -41,7 +55,15 @@ 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");
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);
});
+65 -13
View File
@@ -1,23 +1,75 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
const source = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8")).replace("export function", "function");
const source = new Bun.Transpiler({ loader: "ts" })
.transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8"))
.replace("export function", "function");
test("download keeps direct link after blocked automatic click and releases replaced/detached blobs", () => {
const revoked: string[] = [], observers: Array<{ callback(): void; disconnect(): void }> = [];
let sequence = 0, clicks = 0, disconnected = 0;
const revoked: string[] = [],
observers: Array<{ callback(): void; disconnect(): void }> = [];
let sequence = 0,
clicks = 0,
disconnected = 0;
const links: any[] = [];
const outlet = { replaceChildren: (link: unknown) => { links.splice(0, links.length, link); } };
const host = { isConnected: true, querySelector: () => outlet, ownerDocument: { documentElement: {}, createElement: () => ({ href: "", download: "", textContent: "", click() { clicks++; throw new Error("blocked"); }, remove() { const index = links.indexOf(this); if (index >= 0) links.splice(index, 1); } }) } };
const offer = new Function("URL", "Blob", "MutationObserver", source + ";return offerBuilderDownload;")(
{ createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: (url: string) => revoked.push(url) }, Blob,
class { constructor(public callback: () => void) { observers.push(this); } observe() {} disconnect() { disconnected++; } },
const outlet = {
replaceChildren: (link: unknown) => {
links.splice(0, links.length, link);
},
};
const host = {
isConnected: true,
querySelector: () => outlet,
ownerDocument: {
documentElement: {},
createElement: () => ({
href: "",
download: "",
textContent: "",
click() {
clicks++;
throw new Error("blocked");
},
remove() {
const index = links.indexOf(this);
if (index >= 0) links.splice(index, 1);
},
}),
},
};
const offer = new Function(
"URL",
"Blob",
"MutationObserver",
source + ";return offerBuilderDownload;",
)(
{
createObjectURL: () => `blob:${++sequence}`,
revokeObjectURL: (url: string) => revoked.push(url),
},
Blob,
class {
constructor(public callback: () => void) {
observers.push(this);
}
observe() {}
disconnect() {
disconnected++;
}
},
);
offer(host, "html", "text/html", "draft.html");
expect(links[0].href).toBe("blob:1"); expect(links[0].download).toBe("draft.html");
expect(revoked).toEqual([]); expect(clicks).toBe(1);
expect(links[0].href).toBe("blob:1");
expect(links[0].download).toBe("draft.html");
expect(revoked).toEqual([]);
expect(clicks).toBe(1);
offer(host, "text", "text/plain", "draft.txt");
expect(revoked).toEqual(["blob:1"]); expect(links[0].download).toBe("draft.txt");
host.isConnected = false; observers[1]!.callback(); observers[1]!.callback();
expect(revoked).toEqual(["blob:1", "blob:2"]); expect(links).toEqual([]); expect(disconnected).toBe(2);
expect(revoked).toEqual(["blob:1"]);
expect(links[0].download).toBe("draft.txt");
host.isConnected = false;
observers[1]!.callback();
observers[1]!.callback();
expect(revoked).toEqual(["blob:1", "blob:2"]);
expect(links).toEqual([]);
expect(disconnected).toBe(2);
});
@@ -3,24 +3,39 @@ import { showImageFallbacks } from "../src/image-preview.ts";
test("image-disabled mode exposes safe alt text, proportions and reversible placeholders", () => {
const displayed: any[] = [];
const images = [{ alt: '<b>Logo & name</b>', width: '320', height: '180' }, { alt: '', width: 'not a size', height: '99999' }].map(attributes => ({
const images = [
{ alt: "<b>Logo & name</b>", width: "320", height: "180" },
{ alt: "", width: "not a size", height: "99999" },
].map((attributes) => ({
getAttribute: (key: string) => (attributes as Record<string, string>)[key] ?? null,
after: (element: unknown) => displayed.push(element),
}));
const canvas = {
querySelectorAll: () => images,
createElement: () => ({ dataset: {}, style: {} as Record<string, string>, textContent: "", attributes: {} as Record<string, string>, setAttribute(key: string, value: string) { this.attributes[key] = value; }, remove() { const index = displayed.indexOf(this); if (index >= 0) displayed.splice(index, 1); } }),
createElement: () => ({
dataset: {},
style: {} as Record<string, string>,
textContent: "",
attributes: {} as Record<string, string>,
setAttribute(key: string, value: string) {
this.attributes[key] = value;
},
remove() {
const index = displayed.indexOf(this);
if (index >= 0) displayed.splice(index, 1);
},
}),
} as unknown as Document;
showImageFallbacks(canvas, true);
expect(displayed).toHaveLength(2);
expect(displayed[0].textContent).toBe('<b>Logo & name</b>');
expect(displayed[0].attributes['aria-label']).toBe('<b>Logo & name</b>');
expect(displayed[0].style.aspectRatio).toBe('320/180');
expect(displayed[1].attributes['aria-hidden']).toBe('true');
expect(displayed[0].textContent).toBe("<b>Logo & name</b>");
expect(displayed[0].attributes["aria-label"]).toBe("<b>Logo & name</b>");
expect(displayed[0].style.aspectRatio).toBe("320/180");
expect(displayed[1].attributes["aria-hidden"]).toBe("true");
expect(displayed[1].style.maxWidth).toBeUndefined();
showImageFallbacks(canvas, true);
expect(displayed).toHaveLength(2);
showImageFallbacks(canvas, false);
expect(displayed).toHaveLength(0);
expect(images[0]!.getAttribute('alt')).toBe('<b>Logo & name</b>');
expect(images[0]!.getAttribute("alt")).toBe("<b>Logo & name</b>");
});
+49 -9
View File
@@ -3,16 +3,43 @@ import { defineBlock, object, string } from "@wrnexus/blocks";
import { createBuilderKit } from "../src/kit.ts";
const block = defineBlock({
manifest: { type: "custom.note", version: 1, name: { key: "note", fallback: "Note" }, icon: "note", category: "content",
props: object({ text: string({ min: 1 }) }), styles: object({}), slots: {} },
create: ({ id }: { id(): string }) => ({ id: id(), type: "custom.note", version: 1, props: { text: "Hello" }, styles: {}, slots: {} }),
manifest: {
type: "custom.note",
version: 1,
name: { key: "note", fallback: "Note" },
icon: "note",
category: "content",
props: object({ text: string({ min: 1 }) }),
styles: object({}),
slots: {},
},
create: ({ id }: { id(): string }) => ({
id: id(),
type: "custom.note",
version: 1,
props: { text: "Hello" },
styles: {},
slots: {},
}),
});
test("trusted custom blocks and presets expose independent instances and explicit metadata", () => {
let next = 0; const context = { id: () => String(++next) };
const kit = createBuilderKit({ blocks: [block], presets: [{ id: "note", name: { key: "preset.note", fallback: "Note" }, icon: "note", create: block.create }] });
let next = 0;
const context = { id: () => String(++next) };
const kit = createBuilderKit({
blocks: [block],
presets: [
{
id: "note",
name: { key: "preset.note", fallback: "Note" },
icon: "note",
create: block.create,
},
],
});
expect(kit.blocks()[0].manifest.name.fallback).toBe("Note");
expect(kit.presets()[0].id).toBe("note");
const first = kit.createBlock("custom.note", context), second = kit.createPreset("note", context);
const first = kit.createBlock("custom.note", context),
second = kit.createPreset("note", context);
first.props.text = "Changed";
expect(second.props.text).toBe("Hello");
expect(first.id).not.toBe(second.id);
@@ -22,10 +49,23 @@ test("trusted custom blocks and presets expose independent instances and explici
});
test("duplicate registration and invalid factories fail before insertion", () => {
expect(() => createBuilderKit({ blocks: [block, block] })).toThrow();
const preset = { id: "note", name: { key: "note", fallback: "Note" }, icon: "note", create: block.create };
const preset = {
id: "note",
name: { key: "note", fallback: "Note" },
icon: "note",
create: block.create,
};
expect(() => createBuilderKit({ blocks: [block], presets: [preset, preset] })).toThrow();
const invalid = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) }] });
const invalid = createBuilderKit({
blocks: [
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) },
],
});
expect(() => invalid.createBlock("custom.note", { id: () => "id" })).toThrow();
const wrong = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) }] });
const wrong = createBuilderKit({
blocks: [
{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) },
],
});
expect(() => wrong.createBlock("custom.note", { id: () => "id" })).toThrow();
});
+20 -5
View File
@@ -2,19 +2,34 @@ import { expect, test } from "bun:test";
import type { BlockNode } from "@wrnexus/blocks";
import { builderOutlineLabel } from "../src/outline.ts";
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({ id: "n", type, version: 1, props, styles: {}, slots: {} });
const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({
id: "n",
type,
version: 1,
props,
styles: {},
slots: {},
});
test("outline uses registered names and distinguishes content without mutating it", () => {
const block = node({ label: " Buy\n now " });
expect(builderOutlineLabel(block, { name: { fallback: "Button" } })).toBe("Button — Buy now");
expect(block.props.label).toBe(" Buy\n now ");
expect(builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } })).toBe("Column — 35%");
expect(
builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } }),
).toBe("Column — 35%");
expect(builderOutlineLabel(node({}, "custom.unknown"))).toBe("custom.unknown");
});
test("outline bounds Unicode snippets and treats content as plain text", () => {
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), { name: { fallback: "Button" } });
const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), {
name: { fallback: "Button" },
});
expect(label).toBe("Button — " + "😀".repeat(64) + "…");
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain('<img onerror="bad">');
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe("content.button — Product title");
expect(builderOutlineLabel(node({ label: '<img onerror="bad">' }))).toContain(
'<img onerror="bad">',
);
expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe(
"content.button — Product title",
);
});
@@ -4,10 +4,15 @@ import { builderPreviewHTML } from "../src/preview-html.ts";
test("preview sanitization preserves email layout and editor markers but removes executable content", () => {
const { window } = new JSDOM("");
const result = String(builderPreviewHTML('<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>', window.document as unknown as Document));
const result = String(
builderPreviewHTML(
'<html><head><style>td{padding:12px}</style></head><body><table data-block-id="one"><tr><td><img src="https://example.com/a.png" onerror="alert(1)"><a href="javascript:alert(1)">Hello</a></td></tr></table><script>alert(1)</script><iframe src="https://example.com"></iframe></body></html>',
window.document as unknown as Document,
),
);
expect(result).toContain('data-block-id="one"');
expect(result).toContain('<table');
expect(result).toContain('<style>');
expect(result).toContain("<table");
expect(result).toContain("<style>");
expect(result).not.toMatch(/onerror|javascript:|<script|<iframe/);
window.close();
});
@@ -20,7 +25,9 @@ test("separately bundled preview modules reuse one realm sanitizer", async () =>
const first = (window as any)[key];
const secondPageModule = "../src/preview-html.ts?second-page";
const secondModule = await import(secondPageModule);
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain("Second page");
expect(String(secondModule.builderPreviewHTML("<p>Second page</p>", doc))).toContain(
"Second page",
);
expect((window as any)[key]).toBe(first);
window.close();
});
+19 -5
View File
@@ -5,20 +5,34 @@ import { readBuilderText, mountInlineRichText } from "../src/rich-text.ts";
test("inline rich text extracts only supported semantics", () => {
const window = new Window();
const element = window.document.createElement("p");
element.innerHTML = 'Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>';
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([{text:"Hello "},{text:"bold ",bold:true},{text:"and italic",bold:true,italic:true},{text:"\n"},{text:"line",underline:true}]);
element.innerHTML =
"Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>";
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([
{ text: "Hello " },
{ text: "bold ", bold: true },
{ text: "and italic", bold: true, italic: true },
{ text: "\n" },
{ text: "line", underline: true },
]);
});
test("rich editor commits semantic input and cleans up on Done", () => {
const window = new Window();
const element = window.document.createElement("p");
window.document.body.append(element);
const updates: unknown[] = []; let finished = 0;
mountInlineRichText(window.document as unknown as Document, element as unknown as HTMLElement, "hello", (text, runs) => updates.push({text,runs}), () => finished++);
const updates: unknown[] = [];
let finished = 0;
mountInlineRichText(
window.document as unknown as Document,
element as unknown as HTMLElement,
"hello",
(text, runs) => updates.push({ text, runs }),
() => finished++,
);
expect(element.contentEditable).toBe("true");
element.innerHTML = "<strong>bold</strong>";
element.dispatchEvent(new window.Event("input"));
expect(updates).toEqual([{text:"bold",runs:[{text:"bold",bold:true}]}]);
expect(updates).toEqual([{ text: "bold", runs: [{ text: "bold", bold: true }] }]);
(window.document.querySelector('button[aria-label="Done"]') as any).click();
expect(finished).toBe(1);
expect(element.contentEditable).toBe("false");
+53 -15
View File
@@ -11,22 +11,43 @@ const code = new Bun.Transpiler({ loader: "ts" })
test("partial registration failure cleans the backend and live region on mount and refresh", () => {
for (const failingMount of [1, 2]) {
const window = new Window(), root = window.document.createElement("div");
const window = new Window(),
root = window.document.createElement("div");
window.document.body.append(root);
root.innerHTML = '<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
let mounts = 0, destroyed = 0, cleaned = 0;
root.innerHTML =
'<div data-block-id="a" data-block-parent="root" data-block-slot="content"></div><div data-block-id="b" data-block-parent="root" data-block-slot="content"></div>';
let mounts = 0,
destroyed = 0,
cleaned = 0;
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(() => {
const number = ++mounts; let registrations = 0;
return { registerSortable() { if (++registrations === 2 && number === failingMount) throw new Error("registration failed"); return () => { cleaned++; }; }, destroy() { destroyed++; } };
const number = ++mounts;
let registrations = 0;
return {
registerSortable() {
if (++registrations === 2 && number === failingMount)
throw new Error("registration failed");
return () => {
cleaned++;
};
},
destroy() {
destroyed++;
},
};
});
let editor: MountedEditor | undefined;
if (failingMount === 1) expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
else { editor = mount(root, { onMove() {} }); expect(() => editor!.refresh()).toThrow("registration failed"); }
if (failingMount === 1)
expect(() => mount(root, { onMove() {} })).toThrow("registration failed");
else {
editor = mount(root, { onMove() {} });
expect(() => editor!.refresh()).toThrow("registration failed");
}
expect(root.querySelector('[role="status"]')).toBeNull();
expect(root.querySelectorAll('[data-block-id]')).toHaveLength(2);
expect(root.querySelectorAll("[data-block-id]")).toHaveLength(2);
expect(destroyed).toBe(failingMount);
expect(cleaned).toBe(failingMount === 1 ? 1 : 3);
editor?.destroy(); editor?.refresh();
editor?.destroy();
editor?.refresh();
expect(destroyed).toBe(failingMount);
}
});
@@ -46,7 +67,12 @@ function fixture(onMove: MountEditorOptions["onMove"] = () => false) {
}> = [];
const mount = new Function("createDndManager", code + ";return mountBlockEditor;")(
(options: DndManagerOptions) => {
const backend = { options, registrations: [] as SortableRegistration[], zones: [] as DropZoneRegistration[], destroyed: 0 };
const backend = {
options,
registrations: [] as SortableRegistration[],
zones: [] as DropZoneRegistration[],
destroyed: 0,
};
backends.push(backend);
return {
registerDropZone: (registration: DropZoneRegistration) => {
@@ -100,9 +126,15 @@ test("large canvas registration scans blocks once and preserves interleaved slot
const backend = f.backends.at(-1)!;
expect(scans).toBe(1);
expect(backend.registrations.map(({ id, group, index }) => ({ id, group, index }))).toEqual(
Array.from({ length: 300 }, (_, i) => ({ id: String(i), group: JSON.stringify([`parent:${i % 3}`, "content"]), index: Math.floor(i / 3) })),
Array.from({ length: 300 }, (_, i) => ({
id: String(i),
group: JSON.stringify([`parent:${i % 3}`, "content"]),
index: Math.floor(i / 3),
})),
);
expect(backend.zones.map(zone => zone.group)).toEqual([JSON.stringify(["parent:3", "content"])]);
expect(backend.zones.map((zone) => zone.group)).toEqual([
JSON.stringify(["parent:3", "content"]),
]);
f.editor.destroy();
});
@@ -127,7 +159,10 @@ test("cancelled optimistic moves restore original DOM nodes across containers",
test("empty named slot targets produce validated editor move intents and refresh away when populated", async () => {
const moves: unknown[] = [];
const f = fixture(intent => { moves.push(intent); return true; });
const f = fixture((intent) => {
moves.push(intent);
return true;
});
const target = f.document.createElement("div");
target.dataset.blockParent = "parent:empty";
target.dataset.blockSlotTarget = "content:empty";
@@ -138,9 +173,12 @@ test("empty named slot targets produce validated editor move intents and refresh
const zone = backend.zones[0]!;
expect(backend.options.accept!(["a"], { group: zone.group, index: 0 })).toBe(true);
await backend.options.onMoveIntent({ ids: ["a"], from: [], to: { group: zone.group, index: 0 } });
expect(moves).toEqual([{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } }]);
expect(moves).toEqual([
{ nodeIds: ["a"], to: { parentId: "parent:empty", slot: "content:empty", index: 0 } },
]);
const child = f.root.querySelector('[data-block-id="a"]')! as any;
child.dataset.blockParent = "parent:empty"; child.dataset.blockSlot = "content:empty";
child.dataset.blockParent = "parent:empty";
child.dataset.blockSlot = "content:empty";
target.append(child);
f.editor.refresh();
expect(f.backends.at(-1)!.zones).toHaveLength(0);
@@ -3,10 +3,16 @@ import { Window } from "happy-dom";
import { showBuilderBlockActions } from "../src/live-canvas.ts";
test("compact selection actions retain accessible names and callbacks without interpreting labels", () => {
const window = new Window(), document = window.document;
const window = new Window(),
document = window.document;
document.body.innerHTML = '<div data-block-id="text">Content</div>';
let calls = 0;
showBuilderBlockActions(document as unknown as Document, "text", "Text — <script>example</script>", { Edit: () => calls++, Delete: () => calls++ });
showBuilderBlockActions(
document as unknown as Document,
"text",
"Text — <script>example</script>",
{ Edit: () => calls++, Delete: () => calls++ },
);
const toolbar = document.querySelector('[role="toolbar"]')!;
expect(toolbar.getAttribute("aria-label")).toBe("Text — <script>example</script> actions");
expect(toolbar.querySelector("strong")?.textContent).toBe("Text");
+24 -6
View File
@@ -6,13 +6,29 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
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; };
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 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;
link.dispatchEvent(event);
return event.defaultPrevented;
};
expect(unload()).toBe(true);
expect(click("/builder/two")).toBe(true);
@@ -23,6 +39,8 @@ test("unsaved guard protects unload and ordinary navigation, permits safe links,
expect(click("/builder/two")).toBe(false);
dirty = false;
expect(unload()).toBe(false);
guard.destroy(); guard.destroy(); dirty = true;
guard.destroy();
guard.destroy();
dirty = true;
expect(unload()).toBe(false);
});