import { Plugin, PluginKey, EditorState } from 'prosemirror-state'; import { Decoration, DecorationSet } from 'prosemirror-view'; /** Live state of a trigger-character suggestion (mention or slash command). */ export interface SuggestionMatch { active: boolean; trigger: string; query: string; /** Doc range spanning trigger + query, to replace on selection. */ from: number; to: number; } export const INACTIVE_SUGGESTION: SuggestionMatch = { active: false, trigger: '', query: '', from: 0, to: 0, }; export interface SuggestionOptions { /** Read live so trigger config can change without rebuilding state. */ getTriggers: () => string[]; /** Restrict to the start of an empty-ish textblock (slash commands). */ startOfLine?: boolean; /** Stay inactive until the query reaches this length (emoji ":" noise). */ minQueryLength?: number; /** Notified on every state change (including becoming inactive). */ onChange: (match: SuggestionMatch) => void; /** Intercept keys (arrows/Enter/Escape) while a suggestion is active. */ onKeyDown?: (event: KeyboardEvent, match: SuggestionMatch) => boolean; } const MAX_QUERY_LENGTH = 50; function findSuggestion(state: EditorState, options: SuggestionOptions): SuggestionMatch { const { $from, empty } = state.selection; if (!empty || !$from.parent.isTextblock || $from.parent.type.spec.code) { return INACTIVE_SUGGESTION; } const textBefore = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0'); // Longest triggers first so '{{' wins over '{'. const triggers = [...options.getTriggers()].sort((a, b) => b.length - a.length); for (const trigger of triggers) { if (!trigger) continue; const index = textBefore.lastIndexOf(trigger); if (index === -1) continue; if (options.startOfLine && index !== 0) continue; // Trigger must start a word: preceded by nothing or whitespace. const before = textBefore[index - 1]; if (before !== undefined && !/\s/.test(before)) continue; const query = textBefore.slice(index + trigger.length); if (query.length > MAX_QUERY_LENGTH || /\s/.test(query)) continue; if (query.length < (options.minQueryLength ?? 0)) continue; return { active: true, trigger, query, from: $from.start() + index, to: $from.pos, }; } return INACTIVE_SUGGESTION; } /** * Generic trigger-character suggestion tracker. Recomputes the active * trigger/query from the selection on every transaction, notifies the host, * and softly highlights the query text. Insertion/UI is the host's job. */ export function suggestionPlugin(key: PluginKey, options: SuggestionOptions): Plugin { return new Plugin({ key, state: { init: () => INACTIVE_SUGGESTION, apply(tr, prev, _old, newState) { const next = findSuggestion(newState, options); if ( next.active !== prev.active || next.query !== prev.query || next.trigger !== prev.trigger || next.from !== prev.from ) { options.onChange(next); } return next; }, }, props: { decorations(state) { const match = key.getState(state) as SuggestionMatch; if (!match.active) return null; return DecorationSet.create(state.doc, [ Decoration.inline(match.from, match.to, { class: 'nile-wysiwyg__suggestion', }), ]); }, handleKeyDown(view, event) { const match = key.getState(view.state) as SuggestionMatch; if (!match.active || !options.onKeyDown) return false; return options.onKeyDown(event, match); }, }, }); }