Changeset 0.17.3 (#78)
This commit is contained in:
207
src/editor/note-editor.mjs
Normal file
207
src/editor/note-editor.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
// ==========================================
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user