syncImeBufferToFragment method

void syncImeBufferToFragment()

Syncs the platform IME buffer with the current fragment text and cursor. Call after any document mutation or cursor move.

Implementation

void syncImeBufferToFragment() {
  if (_connection == null || !_connection!.attached) return;
  if (_isComposing) return;

  final doc = _document;
  if (doc == null) return;
  final currentFragId = doc.cursor.focusId.isNotEmpty ? doc.cursor.focusId : doc.cursor.anchorId;

  if (!_shouldSyncBuffer) {
    // Android: only reset the buffer when the cursor moves to a different
    // fragment. If we're still in the same fragment, leave the buffer alone
    // so the IME can keep its suggestion context.
    if (currentFragId != _lastSyncedFragmentId) {
      _resetPlatformBuffer();
    }
    _lastSyncedFragmentId = currentFragId;
    return;
  }

  _lastSyncedFragmentId = currentFragId;
  final text = _getCurrentFragmentText();
  if (text == null) return;
  final cursor = doc.cursor;
  final isSingleFragSelection =
      !cursor.isCollapsed && cursor.anchorId == cursor.focusId;
  final isMultiFragSelection =
      !cursor.isCollapsed && cursor.anchorId != cursor.focusId;
  final offset = _getCursorOffsetInFragment();
  final bool usePlaceholder = _isIOS &&
      cursor.isCollapsed &&
      offset == 0 &&
      !text.startsWith(_emptyFragmentPlaceholder);
  final syncedText = usePlaceholder ? '$_emptyFragmentPlaceholder$text' : text;
  final syncedOffset = usePlaceholder ? 1 : offset.clamp(0, syncedText.length);
  final TextSelection syncedSelection;
  if (isSingleFragSelection && !usePlaceholder) {
    syncedSelection = TextSelection(
      baseOffset: cursor.anchorOffset.clamp(0, syncedText.length),
      extentOffset: cursor.focusOffset.clamp(0, syncedText.length),
    );
  } else if (isMultiFragSelection && !usePlaceholder) {
    // Multi-fragment selection: select the entire buffer so the
    // platform can delete or replace it, triggering the document-
    // level selection deletion/replacement.
    syncedSelection = TextSelection(
      baseOffset: 0,
      extentOffset: syncedText.length,
    );
  } else {
    syncedSelection = TextSelection.collapsed(offset: syncedOffset);
  }
  final currentSelectionKey =
      '${cursor.anchorId}:${cursor.anchorOffset}:${cursor.focusId}:${cursor.focusOffset}';
  final bool selectionChanged = currentSelectionKey != _prevSelectionKey;
  _prevSelectionKey = currentSelectionKey;

  _updatingSelf = true;
  try {
    // On web, if the new text is shorter than what the browser currently
    // holds, the browser sends a delta with a stale composing range that
    // exceeds the new text length, triggering an assertion in
    // TextEditingDelta.fromJSON. Resetting to empty first (same approach
    // as iOS) forces the browser to treat the next setEditingState as a
    // fresh insertion with no stale composition state.
    if (kIsWeb && syncedText.length < _lastSyncedText.length) {
      _connection!.setEditingState(const TextEditingValue());
    }
    if (_isIOS && selectionChanged) {
      _connection!.setEditingState(const TextEditingValue());
    }
    _connection!.setEditingState(TextEditingValue(
      text: syncedText,
      selection: syncedSelection,
      composing: TextRange.empty,
    ));
    _lastSyncedText = syncedText;
  } on PlatformException catch (e) {
    debugPrint('FluentTextInputHandler: syncImeBufferToFragment failed: ${e.message}');
  }
  _updatingSelf = false;
  // On web, reposition the hidden <textarea> so the IME popup follows
  // the cursor after the buffer content changed (e.g. after accepting a
  // suggestion). Without this the popup stays at the old position.
  if (kIsWeb) {
    _updateWebImePosition();
  }
}