Expose reusable builder kit surfaces and email preset pack
This commit is contained in:
1 parent
e019ac34c8
commit
eb1875f0bc
20 files changed
+635
-4
No files matched your search
@@ -1,4 +1,42 @@
|
||||
# @wrnexus/block-editor
|
||||
|
||||
Hydrated editor state and DOM runtime for portable block documents. All changes
|
||||
flow through validated `@wrnexus/blocks` commands; DnD produces move intents.
|
||||
Reusable editor state, DOM surfaces, inspector drafts, clipboard-download UI,
|
||||
selection actions, and explicit block/preset composition.
|
||||
|
||||
## Configure a builder
|
||||
|
||||
```ts
|
||||
import { createBuilderKit, createBlockEditorState, mountBlockCanvas } from "@wrnexus/block-editor";
|
||||
import { emailEditorBlocks } from "@wrnexus/email-renderer/create";
|
||||
import { emailPresets } from "@wrnexus/email-renderer/presets";
|
||||
|
||||
const builder = createBuilderKit({
|
||||
blocks: [...emailEditorBlocks, myTrustedBlockDefinition],
|
||||
presets: [...emailPresets, myTrustedSectionPreset],
|
||||
});
|
||||
|
||||
const palette = builder.blocks(); // manifests: name, icon, category, schemas
|
||||
const presets = builder.presets();
|
||||
const node = builder.createBlock("custom.my-block", { id: () => crypto.randomUUID() });
|
||||
```
|
||||
|
||||
Definitions use `defineBlock` from `@wrnexus/blocks`: manifest, create,
|
||||
renderers, textRenderer, inspector controls, and migrations. Presets have
|
||||
`id`, localized `name`, `icon`, and `create({ id })`. Registration is
|
||||
explicit and frozen; duplicates and invalid created trees throw before insertion.
|
||||
Factories must return schema-valid instances. Placement still goes through the
|
||||
document-kind command validator; kit validation is not publish authorization.
|
||||
|
||||
Use the same trusted definitions in your document kind and server renderer.
|
||||
Adding a block to a palette alone does not enable its rendering or publishing.
|
||||
The built-in email production adapter currently uses its core email kind; custom
|
||||
email target composition and a complete high-level editor component remain work
|
||||
in progress.
|
||||
|
||||
The package exports `InspectorDrafts`, `builderOutlineLabel`,
|
||||
`builderDragAnnouncement`, `updateBuilderCanvas`, `showBuilderBlockActions`,
|
||||
`editBuilderText`, `showImageFallbacks`, and `offerBuilderDownload`.
|
||||
These are low-level reusable surfaces, not a drop-in complete studio yet.
|
||||
The host owns authenticated load/save/render/asset service callbacks.
|
||||
`createBlockEditorState` uses validated commands; host applications must actually
|
||||
wire it rather than assuming all application mutations already use that state.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/block-editor",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.9",
|
||||
"type": "module",
|
||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
type DragEvent = "pickedUp" | "movedOver" | "invalid" | "dropped" | "cancelled";
|
||||
|
||||
const messages: Record<string, Record<DragEvent, string>> = {
|
||||
en: {
|
||||
pickedUp: "Block picked up. Use arrow keys to move, Space to drop, or Escape to cancel.",
|
||||
movedOver: "Block moved over a drop position.",
|
||||
invalid: "This location does not accept the block.",
|
||||
dropped: "Block moved.",
|
||||
cancelled: "Move cancelled. Original block order restored.",
|
||||
},
|
||||
hi: {
|
||||
pickedUp:
|
||||
"ब्लॉक चुन लिया गया है। स्थान बदलने के लिए तीर कुंजियाँ, रखने के लिए स्पेस और रद्द करने के लिए एस्केप दबाएँ।",
|
||||
movedOver: "ब्लॉक रखने की जगह बदल गई है।",
|
||||
invalid: "इस स्थान पर यह ब्लॉक नहीं रखा जा सकता।",
|
||||
dropped: "ब्लॉक का स्थान बदल दिया गया है।",
|
||||
cancelled: "स्थान बदलना रद्द किया गया। ब्लॉक का मूल क्रम बहाल कर दिया गया है।",
|
||||
},
|
||||
ar: {
|
||||
pickedUp: "تم التقاط الكتلة. استخدم مفاتيح الأسهم للتحريك، والمسافة للإفلات، وEscape للإلغاء.",
|
||||
movedOver: "تم تحريك الكتلة فوق موضع إفلات.",
|
||||
invalid: "هذا الموضع لا يقبل هذه الكتلة.",
|
||||
dropped: "تم نقل الكتلة.",
|
||||
cancelled: "تم إلغاء النقل واستعادة ترتيب الكتل الأصلي.",
|
||||
},
|
||||
};
|
||||
|
||||
export function builderDragAnnouncement(type: string, locale = "en"): string {
|
||||
const catalog = messages[locale.toLowerCase().split(/[-_]/)[0]!] ?? messages.en!;
|
||||
return Object.hasOwn(catalog, type) ? catalog[type as DragEvent] : "";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 {
|
||||
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 }));
|
||||
const link = host.ownerDocument.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.textContent = `Download prepared file: ${filename}`;
|
||||
let disposed = false;
|
||||
const observer = new MutationObserver(() => { if (!host.isConnected) dispose(); });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
observer.disconnect();
|
||||
URL.revokeObjectURL(url);
|
||||
link.remove();
|
||||
if (downloads.get(host) === dispose) downloads.delete(host);
|
||||
};
|
||||
try {
|
||||
downloads.get(host)?.();
|
||||
outlet.replaceChildren(link);
|
||||
observer.observe(host.ownerDocument.documentElement, { childList: true, subtree: true });
|
||||
downloads.set(host, dispose);
|
||||
} catch (error) {
|
||||
dispose();
|
||||
throw error;
|
||||
}
|
||||
// A blocked automatic click must not destroy the direct-click fallback.
|
||||
try { link.click(); } catch { /* The visible link remains available. */ }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
const placeholders = new WeakMap<Document, HTMLElement[]>();
|
||||
|
||||
/** Preview-only fallbacks; never writes to the block document or export source. */
|
||||
export function showImageFallbacks(canvas: Document, enabled: boolean): void {
|
||||
for (const previous of placeholders.get(canvas) ?? []) previous.remove();
|
||||
placeholders.delete(canvas);
|
||||
if (!enabled) return;
|
||||
const created: HTMLElement[] = [];
|
||||
for (const image of canvas.querySelectorAll<HTMLElement>("img,amp-img")) {
|
||||
const fallback = canvas.createElement("span");
|
||||
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 (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}`;
|
||||
}
|
||||
image.after(fallback);
|
||||
created.push(fallback);
|
||||
}
|
||||
placeholders.set(canvas, created);
|
||||
}
|
||||
@@ -2,3 +2,10 @@ export * from "./state.ts";
|
||||
export * from "./runtime.ts";
|
||||
export * from "./unsaved.ts";
|
||||
export * from "./canvas.ts";
|
||||
export * from "./inspector-drafts.ts";
|
||||
export * from "./outline.ts";
|
||||
export * from "./live-canvas.ts";
|
||||
export * from "./download.ts";
|
||||
export * from "./image-preview.ts";
|
||||
export * from "./announcements.ts";
|
||||
export * from "./kit.ts";
|
||||
@@ -0,0 +1,64 @@
|
||||
type InspectorNode = { id: string; props: unknown; styles: unknown };
|
||||
export type InspectorValues = {
|
||||
props: Record<string, string | boolean>;
|
||||
styles: Record<string, { value: string; empty: string }>;
|
||||
};
|
||||
type Group = keyof InspectorValues;
|
||||
type Draft = { baseline: string; values: Record<string, unknown> };
|
||||
|
||||
/** Session-only form drafts. Never included in documents, history, or exports. */
|
||||
export class InspectorDrafts {
|
||||
private drafts = new Map<string, Partial<Record<Group, Draft>>>();
|
||||
|
||||
capture(node: InspectorNode, baseline: InspectorValues, current: InspectorValues) {
|
||||
const draft: Partial<Record<Group, Draft>> = {};
|
||||
for (const group of ["props", "styles"] as const) {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(current[group]).filter(
|
||||
([key, value]) => JSON.stringify(value) !== JSON.stringify(baseline[group][key]),
|
||||
),
|
||||
);
|
||||
if (Object.keys(values).length)
|
||||
draft[group] = {
|
||||
baseline: JSON.stringify(node[group] ?? {}),
|
||||
values: structuredClone(values),
|
||||
};
|
||||
}
|
||||
if (Object.keys(draft).length) this.drafts.set(node.id, draft);
|
||||
else this.drafts.delete(node.id);
|
||||
}
|
||||
|
||||
restore(node: InspectorNode, baseline: InspectorValues): InspectorValues {
|
||||
const result = structuredClone(baseline),
|
||||
draft = this.drafts.get(node.id);
|
||||
if (!draft) return result;
|
||||
for (const group of ["props", "styles"] as const) {
|
||||
const saved = draft[group];
|
||||
if (!saved) continue;
|
||||
// An applied edit, undo, or server replacement takes precedence for that group.
|
||||
if (saved.baseline !== JSON.stringify(node[group] ?? {})) {
|
||||
delete draft[group];
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(saved.values)) {
|
||||
if (Object.hasOwn(result[group], key))
|
||||
Object.assign(result[group], { [key]: structuredClone(value) });
|
||||
}
|
||||
}
|
||||
if (!Object.keys(draft).length) this.drafts.delete(node.id);
|
||||
return result;
|
||||
}
|
||||
|
||||
discard(id: string) {
|
||||
this.drafts.delete(id);
|
||||
}
|
||||
has(id: string) {
|
||||
return this.drafts.has(id);
|
||||
}
|
||||
retain(ids: Set<string>) {
|
||||
for (const id of this.drafts.keys()) if (!ids.has(id)) this.drafts.delete(id);
|
||||
}
|
||||
get size() {
|
||||
return this.drafts.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createRegistry, type BlockDefinition, type BlockNode, type LocalizedLabel } from "@wrnexus/blocks";
|
||||
|
||||
export interface BuilderCreateContext { id(): string }
|
||||
export interface BuilderPreset {
|
||||
id: string;
|
||||
name: LocalizedLabel;
|
||||
icon: string;
|
||||
create(context: BuilderCreateContext): BlockNode;
|
||||
}
|
||||
|
||||
/** Trusted explicit configuration, not tenant-supplied executable code. */
|
||||
export function createBuilderKit(options: {
|
||||
blocks: readonly BlockDefinition[];
|
||||
presets?: readonly BuilderPreset[];
|
||||
}) {
|
||||
const registry = createRegistry(options.blocks).freeze();
|
||||
const presets = new Map<string, BuilderPreset>();
|
||||
for (const preset of options.presets ?? []) {
|
||||
if (!preset.id || !preset.name?.key || !preset.name.fallback || !preset.icon)
|
||||
throw new Error("Preset id, localized name and icon are required");
|
||||
if (presets.has(preset.id)) throw new Error("Duplicate preset: " + preset.id);
|
||||
presets.set(preset.id, Object.freeze({ ...preset }));
|
||||
}
|
||||
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.");
|
||||
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"));
|
||||
};
|
||||
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");
|
||||
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);
|
||||
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"));
|
||||
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);
|
||||
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);
|
||||
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"));
|
||||
};
|
||||
const copy = structuredClone(root);
|
||||
visit(copy, 0);
|
||||
return copy;
|
||||
};
|
||||
return Object.freeze({
|
||||
registry,
|
||||
blocks: () => registry.list(),
|
||||
presets: () => Object.freeze([...presets.values()]),
|
||||
validateProps,
|
||||
createBlock(type: string, context: BuilderCreateContext) {
|
||||
const definition = registry.get(type);
|
||||
if (!definition) throw new Error("Unavailable block: " + type);
|
||||
const node = definition.create(context);
|
||||
if (node.type !== type) throw new Error("Block factory returned a different type");
|
||||
return validateCreated(node);
|
||||
},
|
||||
createPreset(id: string, context: BuilderCreateContext) {
|
||||
const preset = presets.get(id);
|
||||
if (!preset) throw new Error("Unavailable preset: " + id);
|
||||
return validateCreated(preset.create(context));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Updates the existing sandbox document without navigating/reloading its iframe. */
|
||||
export function updateBuilderCanvas(frame: HTMLIFrameElement, html: string): void {
|
||||
const canvas = frame.contentDocument;
|
||||
if (!canvas?.body) return;
|
||||
const parsed = new DOMParser().parseFromString(html, "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)));
|
||||
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; }
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
canvas.querySelector("[data-builder-block-actions]")?.remove();
|
||||
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
|
||||
if (!block) return;
|
||||
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;flex-wrap:wrap;gap:4px;align-items:center;padding:6px;box-sizing:border-box;max-width:100%;background:#111827;color:white;font:12px Arial;box-shadow:0 2px 8px #0003";
|
||||
const name = canvas.createElement("strong");
|
||||
name.textContent = label;
|
||||
name.style.cssText = "margin-inline-end:auto";
|
||||
toolbar.append(name);
|
||||
for (const [name, action] of Object.entries(actions)) {
|
||||
const button = canvas.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = name;
|
||||
button.style.cssText = "border:1px solid #64748b;border-radius:4px;background:#1f2937;color:white;padding:5px 8px;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 bounds = block.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.left, canvas.documentElement.clientWidth - toolbar.offsetWidth)) + "px";
|
||||
block.setAttribute("aria-label", label + " selected");
|
||||
}
|
||||
|
||||
/** Text remains a string, not pasted HTML. The owner validates every committed value. */
|
||||
export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string) => void, finish: () => void): 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;
|
||||
element.textContent = value;
|
||||
element.contentEditable = "plaintext-only";
|
||||
element.setAttribute("role", "textbox");
|
||||
element.setAttribute("aria-label", "Edit block text");
|
||||
element.style.whiteSpace = "pre-wrap";
|
||||
element.addEventListener("input", () => update(element.innerText));
|
||||
element.addEventListener("keydown", event => {
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") { event.preventDefault(); element.blur(); }
|
||||
});
|
||||
element.addEventListener("blur", () => {
|
||||
element.contentEditable = "false";
|
||||
finish();
|
||||
}, { once: true });
|
||||
element.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { BlockNode } from "@wrnexus/blocks";
|
||||
|
||||
/** Plain text only; callers must assign this to textContent, never innerHTML. */
|
||||
export function builderOutlineLabel(
|
||||
node: BlockNode,
|
||||
definition?: { name?: { fallback?: string } },
|
||||
): 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 bounded = Array.from(detail);
|
||||
return detail ? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}` : name;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { builderDragAnnouncement } from "../src/announcements.ts";
|
||||
|
||||
test("drag messages cover every lifecycle event in supported locales", () => {
|
||||
for (const event of ["pickedUp", "movedOver", "invalid", "dropped", "cancelled"]) {
|
||||
for (const locale of ["en", "hi", "ar"]) {
|
||||
const message = builderDragAnnouncement(event, locale);
|
||||
expect(message.length).toBeGreaterThan(10);
|
||||
expect(message).not.toBe(event);
|
||||
expect(builderDragAnnouncement(event, `${locale}-IN`)).toBe(message);
|
||||
}
|
||||
}
|
||||
expect(builderDragAnnouncement("dropped", "unknown")).toBe(
|
||||
builderDragAnnouncement("dropped", "en"),
|
||||
);
|
||||
expect(builderDragAnnouncement("toString")).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
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");
|
||||
|
||||
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 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++; } },
|
||||
);
|
||||
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);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
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 => ({
|
||||
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); } }),
|
||||
} 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[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>');
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { InspectorDrafts, type InspectorValues } from "../src/inspector-drafts.ts";
|
||||
|
||||
const node = { id: "text", props: { text: "Saved" }, styles: {} };
|
||||
const baseline: InspectorValues = {
|
||||
props: { text: "Saved", decorative: false },
|
||||
styles: { fontSize: { value: "", empty: "true" } },
|
||||
};
|
||||
const edited: InspectorValues = {
|
||||
props: { text: "Typing", decorative: true },
|
||||
styles: { fontSize: { value: "20px", empty: "" } },
|
||||
};
|
||||
|
||||
test("selection and preview remount preserve independent props, checkbox, and style drafts", () => {
|
||||
const store = new InspectorDrafts();
|
||||
store.capture(node, baseline, edited);
|
||||
expect(store.restore({ ...node, id: "other" }, baseline)).toEqual(baseline);
|
||||
expect(store.restore(node, baseline)).toEqual(edited);
|
||||
expect(store.restore(node, baseline)).toEqual(edited);
|
||||
expect(node.props.text).toBe("Saved");
|
||||
expect(store.size).toBe(1);
|
||||
});
|
||||
|
||||
test("applying content does not discard pending style changes or resurrect applied content", () => {
|
||||
const store = new InspectorDrafts();
|
||||
store.capture(node, baseline, edited);
|
||||
const applied = { ...node, props: { text: "Typing" } };
|
||||
const next = { ...baseline, props: { ...baseline.props, text: "Typing" } };
|
||||
expect(store.restore(applied, next)).toEqual({ ...next, styles: edited.styles });
|
||||
expect(store.restore(node, baseline).props).toEqual(baseline.props);
|
||||
});
|
||||
|
||||
test("applying styles does not erase content drafts", () => {
|
||||
const store = new InspectorDrafts();
|
||||
store.capture(node, baseline, edited);
|
||||
const applied = { ...node, styles: { fontSize: { base: "20px" } } };
|
||||
const next = { ...baseline, styles: edited.styles };
|
||||
expect(store.restore(applied, next)).toEqual({ props: edited.props, styles: edited.styles });
|
||||
});
|
||||
|
||||
test("reverting fields, discarding, and deleting blocks release their drafts", () => {
|
||||
const store = new InspectorDrafts();
|
||||
store.capture(node, baseline, edited);
|
||||
store.capture(node, baseline, baseline);
|
||||
expect(store.size).toBe(0);
|
||||
store.capture(node, baseline, edited);
|
||||
store.discard(node.id);
|
||||
expect(store.restore(node, baseline)).toEqual(baseline);
|
||||
store.capture(node, baseline, edited);
|
||||
store.retain(new Set(["other"]));
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
|
||||
test("capture and restore do not share mutable control snapshots", () => {
|
||||
const store = new InspectorDrafts(),
|
||||
draft = structuredClone(edited);
|
||||
store.capture(node, baseline, draft);
|
||||
draft.styles.fontSize!.value = "99px";
|
||||
const restored = store.restore(node, baseline);
|
||||
restored.styles.fontSize!.value = "88px";
|
||||
expect(store.restore(node, baseline).styles.fontSize!.value).toBe("20px");
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
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: {} }),
|
||||
});
|
||||
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 }] });
|
||||
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);
|
||||
first.props.text = "Changed";
|
||||
expect(second.props.text).toBe("Hello");
|
||||
expect(first.id).not.toBe(second.id);
|
||||
expect(Object.isFrozen(kit)).toBe(true);
|
||||
expect(() => kit.validateProps("custom.note", { text: "" })).toThrow();
|
||||
expect(() => kit.createBlock("missing.block", context)).toThrow();
|
||||
});
|
||||
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 };
|
||||
expect(() => createBuilderKit({ blocks: [block], presets: [preset, preset] })).toThrow();
|
||||
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" }) }] });
|
||||
expect(() => wrong.createBlock("custom.note", { id: () => "id" })).toThrow();
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
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: {} });
|
||||
|
||||
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({}, "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" } });
|
||||
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");
|
||||
});
|
||||
Reference in new issue
Block a user