Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ export class ContentEditableAdapter {
if (shouldTryNativeReplacement) {
const nativeReplacementResult = this.tryNativeReplacement(elem, replacementText);
if (nativeReplacementResult.didMutateDom) {
// execCommand leaves the caret at the end of the inserted text. Plain
// contenteditable has no async host reconciliation to override us, so
// place the caret at the final offset synchronously. This prevents a
// race where a fast follow-up keystroke (e.g. auto-close "()" then an
// immediate "x") lands before a deferred caret correction runs.
this.setCaret(editScope, cursorAfter);
logger.debug("Contenteditable replacement handled by execCommand fallback", {
didDispatchInput: nativeReplacementResult.didDispatchInput,
editorTextLength: (elem.textContent ?? "").length,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,19 @@ export class SuggestionTextEditService {
// DOM asynchronously via microtask. This means didMutateDom is false at check
// time even though the edit WILL be applied. The deferred callback validates
// that the expected text appeared before moving the cursor.
if (edit.cursorOffset !== undefined && !TextTargetAdapter.isTextValue(entry.elem)) {
//
// Plain contenteditable applied via the DOM path ("fallback-dom") already had
// its caret placed synchronously at the final offset by the adapter, so skip
// the deferred relative move-back there — running it would double-correct the
// caret. Only host editors (React via beforeinput, CKEditor via host session)
// reconcile asynchronously and still need the deferred reposition.
const caretPlacedSynchronously =
"appliedBy" in applyResult && applyResult.appliedBy === "fallback-dom";
if (
edit.cursorOffset !== undefined &&
!TextTargetAdapter.isTextValue(entry.elem) &&
!caretPlacedSynchronously
) {
const moveBackCount = replacement.length - edit.cursorOffset;
if (moveBackCount > 0) {
const targetElem = entry.elem as HTMLElement;
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/full.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6069,7 +6069,9 @@ describeE2E(`Extension E2E Test [${BROWSER_TYPE}]`, () => {
await typeInInput(page, selector, "hello (");
await waitForInputContentMatch(page, selector, /^hello \(\)$/, browserTimeout(5000, 9000));

// Verify cursor position: typing after auto-close should insert between brackets
// Verify cursor position: typing after auto-close should insert between
// brackets. Plain contenteditable repositions the caret synchronously, so
// an immediate keystroke must land inside the brackets (no settle needed).
await typeInInput(page, selector, "x");
await waitForInputContentMatch(page, selector, /^hello \(x\)$/, browserTimeout(5000, 9000));

Expand Down
Loading