// ========================================== // 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; }