Phenomenon
In iOS Safari, drag-to-selection creates selection ranges that don’t match the visual selection. The visual selection highlights text correctly, but the actual selection range returned by window.getSelection() may be empty, collapsed, or point to a different position than what’s visually selected.
Reproduction example
- Load a page with a
contenteditableparagraph containing multiple lines of text in iOS Safari. - Start dragging to select text from the middle of a word to the middle of another word.
- Check
window.getSelection().rangeCountandwindow.getSelection().getRangeAt(0). - Compare the range’s start/end positions with the visual selection.
- Try formatting the selection (e.g., bold) - it may not work or may format wrong text.
Observed behavior
When dragging to select text on iOS Safari:
- Visual selection works: Text appears highlighted correctly on screen
- Range may be empty:
selection.rangeCountmay be 0 despite visual selection - Range may be collapsed:
selection.getRangeAt(0).collapsedmay be true despite visual selection - Range may be wrong: Range boundaries may not match visual selection boundaries
- Formatting fails: Operations based on selection range may fail or affect wrong text
- Extraction fails: Getting selected text may return empty string
Specific patterns observed:
- Mid-word selections: Selecting from middle of one word to middle of another often creates empty ranges
- Multi-line selections: Selections spanning multiple lines frequently have incorrect range boundaries
- Fast dragging: Rapid drag gestures more likely to create range/visual mismatch
- Zoom level issues: At different zoom levels, the mismatch becomes more pronounced
Expected behavior
- Selection ranges should always match visual selection
selection.rangeCountshould be 1 when text is visually selectedselection.getRangeAt(0).collapsedshould be false for non-empty selections- Range boundaries should precisely match visual selection boundaries
- All range-based operations should work correctly with visual selections
Impact
- Broken formatting: Bold, italic, underline operations may fail or affect wrong text
- Broken extraction: Getting selected text may return empty or wrong content
- Broken replacement: Replacing selected content may fail or replace wrong content
- Broken deletion: Deleting selected content may fail or delete wrong content
- Inconsistent UX: Users see selection but operations don’t work as expected
Browser Comparison
- iOS Safari: High frequency of range/visual selection mismatch
- Android Chrome: Generally correct behavior, rare mismatches
- Desktop Safari: Correct behavior, matches visual selection
- Desktop Chrome/Edge: Correct behavior, matches visual selection
- Desktop Firefox: Correct behavior, matches visual selection
Workarounds
1. Touch position validation
function getAccurateSelection() {
const selection = window.getSelection();
if (!selection.rangeCount) return null;
const range = selection.getRangeAt(0);
// Check if range is collapsed when it shouldn't be
if (range.collapsed) {
// Try to reconstruct range from touch coordinates
const touch = lastTouch; // Store from touchend event
if (touch) {
const rangeFromTouch = document.caretRangeFromPoint(touch.clientX, touch.clientY);
if (rangeFromTouch) {
// Expand to visual selection using heuristics
return expandToVisualSelection(rangeFromTouch, touch);
}
}
}
return range;
}
2. Visual selection detection
function detectVisualSelection() {
// Use getComputedStyle to check for user-select properties
// and compare with actual selection ranges
const elements = document.querySelectorAll('*');
const selectedElements = [];
elements.forEach(el => {
const style = window.getComputedStyle(el);
const selection = window.getSelection();
if (selection.containsNode(el, true)) {
selectedElements.push(el);
}
});
return selectedElements;
}
3. Debounced range checking
let selectionTimer;
function handleTouchEnd(e) {
clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const selection = window.getSelection();
if (selection.rangeCount === 0 && hasVisualSelection()) {
// Recreate selection from visual state
recreateSelectionFromVisual();
}
}, 100);
}
4. Fallback for iOS Safari
const isIOSSafari = /iPhone|iPad|iPod/.test(navigator.userAgent) &&
/Safari/.test(navigator.userAgent) &&
!/Chrome/.test(navigator.userAgent);
function safeSelectionOperation(operation) {
const selection = window.getSelection();
if (isIOSSafari && selection.rangeCount === 0 && hasVisualSelection()) {
// iOS Safari special handling
return operation(getSelectionFromVisual());
} else {
return operation(selection);
}
}
Testing recommendations
- Multi-word selections: Test selections spanning multiple words
- Cross-line selections: Test selections spanning multiple lines
- Various speeds: Test with different drag speeds
- Different zoom levels: Test at various zoom levels
- Different text sizes: Test with different font sizes
- Mixed content: Test with inline elements, formatting, links
Notes
- This is a long-standing iOS Safari issue
- Apple has not provided official API to resolve this
- Workarounds are heuristic and may not work in all cases
- The issue is more pronounced in complex layouts with CSS transforms
- Mobile browsers generally have less precise selection handling than desktop