This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/editor/wikilink.mjs
Jeffrey Smith 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

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