Selection API

Working with text selection, ranges, and the Selection API in contenteditable elements.

Getting the selection

The window.getSelection() method returns a Selection object representing the current text selection.

const selection = window.getSelection();

if (selection && selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  console.log('Selected text:', range.toString());
  console.log('Start:', range.startOffset);
  console.log('End:', range.endOffset);
}

Range API

A Range represents a contiguous portion of the document. You can create, modify, and manipulate ranges programmatically.

// Create a new range
const range = document.createRange();

// Set the range to cover specific content
range.setStart(element.firstChild, 5);
range.setEnd(element.firstChild, 10);

// Apply the range to the selection
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);

Node Selection

Unlike text selection, which selects characters within text nodes, node selection selects entire element nodes (like images, divs, or other HTML elements). This is useful when you want to select non-text content or entire blocks of content.

Text Selection vs Node Selection

Text Selection

This is a paragraph with text.

Container: #text node
Offset: Character position (0, 1, 2...)
Example: startOffset: 10, endOffset: 19

Node Selection

Selected image
Container: Element node (IMG, DIV, etc.)
Offset: Child index (0, 1, 2...)
Example: startOffset: 0, endOffset: 1

Visual Comparison

Text Selection: Highlights characters within a text node. The selection appears as a colored background over the selected text.
Node Selection: Selects the entire element. Visual representation varies by browser - some show borders, some show background, some may not show any visual indication.

⚠️ Important

You cannot create a selection using just a node object. A node by itself is not selectable - you must use the Range API to define selection boundaries, then apply it to the Selection object. The Range acts as a bridge between the node and the selection.

Selecting Entire Nodes

Use range.selectNode(node) to select an entire element node, including the element itself. This requires creating a Range first:

// Select an entire node (e.g., an image or div)
const range = document.createRange();
range.selectNode(element); // Selects the entire element node

// Or select the contents of a node
range.selectNodeContents(element); // Selects all children, but not the element itself

// Apply to selection
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);

Key Differences from Text Selection

  • Container type: In node selection, startContainer and endContainer are element nodes, not text nodes.
  • Offset meaning: When the container is an element node, startOffset and endOffset represent child node indices (0, 1, 2...), not character positions.
  • Visual appearance: Node selections may appear differently across browsers. Some browsers show a border or outline around selected elements, while others may not visually distinguish node selections from text selections.
  • Common use cases: Selecting images, selecting entire paragraphs or blocks, programmatically selecting content for clipboard operations.

Why Range is Required

A node object alone cannot be directly selected. The Selection API requires a Range object that defines the boundaries of what to select. Here's the process:

  1. Get the node: const node = document.getElementById('myImage');
  2. Create a Range: const range = document.createRange();
  3. Select the node with Range: range.selectNode(node);
  4. Apply Range to Selection: selection.addRange(range);

Without the Range step, you cannot select a node. The Range object is the mechanism that tells the browser "select this node" or "select from this position to that position."

Example: Detecting Node Selection

const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
  const range = selection.getRangeAt(0);
  
  // Check if selection is a node selection
  const isNodeSelection = 
    range.startContainer.nodeType === Node.ELEMENT_NODE ||
    range.endContainer.nodeType === Node.ELEMENT_NODE;
  
  if (isNodeSelection) {
    console.log('Node selection detected');
    console.log('Selected node:', range.startContainer);
    console.log('Child index:', range.startOffset);
  }
}

Important Notes

  • Node selection is less common in user interactions compared to text selection. Most users select text, not entire elements.
  • When startContainer is an element node, startOffset refers to the index of the child node, not a character position.
  • range.toString() on a node selection returns the text content of all descendant text nodes, which may not represent the actual selected element.
  • Some browsers handle node selection differently in contenteditable regions, especially for inline elements like images or links.

Native Selection Visualization

Browsers render text selections using their native styling. The visual appearance of selections varies by browser and operating system. Try selecting text in the contenteditable area below to see how your browser displays selections:

This is a contenteditable paragraph with various inline elements.

Select some text above to see how your browser renders the native selection. The selection appearance depends on your browser and operating system.

You can also select across multiple paragraphs and elements to observe how the selection behaves at element boundaries.

Selection Info:
No selection

Browser Selection Styles

Different browsers and operating systems use different default selection colors:

  • Chrome/Edge (Windows): Blue background (#4285f4)
  • Firefox (Windows): Blue background (#0060df)
  • Safari (macOS): Light blue background (#007aff)
  • Chrome (macOS): Light blue background (#007aff)
  • Mobile browsers: Varies by platform, often uses system accent color

Note: The selection color can be customized using CSS ::selection pseudo-element, but the default browser behavior is what users see by default.

Selection Synchronization with Internal Models

One of the most challenging aspects of building contenteditable editors is maintaining synchronization between the browser's DOM-based Selection API and your editor's internal data model (like Slate, ProseMirror, or custom models).

The Core Problem

Browser Selection (DOM-based)

  • Uses DOM nodes and offsets
  • Changes with every user interaction
  • No concept of "blocks" or "nodes"
  • Tied to actual DOM structure

Editor Model (Abstract)

  • Uses abstract paths/positions
  • Represents document structure
  • Has "block" and "inline" concepts
  • Independent of DOM

These two representations must be kept in sync at all times, which requires constant bidirectional conversion.

Continuous Synchronization Required

Because the browser's Selection API is DOM-based and your editor model is abstract, you need to continuously synchronize between them:

  • DOM → Model: When the user selects text, you must convert the DOM Range to your model's path/offset representation.
  • Model → DOM: When your model changes (e.g., undo/redo, programmatic edits), you must update the DOM and restore the selection.
  • After every DOM mutation: The selection may become invalid, requiring normalization and restoration.

Example: Selection Normalization Flow

// 1. User selects text → DOM Range
const domRange = selection.getRangeAt(0);

// 2. Convert DOM Range to model path
const modelPath = domRangeToModelPath(domRange);
// Result: [{ block: 0, offset: 5 }, { block: 0, offset: 10 }]

// 3. Normalize: ensure path is valid in current model
const normalizedPath = normalizePath(modelPath, editorModel);

// 4. Store in model
editorModel.selection = normalizedPath;

// 5. When model changes, convert back to DOM
const newDomRange = modelPathToDomRange(normalizedPath, editorModel);
selection.removeAllRanges();
selection.addRange(newDomRange);

Handling Node Selection in Different Editors

Even though the browser's Range API represents node selections using element nodes and child indices, different editor frameworks handle this differently in their internal models. Here's how major editors approach node selection:

Slate.js

Node Selection Handling

Slate uses path-based selection. Node selection is represented by selecting the entire node at a specific path:

    // Detect node selection from DOM Range
const range = selection.getRangeAt(0);
if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
  // Find the Slate node path
  const slatePath = ReactEditor.findPath(editor, range.startContainer);
  
  // Set selection to the entire node
  Transforms.select(editor, {
    anchor: { path: slatePath, offset: 0 },
    focus: { path: slatePath, offset: 1 }, // Select entire node
  });
  
  // Or use Editor.nodes() to work with selected nodes
  for (const [node, path] of Editor.nodes(editor, {
    at: slatePath,
    mode: 'lowest',
  })) {
    // Handle selected node
  }
}
  
  • Path-based: Node selection uses the same path format as text selection, but with offset 0-1 to indicate the entire node.
  • Unified API: Same Transforms.select() API for both text and node selections.
  • Detection: Use Editor.nodes() or check if Range.isCollapsed and path offsets.
ProseMirror

Node Selection Handling

ProseMirror has a dedicated NodeSelection class that explicitly represents node selections:

    // Detect node selection from DOM Range
const range = selection.getRangeAt(0);
if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
  // Find the ProseMirror node and position
  const pos = editorView.posAtDOM(range.startContainer, 0);
  const $pos = editorView.state.doc.resolve(pos);
  const node = $pos.nodeAfter || $pos.nodeBefore;
  
  if (node && node.isAtom) { // Images, embeds, etc.
    // Create NodeSelection
    const nodeSelection = NodeSelection.create(
      editorView.state.doc,
      $pos.pos
    );
    
    // Apply selection
    const tr = editorView.state.tr.setSelection(nodeSelection);
    editorView.dispatch(tr);
  }
}
  
  • Separate class: NodeSelection is distinct from TextSelection.
  • Atom nodes: Only "atom" nodes (images, embeds, etc.) can be node-selected, not regular block nodes.
  • Position-based: Uses document positions, not paths like Slate.
Draft.js

Node Selection Handling

Draft.js has limited support for node selection. It primarily focuses on text selection within blocks:

    // Draft.js doesn't have native node selection
// You need to handle it manually by:
// 1. Detecting node selection from DOM Range
// 2. Converting to block key and offset
// 3. Using EditorState.forceSelection() with a collapsed selection

const range = selection.getRangeAt(0);
if (range.startContainer.nodeType === Node.ELEMENT_NODE) {
  // Find the block key
  const blockKey = getBlockKeyForNode(range.startContainer);
  
  // Create a collapsed selection at the block
  const selectionState = SelectionState.createEmpty(blockKey);
  const newEditorState = EditorState.forceSelection(
    editorState,
    selectionState
  );
}
  
  • Limited support: Draft.js doesn't have built-in node selection support.
  • Workaround: Convert node selection to a collapsed text selection at the block level.
  • Block-based: Works with block keys, not individual nodes.

Key Takeaway

Even though the browser's Range API represents node selections uniformly (using element nodes and child indices), each editor framework converts this to its own internal representation. The conversion strategy depends on:

  • The editor's data model structure (path-based, position-based, block-based)
  • Whether the editor distinguishes between text and node selections
  • Which nodes are selectable (all nodes vs. only atom/void nodes)
  • How the editor handles selection restoration after DOM mutations

No Block Selection Concept in Browser API

The browser's Selection API has no concept of "block selection" or "node selection" in the way that editor models do. The browser only knows about:

  • Text nodes and character offsets: For text selections
  • Element nodes and child indices: For node selections

This means you must always normalize selections relative to the parent element, converting between:

DOM representation:
startContainer: P#paragraph-1 > #text @ 5
endContainer: P#paragraph-1 > #text @ 10
Model representation (normalized):
path: [0, 5] to [0, 10]
(block index 0, character offset 5-10)

⚠️ Normalization Challenges

  • Parent-based normalization: Since there's no block concept, you must always calculate positions relative to the parent element, which can change when the DOM structure is modified.
  • Cross-boundary selections: When a selection spans multiple blocks, you need to handle the conversion for each block segment separately.
  • Invalid ranges after mutations: After any DOM mutation (insert, delete, split, merge), the stored Range may point to removed nodes, requiring immediate normalization.
  • Performance: Constant synchronization can be expensive, especially with large documents or frequent updates.

Best Practices

  • Store normalized paths in your model: Always convert DOM selections to model paths immediately and store them.
  • Normalize after every mutation: After any DOM change, check if the selection is still valid and normalize if needed.
  • Use path-based selection: Instead of storing DOM references, store abstract paths (e.g., [blockIndex, offset]) that can be resolved to DOM positions when needed.
  • Debounce normalization: For performance, batch multiple selection changes and normalize once per frame.
  • Handle edge cases: Empty selections, collapsed selections, selections at block boundaries, and cross-block selections all need special handling.

Common issues

Selection lost on focus change

When a contenteditable loses focus, window.getSelection() may return null or an empty selection, making it difficult to preserve selection state.

See cases: scenario-selection-api-behavior

Incorrect range boundaries

When selecting text that spans multiple elements, the Range boundaries may not accurately reflect the visual selection, especially across element boundaries.

See cases: scenario-selection-range-accuracy

Selection collapse on blur

Clicking outside a contenteditable may collapse the selection unexpectedly, even when the click target is within the same document.

See cases: scenario-selection-collapse-on-blur

Related resources