Changeset 0.17.3 (#78)

This commit is contained in:
2026-02-28 15:20:23 +00:00
parent a008dac488
commit 12e316c234
29 changed files with 3347 additions and 65 deletions

View File

@@ -30,12 +30,13 @@ import { emacs } from '@replit/codemirror-emacs';
import { codeEditor } from './code-editor.mjs';
import { chatInput } from './chat-input.mjs';
import { noteEditor } from './note-editor.mjs';
// ── Expose on window ────────────────────────
window.CM = {
// Version (injected by the build script or matched to app version)
version: '0.17.2',
version: '0.17.3',
// Low-level access (for advanced use / debug console)
EditorView,
@@ -49,6 +50,10 @@ window.CM = {
// CM.chatInput(container, { placeholder, onSubmit, onChange, maxHeight, darkMode })
chatInput,
// Factory: rich markdown note editor with wikilinks
// CM.noteEditor(container, { value, darkMode, onChange, onLink, linkCompleter })
noteEditor,
// Bundled language modes (for reference / dynamic use)
languages: {
javascript, json, markdown, sql, html, css, yaml, go, python, rust,

207
src/editor/note-editor.mjs Normal file
View 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();
},
};
}

View File

@@ -184,3 +184,97 @@ export const chatInputTheme = EditorView.theme({
color: 'var(--text-3, #6b6b7b)',
},
});
/**
* Theme for the note editor — full-height, no gutters,
* with markdown live preview styling (heading sizes, blockquotes).
*/
export const noteEditorTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--bg-surface, var(--bg))',
color: 'var(--text)',
fontSize: 'var(--msg-font, 14px)',
minHeight: '200px',
maxHeight: '60vh',
},
'.cm-content': {
fontFamily: 'var(--font, system-ui, sans-serif)',
caretColor: 'var(--accent)',
padding: '8px 0',
lineHeight: '1.6',
},
'&.cm-focused': {
outline: 'none',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--accent)',
borderLeftWidth: '2px',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'var(--accent-muted, rgba(99, 102, 241, 0.2))',
},
'.cm-line': {
padding: '0 4px',
},
'.cm-scroller': {
fontFamily: 'var(--font, system-ui, sans-serif)',
overflow: 'auto',
},
// Tooltip/autocomplete
'.cm-tooltip': {
backgroundColor: 'var(--bg-2)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius, 4px)',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
'.cm-tooltip-autocomplete > ul > li': {
padding: '4px 8px',
},
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--accent)',
color: '#fff',
},
// ── Markdown live preview ──
'.cm-md-heading': {
fontWeight: 'bold',
},
'.cm-md-h1': {
fontSize: '1.6em',
lineHeight: '1.3',
},
'.cm-md-h2': {
fontSize: '1.35em',
lineHeight: '1.3',
},
'.cm-md-h3': {
fontSize: '1.15em',
lineHeight: '1.4',
},
'.cm-md-blockquote': {
borderLeft: '3px solid var(--accent, #6c9fff)',
paddingLeft: '12px',
color: 'var(--text-2, #999)',
fontStyle: 'italic',
},
// Fenced code blocks
'.cm-codeblock': {
backgroundColor: 'var(--bg-2, #1e1e2e)',
fontFamily: 'var(--mono, monospace)',
fontSize: '13px',
padding: '0 10px',
borderLeft: '2px solid var(--accent, #6c9fff)',
},
'.cm-codeblock.cm-codeblock-open': {
borderTopLeftRadius: 'var(--radius, 6px)',
borderTopRightRadius: 'var(--radius, 6px)',
paddingTop: '6px',
color: 'var(--text-3, #6b6b7b)',
},
'.cm-codeblock.cm-codeblock-close': {
borderBottomLeftRadius: 'var(--radius, 6px)',
borderBottomRightRadius: 'var(--radius, 6px)',
paddingBottom: '6px',
color: 'var(--text-3, #6b6b7b)',
},
});

203
src/editor/wikilink.mjs Normal file
View File

@@ -0,0 +1,203 @@
// ==========================================
// Chat Switchboard — 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) => {
// Replace query text with completed title + closing ]]
const insert = completion.label + ']]';
view.dispatch({
changes: { from, to, 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;
}