| 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. |
| 4 | import type { EditorState } from "@codemirror/state"; |
| 5 | |
| 6 | /** Doc offsets of the trigger run and the query typed so far; the range an acceptance replaces. */ |
| 7 | export interface Trigger { |
| 8 | from: number; |
| 9 | to: number; |
| 10 | query: string; |
| 11 | } |
| 12 | |
| 13 | export type MentionTrigger = Trigger; |
| 14 | export type SlashTrigger = Trigger; |
| 15 | export 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. |
| 19 | const MENTION_TRIGGER = /(?<=^|\s)@([^\s@]*)$/; |
| 20 | |
| 21 | export 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. */ |
| 32 | export 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. |
| 44 | const EMOJI_TRIGGER = /(?:^|\s):([a-z0-9_+-]{2,})$/i; |
| 45 | |
| 46 | export 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 | } |