311 lines
10 KiB
JavaScript
311 lines
10 KiB
JavaScript
// ==========================================
|
|
// Chat Switchboard — CM6 Chat Input Factory
|
|
// ==========================================
|
|
// Minimal markdown editor for the chat message
|
|
// input. No line numbers, no gutter. Enter sends,
|
|
// Shift+Enter inserts newline. Auto-growing height.
|
|
//
|
|
// Usage:
|
|
// const editor = CM.chatInput(container, {
|
|
// placeholder: 'Send a message...',
|
|
// onSubmit: (text) => sendMessage(text),
|
|
// onChange: (text) => updateInputTokens(text),
|
|
// maxHeight: 200,
|
|
// darkMode: true,
|
|
// });
|
|
//
|
|
// editor.getValue()
|
|
// editor.setValue(str)
|
|
// editor.focus()
|
|
// editor.destroy()
|
|
// ==========================================
|
|
|
|
import { EditorView, keymap, drawSelection, dropCursor,
|
|
highlightSpecialChars, placeholder as placeholderExt,
|
|
ViewPlugin, Decoration } from '@codemirror/view';
|
|
import { EditorState, Compartment, RangeSetBuilder } from '@codemirror/state';
|
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
|
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
|
import { syntaxHighlighting, defaultHighlightStyle, syntaxTree } from '@codemirror/language';
|
|
|
|
import { chatInputTheme } from './theme.mjs';
|
|
|
|
// ── Auto-Height Extension ───────────────────
|
|
|
|
/**
|
|
* Extension that auto-grows the editor height to fit content,
|
|
* up to maxHeight pixels. Mirrors the existing textarea behavior:
|
|
* this.style.height = 'auto';
|
|
* this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
|
*/
|
|
function autoHeight(maxHeight = 200) {
|
|
return EditorView.updateListener.of((update) => {
|
|
if (update.docChanged || update.geometryChanged) {
|
|
const dom = update.view.dom;
|
|
dom.style.height = 'auto';
|
|
const scrollH = update.view.contentDOM.scrollHeight;
|
|
// Account for .cm-editor padding (12px top + 12px bottom from wrapper CSS)
|
|
const padV = 24;
|
|
const targetH = Math.min(scrollH + padV, maxHeight);
|
|
dom.style.height = targetH + 'px';
|
|
dom.style.overflowY = scrollH + padV > maxHeight ? 'auto' : 'hidden';
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Fenced Code Block Decorations ───────────
|
|
//
|
|
// Scans the markdown syntax tree for FencedCode nodes
|
|
// and applies line decorations so they render as visual
|
|
// code blocks (dark background, monospace, rounded corners)
|
|
// inside the chat input — matching the claude.ai UX.
|
|
|
|
const codeBlockDeco = ViewPlugin.fromClass(class {
|
|
decorations;
|
|
|
|
constructor(view) {
|
|
this.decorations = this.buildDecorations(view);
|
|
}
|
|
|
|
update(update) {
|
|
// Rebuild when content changes, viewport shifts, or the parser
|
|
// finishes an incremental parse (tree reference changes)
|
|
if (update.docChanged || update.viewportChanged ||
|
|
syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
|
this.decorations = this.buildDecorations(update.view);
|
|
}
|
|
}
|
|
|
|
buildDecorations(view) {
|
|
const builder = new RangeSetBuilder();
|
|
const tree = syntaxTree(view.state);
|
|
const doc = view.state.doc;
|
|
|
|
tree.iterate({
|
|
enter(node) {
|
|
if (node.name !== 'FencedCode') return;
|
|
|
|
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;
|
|
|
|
if (ln === startLine.number) {
|
|
cls = 'cm-codeblock cm-codeblock-open';
|
|
} else if (ln === endLine.number) {
|
|
cls = 'cm-codeblock cm-codeblock-close';
|
|
} else {
|
|
cls = 'cm-codeblock cm-codeblock-mid';
|
|
}
|
|
|
|
// Single-line code block (just ```)
|
|
if (startLine.number === endLine.number) {
|
|
cls = 'cm-codeblock cm-codeblock-open cm-codeblock-close';
|
|
}
|
|
|
|
builder.add(line.from, line.from, Decoration.line({ class: cls }));
|
|
}
|
|
},
|
|
});
|
|
|
|
return builder.finish();
|
|
}
|
|
}, {
|
|
decorations: (v) => v.decorations,
|
|
});
|
|
|
|
|
|
// ── Code Block Shortcuts ────────────────────
|
|
|
|
/**
|
|
* Input handler: triple backtick → fenced code block.
|
|
* Triggers when user types the third ` completing ```
|
|
* at the start of a line (possibly preceded only by whitespace/text).
|
|
*/
|
|
function tripleBacktickHandler() {
|
|
return EditorView.inputHandler.of((view, from, to, text) => {
|
|
if (text !== '`') return false;
|
|
|
|
const line = view.state.doc.lineAt(from);
|
|
const textBeforeCursor = view.state.doc.sliceString(line.from, from);
|
|
|
|
// Match `` at end of line (completing ```) — only at line start
|
|
if (/^``$/.test(textBeforeCursor.trimStart()) && textBeforeCursor.trimStart() === '``') {
|
|
const leadingWhitespace = textBeforeCursor.match(/^(\s*)/)[1];
|
|
const block = leadingWhitespace + '```\n' + leadingWhitespace + '\n' + leadingWhitespace + '```';
|
|
view.dispatch({
|
|
changes: { from: line.from, to, insert: block },
|
|
selection: { anchor: line.from + leadingWhitespace.length + 4 },
|
|
});
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Keybinding: Ctrl/Cmd+E wraps selection in inline code backticks,
|
|
* or inserts a pair and places cursor between them.
|
|
*/
|
|
function inlineCodeKeymap() {
|
|
return keymap.of([{
|
|
key: 'Mod-e',
|
|
run: (view) => {
|
|
const { from, to } = view.state.selection.main;
|
|
if (from !== to) {
|
|
const text = view.state.doc.sliceString(from, to);
|
|
view.dispatch({
|
|
changes: { from, to, insert: '`' + text + '`' },
|
|
selection: { anchor: from + 1, head: to + 1 },
|
|
});
|
|
} else {
|
|
view.dispatch({
|
|
changes: { from, to: from, insert: '``' },
|
|
selection: { anchor: from + 1 },
|
|
});
|
|
}
|
|
return true;
|
|
},
|
|
}]);
|
|
}
|
|
|
|
// ── Chat Input Factory ──────────────────────
|
|
|
|
/**
|
|
* Create a chat message input with markdown highlighting.
|
|
*
|
|
* @param {HTMLElement} target - Container element
|
|
* @param {Object} opts
|
|
* @param {string} [opts.placeholder='Send a message...']
|
|
* @param {Function} [opts.onSubmit] - Called with text on Enter (without Shift)
|
|
* @param {Function} [opts.onChange] - Called with text on every edit
|
|
* @param {number} [opts.maxHeight=200] - Max height in pixels before scrolling
|
|
* @param {boolean} [opts.darkMode] - Auto-detected from body if omitted
|
|
* @returns {{ getValue, setValue, focus, getView, destroy }}
|
|
*/
|
|
export function chatInput(target, opts = {}) {
|
|
const {
|
|
placeholder: placeholderText = 'Send a message...',
|
|
onSubmit = null,
|
|
onChange = null,
|
|
maxHeight = 200,
|
|
darkMode = document.documentElement.getAttribute('data-theme') !== 'light',
|
|
} = opts;
|
|
|
|
// Build extensions
|
|
const extensions = [
|
|
// Markdown highlighting
|
|
markdown({ base: markdownLanguage }),
|
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
|
|
|
// Code block shortcuts
|
|
tripleBacktickHandler(), // ``` → fenced code block
|
|
inlineCodeKeymap(), // Ctrl/Cmd+E → inline code
|
|
|
|
// Code block visual decorations (WYSIWYG style)
|
|
codeBlockDeco,
|
|
|
|
// Base editing
|
|
highlightSpecialChars(),
|
|
history(),
|
|
drawSelection(),
|
|
dropCursor(),
|
|
closeBrackets(),
|
|
|
|
// Theme — minimal chrome, no gutters
|
|
chatInputTheme,
|
|
|
|
// Placeholder
|
|
placeholderExt(placeholderText),
|
|
|
|
// Auto-growing height
|
|
autoHeight(maxHeight),
|
|
|
|
// Keymaps — chat-specific: Enter sends, Shift+Enter newline
|
|
keymap.of([
|
|
// Enter to submit (must be before defaultKeymap)
|
|
{
|
|
key: 'Enter',
|
|
run: (view) => {
|
|
if (onSubmit) {
|
|
const text = view.state.doc.toString();
|
|
onSubmit(text);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
},
|
|
// Shift+Enter to insert newline
|
|
{
|
|
key: 'Shift-Enter',
|
|
run: (view) => {
|
|
view.dispatch(
|
|
view.state.replaceSelection('\n')
|
|
);
|
|
return true;
|
|
},
|
|
},
|
|
...closeBracketsKeymap,
|
|
...defaultKeymap,
|
|
...historyKeymap,
|
|
]),
|
|
];
|
|
|
|
// Change listener
|
|
if (onChange) {
|
|
extensions.push(EditorView.updateListener.of((update) => {
|
|
if (update.docChanged) {
|
|
onChange(update.state.doc.toString());
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Enable spell check and standard text input attributes
|
|
extensions.push(
|
|
EditorView.contentAttributes.of({
|
|
spellcheck: 'true',
|
|
autocapitalize: 'sentences',
|
|
autocorrect: 'on',
|
|
})
|
|
);
|
|
|
|
// Single-line display mode: wrap long lines
|
|
extensions.push(EditorView.lineWrapping);
|
|
|
|
const view = new EditorView({
|
|
state: EditorState.create({ doc: '', 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();
|
|
},
|
|
};
|
|
}
|