executeHandleBackspace function
bool
executeHandleBackspace(
- FluentDocument document, {
- bool ctrl = false,
- bool lineStart = false,
Handles the Backspace key.
Supported behaviors:
- If there's an active selection: delete the selection
- If cursor is at the start of a ListItem: outdent (remove from list)
- If cursor is at the start of another node: merge with the previous node
- If cursor is on an image: remove the image
- If lineStart is pressed: delete to beginning of line
- If ctrl is pressed: delete the previous word
- 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;
}