Merge pull request #2880 from franzudev/fix/markdown-fence-caret

fix(composer): keep caret inside completed fence
This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:22:45 +03:00
committed by GitHub
4 changed files with 110 additions and 34 deletions
+10 -30
View File
@@ -109,6 +109,7 @@ import {
appendInlineText,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from './composer/text';
@@ -1622,39 +1623,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
const selEnd = ta?.getSelection().end ?? -1;
if (ta && selStart >= 0) {
const applyEdit = (next: string, caretStart: number, caretEnd: number) => {
const edit = getMarkdownAutoPairEdit(message, e.key, selStart, selEnd);
if (edit) {
e.preventDefault();
setMessage(next);
composerRef.current?.setSelection(caretStart, caretEnd);
updateAutocompleteState(next, caretEnd);
};
// Wrap the current selection: select text, press ` * _ ~ ( [ { " '
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);
ta.replaceRange(
edit.from,
edit.to,
edit.insert,
edit.selectionStart,
edit.selectionEnd,
);
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,
appendWithLineBreaks,
buildImagePasteInsertion,
getMarkdownAutoPairEdit,
shouldWrapSelectionAsLink,
withInlineInsertionBoundaries,
} from '../text';
@@ -119,3 +120,39 @@ describe('shouldWrapSelectionAsLink', () => {
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,
});
});
});
@@ -64,8 +64,8 @@ export interface ComposerEditorHandle {
selectAll(): void;
/** Replace the current selection, leaving the caret after the insertion. */
insertText(text: string): void;
/** Replace an explicit range; the caret lands at `caret` or after the text. */
replaceRange(from: number, to: number, text: string, caret?: number): void;
/** Replace a range; selection defaults to a caret after the inserted text. */
replaceRange(from: number, to: number, text: string, selectionStart?: number, selectionEnd?: number): void;
/** Viewport coordinates of the caret, for positioning popups. */
caretCoords(position?: number): { top: number; bottom: number; left: number } | null;
/** The scrollable element, for measuring and scroll compensation. */
@@ -520,12 +520,13 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
userEvent: 'input.type',
});
},
replaceRange(from, to, text, caret) {
replaceRange(from, to, text, selectionStart, selectionEnd = selectionStart) {
const view = viewRef.current;
if (!view) return;
const anchor = selectionStart ?? from + text.length;
view.dispatch({
changes: { from, to, insert: text },
selection: { anchor: caret ?? from + text.length },
selection: { anchor, head: selectionEnd ?? anchor },
userEvent: 'input.type',
});
},
@@ -104,3 +104,61 @@ export function shouldWrapSelectionAsLink(url: string, selected: string): boolea
&& selected.trim().length > 0
&& !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;
}