updateEditingValue method

  1. @override
void updateEditingValue(
  1. TextEditingValue value, {
  2. int? cursorOffset,
})
override

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();
}