1// Caret-anchored completion triggers: pure functions over EditorState, so a host can poll them from
2// an update listener and drive its own picker UI. Each returns the doc range an accepted completion
3// replaces, or null when the caret is not inside a trigger run.
4import type { EditorState } from "@codemirror/state";
5
6/** Doc offsets of the trigger run and the query typed so far; the range an acceptance replaces. */
7export interface Trigger {
8 from: number;
9 to: number;
10 query: string;
11}
12
13export type MentionTrigger = Trigger;
14export type SlashTrigger = Trigger;
15export type EmojiTrigger = Trigger;
16
17// `@` anchored to a word boundary (start of line or after whitespace); the query runs to the caret
18// over any non-space, non-`@` run, so paths with `/` and `.` work.
19const MENTION_TRIGGER = /(?<=^|\s)@([^\s@]*)$/;
20
21export function mentionTriggerAt(state: EditorState): MentionTrigger | null {
22 const range = state.selection.main;
23 if (!range.empty) return null;
24 const line = state.doc.lineAt(range.head);
25 const before = line.text.slice(0, range.head - line.from);
26 const match = MENTION_TRIGGER.exec(before);
27 if (!match || match.index === undefined) return null;
28 return { from: line.from + match.index, to: range.head, query: match[1] ?? "" };
29}
30
31/** A `/command` run anchored to the start of the line, as chat composers use for slash palettes. */
32export function slashTriggerAt(state: EditorState): SlashTrigger | null {
33 const range = state.selection.main;
34 if (!range.empty) return null;
35 const line = state.doc.lineAt(range.head);
36 const before = line.text.slice(0, range.head - line.from);
37 const match = /^\/([a-z-]*)$/i.exec(before);
38 if (!match) return null;
39 return { from: line.from, to: range.head, query: match[1] ?? "" };
40}
41
42// `:code` at a word boundary. The two-char floor keeps a bare `:` in prose (URLs, ratios) from
43// opening the picker; `mailto:`-style colons fail the boundary anyway.
44const EMOJI_TRIGGER = /(?:^|\s):([a-z0-9_+-]{2,})$/i;
45
46export function emojiTriggerAt(state: EditorState): EmojiTrigger | null {
47 const range = state.selection.main;
48 if (!range.empty) return null;
49 const line = state.doc.lineAt(range.head);
50 const before = line.text.slice(0, range.head - line.from);
51 const match = EMOJI_TRIGGER.exec(before);
52 if (!match) return null;
53 const query = match[1] ?? "";
54 return { from: range.head - query.length - 1, to: range.head, query };
55}