All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
208 lines
6.8 KiB
JavaScript
208 lines
6.8 KiB
JavaScript
// ==========================================
|
|
// Armature — CM6 Note Editor Factory
|
|
// ==========================================
|
|
// Rich markdown editor for the notes panel.
|
|
// Decorates headings, bold, italic, code blocks inline.
|
|
// Integrates wikilink autocomplete and chip rendering.
|
|
//
|
|
// Usage:
|
|
// const editor = CM.noteEditor(container, {
|
|
// value: '# My Note\n\nSome content',
|
|
// darkMode: true,
|
|
// onChange: (text) => { ... },
|
|
// onLink: (title) => { ... },
|
|
// linkCompleter: async (query) => [{ label, id }],
|
|
// });
|
|
//
|
|
// editor.getValue()
|
|
// editor.setValue(str)
|
|
// editor.focus()
|
|
// editor.destroy()
|
|
// ==========================================
|
|
|
|
import { EditorView, keymap, drawSelection, dropCursor,
|
|
highlightSpecialChars, ViewPlugin, Decoration } from '@codemirror/view';
|
|
import { EditorState, RangeSetBuilder } from '@codemirror/state';
|
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language';
|
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
|
|
|
import { noteEditorTheme } from './theme.mjs';
|
|
import { wikilinkExtension } from './wikilink.mjs';
|
|
|
|
// ── Markdown Live Preview Decorations ───────
|
|
|
|
/**
|
|
* ViewPlugin that styles markdown headings, code blocks, etc.
|
|
* at their rendered sizes inside the editor (live preview).
|
|
*/
|
|
const mdPreviewPlugin = ViewPlugin.fromClass(class {
|
|
decorations;
|
|
|
|
constructor(view) {
|
|
this.decorations = this.build(view);
|
|
}
|
|
|
|
update(update) {
|
|
if (update.docChanged || update.viewportChanged ||
|
|
syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
|
this.decorations = this.build(update.view);
|
|
}
|
|
}
|
|
|
|
build(view) {
|
|
const builder = new RangeSetBuilder();
|
|
const tree = syntaxTree(view.state);
|
|
const doc = view.state.doc;
|
|
|
|
tree.iterate({
|
|
enter(node) {
|
|
// Heading decorations
|
|
if (node.name.startsWith('ATXHeading')) {
|
|
const level = parseInt(node.name.replace('ATXHeading', ''), 10) || 1;
|
|
const line = doc.lineAt(node.from);
|
|
builder.add(line.from, line.from,
|
|
Decoration.line({ class: `cm-md-heading cm-md-h${level}` }));
|
|
}
|
|
|
|
// Fenced code block decorations
|
|
if (node.name === 'FencedCode') {
|
|
const startLine = doc.lineAt(node.from);
|
|
const endLine = doc.lineAt(node.to);
|
|
for (let ln = startLine.number; ln <= endLine.number; ln++) {
|
|
const line = doc.line(ln);
|
|
let cls = 'cm-codeblock cm-codeblock-mid';
|
|
if (ln === startLine.number) cls = 'cm-codeblock cm-codeblock-open';
|
|
if (ln === endLine.number) cls = 'cm-codeblock cm-codeblock-close';
|
|
if (startLine.number === endLine.number) cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close';
|
|
builder.add(line.from, line.from, Decoration.line({ class: cls }));
|
|
}
|
|
}
|
|
|
|
// Blockquote decorations
|
|
if (node.name === 'Blockquote') {
|
|
const startLine = doc.lineAt(node.from);
|
|
const endLine = doc.lineAt(node.to);
|
|
for (let ln = startLine.number; ln <= endLine.number; ln++) {
|
|
const line = doc.line(ln);
|
|
builder.add(line.from, line.from,
|
|
Decoration.line({ class: 'cm-md-blockquote' }));
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
return builder.finish();
|
|
}
|
|
}, {
|
|
decorations: (v) => v.decorations,
|
|
});
|
|
|
|
// ── Note Editor Factory ─────────────────────
|
|
|
|
/**
|
|
* Create a rich markdown note editor with wikilink support.
|
|
*
|
|
* @param {HTMLElement} target - Container element
|
|
* @param {Object} opts
|
|
* @param {string} [opts.value='']
|
|
* @param {boolean} [opts.darkMode]
|
|
* @param {Function} [opts.onChange] - Called with text on every edit
|
|
* @param {Function} [opts.onLink] - Called with (title) when a [[link]] is clicked
|
|
* @param {Function} [opts.linkCompleter] - async (query) => [{ label, id }]
|
|
* @returns {{ getValue, setValue, focus, getView, destroy }}
|
|
*/
|
|
export function noteEditor(target, opts = {}) {
|
|
const {
|
|
value = '',
|
|
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
|
|
onChange = null,
|
|
onLink = null,
|
|
linkCompleter = null,
|
|
} = opts;
|
|
|
|
const extensions = [
|
|
// Markdown highlighting with live preview
|
|
markdown({ base: markdownLanguage }),
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
mdPreviewPlugin,
|
|
|
|
// Wikilink support
|
|
...wikilinkExtension({ onLink, linkCompleter }),
|
|
|
|
// Base editing
|
|
highlightSpecialChars(),
|
|
history(),
|
|
drawSelection(),
|
|
dropCursor(),
|
|
closeBrackets(),
|
|
highlightSelectionMatches(),
|
|
|
|
// Theme — note-specific (no line numbers, full-height)
|
|
noteEditorTheme,
|
|
|
|
// Keymaps
|
|
keymap.of([
|
|
indentWithTab,
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
...searchKeymap,
|
|
]),
|
|
|
|
// Spell check
|
|
EditorView.contentAttributes.of({
|
|
spellcheck: 'true',
|
|
autocapitalize: 'sentences',
|
|
autocorrect: 'on',
|
|
}),
|
|
|
|
// Line wrapping
|
|
EditorView.lineWrapping,
|
|
];
|
|
|
|
// Change listener
|
|
if (onChange) {
|
|
extensions.push(EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
onChange(update.state.doc.toString());
|
|
}
|
|
}));
|
|
}
|
|
|
|
const view = new EditorView({
|
|
state: EditorState.create({ doc: value, extensions }),
|
|
parent: target,
|
|
});
|
|
|
|
// ── Public API ──────────────────────────
|
|
|
|
return {
|
|
getValue() {
|
|
return view.state.doc.toString();
|
|
},
|
|
|
|
setValue(text) {
|
|
view.dispatch({
|
|
changes: { from: 0, to: view.state.doc.length, insert: text || '' },
|
|
});
|
|
},
|
|
|
|
focus() {
|
|
view.focus();
|
|
},
|
|
|
|
/** Access the underlying EditorView */
|
|
getView() {
|
|
return view;
|
|
},
|
|
|
|
/** Clean up */
|
|
destroy() {
|
|
view.destroy();
|
|
},
|
|
};
|
|
}
|