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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user