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 headingPath 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 validSelection 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;
}
}