Commit 6eb68bb6 authored by Robert Knight's avatar Robert Knight

Normalize multi-range selections to a single range in Firefox

There are situations where Firefox creates a selection with multiple
ranges, even though the spec forbids this. In various places in our code
we called `selection.getRangeAt(0)` assuming there would only be one
range. In the case where FF had created multiple ranges, this led to
only part of the user's selection being respected.

 - Modifying the `selectedRange` utility to combine multi-range
   selections into a single Range, so that the results in Firefox are
   the same as in other browsers.

 - Replacing various direct calls to `selection.getRangeAt(0)` with
   calls to `selectedRange` so that they take get this normalization
   applied.

   In the tests for `Guest`, this led to refactoring to make some tests
   less tied to implementation details.

Fixes https://github.com/hypothesis/client/issues/5485
parent cf54bdc9
...@@ -35,8 +35,13 @@ import { ...@@ -35,8 +35,13 @@ import {
setHighlightsVisible, setHighlightsVisible,
} from './highlighter'; } from './highlighter';
import { createIntegration } from './integrations'; import { createIntegration } from './integrations';
import * as rangeUtil from './range-util'; import {
import { SelectionObserver, selectedRange } from './selection-observer'; itemsForRange,
isSelectionBackwards,
selectionFocusRect,
selectedRange,
} from './range-util';
import { SelectionObserver } from './selection-observer';
import { findClosestOffscreenAnchor } from './util/buckets'; import { findClosestOffscreenAnchor } from './util/buckets';
import { frameFillsAncestor } from './util/frame'; import { frameFillsAncestor } from './util/frame';
import { normalizeURI } from './util/url'; import { normalizeURI } from './util/url';
...@@ -46,10 +51,8 @@ type AnnotationHighlight = HTMLElement & { _annotation?: AnnotationData }; ...@@ -46,10 +51,8 @@ type AnnotationHighlight = HTMLElement & { _annotation?: AnnotationData };
/** Return all the annotations tags associated with the selected text. */ /** Return all the annotations tags associated with the selected text. */
function annotationsForSelection(): string[] { function annotationsForSelection(): string[] {
const selection = window.getSelection()!; const tags = itemsForRange(
const range = selection.getRangeAt(0); selectedRange() ?? new Range(),
const tags = rangeUtil.itemsForRange(
range,
node => (node as AnnotationHighlight)._annotation?.$tag node => (node as AnnotationHighlight)._annotation?.$tag
); );
return tags; return tags;
...@@ -408,10 +411,10 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable { ...@@ -408,10 +411,10 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable {
* Shift the position of the adder on window 'resize' events * Shift the position of the adder on window 'resize' events
*/ */
_repositionAdder() { _repositionAdder() {
if (this._isAdderVisible === false) { if (!this._isAdderVisible) {
return; return;
} }
const range = window.getSelection()?.getRangeAt(0); const range = selectedRange();
if (range) { if (range) {
this._onSelection(range); this._onSelection(range);
} }
...@@ -419,7 +422,7 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable { ...@@ -419,7 +422,7 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable {
async _connectHost(hostFrame: Window) { async _connectHost(hostFrame: Window) {
this._hostRPC.on('clearSelection', () => { this._hostRPC.on('clearSelection', () => {
if (selectedRange(document)) { if (selectedRange()) {
this._informHostOnNextSelectionClear = false; this._informHostOnNextSelectionClear = false;
removeTextSelection(); removeTextSelection();
} }
...@@ -753,8 +756,8 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable { ...@@ -753,8 +756,8 @@ export class Guest extends TinyEmitter implements Annotator, Destroyable {
} }
const selection = document.getSelection()!; const selection = document.getSelection()!;
const isBackwards = rangeUtil.isSelectionBackwards(selection); const isBackwards = isSelectionBackwards(selection);
const focusRect = rangeUtil.selectionFocusRect(selection); const focusRect = selectionFocusRect(selection);
if (!focusRect) { if (!focusRect) {
// The selected range does not contain any text // The selected range does not contain any text
this._onClearSelection(); this._onClearSelection();
......
import { nodeIsText } from './util/node'; import { nodeIsText } from './util/node';
/**
* Return a range that spans from the earlier of a or b's start point to
* the later of a or b's end point, in document order.
*/
function unionRanges(a: Range, b: Range): Range {
const result = new Range();
if (a.compareBoundaryPoints(Range.START_TO_START, b) <= 0) {
result.setStart(a.startContainer, a.startOffset);
} else {
result.setStart(b.startContainer, b.startOffset);
}
if (a.compareBoundaryPoints(Range.END_TO_END, b) >= 0) {
result.setEnd(a.endContainer, a.endOffset);
} else {
result.setEnd(b.endContainer, b.endOffset);
}
return result;
}
/**
* Return the currently selected {@link Range} or `null` if there is no
* selection.
*/
export function selectedRange(
selection: Selection | null = document.getSelection()
): Range | null {
if (!selection || selection.rangeCount === 0) {
return null;
}
let range = selection.getRangeAt(0);
// Work around a Firefox issue [1] where a selection can have multiple ranges,
// in contradiction to the Selection API [2] spec. The workaround is to
// union the ranges to produce the same single range as other browsers.
//
// [1] https://bugzilla.mozilla.org/show_bug.cgi?id=1773065
// [2] https://w3c.github.io/selection-api/#dom-selection-rangecount
for (let i = 1; i < selection.rangeCount; i++) {
range = unionRanges(range, selection.getRangeAt(i));
}
if (range.collapsed) {
return null;
}
return range;
}
/** /**
* Returns true if the start point of a selection occurs after the end point, * Returns true if the start point of a selection occurs after the end point,
* in document order. * in document order.
...@@ -9,7 +60,8 @@ export function isSelectionBackwards(selection: Selection) { ...@@ -9,7 +60,8 @@ export function isSelectionBackwards(selection: Selection) {
return selection.focusOffset < selection.anchorOffset; return selection.focusOffset < selection.anchorOffset;
} }
const range = selection.getRangeAt(0); const range = selectedRange(selection)!;
// Does not work correctly on iOS when selecting nodes backwards. // Does not work correctly on iOS when selecting nodes backwards.
// https://bugs.webkit.org/show_bug.cgi?id=220523 // https://bugs.webkit.org/show_bug.cgi?id=220523
return range.startContainer === selection.focusNode; return range.startContainer === selection.focusNode;
...@@ -100,10 +152,11 @@ export function getTextBoundingBoxes(range: Range): DOMRect[] { ...@@ -100,10 +152,11 @@ export function getTextBoundingBoxes(range: Range): DOMRect[] {
* Returns null if the selection is empty. * Returns null if the selection is empty.
*/ */
export function selectionFocusRect(selection: Selection): DOMRect | null { export function selectionFocusRect(selection: Selection): DOMRect | null {
if (selection.isCollapsed) { const range = selectedRange(selection);
if (!range) {
return null; return null;
} }
const textBoxes = getTextBoundingBoxes(selection.getRangeAt(0)); const textBoxes = getTextBoundingBoxes(range);
if (textBoxes.length === 0) { if (textBoxes.length === 0) {
return null; return null;
} }
......
import { ListenerCollection } from '../shared/listener-collection'; import { ListenerCollection } from '../shared/listener-collection';
import { selectedRange } from './range-util';
/**
* Return the current selection or `null` if there is no selection, or it is empty.
*/
export function selectedRange(document: Document): Range | null {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0) {
return null;
}
const range = selection.getRangeAt(0);
if (range.collapsed) {
return null;
}
return range;
}
/** /**
* An observer that watches for and buffers changes to the document's current selection. * An observer that watches for and buffers changes to the document's current selection.
...@@ -41,7 +27,7 @@ export class SelectionObserver { ...@@ -41,7 +27,7 @@ export class SelectionObserver {
const scheduleCallback = (delay = 10) => { const scheduleCallback = (delay = 10) => {
this._pendingCallback = setTimeout(() => { this._pendingCallback = setTimeout(() => {
callback(selectedRange(document_)); callback(selectedRange(document_.getSelection()));
}, delay); }, delay);
}; };
......
...@@ -48,7 +48,6 @@ describe('Guest', () => { ...@@ -48,7 +48,6 @@ describe('Guest', () => {
let fakePortFinder; let fakePortFinder;
let FakePortRPC; let FakePortRPC;
let fakePortRPCs; let fakePortRPCs;
let fakeSelectedRange;
const createGuest = (config = {}) => { const createGuest = (config = {}) => {
const element = document.createElement('div'); const element = document.createElement('div');
...@@ -85,6 +84,34 @@ describe('Guest', () => { ...@@ -85,6 +84,34 @@ describe('Guest', () => {
} }
}; };
const simulateSelectionWithText = () => {
rangeUtil.selectionFocusRect.returns({
left: 0,
top: 0,
width: 5,
height: 5,
});
const element = document.createElement('div');
element.textContent = 'foobar';
const range = new Range();
range.selectNodeContents(element);
rangeUtil.selectedRange.returns(range);
notifySelectionChanged(range);
};
const simulateSelectionWithoutText = () => {
rangeUtil.selectionFocusRect.returns(null);
const element = document.createElement('div');
const range = new Range();
range.selectNodeContents(element);
rangeUtil.selectedRange.returns(range);
notifySelectionChanged(range);
};
beforeEach(() => { beforeEach(() => {
guests = []; guests = [];
highlighter = { highlighter = {
...@@ -103,6 +130,7 @@ describe('Guest', () => { ...@@ -103,6 +130,7 @@ describe('Guest', () => {
itemsForRange: sinon.stub().returns([]), itemsForRange: sinon.stub().returns([]),
isSelectionBackwards: sinon.stub(), isSelectionBackwards: sinon.stub(),
selectionFocusRect: sinon.stub(), selectionFocusRect: sinon.stub(),
selectedRange: sinon.stub().returns(null),
}; };
FakeAdder.instance = null; FakeAdder.instance = null;
...@@ -169,8 +197,6 @@ describe('Guest', () => { ...@@ -169,8 +197,6 @@ describe('Guest', () => {
} }
} }
fakeSelectedRange = sinon.stub();
$imports.$mock({ $imports.$mock({
'../shared/messaging': { '../shared/messaging': {
PortFinder: sinon.stub().returns(fakePortFinder), PortFinder: sinon.stub().returns(fakePortFinder),
...@@ -193,7 +219,6 @@ describe('Guest', () => { ...@@ -193,7 +219,6 @@ describe('Guest', () => {
'./range-util': rangeUtil, './range-util': rangeUtil,
'./selection-observer': { './selection-observer': {
SelectionObserver: FakeSelectionObserver, SelectionObserver: FakeSelectionObserver,
selectedRange: fakeSelectedRange,
}, },
'./util/buckets': { './util/buckets': {
findClosestOffscreenAnchor: fakeFindClosestOffscreenAnchor, findClosestOffscreenAnchor: fakeFindClosestOffscreenAnchor,
...@@ -617,26 +642,19 @@ describe('Guest', () => { ...@@ -617,26 +642,19 @@ describe('Guest', () => {
} }
}); });
it('does not reposition the adder on window "resize" event if the adder is hidden', () => { it('does not reposition the adder if hidden when the window is resized', () => {
sandbox.stub(guest, '_repositionAdder').callThrough();
sandbox.stub(guest, '_onSelection'); // Calling _onSelect makes the adder to reposition
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
assert.notCalled(FakeAdder.instance.show);
assert.called(guest._repositionAdder);
assert.notCalled(guest._onSelection);
}); });
it('reposition the adder on window "resize" event if the adder is shown', () => { it('repositions the adder when the window is resized', () => {
sandbox.stub(guest, '_repositionAdder').callThrough(); simulateSelectionWithText();
sandbox.stub(guest, '_onSelection'); // Calling _onSelect makes the adder to reposition assert.calledOnce(FakeAdder.instance.show);
FakeAdder.instance.show.resetHistory();
guest._isAdderVisible = true;
sandbox.stub(window, 'getSelection').returns({ getRangeAt: () => true });
window.dispatchEvent(new Event('resize')); window.dispatchEvent(new Event('resize'));
assert.called(guest._onSelection); assert.called(FakeAdder.instance.show);
}); });
it('focuses annotations in the sidebar when hovering highlights in the document', () => { it('focuses annotations in the sidebar when hovering highlights in the document', () => {
...@@ -691,34 +709,6 @@ describe('Guest', () => { ...@@ -691,34 +709,6 @@ describe('Guest', () => {
}); });
describe('when the selection changes', () => { describe('when the selection changes', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
container.innerHTML = 'test text';
document.body.appendChild(container);
window.getSelection().selectAllChildren(container);
});
afterEach(() => {
container.remove();
});
const simulateSelectionWithText = () => {
rangeUtil.selectionFocusRect.returns({
left: 0,
top: 0,
width: 5,
height: 5,
});
notifySelectionChanged({});
};
const simulateSelectionWithoutText = () => {
rangeUtil.selectionFocusRect.returns(null);
notifySelectionChanged({});
};
it('shows the adder if the selection contains text', () => { it('shows the adder if the selection contains text', () => {
createGuest(); createGuest();
simulateSelectionWithText(); simulateSelectionWithText();
...@@ -728,10 +718,10 @@ describe('Guest', () => { ...@@ -728,10 +718,10 @@ describe('Guest', () => {
it('sets the annotations associated with the selection', () => { it('sets the annotations associated with the selection', () => {
createGuest(); createGuest();
const ann = { $tag: 't1' }; const ann = { $tag: 't1' };
container._annotation = ann; rangeUtil.itemsForRange.callsFake((range, callback) => {
rangeUtil.itemsForRange.callsFake((range, callback) => [ range.startContainer._annotation = ann;
callback(range.startContainer), return [callback(range.startContainer)];
]); });
simulateSelectionWithText(); simulateSelectionWithText();
assert.deepEqual(FakeAdder.instance.annotationsForSelection, ['t1']); assert.deepEqual(FakeAdder.instance.annotationsForSelection, ['t1']);
...@@ -787,7 +777,6 @@ describe('Guest', () => { ...@@ -787,7 +777,6 @@ describe('Guest', () => {
// Guest has text selected // Guest has text selected
simulateSelectionWithText(); simulateSelectionWithText();
fakeSelectedRange.returns({});
hostRPC().call.resetHistory(); hostRPC().call.resetHistory();
emitHostEvent('clearSelection'); emitHostEvent('clearSelection');
...@@ -811,7 +800,7 @@ describe('Guest', () => { ...@@ -811,7 +800,7 @@ describe('Guest', () => {
guest.selectedRanges = [1]; guest.selectedRanges = [1];
// Guest has no text selected // Guest has no text selected
fakeSelectedRange.returns(null); rangeUtil.selectedRange.returns(null);
hostRPC().call.resetHistory(); hostRPC().call.resetHistory();
emitHostEvent('clearSelection'); emitHostEvent('clearSelection');
......
import * as rangeUtil from '../range-util'; import * as rangeUtil from '../range-util';
import { selectedRange } from '../range-util';
function createRange(node, start, end) { function createRange(node, start, end) {
const range = node.ownerDocument.createRange(); const range = node.ownerDocument.createRange();
...@@ -149,6 +150,66 @@ describe('annotator.range-util', () => { ...@@ -149,6 +150,66 @@ describe('annotator.range-util', () => {
}); });
}); });
describe('selectedRange', () => {
it('returns `null` if selection has no ranges', () => {
window.getSelection().empty();
assert.isNull(selectedRange());
});
it('returns `null` if selected range is collapsed', () => {
const range = new Range();
range.setStart(document.body, 0);
range.setEnd(document.body, 0);
window.getSelection().addRange(range);
assert.isNull(selectedRange());
});
it('returns first range in selection if not collapsed', () => {
const range = new Range();
range.selectNodeContents(document.body);
window.getSelection().addRange(range);
assert.instanceOf(selectedRange(), Range);
});
// Test handling of a Firefox-specific issue where selection may contain
// multiple ranges. In spec-compliant browsers (eg. Chrome), the selection
// only contains zero or one range.
it('returns union of all ranges in selection if there are multiple', () => {
const parent = document.createElement('div');
const el1 = document.createElement('div');
el1.textContent = 'foo';
const el2 = document.createElement('div');
el2.textContent = 'bar';
const el3 = document.createElement('div');
el3.textContent = 'baz';
parent.append(el1, el2, el3);
const ranges = [new Range(), new Range(), new Range()];
ranges[0].selectNodeContents(el1);
ranges[1].selectNodeContents(el2);
ranges[2].selectNodeContents(el3);
const fakeSelection = {
rangeCount: 3,
getRangeAt: index => ranges[index],
};
let range = selectedRange(fakeSelection);
assert.equal(range.toString(), 'foobarbaz');
// Test with the ordering of ranges reversed. The merged range should
// be the same.
ranges.reverse();
range = selectedRange(fakeSelection);
assert.equal(range.toString(), 'foobarbaz');
});
});
describe('itemsForRange', () => { describe('itemsForRange', () => {
it('returns unique items for range', () => { it('returns unique items for range', () => {
const range = document.createRange(); const range = document.createRange();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment