syntaxai/tdd.md · main · src / client / blocks.ts
// 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 <handle><body><actions> 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<string, string> => {
const out: Record<string, string> = {};
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 `<a href="${escAttr(inline.href)}">${inlinesToEditableHtml(inline.c)}</a>`;
// text
if (inline.v === "\n") return "<br>";
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}</${tag}>`;
}
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(`<div>${html}</div>`, "text/html");
const root = doc.body.firstChild as HTMLElement | null;
if (!root) return [];
return collectInline(root, []);
};
const MARK_FOR_TAG: Record<string, string> = {
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<string>();
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, "<").replace(/>/g, ">");
const escAttr = (s: string): string =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);