// ========================================== // Chat Switchboard — CM6 @Mention Extension // ========================================== // Provides: // 1. Autocomplete triggered by @ for personas/users // 2. ViewPlugin that decorates @handle as styled marks // 3. Theme for mention styling in the editor // // Modeled on wikilink.mjs — same pattern adapted for // @mention tokens instead of [[wikilink]] syntax. // // Usage: // import { mentionExtension } from './mention.mjs'; // const ext = mentionExtension({ // completer: async (query) => [{ label, handle, type }], // }); // ========================================== import { ViewPlugin, Decoration, EditorView, } from '@codemirror/view'; import { RangeSetBuilder } from '@codemirror/state'; import { autocompletion } from '@codemirror/autocomplete'; // ── Mention Regex ─────────────────────────── // Matches @handle tokens: @ preceded by start-of-string or whitespace, // followed by one or more word chars or hyphens. const MENTION_RE = /(?:^|(?<=\s))@([\w-]+)/g; // ── ViewPlugin: Decorate @mentions ────────── function mentionDecoPlugin() { return ViewPlugin.fromClass(class { decorations; constructor(view) { this.decorations = this.build(view); } update(update) { if (update.docChanged || update.viewportChanged || update.selectionSet) { this.decorations = this.build(update.view); } } build(view) { const builder = new RangeSetBuilder(); const doc = view.state.doc; const text = doc.toString(); const sel = view.state.selection.main; let match; MENTION_RE.lastIndex = 0; while ((match = MENTION_RE.exec(text)) !== null) { // match[0] includes any leading whitespace captured by lookbehind, // but lookbehind is zero-width so match.index points to @ const atIdx = text.indexOf('@', match.index); const from = atIdx; const to = from + 1 + match[1].length; // @handle // Don't decorate if cursor is inside the mention (let user edit) if (sel.from >= from && sel.from <= to) continue; builder.add(from, to, Decoration.mark({ class: 'cm-mention' })); } return builder.finish(); } }, { decorations: (v) => v.decorations, }); } // ── Autocomplete: @ trigger ───────────────── function mentionAutocomplete(completer) { return autocompletion({ override: [ async (ctx) => { // Look for @ before the cursor (at line start or after whitespace) const line = ctx.state.doc.lineAt(ctx.pos); const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos); const triggerMatch = textBefore.match(/(?:^|\s)@([\w-]*)$/); if (!triggerMatch) return null; const query = triggerMatch[1]; // from = position of the first char after @ const from = ctx.pos - query.length; if (!completer) return null; try { const results = await completer(query); if (!results || results.length === 0) return null; return { from, options: results.map(r => ({ label: r.handle, displayLabel: r.label, detail: r.type === 'all' ? 'all users' : r.type, apply: (view, completion, from, to) => { // Insert handle + trailing space const insert = completion.label + ' '; view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, }); }, })), }; } catch (e) { console.warn('[mention] autocomplete error:', e); return null; } }, ], activateOnTyping: true, }); } // ── Theme: Mention Styles ─────────────────── const mentionTheme = EditorView.baseTheme({ '.cm-mention': { color: 'var(--accent, #6366f1)', backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.1)', borderRadius: '3px', padding: '0 2px', fontWeight: '500', }, }); // ── Public: Combined Extension ────────────── /** * Create the @mention CM6 extension bundle. * * @param {Object} opts * @param {Function} opts.completer - async (query) => [{ label, handle, type }] * @returns {Extension[]} Array of CM6 extensions */ export function mentionExtension(opts = {}) { const extensions = [ mentionDecoPlugin(), mentionTheme, ]; if (opts.completer) { extensions.push(mentionAutocomplete(opts.completer)); } return extensions; }