fix(composer): keep caret inside completed fence

This commit is contained in:
Federico François
2026-08-13 18:05:02 +02:00
parent e99f6560be
commit f1d95125f2
4 changed files with 110 additions and 34 deletions
+10 -30
View File
@@ -102,6 +102,7 @@ import {
appendInlineText, appendInlineText,
appendWithLineBreaks, appendWithLineBreaks,
buildImagePasteInsertion, buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink, shouldWrapSelectionAsLink,
withInlineInsertionBoundaries, withInlineInsertionBoundaries,
} from './composer/text'; } from './composer/text';
@@ -1513,39 +1514,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const selEnd = ta?.getSelection().end ?? -1; const selEnd = ta?.getSelection().end ?? -1;
if (ta && selStart >= 0) { if (ta && selStart >= 0) {
const applyEdit = (next: string, caretStart: number, caretEnd: number) => { const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
if (edit) {
e.preventDefault(); e.preventDefault();
setMessage(next); ta.replaceRange(
composerRef.current?.setSelection(caretStart, caretEnd); edit.from,
updateAutocompleteState(next, caretEnd); edit.to,
}; edit.insert,
edit.selectionStart,
// Wrap the current selection: select text, press ` * _ ~ ( [ { " ' edit.selectionEnd,
const WRAP_PAIRS: Record<string, [string, string]> = { );
'`': ['`', '`'], '*': ['*', '*'], '_': ['_', '_'], '~': ['~', '~'],
'(': ['(', ')'], '[': ['[', ']'], '{': ['{', '}'],
'"': ['"', '"'], "'": ["'", "'"],
};
if (selEnd > selStart && WRAP_PAIRS[e.key]) {
const [open, close] = WRAP_PAIRS[e.key];
const selected = message.slice(selStart, selEnd);
const next = `${message.slice(0, selStart)}${open}${selected}${close}${message.slice(selEnd)}`;
applyEdit(next, selStart + open.length, selEnd + open.length);
return; return;
} }
// Typing the third backtick at line start expands into a fenced
// code block with the caret on the empty middle line (Slack-like).
if (e.key === '`' && selStart === selEnd) {
const before = message.slice(0, selStart);
if (/(^|\n)``$/.test(before)) {
const after = message.slice(selEnd);
const next = `${before}\`\n\n\`\`\`${after}`;
const caret = before.length + 2; // after the completed ``` and first newline
applyEdit(next, caret, caret);
return;
}
}
} }
} }
@@ -4,6 +4,7 @@ import {
appendInlineText, appendInlineText,
appendWithLineBreaks, appendWithLineBreaks,
buildImagePasteInsertion, buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink, shouldWrapSelectionAsLink,
withInlineInsertionBoundaries, withInlineInsertionBoundaries,
} from '../text'; } from '../text';
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false); expect(shouldWrapSelectionAsLink('https://x.dev', '[docs](https://y.dev)')).toBe(false);
}); });
}); });
describe('getMarkdownAutoPairEdit', () => {
test('completes a fenced block with the caret on the middle line', () => {
expect(getMarkdownAutoPairEdit('``', '`', 2, 2)).toEqual({
from: 2,
to: 2,
insert: '`\n\n```',
selectionStart: 4,
selectionEnd: 4,
});
});
test('completes a fence at the start of any line', () => {
expect(getMarkdownAutoPairEdit('intro\n``tail', '`', 8, 8)).toEqual({
from: 8,
to: 8,
insert: '`\n\n```',
selectionStart: 10,
selectionEnd: 10,
});
});
test('does not complete two backticks in the middle of a line', () => {
expect(getMarkdownAutoPairEdit('text ``', '`', 7, 7)).toBeNull();
});
test('wraps selected text and keeps the text selected', () => {
expect(getMarkdownAutoPairEdit('hello', '*', 1, 4)).toEqual({
from: 1,
to: 4,
insert: '*ell*',
selectionStart: 2,
selectionEnd: 5,
});
});
});
@@ -63,8 +63,8 @@ export interface ComposerEditorHandle {
selectAll(): void; selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */ /** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void; insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */ /** Replace a range; selection defaults to a caret after the inserted text. */
replaceRange(from: number, to: number, text: string, caret?: number): void; replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */ /** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null; caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */ /** The scrollable element, for measuring and scroll compensation. */
@@ -513,12 +513,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
userEvent: 'input.type', userEvent: 'input.type',
}); });
}, },
replaceRange(from, to, text, caret) { replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current; const view = viewRef.current;
if (!view) return; if (!view) return;
const anchor = selectionStart ?? from + text.length;
view.dispatch({ view.dispatch({
changes: { from, to, insert: text }, changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length }, selection: { anchor, head: selectionEnd ?? anchor },
userEvent: 'input.type', userEvent: 'input.type',
}); });
}, },
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
&& selected.trim().length > 0 && selected.trim().length > 0
&& !selected.includes(']('); && !selected.includes('](');
} }
const MARKDOWN_WRAP_PAIRS: Record<string, [string, string]> = {
'`': ['`', '`'],
'*': ['*', '*'],
'_': ['_', '_'],
'~': ['~', '~'],
'(': ['(', ')'],
'[': ['[', ']'],
'{': ['{', '}'],
'"': ['"', '"'],
"'": ["'", "'"],
};
/**
* Markdown source-mode conveniences handled before CodeMirror inserts a key.
* The returned text change and selection belong to one editor transaction so
* the caret cannot be applied against the previous document.
*/
export function getMarkdownAutoPairEdit(
value: string,
key: string,
selectionStart: number,
selectionEnd: number,
): {
from: number;
to: number;
insert: string;
selectionStart: number;
selectionEnd: number;
} | null {
const pair = MARKDOWN_WRAP_PAIRS[key];
if (selectionEnd > selectionStart && pair) {
const selected = value.slice(selectionStart, selectionEnd);
const [open, close] = pair;
return {
from: selectionStart,
to: selectionEnd,
insert: `${open}${selected}${close}`,
selectionStart: selectionStart + open.length,
selectionEnd: selectionEnd + open.length,
};
}
if (key === '`' && selectionStart === selectionEnd) {
const before = value.slice(0, selectionStart);
if (/(^|\n)``$/.test(before)) {
return {
from: selectionStart,
to: selectionEnd,
insert: '`\n\n```',
selectionStart: selectionStart + 2,
selectionEnd: selectionStart + 2,
};
}
}
return null;
}