Skip to main content
Selection management is a core concept in AppFlowy Editor. This page explains how Position and Selection work together to represent cursor location and text ranges.

Position

A Position represents a specific point in the document, defined by a path and an offset.

Structure

class Position {
  final Path path;    // Path to the node
  final int offset;   // Character offset within the node
}

Creating Positions

// Basic position
final position = Position(
  path: [0, 1],  // Second child of first node
  offset: 5,     // At character position 5
);

// Invalid position (used for error states)
final invalid = Position.invalid();
// Creates position with path [-1] and offset -1

Position Properties

// Access components
final path = position.path;      // List<int>
final offset = position.offset;  // int

// String representation
print(position);  // "path = [0, 1], offset = 5"

Copying Positions

// Copy with modifications
final newPosition = position.copyWith(
  path: [0, 2],
  offset: 10,
);

// Copy single property
final movedPosition = position.copyWith(offset: 8);

Serialization

// To JSON
final json = position.toJson();
// Returns: {'path': [0, 1], 'offset': 5}

// From JSON
final position = Position.fromJson({
  'path': [0, 1],
  'offset': 5,
});

Selection

A Selection represents a range in the document, from a start position to an end position.

Structure

class Selection {
  final Position start;
  final Position end;
}

Selection Direction

Selections are directional:
  • Forward: End position is after start position
  • Backward: End position is before start position
  • Collapsed: Start and end positions are identical (cursor)
// Check direction
if (selection.isForward) {
  // End is after start
}

if (selection.isBackward) {
  // End is before start
}

if (selection.isCollapsed) {
  // Cursor position (no range)
}

Creating Selections

From Start and End Positions

final selection = Selection(
  start: Position(path: [0], offset: 0),
  end: Position(path: [0], offset: 5),
);

Single Node Selection

// Select text in a single node
final selection = Selection.single(
  path: [0],
  startOffset: 0,
  endOffset: 5,
);

// Collapsed selection (cursor)
final cursor = Selection.single(
  path: [0],
  startOffset: 5,
  // endOffset omitted = same as startOffset
);

Collapsed Selection (Cursor)

final cursor = Selection.collapsed(
  Position(path: [0], offset: 5),
);

Invalid Selection

final invalid = Selection.invalid();

Selection Properties

// Direction checks
bool get isForward => 
  (start.path > end.path) || (isSingle && start.offset > end.offset);

bool get isBackward => 
  (start.path < end.path) || (isSingle && start.offset < end.offset);

bool get isCollapsed => start == end;

bool get isSingle => start.path.equals(end.path);

Normalized Selection

Normalized selections always go forward (start before end):
final selection = Selection(
  start: Position(path: [0], offset: 10),
  end: Position(path: [0], offset: 5),
);

final normalized = selection.normalized;
// start: [0, 5], end: [0, 10]

Selection Indices

Get offsets in the normalized order:
int get startIndex => normalized.start.offset;
int get endIndex => normalized.end.offset;
int get length => endIndex - startIndex;

Reversing Selection

final reversed = selection.reversed;
// Swaps start and end positions

Collapsing Selection

Convert a range selection to a cursor:
// Collapse to start
final atStart = selection.collapse(atStart: true);

// Collapse to end
final atEnd = selection.collapse(atStart: false);

Shifting Selection

Move both positions by an offset:
final shifted = selection.shift(5);
// Both start and end offsets increased by 5

Copying Selections

final modified = selection.copyWith(
  start: Position(path: [0], offset: 0),
  end: Position(path: [0], offset: 10),
);

Working with Selection in EditorState

Getting Current Selection

final selection = editorState.selection;
if (selection == null) {
  // No active selection
  return;
}

if (selection.isCollapsed) {
  // Cursor position
  print('Cursor at: ${selection.start}');
} else {
  // Range selected
  print('Selected from ${selection.start} to ${selection.end}');
}

Setting Selection

// Simple assignment
editorState.selection = Selection.collapsed(
  Position(path: [0], offset: 0),
);

// With update reason
await editorState.updateSelectionWithReason(
  newSelection,
  reason: SelectionUpdateReason.uiEvent,
);

Selection Update Reasons

enum SelectionUpdateReason {
  uiEvent,        // Mouse click, keyboard event
  transaction,    // Insert, delete, format operation
  remote,         // Collaborative editing
  selectAll,      // Select all command
  searchHighlight, // Search result highlighting
}

Listening to Selection Changes

editorState.selectionNotifier.addListener(() {
  final selection = editorState.selection;
  if (selection != null) {
    print('Selection changed: $selection');
  }
});

Getting Nodes in Selection

Get All Nodes in Selection

final nodes = editorState.getNodesInSelection(selection);
// Returns nodes in selection order

Get Selected Nodes

final selectedNodes = editorState.getSelectedNodes(
  selection: selection,
  withCopy: true,  // Returns deep copies
);
This method intelligently handles:
  • Partial text selections (slices deltas)
  • Nested node structures
  • Parent-child relationships

Selection Rectangles

Get visual bounds of the selection:
final rects = editorState.selectionRects();
for (final rect in rects) {
  // Each rect is a visual rectangle covering part of the selection
  print('Rect: ${rect.left}, ${rect.top}, ${rect.width}, ${rect.height}');
}

Selection Types

enum SelectionType {
  inline,  // Text-level selection
  block,   // Block-level selection
}
Access via EditorState:
final selectionType = editorState.selectionType;
if (selectionType == SelectionType.block) {
  // Handle block selection
}

Practical Examples

Select Entire Node

final node = editorState.getNodeAtPath([0]);
if (node != null && node.delta != null) {
  final selection = Selection.single(
    path: node.path,
    startOffset: 0,
    endOffset: node.delta!.length,
  );
  editorState.selection = selection;
}

Move Cursor to End of Node

final node = editorState.getNodeAtPath([0]);
if (node != null && node.delta != null) {
  final position = Position(
    path: node.path,
    offset: node.delta!.length,
  );
  editorState.selection = Selection.collapsed(position);
}

Select Multiple Nodes

final selection = Selection(
  start: Position(path: [0], offset: 0),
  end: Position(path: [2], offset: 0),
);
editorState.selection = selection;

Check if Position is in Selection

bool isInSelection(Position position, Selection selection) {
  final normalized = selection.normalized;
  
  // Check path bounds
  if (position.path < normalized.start.path ||
      position.path > normalized.end.path) {
    return false;
  }
  
  // Check offset if on boundary paths
  if (position.path.equals(normalized.start.path) &&
      position.offset < normalized.start.offset) {
    return false;
  }
  
  if (position.path.equals(normalized.end.path) &&
      position.offset > normalized.end.offset) {
    return false;
  }
  
  return true;
}

Extend Selection

// Extend selection forward by 5 characters
if (selection != null && selection.isSingle) {
  final extended = selection.copyWith(
    end: selection.end.copyWith(
      offset: selection.end.offset + 5,
    ),
  );
  editorState.selection = extended;
}

Select Word at Cursor

final selection = editorState.selection;
if (selection != null && selection.isCollapsed) {
  final node = editorState.getNodeAtPath(selection.start.path);
  final delta = node?.delta;
  
  if (delta != null) {
    final text = delta.toPlainText();
    final offset = selection.start.offset;
    
    // Find word boundaries
    int start = offset;
    while (start > 0 && text[start - 1] != ' ') start--;
    
    int end = offset;
    while (end < text.length && text[end] != ' ') end++;
    
    editorState.selection = Selection.single(
      path: selection.start.path,
      startOffset: start,
      endOffset: end,
    );
  }
}

Remote Selection

For collaborative editing, track remote users’ selections:
class RemoteSelection {
  final Selection selection;
  final String userId;
  final Color color;
}

// Set remote selections
editorState.remoteSelections.value = [
  RemoteSelection(
    selection: remoteUserSelection,
    userId: 'user123',
    color: Colors.blue,
  ),
];

Selection Extra Info

Attach metadata to selections:
await editorState.updateSelectionWithReason(
  selection,
  reason: SelectionUpdateReason.uiEvent,
  extraInfo: {
    'gesture': 'double_tap',
    'timestamp': DateTime.now().millisecondsSinceEpoch,
  },
);

// Access later
final extraInfo = editorState.selectionExtraInfo;

Best Practices

Normalize Selections

Use normalized when you need consistent start/end order.

Check for Null

Always check if editorState.selection is null before using.

Use Reasons

Provide SelectionUpdateReason for better debugging and event handling.

Validate Positions

Ensure positions are within valid node bounds.

See Also

Build docs developers (and LLMs) love