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

slashmenu.ts 162 lines · 5753 bytes raw
// 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;
};