updateEditingValue method
Requests that this client update its editing state to the given value.
The new value is treated as user input and thus may subject to input
formatting.
Implementation
@override
void updateEditingValue(TextEditingValue value, {int? cursorOffset}) {
if (_updatingSelf) return;
if (_structuralChangeInProgress) return;
if (_document == null) return;
final doc = _document!;
final cursor = doc.cursor;
// Safety net: if the cursor points to a fragment that was removed (e.g.
// after rapid backspace on a virtual keyboard), snap it to the nearest
// valid caret stop so subsequent delta logic operates on a real node.
final currentFragId = cursor.focusId.isNotEmpty ? cursor.focusId : cursor.anchorId;
if (doc.nodeById(currentFragId) == null) {
final fallback = moveLeft(
doc.content,
CaretStop(cursor.anchorId, cursor.anchorOffset),
stops: doc.caretStops,
cachedLines: doc.logicalLines,
);
if (fallback.position != null) {
cursor.moveTo(fallback.position!.fragmentId, fallback.position!.offset);
} else {
final right = moveRight(
doc.content,
CaretStop(cursor.anchorId, cursor.anchorOffset),
stops: doc.caretStops,
cachedLines: doc.logicalLines,
);
if (right.position != null) {
cursor.moveTo(right.position!.fragmentId, right.position!.offset);
} else if (doc.caretStops.isNotEmpty) {
final first = doc.caretStops.first;
cursor.moveTo(first.fragmentId, first.offset);
}
}
}
// ─── iOS Standard IME Buffer Sync (no dummy string) ──────────
if (_shouldSyncBuffer && !_isComposing) {
final currentText = _getCurrentFragmentText() ?? '';
final cursorOffset = _getCursorOffsetInFragment();
// If the real fragment is empty, we mirrored a zero-width placeholder
// to UIKit (see _emptyFragmentPlaceholder) so Backspace would have
// something to act on. Pure placeholder deletion is already handled
// earlier in updateEditingValueWithDeltas as a structural backspace,
// so if we reach this point with the placeholder still present at the
// very start of the incoming text, it means the user typed something
// new into the empty paragraph (insertion/replacement) rather than
// deleting — strip the placeholder from both sides of the comparison
// so the rest of this method behaves exactly as if the fragment had
// started genuinely empty.
if (_isIOS &&
cursorOffset == 0 &&
value.text.startsWith(_emptyFragmentPlaceholder)) {
final placeholderLen = _emptyFragmentPlaceholder.length;
final strippedText = value.text.substring(placeholderLen);
final strippedBase = (value.selection.baseOffset - placeholderLen).clamp(0, strippedText.length);
final strippedExtent = (value.selection.extentOffset - placeholderLen).clamp(0, strippedText.length);
final strippedComposing = value.composing.isValid
? TextRange(
start: (value.composing.start - placeholderLen).clamp(0, strippedText.length),
end: (value.composing.end - placeholderLen).clamp(0, strippedText.length),
)
: value.composing;
value = TextEditingValue(
text: strippedText,
selection: value.selection.copyWith(
baseOffset: strippedBase,
extentOffset: strippedExtent,
),
composing: strippedComposing,
);
}
// Skip our own echo
if (value.text == currentText &&
value.selection.isCollapsed &&
value.selection.extentOffset == cursorOffset) {
// iOS virtual keyboard: when the fragment is already empty and the
// IME sends a deletion delta on the zero-width placeholder, the
// result is empty text with the cursor at offset 0 — exactly the
// same signature as our own echo. We must still trigger structural
// backspace so the empty paragraph is merged with the previous one.
if (_isIOS && _shouldSyncBuffer && currentText.isEmpty && cursorOffset == 0) {
doc.saveState(description: 'Backspace', forceNewAction: false);
executeHandleBackspace(doc);
syncImeBufferToFragment();
}
return;
}
// New composition started (CJK, emoji, etc.)
if (value.composing.isValid) {
// Defensive extraction to avoid cutting through surrogate pairs (emoji)
final start = FragmentOperations.adjustIndex(value.text, value.composing.start.clamp(0, value.text.length));
final end = FragmentOperations.adjustIndex(value.text, value.composing.end.clamp(0, value.text.length));
final rawPreedit = value.text.substring(start, end);
final preeditText = _sanitizeUtf16(rawPreedit);
// Set _isComposing BEFORE clearing the selection so that
// syncImeBufferToFragment (called via document.updateContent()
// inside executeHandleReplaceSelection) returns early and does
// not sync the buffer with the shorter text while the browser's
// composition is still active. Otherwise the browser retains a
// stale composingText and later sends a delta with an
// out-of-bounds composing range (assertion failure in
// TextEditingDelta.fromJSON).
_isComposing = true;
// When a selection is active, clear it FIRST so the fragment text
// reflects the post-deletion state. The preedit offset must then be
// the cursor position after clearing, not the buffer-relative
// composing start (which is stale after the selection is removed).
int preeditOffset;
if (!cursor.isCollapsed) {
final anchorNode = doc.nodeById(cursor.anchorId);
final focusNode = doc.nodeById(cursor.focusId);
final anchorValid = anchorNode is Fragment &&
cursor.anchorOffset <= anchorNode.text.length;
final focusValid = focusNode is Fragment &&
cursor.focusOffset <= focusNode.text.length;
if (anchorValid && focusValid) {
doc.saveState(description: 'Replace selection', forceNewAction: false);
executeHandleReplaceSelection('', doc);
} else {
if (focusValid) {
cursor.moveTo(cursor.focusId, cursor.focusOffset);
} else if (anchorValid) {
cursor.moveTo(cursor.anchorId, cursor.anchorOffset);
}
doc.selectionManager.collapse();
}
// After clearing the selection the cursor is at the base of the
// former selection — that's where the preedit should start.
preeditOffset = cursor.focusId.isNotEmpty
? cursor.focusOffset
: cursor.anchorOffset;
} else {
// No selection: sync fragment text to match the buffer (excluding
// preedit) only when the buffer differs from the current fragment.
final wholeCleanText = _sanitizeUtf16(value.text);
final currentFragText = _getCurrentFragmentText() ?? '';
if (wholeCleanText != currentFragText) {
_updatingSelf = true;
final node = doc.nodeById(cursor.focusId.isNotEmpty ? cursor.focusId : cursor.anchorId);
if (node is Fragment) {
final textBefore = wholeCleanText.substring(0, start);
final textAfter = wholeCleanText.substring(end);
node.text = _sanitizeUtf16(textBefore + textAfter);
}
_updatingSelf = false;
}
preeditOffset = start;
}
_preeditFragmentId = cursor.focusId.isNotEmpty ? cursor.focusId : cursor.anchorId;
_preeditLocalOffset = preeditOffset;
_preeditContainerId = doc.findLogicalContainerId(_preeditFragmentId) ?? '';
cursor.imeComposing = true;
cursor.imeComposingStart = _preeditLocalOffset;
doc.selectionManager.clear();
_preeditText = preeditText;
_composingRange = TextRange(start: 0, end: preeditText.length);
_preeditCaretOffset = value.selection.isValid
? (value.selection.extentOffset - start).clamp(0, preeditText.length)
: preeditText.length;
_invalidatePreeditRender();
return;
}
final oldText = currentText;
final newText = value.text;
// When a selection is active, handle insertion/deletion through the
// selection-aware path instead of the single-char paths below, which
// would collapse the selection and insert at the wrong position.
if (!cursor.isCollapsed) {
if (newText != oldText) {
String insertedText;
if (cursor.anchorId == cursor.focusId) {
// Single-fragment selection: use cursor offsets to precisely
// extract the replacement text from the buffer. The text before
// and after the selection in oldText remains unchanged in
// newText, so we can slice it out directly.
final selStart = cursor.anchorOffset < cursor.focusOffset
? cursor.anchorOffset
: cursor.focusOffset;
final selEnd = cursor.anchorOffset < cursor.focusOffset
? cursor.focusOffset
: cursor.anchorOffset;
final clampedStart = selStart.clamp(0, oldText.length);
final clampedEnd = selEnd.clamp(0, oldText.length);
final prefixLen = clampedStart;
final suffixLen = oldText.length - clampedEnd;
final expectedPrefix = oldText.substring(0, prefixLen);
final expectedSuffix = oldText.substring(clampedEnd);
final newTextPrefix = newText.length >= prefixLen
? newText.substring(0, prefixLen)
: newText;
final newTextSuffix = newText.length >= suffixLen
? newText.substring(newText.length - suffixLen)
: newText;
if (newText.length >= prefixLen + suffixLen &&
newTextPrefix == expectedPrefix &&
newTextSuffix == expectedSuffix) {
insertedText = newText.substring(
prefixLen, newText.length - suffixLen);
} else {
insertedText = _computeInsertedText(oldText, newText);
}
} else {
// Multi-fragment selection: the buffer has a full selection
// (0..text.length), so whatever text remains after the
// platform operation is what was inserted. If newText is
// empty, the user pressed Backspace; if it contains text,
// the user typed to replace the selection.
insertedText = newText;
}
doc.saveState(description: 'Replace selection', forceNewAction: false);
if (insertedText.isNotEmpty) {
_insertTextOrReplaceSelection(insertedText, doc);
} else {
executeHandleReplaceSelection('', doc);
}
syncImeBufferToFragment();
doc.updateContent();
return;
}
}
// Single character insertion (including emoji which are 2 code units)
final lengthDiff = newText.length - oldText.length;
if (lengthDiff == 1 || lengthDiff == 2) {
final diffIndex = _findDiffIndex(oldText, newText);
if (diffIndex >= 0) {
// Extract the inserted text (could be 1 or 2 code units for emoji)
final insertedText = newText.substring(diffIndex, diffIndex + lengthDiff);
_moveCursorToFragmentOffset(diffIndex);
_insertFinalizedText(insertedText);
return;
}
}
// Single character deletion (including emoji which are 2 code units)
final deleteLengthDiff = oldText.length - newText.length;
if (deleteLengthDiff == 1 || deleteLengthDiff == 2) {
final cursorOffset = _getCursorOffsetInFragment();
if (cursorOffset == 0) {
// Cursor at start of fragment: structural backspace (merge with
// previous node) regardless of where the platform thinks the
// deletion happened.
doc.saveState(description: 'Backspace', forceNewAction: false);
executeHandleBackspace(doc);
syncImeBufferToFragment();
return;
}
// Normal backspace: delete relative to the DOCUMENT cursor position,
// not the text-diff position. The platform may have computed the
// deletion against a stale buffer with a different cursor offset
// (e.g. after a rapid cursor move or preceding rapid backspace).
// Using _findDiffIndex in that case moves the cursor to the wrong
// position and executeHandleBackspace deletes the wrong character
// — typically the one right after the intended cursor position.
doc.saveState(description: 'Backspace', forceNewAction: false);
if (deleteLengthDiff == 2) {
// Could be an emoji (1 grapheme = 2 code units) or two separate
// characters batched into one delta (rapid double backspace).
// executeHandleBackspace is grapheme-aware, so one call handles
// emoji. For two separate characters, call it twice.
final diffIndex = _findDiffIndex(newText, oldText);
if (diffIndex >= 0) {
final deletedText = oldText.substring(diffIndex, diffIndex + 2);
final graphemeCount = deletedText.characters.length;
for (int i = 0; i < graphemeCount; i++) {
executeHandleBackspace(doc);
}
} else {
executeHandleBackspace(doc);
}
} else {
executeHandleBackspace(doc);
}
syncImeBufferToFragment();
return;
}
// Text replacement (suggestion, paste, etc.)
if (newText != oldText) {
// When the replacement completely empties the fragment (common on
// iOS virtual keyboard when the user holds backspace), we must not
// leave a zombie empty fragment that traps the cursor. Empty the
// fragment and let the structural backspace path handle container
// merging just like the physical keyboard does.
if (newText.isEmpty && oldText.isNotEmpty) {
final fragId = doc.cursor.focusId.isNotEmpty ? doc.cursor.focusId : doc.cursor.anchorId;
final node = doc.nodeById(fragId);
if (node is Fragment) {
// If the fragment is inside a table cell, re-insert ZWS to keep
// the cell navigable instead of triggering structural backspace.
final cellParent = findAncestorCell(doc.content, node);
if (cellParent != null) {
node.text = '\u200B';
doc.cursor.moveTo(fragId, 0);
doc.updateContent();
syncImeBufferToFragment();
return;
}
doc.saveState(description: 'Backspace', forceNewAction: false);
node.text = '';
doc.cursor.moveTo(fragId, 0);
doc.updateContent();
executeHandleBackspace(doc);
syncImeBufferToFragment();
return;
}
}
// If there's an active document selection, replace it with the
// inserted text instead of overwriting the entire fragment.
// This correctly handles multi-node selections, matching the
// physical-keyboard path in EventHandler.handleCharacterInput.
if (!cursor.isCollapsed) {
// The buffer has a full selection for multi-fragment selections,
// so whatever text remains is what was inserted.
final insertedText = newText;
doc.saveState(description: 'Replace selection', forceNewAction: false);
if (insertedText.isNotEmpty) {
_insertTextOrReplaceSelection(insertedText, doc);
} else {
executeHandleReplaceSelection('', doc);
}
syncImeBufferToFragment();
doc.updateContent();
return;
}
_replaceFragmentText(newText, cursorOffset: cursorOffset);
return;
}
// Cursor moved without text change
if (newText == oldText && value.selection.isValid && value.selection.isCollapsed) {
_moveCursorToFragmentOffset(value.selection.extentOffset);
return;
}
return;
}
// ─── Active composition handling ────────────────────────────────
if (_isComposing) {
if (value.composing.isValid) {
// Composition shrank (backspace inside the preedit on iOS).
if (value.text.isEmpty) {
// Preedit fully erased. The preedit lives isolated from the
// document, so cancelling it removes exactly the characters the
// user typed — we must NOT also backspace the document, otherwise
// the committed character before the preedit gets deleted too.
_cancelPreedit();
return;
}
// Still has preedit content: extract it from the full buffer.
if (_shouldSyncBuffer) {
final start = FragmentOperations.adjustIndex(value.text, value.composing.start.clamp(0, value.text.length));
final end = FragmentOperations.adjustIndex(value.text, value.composing.end.clamp(0, value.text.length));
final rawPreedit = value.text.substring(start, end);
final preeditText = _sanitizeUtf16(rawPreedit);
_preeditText = preeditText;
_composingRange = TextRange(start: 0, end: preeditText.length);
_preeditCaretOffset = value.selection.isValid
? (value.selection.extentOffset - start).clamp(0, preeditText.length)
: preeditText.length;
} else {
final start = value.composing.start.clamp(0, value.text.length);
final end = value.composing.end.clamp(0, value.text.length);
final rawPreedit = value.text.substring(start, end);
final preeditText = _sanitizeUtf16(rawPreedit);
_preeditText = preeditText;
_composingRange = TextRange(start: 0, end: preeditText.length);
_preeditCaretOffset = value.selection.isValid
? (value.selection.extentOffset - start).clamp(0, preeditText.length)
: preeditText.length;
}
_invalidatePreeditRender();
return;
}
// Composing became invalid (text confirmed / space pressed).
// On non-buffer-sync platforms (Android), the buffer is just the
// preedit. If the platform echoes it back with no composing range,
// it's a spurious echo. On buffer-sync platforms (web, iOS, macOS),
// the buffer includes the fragment text, so value.text == _preeditText
// only happens when the fragment is empty and the committed text
// equals the preedit — which is a real commit, not an echo.
if (!_shouldSyncBuffer && value.text == _preeditText) {
// Spurious echo, keep composing
return;
}
if (value.text.isEmpty) {
// Preedit became empty - the last preedit character was erased.
// Only cancel the preedit; do NOT backspace the document (the
// preedit was never committed, so there is nothing extra to delete).
_cancelPreedit();
return;
}
if (_shouldSyncBuffer) {
// Buffer-sync commit: the full buffer already contains the
// committed text merged with the surrounding fragment text.
final fragId = doc.cursor.focusId.isNotEmpty ? doc.cursor.focusId : doc.cursor.anchorId;
final node = doc.nodeById(fragId);
if (node is Fragment) {
_updatingSelf = true; // Block platform echoes during document mutation
doc.saveState(description: 'Replace text', forceNewAction: false);
// Reconstruct the fragment text by inserting the committed text
// at _preeditLocalOffset. We cannot use value.text directly
// because on web the browser's buffer may still contain stale
// text from before the selection was cleared (we skip
// syncImeBufferToFragment during composition to avoid disrupting
// the platform's composition state). On iOS, value.text can also
// be stale/hybrid.
//
// The committed text is extracted from value.text by removing the
// fragment's prefix and suffix around the preedit position. This
// handles both stale selection text (browser didn't update the
// buffer after selection clearing) and predictive text correction
// (committed text differs from the last preedit).
final currentFragText = node.text;
final insertOffset = _preeditLocalOffset.clamp(0, currentFragText.length);
final prefix = currentFragText.substring(0, insertOffset);
final suffix = currentFragText.substring(insertOffset);
String committedText;
if (value.text.length >= prefix.length + suffix.length &&
value.text.startsWith(prefix) &&
value.text.endsWith(suffix)) {
committedText = _sanitizeUtf16(
value.text.substring(prefix.length, value.text.length - suffix.length),
);
} else {
// Fallback: use _preeditText if value.text doesn't match the
// expected structure (e.g., iOS stale buffer).
committedText = _sanitizeUtf16(_preeditText);
}
node.text = _sanitizeUtf16(prefix + committedText + suffix);
// On Linux, Windows & macOS, use the platform's selection to position the
// cursor when valid, since value.selection.extentOffset reflects the
// actual cursor position after commit. Other platforms use the
// computed offset.
final _usePlatformSelection = !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.macOS);
final newCursorOffset = (_usePlatformSelection &&
value.selection.isValid &&
value.selection.isCollapsed &&
value.selection.extentOffset >= prefix.length &&
value.selection.extentOffset <=
prefix.length + committedText.length)
? value.selection.extentOffset
: insertOffset + committedText.length;
doc.cursor.moveTo(fragId, _snapCursorOffset(node.text, newCursorOffset));
_resetComposition();
doc.cursor.imeComposing = false;
doc.updateContent();
_updatingSelf = false; // Unblock after document is updated
syncImeBufferToFragment(); // Now safe to sync
}
return;
}
// ─── Android: Composition confirmation ──────────────────────
// IMPORTANT: On Android, the buffer is NOT synced with the document.
// When composition ends, value.text contains the finalized text (e.g., CJK ideograph).
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
// If the buffer text has changed from the previous preedit, it means
// the user selected a replacement suggestion (e.g. CJK).
final finalizedText = value.text.isNotEmpty ? value.text : _preeditText;
_commitPreedit(finalizedText);
} else {
_commitPreedit(_preeditText);
}
_resetPlatformBuffer();
return;
}
// ─── New composition start ────────────────────────────────────
if (value.text.isEmpty) return;
if (!value.composing.isValid) {
// Caso 4: Testo confermato senza composizione
_insertFinalizedText(value.text);
if (_shouldSyncBuffer) {
syncImeBufferToFragment();
} else {
_resetPlatformBuffer();
}
return;
}
// Set _isComposing BEFORE clearing the selection so that
// syncImeBufferToFragment (called via document.updateContent()
// inside executeHandleReplaceSelection) returns early and does
// not sync the buffer with the shorter text while the browser's
// composition is still active.
_isComposing = true;
// Start new composition
if (!cursor.isCollapsed) {
// Guard against stale cursor offsets (see the same guard in the
// buffer-sync composition-start path above).
final anchorNode = doc.nodeById(cursor.anchorId);
final focusNode = doc.nodeById(cursor.focusId);
final anchorValid = anchorNode is Fragment &&
cursor.anchorOffset <= anchorNode.text.length;
final focusValid = focusNode is Fragment &&
cursor.focusOffset <= focusNode.text.length;
if (anchorValid && focusValid) {
doc.saveState(description: 'Replace selection', forceNewAction: false);
executeHandleReplaceSelection('', doc);
} else {
if (focusValid) {
cursor.moveTo(cursor.focusId, cursor.focusOffset);
} else if (anchorValid) {
cursor.moveTo(cursor.anchorId, cursor.anchorOffset);
}
doc.selectionManager.collapse();
}
}
_preeditFragmentId = cursor.focusId.isNotEmpty ? cursor.focusId : cursor.anchorId;
_preeditLocalOffset = cursor.focusId.isNotEmpty ? cursor.focusOffset : cursor.anchorOffset;
_preeditContainerId = doc.findLogicalContainerId(_preeditFragmentId) ?? '';
cursor.imeComposing = true;
cursor.imeComposingStart = _preeditLocalOffset;
doc.selectionManager.clear();
final compStart = value.composing.start.clamp(0, value.text.length);
final compEnd = value.composing.end.clamp(0, value.text.length);
final rawPreedit = value.text.substring(compStart, compEnd);
final preeditText = _sanitizeUtf16(rawPreedit);
_preeditText = preeditText;
_composingRange = TextRange(start: 0, end: preeditText.length);
_preeditCaretOffset = value.selection.isValid
? (value.selection.extentOffset - compStart).clamp(0, preeditText.length)
: preeditText.length;
_invalidatePreeditRender();
}