Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
157 lines
5.3 KiB
JavaScript
157 lines
5.3 KiB
JavaScript
// ==========================================
|
|
// 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;
|
|
}
|