/** * Mention autocomplete — the `@` trigger that opens an entity picker and * inserts a `CharacterMention` node. * * The editor package owns the low-level mechanics (detecting `@query`, * positioning the picker, inserting the mention atom). The *data* — which * entities to search — is injected by the app through a `MentionProvider`, so * this package never imports the app's entity store. * * The picker is a React component (`MentionPicker`) that reads a small * module-level `mentionPickerState` object shared with this extension. The * extension exposes an `insertMention` command; the picker calls it on Enter. */ import { Extension, type Editor, type RawCommands } from "@tiptap/core"; import type { Node } from "@tiptap/pm/model"; import { CHARACTER_MENTION } from "./constants.js"; /** A single entity option shown in the autocomplete picker. */ export interface MentionEntity { id: string; label: string; /** Optional subtitle (role / one-line bio) shown under the name. */ subtitle?: string; } /** * The search provider the app injects. Given a query, return matching * entities sorted by relevance (best first). An empty query should return the * full list (or a capped prefix) so the picker shows candidates immediately. */ export interface MentionProvider { search: (query: string) => MentionEntity[]; } /** * Shared picker state between the extension and the React picker. Kept at module * scope because the TipTap extension is a singleton and the picker is rendered * by the app alongside the editor — there is no cleaner channel between them. */ export interface MentionPickerState { open: boolean; query: string; /** Doc position of the `@` trigger (where the mention will be inserted). */ atPos: number; /** Cursor position (end of the query). */ from: number; /** Currently highlighted result index. */ selectedIndex: number; } export const mentionPickerState: MentionPickerState = { open: false, query: "", atPos: 0, from: 0, selectedIndex: 0, }; /** * Walk backwards from `from` over the doc text looking for the `@` trigger. * Returns the trigger position, or `-1` when the text before the cursor is not * a live mention query (no `@`, or a space/newline interrupts it). */ export function findMentionTrigger(doc: Node, from: number): number { let atPos = from; while (atPos > 0) { const ch = doc.textBetween(atPos - 1, atPos) ?? ""; if (ch === "@") return atPos - 1; if (ch === " " || ch === "\n" || ch === "\t") return -1; atPos -= 1; } return -1; } /** * The autocomplete extension. Adds the `insertMention` command used by the * picker and (optionally) a keyboard shortcut to open the picker manually. */ export const MentionAutocomplete = Extension.create({ name: "mentionAutocomplete", addOptions() { return { /** The trigger character. */ trigger: "@", }; }, addCommands() { return { /** * Insert a `CharacterMention` atom at the current trigger, replacing the * `@query` text between the trigger and the cursor. */ insertMention: (id: string, label: string) => ({ editor }: { editor: Editor }) => { const { state, schema } = editor; const { from } = state.selection; const atPos = findMentionTrigger(state.doc, from); if (atPos < 0) return false; const nodeType = schema.nodes[CHARACTER_MENTION]; if (!nodeType) return false; const mention = nodeType.create({ entityId: id, label }); const tr = state.tr.deleteRange(atPos, from).insert(atPos, mention); editor.view.dispatch(tr); return true; }, } as unknown as Partial; }, }); export default MentionAutocomplete;