Changeset 0.17.3 (#78)
This commit is contained in:
203
src/editor/wikilink.mjs
Normal file
203
src/editor/wikilink.mjs
Normal file
@@ -0,0 +1,203 @@
|
||||
// ==========================================
|
||||
// Chat Switchboard — CM6 Wikilink Extension
|
||||
// ==========================================
|
||||
// Provides:
|
||||
// 1. ViewPlugin that decorates [[Title]] as clickable chips
|
||||
// 2. Autocomplete source triggered by [[
|
||||
// 3. Distinct styling for ![[transclusion]] markers
|
||||
//
|
||||
// Usage:
|
||||
// import { wikilinkExtension } from './wikilink.mjs';
|
||||
// const ext = wikilinkExtension({
|
||||
// onLink: (title) => { ... },
|
||||
// linkCompleter: async (query) => [{ label, id }],
|
||||
// });
|
||||
// ==========================================
|
||||
|
||||
import {
|
||||
ViewPlugin, Decoration, WidgetType, EditorView,
|
||||
} from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
|
||||
// ── Wikilink Regex ──────────────────────────
|
||||
|
||||
const WIKILINK_RE = /(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g;
|
||||
|
||||
// ── Widget: Clickable Link Chip ─────────────
|
||||
|
||||
class WikilinkWidget extends WidgetType {
|
||||
constructor(title, displayText, isTransclusion, onLink) {
|
||||
super();
|
||||
this.title = title;
|
||||
this.displayText = displayText || title;
|
||||
this.isTransclusion = isTransclusion;
|
||||
this.onLink = onLink;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = this.isTransclusion
|
||||
? 'cm-wikilink cm-wikilink-transclusion'
|
||||
: 'cm-wikilink';
|
||||
chip.textContent = (this.isTransclusion ? '↗ ' : '') + this.displayText;
|
||||
chip.title = this.isTransclusion
|
||||
? `Embed: ${this.title}`
|
||||
: `Link: ${this.title}`;
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.onLink) this.onLink(this.title);
|
||||
});
|
||||
return chip;
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
return this.title === other.title
|
||||
&& this.displayText === other.displayText
|
||||
&& this.isTransclusion === other.isTransclusion;
|
||||
}
|
||||
|
||||
ignoreEvent() { return false; }
|
||||
}
|
||||
|
||||
// ── ViewPlugin: Decorate [[links]] ──────────
|
||||
|
||||
function wikilinkDecoPlugin(onLink) {
|
||||
return ViewPlugin.fromClass(class {
|
||||
decorations;
|
||||
|
||||
constructor(view) {
|
||||
this.decorations = this.build(view);
|
||||
}
|
||||
|
||||
update(update) {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = this.build(update.view);
|
||||
}
|
||||
}
|
||||
|
||||
build(view) {
|
||||
const builder = new RangeSetBuilder();
|
||||
const doc = view.state.doc;
|
||||
const text = doc.toString();
|
||||
|
||||
let match;
|
||||
WIKILINK_RE.lastIndex = 0;
|
||||
while ((match = WIKILINK_RE.exec(text)) !== null) {
|
||||
const from = match.index;
|
||||
const to = from + match[0].length;
|
||||
|
||||
// Don't decorate if cursor is inside the link (let user edit)
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.from >= from && sel.from <= to) continue;
|
||||
|
||||
const isTransclusion = match[1] === '!';
|
||||
const title = match[2].trim();
|
||||
const displayText = match[3] ? match[3].trim() : '';
|
||||
|
||||
builder.add(from, to, Decoration.replace({
|
||||
widget: new WikilinkWidget(title, displayText, isTransclusion, onLink),
|
||||
}));
|
||||
}
|
||||
|
||||
return builder.finish();
|
||||
}
|
||||
}, {
|
||||
decorations: (v) => v.decorations,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Autocomplete: [[ trigger ────────────────
|
||||
|
||||
function wikilinkAutocomplete(linkCompleter) {
|
||||
return autocompletion({
|
||||
override: [
|
||||
async (ctx) => {
|
||||
// Look for [[ before the cursor
|
||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||
const textBefore = ctx.state.doc.sliceString(line.from, ctx.pos);
|
||||
const triggerMatch = textBefore.match(/\[\[([^\]]*?)$/);
|
||||
|
||||
if (!triggerMatch) return null;
|
||||
|
||||
const query = triggerMatch[1];
|
||||
const from = ctx.pos - query.length;
|
||||
|
||||
if (!linkCompleter) return null;
|
||||
|
||||
try {
|
||||
const results = await linkCompleter(query);
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
return {
|
||||
from,
|
||||
options: results.map(r => ({
|
||||
label: r.label || r.title,
|
||||
apply: (view, completion, from, to) => {
|
||||
// Replace query text with completed title + closing ]]
|
||||
const insert = completion.label + ']]';
|
||||
view.dispatch({
|
||||
changes: { from, to, insert },
|
||||
selection: { anchor: from + insert.length },
|
||||
});
|
||||
},
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[wikilink] autocomplete error:', e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
],
|
||||
activateOnTyping: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Theme: Wikilink Chip Styles ─────────────
|
||||
|
||||
const wikilinkTheme = EditorView.baseTheme({
|
||||
'.cm-wikilink': {
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
margin: '0 1px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.15)',
|
||||
color: 'var(--accent, #6366f1)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9em',
|
||||
fontWeight: '500',
|
||||
textDecoration: 'none',
|
||||
verticalAlign: 'baseline',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(var(--accent-rgb, 99, 102, 241), 0.25)',
|
||||
},
|
||||
},
|
||||
'.cm-wikilink-transclusion': {
|
||||
borderLeft: '2px solid var(--accent, #6366f1)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
|
||||
// ── Public: Combined Extension ──────────────
|
||||
|
||||
/**
|
||||
* Create the wikilink CM6 extension bundle.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {Function} [opts.onLink] - Called with (title) when a link chip is clicked
|
||||
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
|
||||
* @returns {Extension[]} Array of CM6 extensions
|
||||
*/
|
||||
export function wikilinkExtension(opts = {}) {
|
||||
const extensions = [
|
||||
wikilinkDecoPlugin(opts.onLink || null),
|
||||
wikilinkTheme,
|
||||
];
|
||||
|
||||
if (opts.linkCompleter) {
|
||||
extensions.push(wikilinkAutocomplete(opts.linkCompleter));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}
|
||||
Reference in New Issue
Block a user