feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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());
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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. */
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user