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