syntaxai/tdd.md · main · src / client / blocks.ts

blocks.ts 394 lines · 15227 bytes raw
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

const escAttr = (s: string): string =>
  s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");