Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
@@ -147,29 +147,36 @@ function tripleBacktickHandler() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks,
|
||||
* or inserts a pair and places cursor between them.
|
||||
* Wrap the selection with `before`/`after` markers (or insert an empty
|
||||
* pair and place the cursor between them). Used for bold, italic, code.
|
||||
*/
|
||||
function inlineCodeKeymap() {
|
||||
return keymap.of([{
|
||||
key: 'Mod-e',
|
||||
run: (view) => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (from !== to) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: '`' + text + '`' },
|
||||
selection: { anchor: from + 1, head: to + 1 },
|
||||
});
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from, to: from, insert: '``' },
|
||||
selection: { anchor: from + 1 },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}]);
|
||||
function _wrapWith(view, before, after) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (from !== to) {
|
||||
const text = view.state.doc.sliceString(from, to);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: before + text + after },
|
||||
selection: { anchor: from + before.length, head: to + before.length },
|
||||
});
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from, to: from, insert: before + after },
|
||||
selection: { anchor: from + before.length },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keybindings: Ctrl/Cmd+B (bold), Ctrl/Cmd+I (italic), Ctrl/Cmd+E (inline code).
|
||||
* Wraps selection or inserts an empty pair with cursor between markers.
|
||||
*/
|
||||
function formattingKeymap() {
|
||||
return keymap.of([
|
||||
{ key: 'Mod-b', run: (view) => _wrapWith(view, '**', '**') },
|
||||
{ key: 'Mod-i', run: (view) => _wrapWith(view, '_', '_') },
|
||||
{ key: 'Mod-e', run: (view) => _wrapWith(view, '`', '`') },
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Chat Input Factory ──────────────────────
|
||||
@@ -193,6 +200,7 @@ export function chatInput(target, opts = {}) {
|
||||
onChange = null,
|
||||
maxHeight = 200,
|
||||
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
|
||||
extensions: extraExtensions = [],
|
||||
} = opts;
|
||||
|
||||
// Build extensions
|
||||
@@ -201,9 +209,9 @@ export function chatInput(target, opts = {}) {
|
||||
markdown({ base: markdownLanguage }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
|
||||
// Code block shortcuts
|
||||
// Markdown shortcuts
|
||||
tripleBacktickHandler(), // ``` → fenced code block
|
||||
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code
|
||||
formattingKeymap(), // Ctrl/Cmd+B, I, E → bold, italic, code
|
||||
|
||||
// Code block visual decorations (WYSIWYG style)
|
||||
codeBlockDeco,
|
||||
@@ -275,6 +283,11 @@ export function chatInput(target, opts = {}) {
|
||||
// Single-line display mode: wrap long lines
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
|
||||
// Extra extensions (e.g. @mention autocomplete)
|
||||
if (extraExtensions.length) {
|
||||
extensions.push(...extraExtensions);
|
||||
}
|
||||
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: '', extensions }),
|
||||
parent: target,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { emacs } from '@replit/codemirror-emacs';
|
||||
import { codeEditor } from './code-editor.mjs';
|
||||
import { chatInput } from './chat-input.mjs';
|
||||
import { noteEditor } from './note-editor.mjs';
|
||||
import { mentionExtension } from './mention.mjs';
|
||||
|
||||
// ── Expose on window ────────────────────────
|
||||
|
||||
@@ -54,6 +55,10 @@ window.CM = {
|
||||
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
|
||||
noteEditor,
|
||||
|
||||
// Extension: @mention autocomplete + decoration for chat input
|
||||
// CM.mentionExtension({ completer: async (query) => [{ label, handle, type }] })
|
||||
mentionExtension,
|
||||
|
||||
// Bundled language modes (for reference / dynamic use)
|
||||
languages: {
|
||||
javascript, json, markdown, sql, html, css, yaml, go, python, rust,
|
||||
|
||||
156
src/editor/mention.mjs
Normal file
156
src/editor/mention.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
// ==========================================
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user