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