/** * @factory/editor — block context menu definition (TASK-0055, PRODUCT-SPEC §7). * * A single data-driven menu: rows are plain data (`{label, shortcut?, * divider?, submenu?, disabled?, run(ctx)}`) rendered by the shared Radix * menu in `packages/ui`. This package owns the row model + the row * computation (which rows appear, which are disabled, for which block * type/selection) — the UI is not. * * Row order (PRODUCT-SPEC §7): Turn into ▸ · Duplicate · Copy · Copy link · * Move to ▸ · Delete · — · Comment · Ask AI ▸ · — · Color ▸ · Background ▸ · * — · Convert to ▸ · — · More ▸. * * "Ask AI" is a frozen Phase-12 shape (rows present, `disabled: true`, * tooltip "Available in Phase 12"); Phase 12 only flips `enabled`. */ import type { BlockType } from "@factory/shared/dto"; /** Execution context handed to a row's `run` callback. */ export interface MenuContext { /** The blocks this menu operates on (single block or selection). */ readonly blockIds: readonly string[]; readonly pageId: string; readonly workspaceId: string; readonly readOnly: boolean; } /** A computed row of the block context menu (data only — no React). */ export interface MenuRow { readonly id: string; readonly label: string; readonly shortcut?: string; readonly divider?: boolean; readonly disabled?: boolean; readonly tooltip?: string; readonly submenu?: readonly MenuRow[]; readonly run?: (ctx: MenuContext) => void; } /** The 9 text + 9 background color tokens (closed palette — never raw hex). */ export const TEXT_COLOR_TOKENS: readonly string[] = [ "default", "gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", ]; export const BACKGROUND_COLOR_TOKENS: readonly string[] = [ "default", "gray_background", "brown_background", "orange_background", "yellow_background", "green_background", "blue_background", "purple_background", "pink_background", ]; /** Block types that accept a text `content.text` (turn-into/convert-to targets). */ export const TEXT_BLOCK_TYPES: readonly BlockType[] = [ "paragraph", "heading_1", "heading_2", "heading_3", "quote", "callout", "bulleted_list", "numbered_list", "todo", "toggle", ]; /** The "Turn into ▸" submenu: every text-capable type (checkmark = current). */ export function turnIntoRows(currentType: BlockType): readonly MenuRow[] { return TEXT_BLOCK_TYPES.map((type) => ({ id: `turn_into:${type}`, label: turnIntoLabel(type), // `disabled` is ALWAYS explicit (the UI renders it truthily; the // current type is the only disabled row — the checkmark itself is // conveyed by the renderer via `id === turnIntoId(type)`). disabled: type === currentType, })); } export function turnIntoId(type: BlockType): string { return `turn_into:${type}`; } function turnIntoLabel(type: BlockType): string { const labels: Record = { paragraph: "Text", heading_1: "Heading 1", heading_2: "Heading 2", heading_3: "Heading 3", quote: "Quote", callout: "Callout", bulleted_list: "Bulleted list", numbered_list: "Numbered list", todo: "To-do", toggle: "Toggle", }; return labels[type] ?? type; } /** The "Convert to ▸" quick submenu (heading 1–3 / quote / callout / todo). */ export function convertToRows(): readonly MenuRow[] { return ( ["heading_1", "heading_2", "heading_3", "quote", "callout", "todo"] as const ).map((type) => ({ id: `convert_to:${type}`, label: turnIntoLabel(type), })); } /** The frozen Phase-12 "Ask AI ▸" shape. */ export const ASK_AI_ROWS: readonly MenuRow[] = [ { id: "ai:summarize", label: "Summarize", disabled: true, tooltip: "Available in Phase 12" }, { id: "ai:rewrite", label: "Rewrite", disabled: true, tooltip: "Available in Phase 12" }, { id: "ai:improve", label: "Improve", disabled: true, tooltip: "Available in Phase 12" }, { id: "ai:translate", label: "Translate", disabled: true, tooltip: "Available in Phase 12" }, ]; /** * The "More ▸" submenu. `synced_block` (Phase 5) and `template` (Phase 14 * scaffold) are disabled placeholders; `permissions` is enabled only for * page-root blocks (handled by the caller via `ctx` gating). */ export function moreRows(isPageRoot: boolean): readonly MenuRow[] { return [ { id: "more:synced", label: "Duplicate as synced block", disabled: true, tooltip: "Available in Phase 5" }, { id: "more:template", label: "Save as template", disabled: true, tooltip: "Available in Phase 14" }, { id: "more:permissions", label: "Permissions", disabled: !isPageRoot }, ]; } export interface BuildBlockMenuOptions { readonly blockIds: readonly string[]; readonly type: BlockType; readonly isPageRoot?: boolean; readonly readOnly?: boolean; /** Color/background setters (wire the properties write-back). */ readonly onTextColor?: (token: string, ctx: MenuContext) => void; readonly onBackgroundColor?: (token: string, ctx: MenuContext) => void; } /** * Compute the full menu for a block (or selection) per PRODUCT-SPEC §7. * Selection > 1 adjusts the duplicate label ("Duplicate 3 blocks") and the * `run` callbacks receive all `blockIds`. */ export function buildBlockMenu(options: BuildBlockMenuOptions): readonly MenuRow[] { const many = options.blockIds.length > 1; const readOnly = options.readOnly ?? false; const type = options.type; const isPageRoot = options.isPageRoot ?? false; const rows: MenuRow[] = [ { id: "turn_into", label: "Turn into", submenu: turnIntoRows(type), ...(readOnly ? { disabled: true } : {}), }, { id: "duplicate", label: many ? `Duplicate ${options.blockIds.length} blocks` : "Duplicate", shortcut: "⌘⇧D", disabled: readOnly }, { id: "copy", label: "Copy", shortcut: "⌘C", disabled: options.blockIds.length === 0 }, { id: "copy_link", label: "Copy link", shortcut: "⌘⇧C", disabled: options.blockIds.length === 0 }, { id: "move_to", label: "Move to", shortcut: "⌘⇧M", disabled: readOnly }, { id: "delete", label: "Delete", disabled: readOnly }, { id: "sep-1", label: "", divider: true }, { id: "comment", label: "Comment" }, { id: "ask_ai", label: "Ask AI", submenu: ASK_AI_ROWS }, { id: "sep-2", label: "", divider: true }, { id: "color", label: "Color", disabled: readOnly, submenu: TEXT_COLOR_TOKENS.map((token) => ({ id: `color:${token}`, label: colorLabel(token), run: (c: MenuContext) => options.onTextColor?.(token, c), })), }, { id: "background", label: "Background", disabled: readOnly, submenu: BACKGROUND_COLOR_TOKENS.map((token) => ({ id: `background:${token}`, label: colorLabel(token), run: (c: MenuContext) => options.onBackgroundColor?.(token, c), })), }, { id: "sep-3", label: "", divider: true }, { id: "convert_to", label: "Convert to", submenu: convertToRows(), ...(readOnly ? { disabled: true } : {}) }, { id: "sep-4", label: "", divider: true }, { id: "more", label: "More", submenu: moreRows(isPageRoot) }, ]; // Rows without an explicit `run` (duplicate/copy/copy_link/move_to/delete/ // comment/turn_into/convert_to) are wired by the renderer through their // stable `id`; the ones with a `run` (color/background swatches) capture // their callbacks here. return rows; } function colorLabel(token: string): string { if (token === "default") return "Default"; const name = token.replace(/_background$/, ""); return name.charAt(0).toUpperCase() + name.slice(1); }