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