// src/client — per-block-kind interactive renderers. Each renderer // returns an HTMLElement that's the editable surface for a block, and // calls `onChange(next)` whenever the user's edit changes the typed // block shape. blockeditor.ts owns state/persistence/slash-menu wiring // and renders chrome around each call here. // // Split per Atomic threshold: keep this file ≤700 LOC. If marketing // blocks ever land they get their own file. import type { SxBlock, SxInline, SxText, SxLink, SxParagraph, SxHeading, SxList, SxQuote, SxCodeBlock, SxImage, SxHtml, SxShortcode, } from "../c31_sxdoc.ts"; export const renderBlock = (block: SxBlock, onChange: (next: SxBlock) => void): HTMLElement => { switch (block.t) { case "p": return renderParagraph(block, onChange); case "h": return renderHeading(block, onChange); case "ul": case "ol": return renderList(block, onChange); case "li": return renderParagraph({ t: "p", c: extractInlines(block.c) }, (next) => onChange({ t: "li", c: [next] })); case "quote": return renderQuote(block, onChange); case "code": return renderCode(block, onChange); case "img": return renderImage(block, onChange); case "hr": return renderHr(); case "html": return renderHtml(block, onChange); case "shortcode": return renderShortcode(block, onChange); } }; // ─── paragraph ─────────────────────────────────────────────────────────── const renderParagraph = (block: SxParagraph, onChange: (next: SxBlock) => void): HTMLElement => { const el = document.createElement("p"); el.contentEditable = "true"; el.dataset.placeholder = "Type / for menu"; el.innerHTML = inlinesToEditableHtml(block.c); attachInlineEditing(el, () => onChange({ t: "p", c: parseInlinesFromHtml(el.innerHTML) })); attachSlashTrigger(el); return el; }; // ─── heading ───────────────────────────────────────────────────────────── const renderHeading = (block: SxHeading, onChange: (next: SxBlock) => void): HTMLElement => { const el = document.createElement(`h${block.level}`); el.contentEditable = "true"; el.dataset.placeholder = `Heading ${block.level}`; el.innerHTML = inlinesToEditableHtml(block.c); attachInlineEditing(el, () => onChange({ t: "h", level: block.level, c: parseInlinesFromHtml(el.innerHTML) })); attachSlashTrigger(el); return el; }; // ─── list ──────────────────────────────────────────────────────────────── const renderList = (block: SxList, onChange: (next: SxBlock) => void): HTMLElement => { const el = document.createElement(block.t); // Each list item is an SxBlock[] — for the editor we treat the first // paragraph as the editable item content. Nested lists/quotes inside // a list item are kept structural and re-rendered on each change. block.items.forEach((itemBlocks, itemIdx) => { const li = document.createElement("li"); const text = itemBlocks.find((b) => b.t === "p") as SxParagraph | undefined; li.contentEditable = "true"; li.dataset.placeholder = "List item"; li.innerHTML = inlinesToEditableHtml(text?.c ?? []); attachInlineEditing(li, () => { const newItems = block.items.slice(); newItems[itemIdx] = [{ t: "p", c: parseInlinesFromHtml(li.innerHTML) }]; onChange({ t: block.t, items: newItems }); }); li.addEventListener("keydown", (evt) => { if (evt.key === "Enter" && !evt.shiftKey) { evt.preventDefault(); const newItems = block.items.slice(); newItems.splice(itemIdx + 1, 0, [{ t: "p", c: [] }]); onChange({ t: block.t, items: newItems }); } }); el.appendChild(li); }); return el; }; // ─── quote ──────────────────────────────────────────────────────────────── const renderQuote = (block: SxQuote, onChange: (next: SxBlock) => void): HTMLElement => { const el = document.createElement("blockquote"); el.contentEditable = "true"; el.dataset.placeholder = "Quote"; const firstPara = block.c.find((b) => b.t === "p") as SxParagraph | undefined; el.innerHTML = inlinesToEditableHtml(firstPara?.c ?? []); attachInlineEditing(el, () => onChange({ t: "quote", c: [{ t: "p", c: parseInlinesFromHtml(el.innerHTML) }] })); return el; }; // ─── code ──────────────────────────────────────────────────────────────── const renderCode = (block: SxCodeBlock, onChange: (next: SxBlock) => void): HTMLElement => { const wrap = document.createElement("div"); wrap.className = "code-shell"; const lang = document.createElement("input"); lang.type = "text"; lang.placeholder = "language (ts, py, …)"; lang.value = block.lang ?? ""; lang.className = "code-lang"; const ta = document.createElement("textarea"); ta.value = block.src; ta.spellcheck = false; ta.rows = Math.max(3, block.src.split("\n").length); ta.className = "code-src"; const emit = (): void => onChange({ t: "code", lang: lang.value.trim() || "", src: ta.value }); lang.addEventListener("input", emit); ta.addEventListener("input", () => { ta.rows = Math.max(3, ta.value.split("\n").length); emit(); }); wrap.appendChild(lang); wrap.appendChild(ta); return wrap; }; // ─── image ─────────────────────────────────────────────────────────────── const renderImage = (block: SxImage, onChange: (next: SxBlock) => void): HTMLElement => { const wrap = document.createElement("div"); wrap.className = "img-shell"; const src = inputRow("src", block.src); const alt = inputRow("alt", block.alt ?? ""); const cap = inputRow("caption", block.caption ?? ""); const emit = (): void => { const next: SxImage = { t: "img", src: (src.input.value || "").trim() }; if (alt.input.value.trim()) next.alt = alt.input.value.trim(); if (cap.input.value.trim()) next.caption = cap.input.value.trim(); if (block.w !== undefined) next.w = block.w; if (block.h !== undefined) next.h = block.h; onChange(next); }; [src.input, alt.input, cap.input].forEach((i) => i.addEventListener("input", emit)); wrap.appendChild(src.row); wrap.appendChild(alt.row); wrap.appendChild(cap.row); if (block.src) { const preview = document.createElement("img"); preview.className = "img-preview"; preview.src = block.src; preview.alt = block.alt ?? ""; wrap.appendChild(preview); } return wrap; }; const inputRow = (label: string, value: string): { row: HTMLElement; input: HTMLInputElement } => { const row = document.createElement("label"); row.className = "img-row"; const span = document.createElement("span"); span.textContent = label; const input = document.createElement("input"); input.type = "text"; input.value = value; row.appendChild(span); row.appendChild(input); return { row, input }; }; // ─── hr ────────────────────────────────────────────────────────────────── const renderHr = (): HTMLElement => { const wrap = document.createElement("div"); wrap.className = "hr-shell"; wrap.appendChild(document.createElement("hr")); return wrap; }; // ─── html escape-hatch ────────────────────────────────────────────────── const renderHtml = (block: SxHtml, onChange: (next: SxBlock) => void): HTMLElement => { const ta = document.createElement("textarea"); ta.className = "html-shell"; ta.value = block.src; ta.spellcheck = false; ta.rows = Math.max(3, block.src.split("\n").length); ta.addEventListener("input", () => { ta.rows = Math.max(3, ta.value.split("\n").length); onChange({ t: "html", src: ta.value }); }); return ta; }; // ─── shortcode ────────────────────────────────────────────────────────── const renderShortcode = (block: SxShortcode, onChange: (next: SxBlock) => void): HTMLElement => { const wrap = document.createElement("div"); wrap.className = "shortcode-shell"; const name = document.createElement("input"); name.type = "text"; name.placeholder = "shortcode name (e.g. event-count)"; name.value = block.name; const args = document.createElement("input"); args.type = "text"; args.placeholder = `args as key="value" pairs`; args.value = Object.entries(block.args).map(([k, v]) => `${k}="${v}"`).join(" "); const parseArgs = (raw: string): Record => { const out: Record = {}; const re = /([a-z0-9_-]+)=(?:"([^"]*)"|([^\s"]+))/g; for (const m of raw.matchAll(re)) { out[m[1]!] = m[2] ?? m[3] ?? ""; } return out; }; const emit = (): void => onChange({ t: "shortcode", name: name.value.trim(), args: parseArgs(args.value) }); name.addEventListener("input", emit); args.addEventListener("input", emit); wrap.appendChild(name); wrap.appendChild(args); return wrap; }; // ─── inline ←→ HTML helpers ────────────────────────────────────────────── // Render SxInline[] as the minimal HTML the contenteditable surface // can preserve. We only emit tags we know how to parse back: a, strong, // em, u, s, code, br. Anything fancier round-trips as plain text. export const inlinesToEditableHtml = (inlines: SxInline[]): string => { if (inlines.length === 0) return ""; return inlines.map(renderOneInline).join(""); }; const renderOneInline = (inline: SxInline): string => { if (inline.t === "a") return `${inlinesToEditableHtml(inline.c)}`; // text if (inline.v === "\n") return "
"; let body = escText(inline.v); // Stable nesting: b > i > u > s > c (matches server c51_render_sxdoc). const marks = inline.m ?? []; const order: Array<{ m: string; tag: string }> = [ { m: "b", tag: "strong" }, { m: "i", tag: "em" }, { m: "u", tag: "u" }, { m: "s", tag: "s" }, { m: "c", tag: "code" }, ]; for (let i = order.length - 1; i >= 0; i--) { const { m, tag } = order[i]!; if (marks.includes(m as never)) body = `<${tag}>${body}`; } return body; }; // Lossy-by-design: re-parse the contenteditable's innerHTML into the // inline subset we support. Uses DOMParser → walks the tree → emits // SxText / SxLink. Marks are accumulated as we descend. export const parseInlinesFromHtml = (html: string): SxInline[] => { const parser = new DOMParser(); const doc = parser.parseFromString(`
${html}
`, "text/html"); const root = doc.body.firstChild as HTMLElement | null; if (!root) return []; return collectInline(root, []); }; const MARK_FOR_TAG: Record = { b: "b", strong: "b", i: "i", em: "i", u: "u", s: "s", strike: "s", del: "s", code: "c", }; const collectInline = (node: Node, marks: string[]): SxInline[] => { const out: SxInline[] = []; for (const child of Array.from(node.childNodes)) { if (child.nodeType === Node.TEXT_NODE) { const v = (child as Text).data; if (v.length > 0) { const text: SxText = { t: "text", v }; if (marks.length) (text as { m?: string[] }).m = dedupe(marks); out.push(text as SxText); } continue; } if (child.nodeType !== Node.ELEMENT_NODE) continue; const el = child as HTMLElement; const tag = el.tagName.toLowerCase(); if (tag === "br") { const t: SxText = { t: "text", v: "\n" }; if (marks.length) (t as { m?: string[] }).m = dedupe(marks); out.push(t); continue; } if (tag === "a") { const link: SxLink = { t: "a", href: (el as HTMLAnchorElement).getAttribute("href") ?? "", c: collectInline(el, marks), }; out.push(link); continue; } const mark = MARK_FOR_TAG[tag]; if (mark) { out.push(...collectInline(el, [...marks, mark])); continue; } out.push(...collectInline(el, marks)); } // Best-effort: cast marks to SxMark inside the consumer. Marks were // already validated by the MARK_FOR_TAG whitelist. return out as SxInline[]; }; const dedupe = (marks: string[]): string[] => { const seen = new Set(); const out: string[] = []; for (const m of marks) if (!seen.has(m)) { seen.add(m); out.push(m); } return out; }; // ─── slash-trigger ────────────────────────────────────────────────────── // When the user types "/" at the start of an empty contenteditable // surface, fire a CustomEvent with the caret position so the editor // shell can open the slash menu near the cursor. const attachSlashTrigger = (el: HTMLElement): void => { el.addEventListener("keydown", (evt) => { if (evt.key !== "/") return; const text = el.textContent ?? ""; if (text.trim().length > 0) return; // only fire on empty blocks evt.preventDefault(); const rect = el.getBoundingClientRect(); el.dispatchEvent(new CustomEvent("sxdoc:slash", { detail: { x: rect.left, y: rect.bottom + 4 }, bubbles: true, })); }); }; // Debounced inline-edit signal — fires onChange after a brief idle so we // aren't re-serialising on every keystroke. State stays consistent // because the input event always wins-last-write. const attachInlineEditing = (el: HTMLElement, onChange: () => void): void => { let t: number | null = null; el.addEventListener("input", () => { if (t !== null) clearTimeout(t); t = window.setTimeout(() => { t = null; onChange(); }, 150); }); // Blur flushes immediately so leaving the field saves the latest edit. el.addEventListener("blur", () => { if (t !== null) { clearTimeout(t); t = null; } onChange(); }); }; // ─── conversion helpers (used by blockeditor.ts) ──────────────────────── // Extract a plain-text projection of a block — used when converting one // block kind to another (so the user's typed prefix carries over). export const blockToInlineText = (block: SxBlock): string => { switch (block.t) { case "p": case "h": return inlinesToPlain(block.c); case "quote": return block.c.flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" "); case "ul": case "ol": return block.items.flat().flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" "); case "li": return block.c.flatMap((b) => b.t === "p" ? [inlinesToPlain(b.c)] : []).join(" "); case "code": return block.src; case "img": return block.caption ?? block.alt ?? ""; case "hr": return ""; case "html": return ""; case "shortcode": return ""; } }; const inlinesToPlain = (inlines: SxInline[]): string => inlines.map((i) => (i.t === "text" ? i.v : inlinesToPlain(i.c))).join(""); // Wrap a plain string into a single SxText inline (no marks). export const plainTextToInlines = (text: string): SxInline[] => text.length === 0 ? [] : [{ t: "text", v: text }]; // Helpers for `extractInlines` of nested-li edge case. const extractInlines = (blocks: SxBlock[]): SxInline[] => { const para = blocks.find((b) => b.t === "p") as SxParagraph | undefined; return para?.c ?? []; }; // ─── escape helpers ───────────────────────────────────────────────────── const escText = (s: string): string => s.replace(/&/g, "&").replace(//g, ">"); const escAttr = (s: string): string => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);