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

+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;