executeHandleBackspace function

bool executeHandleBackspace(
  1. FluentDocument document, {
  2. bool ctrl = false,
  3. bool lineStart = false,
})

Handles the Backspace key.

Supported behaviors:

  1. If there's an active selection: delete the selection
  2. If cursor is at the start of a ListItem: outdent (remove from list)
  3. If cursor is at the start of another node: merge with the previous node
  4. If cursor is on an image: remove the image
  5. If lineStart is pressed: delete to beginning of line
  6. If ctrl is pressed: delete the previous word
  7. Otherwise: delete the previous character in the fragment

Implementation

bool executeHandleBackspace(FluentDocument document, {bool ctrl = false, bool lineStart = false}) {
  final root = document.content;
  final cursor = document.cursor;

  // Case 1: If there's an active selection, delete it
  final selection = resolveSelection(
    root,
    cursor.anchorId,
    cursor.anchorOffset,
    cursor.focusId,
    cursor.focusOffset,
    cachedStops: document.caretStops,
    cachedLines: document.logicalLines,
  );

  if (selection != null) {
    // Delete the selection by replacing it with an empty string
    executeHandleReplaceSelection('', document);
    return true;
  }

  // Case 1.5: If lineStart is pressed, delete to beginning of current line
  if (lineStart) {
    return _handleDeleteToLineStart(document);
  }

  // Case 1.6: If ctrl is pressed, delete the previous word
  if (ctrl) {
    return _handleDeleteWord(document);
  }

  // Find the fragment and the current container
  // Case 2a: If cursor is on a HorizontalRule, remove it.
  final currentNode = document.nodeById(cursor.anchorId);
  if (currentNode is HorizontalRule) {
    final targetStop = _findPreviousStop(
      root, cursor.anchorId, 0,
      cachedStops: document.caretStops,
      cachedLines: document.logicalLines,
    );
    removeNode(root, currentNode);
    if (targetStop != null) {
      cursor.moveTo(targetStop.fragmentId, targetStop.offset);
    }
    document.updateContent();
    return true;
  }

  // Resolve the actual Fragment from the cursor anchor (which may be a
  // Paragraph, Image, HorizontalRule, or Fragment itself).
  Fragment? currentFrag;
  if (currentNode is Fragment) {
    currentFrag = currentNode;
  } else if (currentNode is InlineContainerNode) {
    final containerNode = currentNode as InlineContainerNode;
    final children = containerNode.getChildren();
    if (cursor.anchorOffset >= 0 && cursor.anchorOffset < children.length) {
      final child = children[cursor.anchorOffset];
      if (child is Fragment) currentFrag = child;
    }
    if (currentFrag == null) {
      for (final child in children) {
        if (child is Fragment) {
          currentFrag = child;
          break;
        }
      }
    }
  }
  if (currentFrag == null) return false;

  final container = findLogicalContainer(root, cursor.anchorId);
  if (container == null) return false;

  // Special case: cursor is inside an empty paragraph.
  // Remove the paragraph and move the cursor to the end of the previous one.
  // But not if the paragraph is inside a table cell — empty cells must keep
  // their paragraph to remain navigable.
  if (container is Paragraph && container.text.isEmpty &&
      findAncestorCell(root, container as FNode) == null) {
    final prevStop = _findPreviousStop(
      root, cursor.anchorId, 0,
      cachedStops: document.caretStops,
      cachedLines: document.logicalLines,
    );
    if (prevStop != null) {
      removeNode(root, container as FNode);
      cursor.moveTo(prevStop.fragmentId, prevStop.offset);
      document.updateContent();
      return true;
    }
    // At the very start of the document: nothing to merge with.
    return false;
  }

  // Case 2b: If it's an image, remove the entire node.
  // The cursor should be positioned on the stop that precedes the image
  // (offset 0 of the image → moveLeft gives the true previous).
  if (currentFrag is FluentImage) {
    final targetStop = _findPreviousStop(
      root, cursor.anchorId, 0,
      cachedStops: document.caretStops,
      cachedLines: document.logicalLines,
    );
    removeNode(root, currentFrag);
    if (targetStop != null) {
      cursor.moveTo(targetStop.fragmentId, targetStop.offset);
    }
    document.updateContent();
    return true;
  }

  // Guard: if the fragment is inside a table cell and contains only ZWS,
  // backspace is a no-op — the ZWS must be preserved to keep the cell
  // navigable. This catches both offset 0 (would enter Case 3) and
  // offset 1 (would enter Case 4).
  if (findAncestorCell(root, currentFrag) != null &&
      currentFrag.text.isNotEmpty &&
      currentFrag.text.replaceAll('\u200B', '').isEmpty) {
    return true;
  }

  // Case 3: If we're at the start of the container (offset == 0)
  // handle the merge with the previous node or outdent for lists
  if (cursor.anchorOffset == 0) {
    return _handleBackspaceAtStart(document, container, currentFrag);
  }

  // Case 4: Normal character deletion
  // Use grapheme-aware offset to handle emoji (surrogate pairs) correctly
  // If the character before the cursor is a ZWS and the fragment has visible
  // content, skip the ZWS and delete the visible character before it.
  // ZWS is invisible — backspace should delete the visible character, not the
  // invisible marker. If the fragment is all-ZWS, preserve original behavior.
  int newOffset = FragmentOperations.getPreviousGraphemeOffset(currentFrag.text, cursor.anchorOffset);
  if (newOffset >= 0 && newOffset < currentFrag.text.length &&
      currentFrag.text.codeUnitAt(newOffset) == 0x200B &&
      currentFrag.text.replaceAll('\u200B', '').isNotEmpty) {
    final skipOffset = FragmentOperations.getPreviousGraphemeOffset(currentFrag.text, newOffset);
    if (skipOffset < newOffset) newOffset = skipOffset;
  }
  final deleteCount = cursor.anchorOffset - newOffset;

  final cellParent = findAncestorCell(root, currentFrag);

  FragmentOperations.deleteTextInFragment(currentFrag, newOffset, count: deleteCount);

  // If the fragment is inside a table cell and became empty after deletion,
  // re-insert a ZWS so the cell remains navigable.
  if (cellParent != null && currentFrag.text.isEmpty) {
    currentFrag.text = '\u200B';
    cursor.moveTo(currentFrag.id, 0);
    document.updateContent();
    return true;
  }

  // If the fragment became empty, remove it (and any empty parent Links)
  // so it doesn't block future backspace navigation.
  if (currentFrag.text.isEmpty) {
    // Use the logical container (e.g. Paragraph) to decide whether we
    // can safely remove the empty fragment.  A fragment that is the only
    // child of a style wrapper must still be removed when other fragments
    // exist elsewhere in the paragraph.
    final flat = _flattenInlineChildren(container);
    if (flat.length > 1) {
      int fragIdx = -1;
      for (int i = 0; i < flat.length; i++) {
        if (flat[i].id == currentFrag.id) {
          fragIdx = i;
          break;
        }
      }

      final parent = findParent(root, currentFrag);
      removeNode(root, currentFrag);
      // Clean up empty Links / style wrappers that might have contained
      // this fragment.
      _cleanupEmptyInlineParents(root, parent);

      if (fragIdx > 0) {
        // Move cursor to the end of the nearest non-empty predecessor.
        for (int i = fragIdx - 1; i >= 0; i--) {
          final prev = flat[i];
          if (prev is Fragment && prev is! InlineContainerNode && prev.text.isNotEmpty) {
            cursor.moveTo(prev.id, prev.text.length);
            break;
          }
        }
      } else if (fragIdx == 0 && flat.length > 1) {
        // First fragment removed: move cursor to the start of what is now
        // the first fragment (offset 0 triggers container-boundary logic
        // on the next backspace if the user keeps deleting).
        final next = flat[1];
        if (next is Fragment && next is! InlineContainerNode) {
          cursor.moveTo(next.id, 0);
        }
      }
      // Fallback: if the cursor still points to the removed fragment,
      // use moveLeft/moveRight to find the nearest valid caret stop.
      if (cursor.anchorId == currentFrag.id) {
        final fallbackStop = moveLeft(
          root,
          CaretStop(cursor.anchorId, cursor.anchorOffset),
          stops: document.caretStops,
          cachedLines: document.logicalLines,
        );
        if (fallbackStop.position != null) {
          cursor.moveTo(fallbackStop.position!.fragmentId, fallbackStop.position!.offset);
        } else {
          final rightStop = moveRight(
            root,
            CaretStop(cursor.anchorId, cursor.anchorOffset),
            stops: document.caretStops,
            cachedLines: document.logicalLines,
          );
          if (rightStop.position != null) {
            cursor.moveTo(rightStop.position!.fragmentId, rightStop.position!.offset);
          }
        }
      }
      document.updateContent();
      return true;
    }
  }

  // Update the cursor
  cursor.moveTo(currentFrag.id, newOffset);

  // Notify comment system of the text mutation.
  if (container is Paragraph) {
    final globalOffset = document.getGlobalOffsetInParagraph(
      container.id,
      currentFrag.id,
      newOffset,
    );
    if (globalOffset != null) {
      document.notifyTextMutation(container.id, globalOffset, -1);
    }
  }

  document.updateContent();
  return true;
}