updateEditingValueWithDeltas method
Requests that this client update its editing state by applying the deltas received from the engine.
The list of TextEditingDelta's are treated as changes that will be applied to the client's editing state. A change is any mutation to the raw text value, or any updates to the selection and/or composing region.
{@tool snippet} This example shows what an implementation of this method could look like.
class MyClient with DeltaTextInputClient {
TextEditingValue? _localValue;
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) {
if (_localValue == null) {
return;
}
TextEditingValue newValue = _localValue!;
for (final TextEditingDelta delta in textEditingDeltas) {
newValue = delta.apply(newValue);
}
_localValue = newValue;
}
// ...
}
{@end-tool}
Implementation
@override
void updateEditingValueWithDeltas(List<TextEditingDelta> deltas) {
final doc = _document;
if (doc == null) return;
if (state.updatingSelf) return;
if (defaultTargetPlatform == TargetPlatform.macOS) {
for (final delta in deltas) {
_currentPlatformValue = delta.apply(_currentPlatformValue);
}
}
final bool batchEndsComposing =
(kIsWeb || defaultTargetPlatform != TargetPlatform.macOS) &&
deltas.length > 1 &&
deltas.last is TextEditingDeltaNonTextUpdate &&
!(deltas.last as TextEditingDeltaNonTextUpdate).composing.isValid;
if (state.justCommittedComposition) {
state.justCommittedComposition = false;
final hasRealEdits = deltas.any(
(d) => d is! TextEditingDeltaNonTextUpdate,
);
if (!kIsWeb && !hasRealEdits) {
return;
}
}
for (final delta in deltas) {
final fragId = doc.cursor.focusId.isNotEmpty
? doc.cursor.focusId
: doc.cursor.anchorId;
final node = doc.nodeById(fragId);
// -----------------------------------------------------------------------
// 1. DELETION HANDLING
// -----------------------------------------------------------------------
if (delta is TextEditingDeltaDeletion ||
(delta is TextEditingDeltaReplacement &&
delta.replacementText.isEmpty)) {
doc.saveState(description: 'Delete', forceNewAction: false);
final deletionRange = delta is TextEditingDeltaDeletion
? delta.deletedRange
: (delta as TextEditingDeltaReplacement).replacedRange;
// We reposition ONLY if the keyboard provides a mathematically
// valid range (e.g., selecting and deleting a whole word).
if (node is Fragment &&
deletionRange.isValid &&
deletionRange.start < deletionRange.end) {
final safeEnd = deletionRange.end.clamp(0, node.text.length);
doc.cursor.moveTo(fragId, safeEnd);
final count = deletionRange.end - deletionRange.start;
for (var i = 0; i < count; i++) {
executeHandleBackspace(doc);
}
} else {
// Fallback for physical keyboards: a normal backspace stroke
// starting from the current cursor position.
executeHandleBackspace(doc);
}
syncImeBufferToFragment();
return;
}
// -----------------------------------------------------------------------
// 2. INSERTION HANDLING
// -----------------------------------------------------------------------
if (delta is TextEditingDeltaInsertion) {
doc.saveState(description: 'Type', forceNewAction: false);
if (delta.composing.isValid &&
delta.composing.start < delta.composing.end) {
state.isComposing = true;
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.android) {
final fullText = delta.oldText.replaceRange(
delta.insertionOffset,
delta.insertionOffset,
delta.textInserted,
);
final composingStart = delta.composing.start.clamp(
0,
fullText.length,
);
final composingEnd = delta.composing.end.clamp(0, fullText.length);
state.preeditText = fullText.substring(
composingStart,
composingEnd,
);
state.composingRange = delta.composing;
} else {
state.preeditText = delta.textInserted;
state.composingRange = delta.composing;
}
state.preeditLocalOffset = doc.cursor.focusOffset;
final parentId = doc.findParentCached(fragId);
state.preeditContainerId = parentId ?? '';
doc.cursor.imeComposing = true;
doc.cursor.imeComposingStart = state.preeditLocalOffset;
if (batchEndsComposing) {
commitIfComposing();
} else {
_invalidatePreeditRender();
}
return;
}
if (state.isComposing) {
_resetComposition();
_invalidatePreeditRender();
}
// iOS Predictive Text Bug Fix for Insertions:
// Sometimes iOS sends an insertion for the suffix of a word (e.g. inserting "zione" after "documenta")
// leaving the old suffix ("tion") intact, resulting in "documentazione tion".
if (defaultTargetPlatform == TargetPlatform.iOS &&
delta.textInserted.length > 1) {
final currentText = node is Fragment ? node.text : '';
final offset = delta.insertionOffset.clamp(0, currentText.length);
if (offset > 0 && offset < currentText.length) {
bool isWordChar(String s) => RegExp(r'[a-zA-Z0-9_À-ÿ]').hasMatch(s);
if (isWordChar(currentText[offset - 1])) {
int wordEnd = offset;
while (wordEnd < currentText.length &&
isWordChar(currentText[wordEnd])) {
wordEnd++;
}
final charsToDelete = wordEnd - offset;
if (charsToDelete > 0) {
// Delete the remaining suffix before inserting the new one
doc.cursor.moveTo(fragId, wordEnd);
for (var i = 0; i < charsToDelete; i++) {
executeHandleBackspace(doc);
}
}
}
}
}
_insertFinalizedText(delta.textInserted);
syncImeBufferToFragment();
return;
}
// -----------------------------------------------------------------------
// 3. REPLACEMENT HANDLING (Optimized with Diffing)
// -----------------------------------------------------------------------
if (delta is TextEditingDeltaReplacement) {
doc.saveState(description: 'Replace', forceNewAction: false);
if (delta.composing.isValid &&
delta.composing.start < delta.composing.end) {
state.isComposing = true;
state.preeditText = delta.replacementText;
state.composingRange = delta.composing;
state.preeditLocalOffset = doc.cursor.focusOffset;
doc.cursor.imeComposing = true;
doc.cursor.imeComposingStart = state.preeditLocalOffset;
if (batchEndsComposing) {
commitIfComposing();
} else {
_invalidatePreeditRender();
}
return;
}
// Unlock cursor at the end of composition
if (state.isComposing) {
_resetComposition();
_invalidatePreeditRender();
}
if (node is Fragment && delta.replacedRange.isValid) {
final currentText = node.text;
final safeStart = delta.replacedRange.start.clamp(
0,
currentText.length,
);
var safeEnd = delta.replacedRange.end.clamp(0, currentText.length);
// iOS Predictive Text Bug Fix:
// iOS often replaces only the typed prefix of a word (e.g., replacing "documenta" with "documentazione")
// leaving the suffix ("tion") intact, resulting in "documentazione tion".
// We detect if we are in the middle of a word and expand safeEnd to consume the suffix.
if (defaultTargetPlatform == TargetPlatform.iOS &&
delta.replacementText.length > 1 &&
safeStart < safeEnd &&
safeEnd < currentText.length) {
bool isWordChar(String s) => RegExp(r'[a-zA-Z0-9_À-ÿ]').hasMatch(s);
if (isWordChar(currentText[safeEnd - 1])) {
int wordEnd = safeEnd;
while (wordEnd < currentText.length &&
isWordChar(currentText[wordEnd])) {
wordEnd++;
}
safeEnd = wordEnd;
}
}
final oldSlice = currentText.substring(safeStart, safeEnd);
final newSlice = delta.replacementText;
final prefixLen = _commonPrefixLength(oldSlice, newSlice);
final suffixLen = _commonSuffixLength(
oldSlice.substring(prefixLen),
newSlice.substring(prefixLen),
);
final charsToDelete = oldSlice.length - prefixLen - suffixLen;
final stringToInsert = newSlice.substring(
prefixLen,
newSlice.length - suffixLen,
);
final targetCursorPos = safeStart + prefixLen + charsToDelete;
doc.cursor.moveTo(fragId, targetCursorPos);
for (var i = 0; i < charsToDelete; i++) {
executeHandleBackspace(doc);
}
if (stringToInsert.isNotEmpty) {
_insertFinalizedText(stringToInsert);
}
} else {
_replaceFragmentText(delta.replacementText);
}
syncImeBufferToFragment();
return;
}
// -----------------------------------------------------------------------
// 4. NON-TEXT UPDATE HANDLING (Commit IME Desktop/Linux/MacOS)
// -----------------------------------------------------------------------
final isDesktopOrWeb =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.macOS;
if ((isDesktopOrWeb || defaultTargetPlatform == TargetPlatform.iOS) &&
delta is TextEditingDeltaNonTextUpdate) {
if (state.isComposing &&
(!delta.composing.isValid ||
delta.composing.start >= delta.composing.end)) {
commitIfComposing();
syncImeBufferToFragment();
return;
}
if (doc.cursor.imeComposing && !state.isComposing) {
doc.cursor.imeComposing = false;
doc.cursor.imeComposingStart = 0;
}
continue;
}
}
}