/** * MentionPicker — the React surface for the `@` autocomplete. * * It watches the editor for a live `@query` (via `editor.on("update")`), runs * the injected `MentionProvider.search`, and renders a floating list positioned * at the cursor. Arrow keys move the highlight, Enter/Click insert the mention, * Escape closes. The actual insertion goes through the editor's `insertMention` * command (see `mention.ts`). * * Render this once, next to ``, passing the editor and a * provider. The provider is supplied by the app so this package never touches * the entity store directly. */ import { useEffect, useRef, useState } from "react"; import type { Editor } from "@tiptap/core"; import { mentionPickerState, findMentionTrigger, type MentionProvider } from "../mention.js"; interface MentionPickerProps { editor: Editor | null; provider: MentionProvider | null; } interface Candidate { id: string; label: string; subtitle?: string; } // The `insertMention` command is custom (not in TipTap's RawCommands), so we // reach it through a typed cast. type InsertMention = (id: string, label: string) => boolean; export function MentionPicker({ editor, provider }: MentionPickerProps) { const [open, setOpen] = useState(false); const [candidates, setCandidates] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const [pos, setPos] = useState({ x: 0, y: 0 }); const listRef = useRef(null); // Detect `@query` on every editor update and drive the picker state. useEffect(() => { if (!editor || !provider) return; const onUpdate = () => { const { from } = editor.state.selection; const atPos = findMentionTrigger(editor.state.doc, from); if (atPos < 0) { setOpen(false); mentionPickerState.open = false; return; } const queryText = editor.state.doc.textBetween(atPos + 1, from) ?? ""; const coords = editor.view.coordsAtPos(from); setPos({ x: coords.left, y: coords.bottom }); setOpen(true); mentionPickerState.open = true; mentionPickerState.query = queryText; mentionPickerState.atPos = atPos; mentionPickerState.from = from; mentionPickerState.selectedIndex = 0; const matches = provider.search(queryText); setCandidates(matches); setSelectedIndex(0); }; editor.on("update", onUpdate); return () => { editor.off("update", onUpdate); }; }, [editor, provider]); // Auto-scroll the highlighted item into view. useEffect(() => { listRef.current?.children[selectedIndex]?.scrollIntoView({ block: "nearest" }); }, [selectedIndex]); // Keep focus on the list so keyboard events are captured while open. useEffect(() => { if (open) listRef.current?.focus(); }, [open]); const insert = (c: Candidate) => { const cmd = (editor?.commands as unknown as { insertMention: InsertMention }).insertMention; if (cmd) cmd(c.id, c.label); setOpen(false); mentionPickerState.open = false; }; const onKeyDown = (e: React.KeyboardEvent) => { e.stopPropagation(); if (e.key === "ArrowDown") { e.preventDefault(); setSelectedIndex((i) => (i + 1) % Math.max(1, candidates.length)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => (i - 1 + candidates.length) % Math.max(1, candidates.length)); } else if (e.key === "Enter") { e.preventDefault(); const sel = candidates[selectedIndex]; if (sel) insert(sel); } else if (e.key === "Escape") { e.preventDefault(); setOpen(false); mentionPickerState.open = false; } }; if (!open || !provider) return null; return (
    {candidates.length === 0 ? (
  • No matches
  • ) : ( candidates.map((c, i) => (
  • { e.preventDefault(); insert(c); }} onMouseEnter={() => setSelectedIndex(i)} style={{ padding: "8px 12px", borderRadius: 6, cursor: "pointer", background: i === selectedIndex ? "var(--ps-accent, #0f3d3e)" : "transparent", color: i === selectedIndex ? "#fff" : "inherit", }} >
    {c.label}
    {c.subtitle && (
    {c.subtitle}
    )}
  • )) )}
); } export default MentionPicker;