Position & Selection Management

Managing positions and selections in your model: path-based positions, selection representation, DOM conversion, and normalization.

Overview

Positions and selections in your model are represented differently than in the DOM. Understanding this difference and how to convert between them is crucial for building a reliable editor.

DOM Position

{
  node: TextNode,
  offset: 5
}
  • References actual DOM nodes
  • Breaks when DOM changes
  • Browser-specific
  • Hard to serialize

Model Position

{
  path: [0, 1, 2],
  offset: 5
}
  • References model structure
  • Stable across DOM updates
  • Framework-agnostic
  • Easy to serialize

Position Representation

Path-Based Positions

Positions in the model use paths (array of indices) to navigate the document tree:

// Path structure: [blockIndex, inlineIndex, textOffset]
// Example document:
{
  type: 'document',
  children: [
    { type: 'paragraph', children: [...] },  // Index 0
    { type: 'heading', children: [...] },     // Index 1
    { type: 'paragraph', children: [...] }    // Index 2
  ]
}

// Position examples:
{ path: [0], offset: 0 }           // Start of first paragraph
{ path: [0, 0], offset: 5 }        // 5th character in first inline of first paragraph
{ path: [1, 0, 1], offset: 3 }     // 3rd character in 2nd text node of first inline of heading

Path interpretation:

  • Each number is an index into the parent's children array
  • Last number is the character offset within a text node
  • Paths are stable even when DOM is re-rendered

Offset in Position

The offset represents the character position within the target node:

// For text nodes, offset is character position
{
  path: [0, 0],  // First paragraph, first inline
  offset: 5      // 5th character in the text node
}

// For element nodes, offset is child index
{
  path: [0],     // First paragraph
  offset: 2      // After 2nd child of paragraph
}

// Finding position in model
function findPosition(path, offset) {
  let node = document;
  
  // Navigate using path
  for (let i = 0; i < path.length - 1; i++) {
    node = node.children[path[i]];
  }
  
  // Last path index points to target node
  const targetNode = node.children[path[path.length - 1]];
  
  return {
    node: targetNode,
    offset: offset
  };
}

Position Stability

Path-based positions remain valid even when DOM changes:

// Position before edit
const position = { path: [0, 0], offset: 10 };

// User inserts text at position [0, 0], offset: 5
// Model updates, but position path structure remains

// Position after edit (offset adjusted)
const newPosition = { path: [0, 0], offset: 15 };  // 10 + 5 inserted chars

// If text is deleted, offset decreases
// If node is split, path might change
// But path structure is always valid

Selection Representation

Anchor and Focus

Selection is represented by two positions: anchor (where selection started) and focus (where selection ended):

// Model selection
{
  anchor: { path: [0, 0], offset: 5 },
  focus: { path: [0, 2], offset: 3 },
  isBackward: false
}

// Collapsed selection (cursor)
{
  anchor: { path: [0, 1], offset: 10 },
  focus: { path: [0, 1], offset: 10 },
  isBackward: false
}

// Backward selection (selected from right to left)
{
  anchor: { path: [0, 2], offset: 10 },
  focus: { path: [0, 0], offset: 5 },
  isBackward: true
}

Why anchor and focus?

  • Anchor is where user started selecting (mouse down or Shift+Arrow start)
  • Focus is where selection currently ends (mouse position or cursor)
  • isBackward indicates selection direction
  • Allows proper handling of selection expansion/contraction

Collapsed Selection

A collapsed selection represents a cursor (no text selected):

// Collapsed selection
{
  anchor: { path: [0, 1], offset: 10 },
  focus: { path: [0, 1], offset: 10 },
  isBackward: false
}

// Check if collapsed
function isCollapsed(selection) {
  return (
    selection.anchor.path.join(',') === selection.focus.path.join(',') &&
    selection.anchor.offset === selection.focus.offset
  );
}

// Get cursor position
function getCursorPosition(selection) {
  if (isCollapsed(selection)) {
    return selection.anchor;  // or focus, they're the same
  }
  return null;
}

DOM to Model Conversion

Convert browser's DOM selection to your model selection:

DOM Position to Path

function domPositionToPath(domNode, domOffset, model) {
  // Find the model node that corresponds to DOM node
  const modelNode = findModelNodeForDOM(domNode, model);
  
  if (!modelNode) {
    return null;
  }
  
  // Calculate path by walking up the model tree
  const path = [];
  let current = modelNode;
  
  while (current && current !== model) {
    const parent = findParent(current, model);
    if (parent) {
      const index = parent.children.indexOf(current);
      path.unshift(index);
    }
    current = parent;
  }
  
  // Add offset
  if (domNode.nodeType === Node.TEXT_NODE) {
    // Offset is character position in text node
    return { path, offset: domOffset };
  } else {
    // Offset is child index in element
    return { path: [...path, domOffset], offset: 0 };
  }
}

function findModelNodeForDOM(domNode, model) {
  // Walk up DOM tree to find element with data-model-id
  let current = domNode;
  while (current) {
    if (current.nodeType === Node.ELEMENT_NODE) {
      const modelId = current.getAttribute('data-model-id');
      if (modelId) {
        return findNodeById(model, modelId);
      }
    }
    current = current.parentElement;
  }
  return null;
}

DOM Range to Selection

function domSelectionToModel(domSelection) {
  if (domSelection.rangeCount === 0) {
    return null;
  }
  
  const range = domSelection.getRangeAt(0);
  
  // Convert start position
  const anchor = domPositionToPath(
    range.startContainer,
    range.startOffset,
    model
  );
  
  // Convert end position
  const focus = domPositionToPath(
    range.endContainer,
    range.endOffset,
    model
  );
  
  if (!anchor || !focus) {
    return null;
  }
  
  // Determine if backward
  const isBackward = comparePositions(anchor, focus) > 0;
  
  return {
    anchor: isBackward ? focus : anchor,
    focus: isBackward ? anchor : focus,
    isBackward
  };
}

function comparePositions(pos1, pos2) {
  // Compare paths lexicographically
  for (let i = 0; i < Math.max(pos1.path.length, pos2.path.length); i++) {
    const idx1 = pos1.path[i] || 0;
    const idx2 = pos2.path[i] || 0;
    if (idx1 !== idx2) {
      return idx1 - idx2;
    }
  }
  // Paths are equal, compare offsets
  return pos1.offset - pos2.offset;
}

Model to DOM Conversion

Convert your model selection to DOM selection:

Path to DOM Position

function pathToDOMPosition(path, offset, model) {
  // Navigate model tree using path
  let node = model;
  for (let i = 0; i < path.length; i++) {
    if (!node.children || node.children.length <= path[i]) {
      return null;  // Invalid path
    }
    node = node.children[path[i]];
  }
  
  // Find corresponding DOM node
  const domNode = findDOMNodeForModel(node);
  if (!domNode) {
    return null;
  }
  
  // Handle offset
  if (node.type === 'text') {
    // For text nodes, offset is character position
    return {
      node: domNode,
      offset: offset
    };
  } else {
    // For element nodes, offset is child index
    if (domNode.childNodes.length > offset) {
      return {
        node: domNode,
        offset: offset
      };
    }
    // Offset beyond children, use last child
    return {
      node: domNode,
      offset: domNode.childNodes.length
    };
  }
}

function findDOMNodeForModel(modelNode) {
  // Find DOM element with matching data-model-id
  const modelId = getModelId(modelNode);
  return editor.querySelector(`[data-model-id="${modelId}"]`);
}

Selection to DOM Range

function modelSelectionToDOM(modelSelection) {
  // Convert anchor position
  const anchorDOM = pathToDOMPosition(
    modelSelection.anchor.path,
    modelSelection.anchor.offset,
    model
  );
  
  // Convert focus position
  const focusDOM = pathToDOMPosition(
    modelSelection.focus.path,
    modelSelection.focus.offset,
    model
  );
  
  if (!anchorDOM || !focusDOM) {
    return null;
  }
  
  // Create DOM range
  const range = document.createRange();
  
  if (modelSelection.isBackward) {
    range.setStart(focusDOM.node, focusDOM.offset);
    range.setEnd(anchorDOM.node, anchorDOM.offset);
  } else {
    range.setStart(anchorDOM.node, anchorDOM.offset);
    range.setEnd(focusDOM.node, focusDOM.offset);
  }
  
  // Apply to selection
  const selection = window.getSelection();
  selection.removeAllRanges();
  selection.addRange(range);
  
  return range;
}

Selection Normalization

Selections can become invalid after model changes. Normalize them:

Normalize Invalid Selections

function normalizeSelection(selection, model) {
  // Check if anchor is valid
  const anchorValid = isValidPosition(selection.anchor, model);
  if (!anchorValid) {
    selection.anchor = findNearestValidPosition(selection.anchor, model);
  }
  
  // Check if focus is valid
  const focusValid = isValidPosition(selection.focus, model);
  if (!focusValid) {
    selection.focus = findNearestValidPosition(selection.focus, model);
  }
  
  // If both positions are now the same, collapse
  if (isSamePosition(selection.anchor, selection.focus)) {
    selection.focus = { ...selection.anchor };
    selection.isBackward = false;
  }
  
  return selection;
}

function isValidPosition(position, model) {
  try {
    const node = getNodeAtPath(model, position.path);
    if (!node) return false;
    
    if (node.type === 'text') {
      return position.offset <= node.text.length;
    } else {
      return position.offset <= node.children.length;
    }
  } catch (e) {
    return false;
  }
}

function findNearestValidPosition(position, model) {
  // Try to find valid position near the invalid one
  // Walk up the path until finding a valid node
  // Then set offset to end of that node
  let path = [...position.path];
  
  while (path.length > 0) {
    const node = getNodeAtPath(model, path);
    if (node) {
      if (node.type === 'text') {
        return { path, offset: node.text.length };
      } else {
        return { path, offset: node.children.length };
      }
    }
    path.pop();
  }
  
  // Fallback to document start
  return { path: [0], offset: 0 };
}

Expand Selection

Sometimes you need to expand selection to include full nodes:

function expandSelectionToNodes(selection, model) {
  // Expand anchor to start of node
  const anchorNode = getNodeAtPath(model, selection.anchor.path);
  if (anchorNode && selection.anchor.offset > 0) {
    selection.anchor = {
      path: selection.anchor.path,
      offset: 0
    };
  }
  
  // Expand focus to end of node
  const focusNode = getNodeAtPath(model, selection.focus.path);
  if (focusNode) {
    const endOffset = focusNode.type === 'text' 
      ? focusNode.text.length 
      : focusNode.children.length;
    
    selection.focus = {
      path: selection.focus.path,
      offset: endOffset
    };
  }
  
  return selection;
}

Position Updates

When the model changes, positions need to be updated:

Tracking Positions

Track positions that need updating after operations:

class PositionTracker {
  constructor() {
    this.positions = new Map();
  }
  
  track(position, id) {
    this.positions.set(id, position);
  }
  
  updateAfterOperation(operation) {
    // Update all tracked positions based on operation
    for (const [id, position] of this.positions.entries()) {
      const updated = this.updatePosition(position, operation);
      this.positions.set(id, updated);
    }
  }
  
  updatePosition(position, operation) {
    // If operation is before position, position stays same
    // If operation is at position, position moves forward
    // If operation is after position, position unchanged
    
    if (operation.type === 'insertText') {
      if (isBefore(operation.position, position)) {
        // Text inserted before, position moves forward
        return {
          ...position,
          offset: position.offset + operation.text.length
        };
      }
    }
    
    if (operation.type === 'deleteRange') {
      if (overlaps(operation.range, position)) {
        // Position is in deleted range, move to range start
        return { ...operation.range.anchor };
      } else if (isAfter(operation.range, position)) {
        // Deletion after position, position unchanged
        return position;
      } else {
        // Deletion before position, adjust offset
        const deletedLength = getRangeLength(operation.range);
        return {
          ...position,
          offset: position.offset - deletedLength
        };
      }
    }
    
    return position;
  }
}

Updating Positions After Edits

function updateSelectionAfterEdit(selection, operation) {
  // Update anchor
  selection.anchor = updatePosition(selection.anchor, operation);
  
  // Update focus
  selection.focus = updatePosition(selection.focus, operation);
  
  // Normalize if needed
  return normalizeSelection(selection, model);
}

function updatePosition(position, operation) {
  switch (operation.type) {
    case 'insertText':
      return updatePositionForInsert(position, operation);
    case 'deleteRange':
      return updatePositionForDelete(position, operation);
    case 'splitNode':
      return updatePositionForSplit(position, operation);
    case 'mergeNodes':
      return updatePositionForMerge(position, operation);
    default:
      return position;
  }
}