// src/client — slash menu. Pop-up at (x, y) with a filterable list of // block kinds. blockeditor.ts opens this when the user types "/" at the // start of an empty block, or clicks the inline "+" affordance. // // API is intentionally tiny: openSlashMenu({ anchor, onPick }). The // menu owns its own DOM and clean-up; closes on Escape, click-outside, // or selection. export interface SlashMenuOptions { anchor: { x: number; y: number }; onPick: (kind: string) => void; } interface MenuItem { kind: string; label: string; hint: string; // Aliases the user might type instead of the canonical kind. Helps // muscle-memory ("/heading" → h2, "/list" → ul). aliases: string[]; } const ITEMS: MenuItem[] = [ { kind: "p", label: "Paragraph", hint: "text", aliases: ["paragraph", "text"] }, { kind: "h1", label: "Heading 1", hint: "section title", aliases: ["h1", "heading"] }, { kind: "h2", label: "Heading 2", hint: "subsection title", aliases: ["h2", "heading"] }, { kind: "h3", label: "Heading 3", hint: "small section", aliases: ["h3"] }, { kind: "ul", label: "Bulleted list", hint: "unordered list", aliases: ["ul", "list", "bullets"] }, { kind: "ol", label: "Numbered list", hint: "ordered list", aliases: ["ol", "numbered"] }, { kind: "quote", label: "Quote", hint: "blockquote", aliases: ["quote", "blockquote"] }, { kind: "code", label: "Code block", hint: "fenced code with language", aliases: ["code", "pre"] }, { kind: "img", label: "Image", hint: "src + alt + caption", aliases: ["image", "img", "picture"] }, { kind: "hr", label: "Divider", hint: "horizontal rule", aliases: ["divider", "hr", "rule"] }, { kind: "shortcode", label: "Shortcode", hint: "[[sx:name args]]", aliases: ["shortcode", "sx"] }, { kind: "html", label: "Raw HTML", hint: "escape hatch", aliases: ["html", "raw"] }, ]; let openMenu: HTMLElement | null = null; let openCleanup: (() => void) | null = null; export const openSlashMenu = (opts: SlashMenuOptions): void => { closeSlashMenu(); const menu = document.createElement("div"); menu.className = "slash-menu"; menu.style.left = `${opts.anchor.x}px`; menu.style.top = `${opts.anchor.y}px`; const input = document.createElement("input"); input.type = "text"; input.className = "slash-menu-filter"; input.placeholder = "filter…"; menu.appendChild(input); const listEl = document.createElement("ul"); listEl.className = "slash-menu-list"; menu.appendChild(listEl); let filtered: MenuItem[] = ITEMS; let highlighted = 0; const render = (): void => { listEl.innerHTML = ""; if (filtered.length === 0) { const empty = document.createElement("li"); empty.className = "slash-menu-empty"; empty.textContent = "no matches"; listEl.appendChild(empty); return; } filtered.forEach((item, i) => { const li = document.createElement("li"); li.className = i === highlighted ? "slash-menu-item highlighted" : "slash-menu-item"; const label = document.createElement("span"); label.className = "slash-menu-label"; label.textContent = item.label; const hint = document.createElement("span"); hint.className = "slash-menu-hint"; hint.textContent = item.hint; li.appendChild(label); li.appendChild(hint); li.addEventListener("mouseenter", () => { highlighted = i; render(); }); li.addEventListener("mousedown", (evt) => { // mousedown (not click) so the menu doesn't lose focus before // the pick fires — the click handler can race with blur. evt.preventDefault(); pick(item.kind); }); listEl.appendChild(li); }); }; const filter = (query: string): void => { const q = query.toLowerCase().trim(); if (!q) { filtered = ITEMS; } else { filtered = ITEMS.filter((item) => item.kind.toLowerCase().includes(q) || item.label.toLowerCase().includes(q) || item.aliases.some((a) => a.startsWith(q)), ); } highlighted = Math.min(highlighted, Math.max(0, filtered.length - 1)); render(); }; const pick = (kind: string): void => { closeSlashMenu(); opts.onPick(kind); }; input.addEventListener("input", () => filter(input.value)); input.addEventListener("keydown", (evt) => { if (evt.key === "Escape") { evt.preventDefault(); closeSlashMenu(); return; } if (evt.key === "ArrowDown") { evt.preventDefault(); highlighted = Math.min(filtered.length - 1, highlighted + 1); render(); return; } if (evt.key === "ArrowUp") { evt.preventDefault(); highlighted = Math.max(0, highlighted - 1); render(); return; } if (evt.key === "Enter") { evt.preventDefault(); const choice = filtered[highlighted]; if (choice) pick(choice.kind); return; } }); document.body.appendChild(menu); openMenu = menu; // Defer focus until after the mount so the input actually accepts keys. setTimeout(() => input.focus(), 0); render(); const onDocMouseDown = (evt: MouseEvent): void => { if (!menu.contains(evt.target as Node)) closeSlashMenu(); }; document.addEventListener("mousedown", onDocMouseDown, true); openCleanup = (): void => { document.removeEventListener("mousedown", onDocMouseDown, true); menu.remove(); }; }; export const closeSlashMenu = (): void => { if (openCleanup) openCleanup(); openCleanup = null; openMenu = null; };