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.
Node Selection
Visual Comparison
⚠️ 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,
startContainerandendContainerare element nodes, not text nodes. - Offset meaning: When the container is an element node,
startOffsetandendOffsetrepresent 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:
- Get the node:
const node = document.getElementById('myImage'); - Create a Range:
const range = document.createRange(); - Select the node with Range:
range.selectNode(node); - 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
startContaineris an element node,startOffsetrefers 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.
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:
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 ifRange.isCollapsedand path offsets.
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:
NodeSelectionis distinct fromTextSelection. - 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.
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:
startContainer: P#paragraph-1 > #text @ 5endContainer: P#paragraph-1 > #text @ 10 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