syntaxai/tdd.md · main · src / client / blockeditor.ts
// 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();
}