/** * Block insertion, driven by typing `/`. * * Everything inserts locally and immediately, including the components that * carry a durable id: a ULID has enough entropy that two clients cannot mint * the same one, so buying uniqueness with a server round trip would only make * the block appear later than the keystroke that asked for it. */ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $copyBlockFormatIndent, $setBlocksType } from "@lexical/selection"; import { $getSelection, $isRangeSelection, $isTextNode, COLLABORATION_TAG, COMMAND_PRIORITY_LOW, HISTORIC_TAG, KEY_DOWN_COMMAND, } from "lexical"; import { $createCalloutNode, $createCodeBlockNode, $createImageNode, $createMathNode, $createTabNode, $createTabsNode, DIFF_LANGUAGE, IMAGE_PROTOCOLS, MERMAID_LANGUAGE, ulid, } from "@chopin/dialect"; import { INSERT_CHECK_LIST_COMMAND } from "@lexical/list"; import { $createTableNodeWithDimensions } from "@lexical/table"; import { $createParagraphNode, $createTextNode, $insertNodes } from "lexical"; import { CheckIcon, CodeIcon, DiagramIcon, DiffIcon, FormulaIcon, ImageIcon, InfoIcon, MagnifierIcon, TableIcon, TabsIcon, } from "@chopin/icons"; import { askForUrl } from "./url"; import { placeSurface } from "./placement"; import { OPEN_RESEARCH_COMMAND } from "./research"; import { DIVIDER, editorSurfaceViewport, listenToEditorGeometry, nativeSelectionRect, ROW, SHELL, } from "./surface"; import type { ReactElement } from "react"; import type { IconProps } from "@chopin/icons"; import type { ElementNode, LexicalEditor, LexicalNode } from "lexical"; import type { DOMRectLike, SurfacePlacement } from "./placement"; import type { OpenResearch } from "./research"; const MULTI_WORD_COMMANDS = ["web search"]; type SlashCommandBase = { id: string; label: string; hint: string; icon: (props: IconProps) => ReactElement; group: string; keywords: string[]; }; export type SlashCommand = & SlashCommandBase & ( | { kind: "insert"; run: (editor: LexicalEditor) => void } | { kind: "action"; run: (editor: LexicalEditor, action: OpenResearch) => boolean } ); /** Replace the current block, dropping the `/query` the user typed. */ function replace(editor: LexicalEditor, build: () => ElementNode) { editor.update(() => { let selection = $getSelection(); if (!$isRangeSelection(selection)) return; $setBlocksType(selection, build); }); } /** Keep the replaced block as a block inside the new container. */ function insertContainer( editor: LexicalEditor, build: () => ElementNode, allowed: (node: LexicalNode) => boolean = () => true, ) { editor.update(() => { let selection = $getSelection(); if (!$isRangeSelection(selection)) return; if (!allowed(selection.anchor.getNode())) return; let body: ElementNode | undefined; $setBlocksType( selection, build, (previous, container) => { let paragraph = $createParagraphNode(); $copyBlockFormatIndent(previous, paragraph); paragraph.append(...previous.getChildren()); container.append(paragraph); body = paragraph; }, ); // An empty text child is normalised away after this update; anchor the // caret to its paragraph so the next character does not become a direct // child of the callout instead. if (body?.getTextContentSize() === 0) body.selectEnd(); }); } /** * What the menu offers. * * Only blocks markdown cannot type. Headings, quotes and lists have shortcuts * — `# `, `> `, `- `, `1. ` — and a control in the selection bubble, so listing * them here would be a third way to do what two already do, and would bury the * things that have no other route. * * Task lists are the second exception: `[ ] ` converts a line, but nobody * guesses that, so the menu is where they are found. * * A code fence is the other exception: ``` is not wired, because * MDXEditor's transformer builds its own code node rather than the dialect's. * * Order is the menu order, and the first match is preselected: ordinary blocks * come first and Research stays last, so a bare `/` then Enter never starts a * durable job. It becomes the first match only once the query narrows to it. */ const COMMANDS: SlashCommand[] = [ { id: "callout", label: "Callout", hint: "Highlight a note or warning", icon: InfoIcon, group: "Blocks", keywords: ["note", "warning", "aside", "admonition"], kind: "insert", run: editor => insertContainer(editor, () => $createCalloutNode(ulid())), }, { id: "table", label: "Table", hint: "Rows and columns", icon: TableIcon, group: "Blocks", keywords: ["table", "grid", "row", "column", "spreadsheet"], kind: "insert", run: editor => editor.update(() => { /* * Row headers only. * * `includeHeaders: true` would mark the first cell of every row * as a header too, which renders as a bold shaded column — and * GFM has no way to write one, so the table would claim * something on screen that the saved source does not say. */ $insertNodes([$createTableNodeWithDimensions(3, 3, { rows: true, columns: false })]); }), }, { id: "tasks", label: "Task list", hint: "Items to check off", icon: CheckIcon, group: "Blocks", keywords: ["task", "todo", "checklist", "checkbox"], kind: "insert", run: editor => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined), }, { id: "code", label: "Code block", hint: "Syntax-coloured code", icon: CodeIcon, group: "Blocks", keywords: ["code", "snippet", "fence"], kind: "insert", run: editor => replace(editor, () => $createCodeBlockNode("")), }, { id: "image", label: "Image", hint: "Insert from a URL", icon: ImageIcon, group: "Blocks", keywords: ["image", "picture", "figure", "screenshot"], kind: "insert", run: editor => { let src = askForUrl("Image URL", { protocols: IMAGE_PROTOCOLS, relative: false }); if (!src) return; editor.update(() => { $insertNodes([$createImageNode(src, "")]); }); }, }, { id: "mermaid", label: "Diagram", hint: "Flowchart from Mermaid text", icon: DiagramIcon, group: "Blocks", keywords: ["mermaid", "diagram", "flowchart", "graph"], kind: "insert", run: editor => replace(editor, () => $createCodeBlockNode(MERMAID_LANGUAGE)), }, { id: "math", label: "Formula", hint: "LaTeX equation", icon: FormulaIcon, group: "Blocks", keywords: ["math", "latex", "katex", "equation"], kind: "insert", run: editor => replace(editor, () => $createMathNode(false)), }, { id: "tabs", label: "Tabs", hint: "Switch between panels", icon: TabsIcon, group: "Blocks", keywords: ["tab", "switch", "panel"], kind: "insert", run: editor => editor.update(() => { let tabs = $createTabsNode(); tabs.setId(ulid()); for (let label of ["One", "Two"]) { let tab = $createTabNode(); tab.setId(ulid()); tab.setLabel(label); tab.append($createParagraphNode().append($createTextNode(""))); tabs.append(tab); } $insertNodes([tabs]); }), }, { id: "diff", label: "Diff", hint: "Added and removed lines", icon: DiffIcon, group: "Blocks", keywords: ["diff", "patch", "change"], kind: "insert", run: editor => replace(editor, () => $createCodeBlockNode(DIFF_LANGUAGE)), }, { id: "research", label: "Research", hint: "Ask Chopin to research the web", icon: MagnifierIcon, group: "Research", keywords: ["research", "web search"], kind: "action", run: (editor, action) => editor.dispatchCommand(OPEN_RESEARCH_COMMAND, action), }, ]; function match(command: SlashCommand, query: string): boolean { if (!query) return true; let needle = query.toLowerCase(); return command.label.toLowerCase().includes(needle) || command.keywords.some(word => word.includes(needle)); } export function availableCommands(query: string): SlashCommand[] { return COMMANDS.filter(command => match(command, query)); } /** * What the caret is asking to insert, if anything. * * The slash has to start a word — beginning the block, or following a space. * A plan is full of paths, URLs and dates, and a rule that only looked for the * last slash in the line would put a menu over `src/index.ts`, `https://x.com` * and `24/7`. Only text behind the caret counts, so editing earlier in a line * that happens to contain a slash stays quiet too. * * Returns the query typed after the slash, or undefined for no trigger. An * empty string is a bare `/`, which opens the full list. */ export function trigger(text: string, offset: number): string | undefined { let before = text.slice(0, offset); let slash = before.lastIndexOf("/"); if (slash < 0) return undefined; let preceding = slash > 0 ? before[slash - 1] : undefined; if (preceding !== undefined && !/\s/.test(preceding)) return undefined; let query = before.slice(slash + 1); // Spaces continue only a known multi-word command. Ordinary prose still // closes immediately, rather than leaving a menu over the sentence. if ( /\s/.test(query) && !MULTI_WORD_COMMANDS.some(command => command.startsWith(query.toLowerCase())) ) return undefined; return query; } /** Remove the active slash trigger inside the Lexical transaction handling its command. */ export function $consumeSlashTrigger(): boolean { let selection = $getSelection(); if (!$isRangeSelection(selection)) return false; let node = selection.anchor.getNode(); if (!$isTextNode(node)) return false; let offset = selection.anchor.offset; let text = node.getTextContent(); let typed = trigger(text, offset); if (typed === undefined) return false; let start = offset - typed.length - 1; node.setTextContent(text.slice(0, start) + text.slice(offset)); node.select(start, start); return true; } export type Decision = "open" | "close" | "ignore"; /** * Whether an editor update should show, hide or be ignored by the menu. * * Opening is caused, not derived. Lexical commits an update for every change * including a bare caret move, and an agent turn writing `/workspace/project` * lands text and recovers the local selection into it — so a menu that simply * asked "does the caret sit after a slash?" would open over content nobody * typed. Only a keystroke arms it. * * Closing stays unconditional: a peer deleting the slash this menu is attached * to should dismiss it, whoever caused the change. */ export function decide( { typed, open, armed, remote, composing }: { /** Query behind the caret, from `trigger`. */ typed: string | undefined; open: boolean; /** The local user pressed `/`. */ armed: boolean; /** Applied from collaboration or history rather than this client. */ remote: boolean; composing: boolean; }, ): Decision { // Intermediate IME text is not a decision the author has made yet. if (composing) return "ignore"; if (typed === undefined) return "close"; if (open) return "open"; if (remote || !armed) return "ignore"; return "open"; } export type SlashMenuProps = { actions?: ReadonlySet; disabled?: boolean; }; const NO_ACTIONS = new Set(); export function SlashMenu({ actions = NO_ACTIONS, disabled }: SlashMenuProps) { let [editor] = useLexicalComposerContext(); let [query, setQuery] = useState(); let [anchor, setAnchor] = useState(); let [position, setPosition] = useState(); let [index, setIndex] = useState(0); let surface = useRef(null); let open = query !== undefined && !disabled; let matches = useMemo( () => availableCommands(query ?? "").filter(command => command.kind === "insert" || actions.has(command.id) ), [actions, query], ); // Groups now place dividers while options remain direct listbox children. let grouped = useMemo(() => { let out = new Map(); for (let command of matches) { out.set(command.group, [...(out.get(command.group) ?? []), command]); } return [...out]; }, [matches]); /** * Set when the local user presses `/`. * * A ref rather than state: it gates the very update that follows the * keystroke, which is committed long before a re-render would land. */ let armed = useRef(false); let currentQuery = useRef(undefined); /** Read inside the update listener, which must not re-register per keystroke. */ let showing = useRef(false); showing.current = open; let close = useCallback(() => { // Dismissing has to disarm, or the next update would re-derive the same // trigger and reopen what the user just closed. armed.current = false; currentQuery.current = undefined; setQuery(undefined); setAnchor(undefined); setPosition(undefined); setIndex(0); }, []); /** * Remove the typed `/query` before acting on it. * * Only that span: anything the user had already written after the caret * belongs to them, and truncating the line to the slash would eat it. */ let consume = useCallback((): boolean => { let consumed = false; editor.update(() => consumed = $consumeSlashTrigger(), { discrete: true }); return consumed; }, [editor]); let choose = useCallback((command: SlashCommand) => { if (command.kind === "action") { if (anchor && command.run(editor, { anchor, consume: $consumeSlashTrigger })) close(); return; } consume(); close(); command.run(editor); }, [anchor, consume, close, editor]); // Arming is what separates a slash the author typed from one that arrived // with someone else's edit. Registered whether or not the menu is showing, // because it is the thing that decides whether it should. useEffect(() => { return editor.registerCommand( KEY_DOWN_COMMAND, (event: KeyboardEvent) => { if (event.key === "/") armed.current = true; return false; }, COMMAND_PRIORITY_LOW, ); }, [editor]); // Track what follows the `/` so the list can filter as the user types. useEffect(() => { // Clears any query left behind by the lock, which would otherwise spring // the menu open the moment an agent turn ends. if (disabled) { close(); return; } return editor.registerUpdateListener(({ tags }) => { editor.getEditorState().read(() => { let selection = $getSelection(); if (!$isRangeSelection(selection) || !selection.isCollapsed()) return close(); let typed = trigger( selection.anchor.getNode().getTextContent(), selection.anchor.offset, ); let decision = decide({ typed, open: showing.current, armed: armed.current, remote: tags.has(COLLABORATION_TAG) || tags.has(HISTORIC_TAG), composing: editor.isComposing(), }); if (decision === "ignore") return; if (decision === "close") return close(); let rect = nativeSelectionRect(); if (!rect) return close(); if (typed !== currentQuery.current) setIndex(0); currentQuery.current = typed; setQuery(typed); setAnchor(rect); }); }); }, [editor, close, disabled]); let place = useCallback(() => { let element = surface.current; if (!element || !anchor) return; let liveAnchor = nativeSelectionRect(); if (!liveAnchor) { setPosition(undefined); return; } let viewport = editorSurfaceViewport(editor); element.style.maxWidth = `${Math.max(0, viewport.width - 16)}px`; let next = placeSurface( liveAnchor, { width: element.offsetWidth, height: element.scrollHeight }, viewport, ); setPosition(current => current?.left === next.left && current.top === next.top && current.maxHeight === next.maxHeight ? current : next ); }, [anchor]); useLayoutEffect(() => { if (open) place(); }, [open, grouped, place]); useEffect(() => { if (!open) return; return listenToEditorGeometry(editor, place); }, [editor, open, place]); let selected = useRef(matches[0]); selected.current = matches[index]; useEffect(() => { if (!open) return; return editor.registerCommand( KEY_DOWN_COMMAND, (event: KeyboardEvent) => { if (event.key === "Escape") { close(); } else if (event.key === "ArrowDown") { setIndex(value => (value + 1) % Math.max(matches.length, 1)); } else if (event.key === "ArrowUp") { setIndex(value => (value - 1 + matches.length) % Math.max(matches.length, 1)); } else if (event.key === "Enter" || event.key === "Tab") { let command = selected.current; if (!command) return false; choose(command); } else { return false; } event.preventDefault(); return true; }, COMMAND_PRIORITY_LOW, ); }, [editor, open, matches.length, close, choose]); if (!open || !anchor || matches.length === 0) return null; let flat = matches; return (
event.preventDefault()} > {grouped.map(([group, commands], place) => ( {place > 0 && ); }