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

blockeditor.ts 337 lines · 11899 bytes raw
// src/client — admin block editor: hydrates the admin edit form's
// textarea into a typed-block UI. Read SxDocument JSON from a
// <script id="sxdoc-initial"> tag, render blocks, persist changes
// back as HTML in the hidden textarea, autosave POST on debounce.
//
// SAMA note: src/client/**.ts lives outside the verifier's cXX_*.ts
// glob (per plan.md werkwijze). Files here can free-name but stay
// pure-functional where possible and avoid I/O modules that don't
// work in browsers (no node:fs, no bun:sqlite).
//
// The server's c51_render_sxdoc.sxToHtml is reused here for client-side
// serialisation. It's a pure type-imports-only module so Bun.build
// bundles it cleanly into the browser output.

import type { SxDocument, SxBlock } from "../c31_sxdoc.ts";
import { SX_DOC_VERSION, emptyDocument } from "../c31_sxdoc.ts";
import { sxToHtml } from "../c51_render_sxdoc.ts";
import { renderBlock, blockToInlineText, plainTextToInlines } from "./blocks.ts";
import { openSlashMenu } from "./slashmenu.ts";

const AUTOSAVE_DEBOUNCE_MS = 1000;
const SAVE_TOAST_MS = 2500;

interface EditorState {
  doc: SxDocument;
  form: HTMLFormElement;
  htmlField: HTMLTextAreaElement;
  mount: HTMLElement;
  toast: HTMLElement;
  saveTimer: number | null;
  toastTimer: number | null;
  lastSavedHash: string;
}

const hashStr = (s: string): number => {
  let h = 0;
  for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
  return h;
};

// ─── hydration ───────────────────────────────────────────────────────────

const init = (): void => {
  const initialScript = document.getElementById("sxdoc-initial");
  const form = document.querySelector<HTMLFormElement>("form.admin-form");
  const htmlField = document.querySelector<HTMLTextAreaElement>('form.admin-form textarea[name="html"]');
  if (!initialScript || !form || !htmlField) return; // not an admin edit page

  let doc: SxDocument;
  try {
    const raw = initialScript.textContent ?? "";
    doc = raw.trim() ? (JSON.parse(raw) as SxDocument) : emptyDocument();
    if (!doc || !Array.isArray(doc.blocks)) doc = emptyDocument();
  } catch {
    doc = emptyDocument();
  }
  if (doc.v !== SX_DOC_VERSION) {
    console.warn(`[blockeditor] sxdoc version ${doc.v} ≠ expected ${SX_DOC_VERSION}`);
  }

  const mount = document.createElement("div");
  mount.className = "block-editor";
  htmlField.parentElement?.insertBefore(mount, htmlField);
  htmlField.classList.add("block-editor-raw");
  // Keep the raw textarea reachable as the escape-hatch — hidden by
  // default, toggleable via a "raw" button so users can rescue malformed
  // content if the block editor stumbles.
  htmlField.style.display = "none";

  const toast = document.createElement("div");
  toast.className = "block-editor-toast";
  toast.setAttribute("aria-live", "polite");
  document.body.appendChild(toast);

  const initialHtml = sxToHtml(doc);
  htmlField.value = initialHtml;

  const state: EditorState = {
    doc,
    form,
    htmlField,
    mount,
    toast,
    saveTimer: null,
    toastTimer: null,
    lastSavedHash: hashStr(initialHtml),
  };

  // Mode toggle: a "raw" link to drop into the textarea if the block
  // editor mis-parses something. The user's escape hatch — small but
  // load-bearing per the SAMA escape-hatch principle.
  const toggle = document.createElement("button");
  toggle.type = "button";
  toggle.className = "block-editor-mode";
  toggle.textContent = "raw mode";
  toggle.addEventListener("click", () => {
    if (htmlField.style.display === "none") {
      htmlField.style.display = "";
      mount.style.display = "none";
      toggle.textContent = "block mode";
    } else {
      // Re-hydrate from the raw textarea HTML — parse there happens
      // server-side on form submit, so just round-trip the value.
      htmlField.style.display = "none";
      mount.style.display = "";
      toggle.textContent = "raw mode";
    }
  });
  htmlField.parentElement?.insertBefore(toggle, mount);

  renderAll(state);
  attachAutosaveOnSubmit(state);
};

// ─── rendering ───────────────────────────────────────────────────────────

const renderAll = (state: EditorState): void => {
  state.mount.innerHTML = "";
  if (state.doc.blocks.length === 0) {
    state.mount.appendChild(emptyBlockSlot(state, 0));
    return;
  }
  state.doc.blocks.forEach((block, idx) => {
    state.mount.appendChild(blockWrapper(state, block, idx));
  });
  // Final trailing insert-slot so the user can append without dancing
  // around the last block's hover affordance.
  state.mount.appendChild(insertSlot(state, state.doc.blocks.length));
};

const blockWrapper = (state: EditorState, block: SxBlock, idx: number): HTMLElement => {
  const wrap = document.createElement("div");
  wrap.className = `block block-${block.t}`;
  wrap.dataset.idx = String(idx);

  const handle = document.createElement("div");
  handle.className = "block-handle";
  handle.textContent = "⋮⋮";
  handle.title = "drag (todo) · click for actions";
  wrap.appendChild(handle);

  const body = renderBlock(block, (next) => updateBlock(state, idx, next));
  body.classList.add("block-body");
  wrap.appendChild(body);

  const actions = document.createElement("div");
  actions.className = "block-actions";
  const del = document.createElement("button");
  del.type = "button";
  del.className = "block-delete";
  del.title = "delete block";
  del.textContent = "×";
  del.addEventListener("click", () => deleteBlock(state, idx));
  actions.appendChild(del);
  wrap.appendChild(actions);

  // Slash trigger: when an empty paragraph's contenteditable gets a "/"
  // at position 0, surface the conversion menu instead of typing the
  // character literally. The block renderer wires this signal via a
  // CustomEvent("sxdoc:slash"), keeping per-block logic in blocks.ts.
  body.addEventListener("sxdoc:slash", (evt) => {
    const ce = evt as CustomEvent<{ x: number; y: number }>;
    openSlashMenu({
      anchor: { x: ce.detail.x, y: ce.detail.y },
      onPick: (kind) => convertBlock(state, idx, kind),
    });
  });

  return wrap;
};

const emptyBlockSlot = (state: EditorState, idx: number): HTMLElement => {
  const wrap = document.createElement("div");
  wrap.className = "block-empty";
  const hint = document.createElement("p");
  hint.className = "block-empty-hint";
  hint.textContent = "Type / to insert a block, or click +";
  wrap.appendChild(hint);
  wrap.appendChild(insertSlot(state, idx));
  return wrap;
};

const insertSlot = (state: EditorState, idx: number): HTMLElement => {
  const slot = document.createElement("div");
  slot.className = "block-insert";
  const btn = document.createElement("button");
  btn.type = "button";
  btn.className = "block-insert-btn";
  btn.textContent = "+";
  btn.title = "insert block here";
  btn.addEventListener("click", (e) => {
    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
    openSlashMenu({
      anchor: { x: rect.left, y: rect.bottom + 4 },
      onPick: (kind) => insertBlock(state, idx, kind),
    });
  });
  slot.appendChild(btn);
  return slot;
};

// ─── state mutations ─────────────────────────────────────────────────────

const updateBlock = (state: EditorState, idx: number, next: SxBlock): void => {
  state.doc = {
    ...state.doc,
    blocks: state.doc.blocks.map((b, i) => (i === idx ? next : b)),
  };
  // No full re-render here — the per-block element kept its own DOM
  // and only the underlying state changed. Just persist + autosave.
  persistAndAutosave(state);
};

const deleteBlock = (state: EditorState, idx: number): void => {
  state.doc = {
    ...state.doc,
    blocks: state.doc.blocks.filter((_, i) => i !== idx),
  };
  renderAll(state);
  persistAndAutosave(state);
};

const insertBlock = (state: EditorState, idx: number, kind: string): void => {
  const block = newBlock(kind);
  if (!block) return;
  const blocks = state.doc.blocks.slice();
  blocks.splice(idx, 0, block);
  state.doc = { ...state.doc, blocks };
  renderAll(state);
  persistAndAutosave(state);
};

const convertBlock = (state: EditorState, idx: number, kind: string): void => {
  const current = state.doc.blocks[idx];
  if (!current) return;
  // Carry over any inline content so converting "Hello/" → heading
  // doesn't wipe what the user just typed.
  const text = blockToInlineText(current);
  const inlines = plainTextToInlines(text);
  const next = newBlock(kind, inlines);
  if (!next) return;
  state.doc = {
    ...state.doc,
    blocks: state.doc.blocks.map((b, i) => (i === idx ? next : b)),
  };
  renderAll(state);
  persistAndAutosave(state);
};

const newBlock = (kind: string, inlines?: import("../c31_sxdoc.ts").SxInline[]): SxBlock | null => {
  const c = inlines ?? [];
  switch (kind) {
    case "p":     return { t: "p", c };
    case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": {
      const level = parseInt(kind.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
      return { t: "h", level, c };
    }
    case "ul":    return { t: "ul", items: [[{ t: "p", c }]] };
    case "ol":    return { t: "ol", items: [[{ t: "p", c }]] };
    case "quote": return { t: "quote", c: [{ t: "p", c }] };
    case "code":  return { t: "code", lang: "", src: inlines ? inlines.map((i) => (i.t === "text" ? i.v : "")).join("") : "" };
    case "hr":    return { t: "hr" };
    case "html":  return { t: "html", src: "" };
    case "shortcode": return { t: "shortcode", name: "", args: {} };
    default:      return null;
  }
};

// ─── persistence ─────────────────────────────────────────────────────────

const persistAndAutosave = (state: EditorState): void => {
  const html = sxToHtml(state.doc);
  state.htmlField.value = html;
  const h = hashStr(html);
  if (h === state.lastSavedHash) return; // no diff

  if (state.saveTimer !== null) {
    clearTimeout(state.saveTimer);
  }
  state.saveTimer = window.setTimeout(() => {
    void doAutosave(state, html, h);
  }, AUTOSAVE_DEBOUNCE_MS);
};

const doAutosave = async (state: EditorState, html: string, hash: number): Promise<void> => {
  state.saveTimer = null;
  const fd = new FormData(state.form);
  // Ensure the html field carries our serialised value (FormData reads
  // from the form fields, which we already wrote into).
  fd.set("html", html);
  try {
    const res = await fetch(state.form.action, {
      method: "POST",
      body: fd,
      headers: { Accept: "application/json" },
    });
    if (!res.ok) {
      const txt = await res.text();
      showToast(state, `save failed: ${res.status} ${txt.slice(0, 80)}`, "error");
      return;
    }
    state.lastSavedHash = hash;
    showToast(state, "saved · just now", "ok");
  } catch (e) {
    showToast(state, `save failed: ${(e as Error).message}`, "error");
  }
};

// If the user hits the explicit Save button before the debounce fires,
// the form submits naturally — but we still want the latest HTML in the
// textarea. Our writes are synchronous, so by submit time the field is
// already fresh; this handler is a paranoid backstop.
const attachAutosaveOnSubmit = (state: EditorState): void => {
  state.form.addEventListener("submit", () => {
    state.htmlField.value = sxToHtml(state.doc);
  });
};

// ─── toast ────────────────────────────────────────────────────────────────

const showToast = (state: EditorState, msg: string, kind: "ok" | "error"): void => {
  state.toast.textContent = msg;
  state.toast.className = `block-editor-toast block-editor-toast-${kind} block-editor-toast-show`;
  if (state.toastTimer !== null) clearTimeout(state.toastTimer);
  state.toastTimer = window.setTimeout(() => {
    state.toast.className = "block-editor-toast";
  }, SAVE_TOAST_MS);
};

// ─── boot ────────────────────────────────────────────────────────────────

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", init);
} else {
  init();
}