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