Merge pull request #2880 from franzudev/fix/markdown-fence-caret
fix(composer): keep caret inside completed fence
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user