Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "@wrnexus/block-editor",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.18",
|
||||
"type": "module",
|
||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime.ts"
|
||||
"./runtime": "./src/runtime.ts",
|
||||
"./source-editor": "./src/source-editor.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
|
||||
@@ -17,6 +17,12 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
|
||||
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;
|
||||
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");
|
||||
@@ -53,31 +59,19 @@ export function showBuilderBlockActions(canvas: Document, id: string, label: str
|
||||
}
|
||||
// Keep controls outside presentation tables so browser table repair cannot move them.
|
||||
canvas.body.prepend(toolbar);
|
||||
const bounds = block.getBoundingClientRect();
|
||||
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";
|
||||
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 {
|
||||
/** 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);
|
||||
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 });
|
||||
mountInlineRichText(canvas, element, value, update, finish, runs);
|
||||
return true;
|
||||
}
|
||||
import { mountInlineRichText, type BuilderTextRun } from "./rich-text.ts";
|
||||
@@ -0,0 +1,83 @@
|
||||
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[] {
|
||||
const runs: 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});
|
||||
};
|
||||
const walk = (node: Node, marks: Omit<BuilderTextRun, "text">) => {
|
||||
if (node.nodeType === 3) { append(node.textContent ?? "", marks); return; }
|
||||
if (node.nodeType !== 1) return;
|
||||
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 (["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));
|
||||
};
|
||||
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 {
|
||||
element.replaceChildren();
|
||||
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; }
|
||||
}
|
||||
element.append(child);
|
||||
}
|
||||
element.contentEditable = "true";
|
||||
element.setAttribute("role", "textbox");
|
||||
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}';
|
||||
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());
|
||||
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); };
|
||||
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};
|
||||
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 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);
|
||||
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);
|
||||
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.defaultView?.addEventListener("scroll", position, options);
|
||||
element.focus({preventScroll:true}); position();
|
||||
}
|
||||
@@ -1,37 +1,90 @@
|
||||
/** Read-only derived output. Loading this entry does not load CodeMirror until opened. */
|
||||
export async function mountBuilderSourceEditor(parent: HTMLElement, language: "text" | "xml", content: string) {
|
||||
const [{ EditorView, basicSetup }, { EditorState, Compartment }, { xml }, { HighlightStyle, syntaxHighlighting }, { tags }] = await Promise.all([
|
||||
import("codemirror"), import("@codemirror/state"), import("@codemirror/lang-xml"),
|
||||
import("@codemirror/language"), import("@lezer/highlight"),
|
||||
export async function mountBuilderSourceEditor(
|
||||
parent: HTMLElement,
|
||||
language: "text" | "xml",
|
||||
content: string,
|
||||
options: { label?: string; theme?: "tokens" } = {},
|
||||
) {
|
||||
const [
|
||||
{ EditorView, basicSetup },
|
||||
{ EditorState, Compartment },
|
||||
{ xml },
|
||||
{ HighlightStyle, syntaxHighlighting },
|
||||
{ tags },
|
||||
] = await Promise.all([
|
||||
import("codemirror"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-xml"),
|
||||
import("@codemirror/language"),
|
||||
import("@lezer/highlight"),
|
||||
]);
|
||||
if (!parent.isConnected) return null;
|
||||
const stopShortcut = (event: KeyboardEvent) => event.stopPropagation();
|
||||
parent.addEventListener("keydown", stopShortcut);
|
||||
const wrapping = new Compartment();
|
||||
const view = new EditorView({ parent, doc: content, extensions: [
|
||||
basicSetup, EditorState.readOnly.of(true), EditorView.editable.of(false),
|
||||
EditorView.contentAttributes.of({ tabindex: "0", "aria-label": "Generated email source" }),
|
||||
language === "xml" ? xml() : [], wrapping.of([]),
|
||||
syntaxHighlighting(HighlightStyle.define([
|
||||
{ tag: tags.tagName, color: "#7dd3fc" }, { tag: tags.attributeName, color: "#c4b5fd" },
|
||||
{ tag: tags.attributeValue, color: "#a7f3d0" }, { tag: tags.angleBracket, color: "#94a3b8" },
|
||||
{ tag: tags.comment, color: "#94a3b8" },
|
||||
])),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "12px", backgroundColor: "#111827", color: "#e5e7eb" },
|
||||
".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, Consolas, monospace" },
|
||||
".cm-gutters": { backgroundColor: "#111827", color: "#94a3b8", borderColor: "#334155" },
|
||||
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
|
||||
"&.cm-focused": { outline: "2px solid var(--wrn-color-primary)", outlineOffset: "-2px" },
|
||||
}),
|
||||
] });
|
||||
const color = (token: string, legacy: string) =>
|
||||
options.theme === "tokens" ? `var(--wrn-color-${token})` : legacy;
|
||||
const view = new EditorView({
|
||||
parent,
|
||||
doc: content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.contentAttributes.of({
|
||||
tabindex: "0",
|
||||
"aria-label": options.label ?? "Generated email source",
|
||||
}),
|
||||
language === "xml" ? xml() : [],
|
||||
wrapping.of([]),
|
||||
syntaxHighlighting(
|
||||
HighlightStyle.define([
|
||||
{ tag: tags.tagName, color: color("primary", "#7dd3fc") },
|
||||
{ tag: tags.attributeName, color: color("text", "#c4b5fd") },
|
||||
{ tag: tags.attributeValue, color: color("primary", "#a7f3d0") },
|
||||
{ tag: tags.angleBracket, color: color("muted", "#94a3b8") },
|
||||
{ tag: tags.comment, color: color("muted", "#94a3b8") },
|
||||
]),
|
||||
),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "12px",
|
||||
backgroundColor: color("surface", "#111827"),
|
||||
color: color("text", "#e5e7eb"),
|
||||
},
|
||||
".cm-scroller": { overflow: "auto", fontFamily: "ui-monospace, Consolas, monospace" },
|
||||
".cm-gutters": {
|
||||
backgroundColor: color("surface", "#111827"),
|
||||
color: color("muted", "#94a3b8"),
|
||||
borderColor: color("border", "#334155"),
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
|
||||
"&.cm-focused": { outline: "2px solid var(--wrn-color-primary)", outlineOffset: "-2px" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
let destroyed = false;
|
||||
const observer = new MutationObserver(() => { if (!parent.isConnected) destroy(); });
|
||||
function destroy() { if (destroyed) return; destroyed = true; observer.disconnect(); parent.removeEventListener("keydown", stopShortcut); view.destroy(); }
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!parent.isConnected) destroy();
|
||||
});
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
observer.disconnect();
|
||||
parent.removeEventListener("keydown", stopShortcut);
|
||||
view.destroy();
|
||||
}
|
||||
observer.observe(parent.ownerDocument.documentElement, { childList: true, subtree: true });
|
||||
return {
|
||||
update(value: string) { if (!destroyed && view.state.doc.toString() !== value) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } }); },
|
||||
wrap(enabled: boolean) { if (!destroyed) view.dispatch({ effects: wrapping.reconfigure(enabled ? EditorView.lineWrapping : []) }); },
|
||||
update(value: string) {
|
||||
if (!destroyed && view.state.doc.toString() !== value)
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||
},
|
||||
wrap(enabled: boolean) {
|
||||
if (!destroyed)
|
||||
view.dispatch({ effects: wrapping.reconfigure(enabled ? EditorView.lineWrapping : []) });
|
||||
},
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { readBuilderText, mountInlineRichText } from "../src/rich-text.ts";
|
||||
|
||||
test("inline rich text extracts only supported semantics", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("p");
|
||||
element.innerHTML = 'Hello <strong>bold <em>and italic</em></strong><br><u>line</u><script>bad()</script>';
|
||||
expect(readBuilderText(element as unknown as HTMLElement)).toEqual([{text:"Hello "},{text:"bold ",bold:true},{text:"and italic",bold:true,italic:true},{text:"\n"},{text:"line",underline:true}]);
|
||||
});
|
||||
|
||||
test("rich editor commits semantic input and cleans up on Done", () => {
|
||||
const window = new Window();
|
||||
const element = window.document.createElement("p");
|
||||
window.document.body.append(element);
|
||||
const updates: unknown[] = []; let finished = 0;
|
||||
mountInlineRichText(window.document as unknown as Document, element as unknown as HTMLElement, "hello", (text, runs) => updates.push({text,runs}), () => finished++);
|
||||
expect(element.contentEditable).toBe("true");
|
||||
element.innerHTML = "<strong>bold</strong>";
|
||||
element.dispatchEvent(new window.Event("input"));
|
||||
expect(updates).toEqual([{text:"bold",runs:[{text:"bold",bold:true}]}]);
|
||||
(window.document.querySelector('button[aria-label="Done"]') as any).click();
|
||||
expect(finished).toBe(1);
|
||||
expect(element.contentEditable).toBe("false");
|
||||
expect(window.document.querySelector('[role="toolbar"]')).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user