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/note-editor.mjs
2026-02-28 15:20:23 +00:00

208 lines
6.8 KiB
JavaScript

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