All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
206 lines
6.8 KiB
JavaScript
206 lines
6.8 KiB
JavaScript
// ==========================================
|
|
// Armature — 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) => {
|
|
// Check if ]] already exists after cursor (from closeBrackets auto-close)
|
|
const afterCursor = view.state.doc.sliceString(to, to + 2);
|
|
const endPos = afterCursor === ']]' ? to + 2 : to;
|
|
const insert = completion.label + ']]';
|
|
view.dispatch({
|
|
changes: { from, to: endPos, 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;
|
|
}
|