/** * @factory/editor — slash command registry + fuzzy search (TASK-0054). * * The single, data-driven source for the `/` menu, the block `+` menu and * the command palette (PRODUCT-SPEC §6, ux-requirements §12: every command * is reachable from the menus too — never palette-only). Icons are NOT * part of the registry (this package has no React/DOM dependency); * apps/web attaches its own icon per command id. * * `blockType` values are the wire format (`@factory/shared` snake_case * BlockType); `special` marks commands that are not a plain block insert * (database creation flow, synced block, template). */ import type { BlockType } from "@factory/shared/dto"; /** One slash command (a row of the `/` menu). */ export interface SlashCommandDef { /** Stable id (menu icon key, E2E anchor). */ readonly id: string; /** Display label (exactly what the user sees). */ readonly label: string; /** Space-separated extra match terms for fuzzy search. */ readonly keywords: string; /** The block type inserted when picked (plain-block commands). */ readonly blockType?: BlockType; /** Non-insert commands. */ readonly special?: "database" | "synced" | "template"; /** Menu group header. */ readonly group: string; /** Keyboard hint shown in the menu (`kbd`). */ readonly shortcut?: string; } /** All slash commands, in menu order (grouped by `group`). */ export const SLASH_COMMANDS: readonly SlashCommandDef[] = [ // --- Basic writing ------------------------------------------------------- { id: "paragraph", label: "Text", keywords: "text plain paragraph default", blockType: "paragraph", group: "Basic writing" }, { id: "heading_1", label: "Heading 1", keywords: "h1 title big large", blockType: "heading_1", group: "Basic writing" }, { id: "heading_2", label: "Heading 2", keywords: "h2 subtitle medium", blockType: "heading_2", group: "Basic writing" }, { id: "heading_3", label: "Heading 3", keywords: "h3 subheading small", blockType: "heading_3", group: "Basic writing" }, { id: "quote", label: "Quote", keywords: "quotation blockquote cite", blockType: "quote", group: "Basic writing" }, { id: "callout", label: "Callout", keywords: "note hint info banner", blockType: "callout", group: "Basic writing" }, // --- Lists ---------------------------------------------------------------- { id: "bulleted_list", label: "Bulleted list", keywords: "bullet unordered list items", blockType: "bulleted_list", group: "Lists" }, { id: "numbered_list", label: "Numbered list", keywords: "ordered list steps numbers", blockType: "numbered_list", group: "Lists" }, { id: "todo", label: "To-do", keywords: "todo task checkbox check", blockType: "todo", group: "Lists" }, { id: "toggle", label: "Toggle", keywords: "collapse collapsible expand fold", blockType: "toggle", group: "Lists" }, // --- Media ---------------------------------------------------------------- { id: "image", label: "Image", keywords: "picture photo upload media", blockType: "image", group: "Media" }, { id: "video", label: "Video", keywords: "movie film clip media", blockType: "video", group: "Media" }, { id: "audio", label: "Audio", keywords: "sound music voice media", blockType: "audio", group: "Media" }, { id: "file", label: "File", keywords: "document attachment upload pdf", blockType: "file", group: "Media" }, { id: "bookmark", label: "Bookmark", keywords: "link url web page", blockType: "bookmark", group: "Media" }, { id: "embed", label: "Embed", keywords: "loom figma twitter youtube link url", blockType: "embed", group: "Media" }, // --- Code & math ---------------------------------------------------------- { id: "code", label: "Code", keywords: "snippet programming codeblock", blockType: "code", group: "Code & math" }, { id: "equation", label: "Equation", keywords: "formula math latex", blockType: "equation", group: "Code & math" }, // --- Layout --------------------------------------------------------------- { id: "divider", label: "Divider", keywords: "separator hr line rule", blockType: "divider", group: "Layout" }, { id: "toc", label: "Table of contents", keywords: "toc contents headings outline navigation", blockType: "toc", group: "Layout" }, { id: "spacer", label: "Spacer", keywords: "space padding blank gap", blockType: "spacer", group: "Layout" }, { id: "column_list", label: "Columns", keywords: "grid layout two three", blockType: "column_list", group: "Layout" }, // --- Data ----------------------------------------------------------------- { id: "table", label: "Table", keywords: "rows columns grid static", blockType: "table", group: "Data" }, { id: "database", label: "Database", keywords: "db records tracker table", special: "database", group: "Data" }, { id: "linked_database", label: "Linked view", keywords: "linked database view embed", blockType: "linked_database", group: "Data" }, { id: "chart", label: "Chart", keywords: "bar line pie donut number graph", blockType: "chart", group: "Data" }, { id: "form", label: "Form", keywords: "survey input questionnaire", blockType: "form", group: "Data" }, // --- Advanced ------------------------------------------------------------- { id: "child_page", label: "Sub-page", keywords: "page nested subpage child", blockType: "child_page", group: "Advanced" }, { id: "child_database", label: "Linked database block", keywords: "child database embed", blockType: "child_database", group: "Advanced" }, { id: "synced_block", label: "Synced block", keywords: "sync duplicate mirror", special: "synced", group: "Advanced" }, ]; /** A command with its fuzzy rank (lower = better). */ export interface SlashCommandHit { readonly command: SlashCommandDef; readonly score: number; } /** * Subsequence fuzzy match: does `query` occur in `text` in order (case * insensitive)? Returns the rank (0 = best; each gap/case-penalty raises it) * or `null` when the query is not a subsequence. Deterministic. */ export function fuzzyScore(query: string, text: string): number | null { if (query.length === 0) return 0; const q = query.toLowerCase(); const t = text.toLowerCase(); let qi = 0; let score = 0; let lastMatch = -2; for (let ti = 0; ti < t.length && qi < q.length; ti += 1) { if (t[ti] === q[qi]) { // gap penalty (distance from previous match) + non-consecutive bonus if (lastMatch >= 0 && ti !== lastMatch + 1) score += 4; score += 1; lastMatch = ti; qi += 1; } } if (qi < q.length) return null; // exact prefix beats subsequence if (t.startsWith(q)) score = 1; return score; } /** * Search the registry for a query. An empty query returns every command in * menu order. Otherwise commands are ranked by (score, menu index) so the * result order is stable and deterministic. */ export function searchSlashCommands(query: string): readonly SlashCommandHit[] { const trimmed = query.trim().toLowerCase(); if (trimmed.length === 0) { return SLASH_COMMANDS.map((command) => ({ command, score: 0 })); } const hits: SlashCommandHit[] = []; SLASH_COMMANDS.forEach((command, index) => { const haystack = `${command.label} ${command.keywords}`; const exact = fuzzyScore(trimmed, command.label); const loose = exact !== null ? exact + 10 : fuzzyScore(trimmed, haystack); if (loose !== null) { hits.push({ command, score: loose * 1000 + index }); } }); hits.sort((a, b) => a.score - b.score); return hits; }