fix(composer): keep caret inside completed fence
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user