Commit e70287cf authored by Robert Knight's avatar Robert Knight

Catch mouseup/mousedown events earlier in SelectionObserver

Listen for mouseup/mousedown events on the document body rather than document
itself, so that we can still capture them if an event listener on
`document.body` or `document.documentElement` stops propagation of the event.
The VitalSource integration does this for example to prevent VitalSource's own
selection UI appearing, but ordinary web pages could do something similar.

In VitalSource books, this change makes the adder behave the same as it does in
HTML and PDF documents where, when making a selection with the mouse, the adder
only appears when the mouse is released so it doesn't get in the way during the
selection.

While updating the tests for this change, they were converted to use a real
document, as the complexity of mocking the relevant parts of the DOM document
interface accurately is now too high.
parent 65a6270e
......@@ -225,7 +225,7 @@ describe('annotator/integrations/vitalsource', () => {
it('stops mouse events from propagating to parent frame', () => {
createIntegration();
const events = ['mousedown', 'mouseup', 'mouseout'];
const events = ['mouseup', 'mouseout'];
for (let eventName of events) {
const listener = sinon.stub();
......
......@@ -214,12 +214,10 @@ export class VitalSourceContentIntegration {
// from showing its native selection menu, which obscures the client's
// annotation toolbar.
//
// VitalSource only checks the selection on the `mouseup` and `mouseout` events,
// but we also need to stop `mousedown` to prevent the client's `SelectionObserver`
// from thinking that the mouse is held down when a selection change occurs.
// This has the unwanted side effect of allowing the adder to appear while
// dragging the mouse.
const stopEvents = ['mousedown', 'mouseup', 'mouseout'];
// To avoid interfering with the client's own selection handling, this
// event blocking must happen at the same level or higher in the DOM tree
// than where SelectionObserver listens.
const stopEvents = ['mouseup', 'mouseout'];
for (let event of stopEvents) {
this._listeners.add(document.documentElement, event, e => {
e.stopPropagation();
......
......@@ -42,7 +42,7 @@ export class SelectionObserver {
};
/** @param {Event} event */
this._eventHandler = event => {
const eventHandler = event => {
if (event.type === 'mousedown') {
isMouseDown = true;
}
......@@ -76,10 +76,13 @@ export class SelectionObserver {
this._document = document_;
this._listeners = new ListenerCollection();
this._events = ['mousedown', 'mouseup', 'selectionchange'];
for (let event of this._events) {
this._listeners.add(document_, event, this._eventHandler);
}
this._listeners.add(document_, 'selectionchange', eventHandler);
// Mouse events are handled on the body because propagation may be stopped
// before they reach the document in some environments (eg. VitalSource).
this._listeners.add(document_.body, 'mousedown', eventHandler);
this._listeners.add(document_.body, 'mouseup', eventHandler);
// Report the initial selection.
scheduleCallback(1);
......
import { SelectionObserver } from '../selection-observer';
class FakeDocument extends EventTarget {
constructor() {
super();
this.selection = null;
}
getSelection() {
return this.selection;
}
}
describe('SelectionObserver', () => {
let clock;
let fakeDocument;
let range;
let frame;
let observer;
let onSelectionChanged;
let testDocument;
function getSelectedRange() {
return testDocument.getSelection().getRangeAt(0);
}
before(() => {
frame = document.createElement('iframe');
document.body.append(frame);
testDocument = frame.contentDocument;
testDocument.body.innerHTML = 'Some test content';
testDocument.getSelection().selectAllChildren(testDocument.body);
assert.isNotNull(getSelectedRange());
});
after(() => {
frame.remove();
});
beforeEach(() => {
clock = sinon.useFakeTimers();
fakeDocument = new FakeDocument();
onSelectionChanged = sinon.stub();
range = { collapsed: false };
fakeDocument.selection = {
rangeCount: 1,
getRangeAt: function (index) {
return index === 0 ? range : null;
},
};
observer = new SelectionObserver(range => {
onSelectionChanged(range);
}, fakeDocument);
observer = new SelectionObserver(onSelectionChanged, testDocument);
// Move the clock forwards past the initial event.
clock.tick(10);
......@@ -46,14 +42,14 @@ describe('SelectionObserver', () => {
});
it('invokes callback when mouseup occurs', () => {
fakeDocument.dispatchEvent(new Event('mouseup'));
testDocument.body.dispatchEvent(new Event('mouseup'));
clock.tick(20);
assert.calledWith(onSelectionChanged, range);
assert.calledWith(onSelectionChanged, getSelectedRange());
});
it('invokes callback with initial selection', () => {
const onInitialSelection = sinon.stub();
const observer = new SelectionObserver(onInitialSelection, fakeDocument);
const observer = new SelectionObserver(onInitialSelection, testDocument);
clock.tick(10);
assert.called(onInitialSelection);
observer.disconnect();
......@@ -61,22 +57,22 @@ describe('SelectionObserver', () => {
describe('when the selection changes', () => {
it('invokes callback if mouse is not down', () => {
fakeDocument.dispatchEvent(new Event('selectionchange'));
testDocument.dispatchEvent(new Event('selectionchange'));
clock.tick(200);
assert.calledWith(onSelectionChanged, range);
assert.calledWith(onSelectionChanged, getSelectedRange());
});
it('does not invoke callback if mouse is down', () => {
fakeDocument.dispatchEvent(new Event('mousedown'));
fakeDocument.dispatchEvent(new Event('selectionchange'));
testDocument.body.dispatchEvent(new Event('mousedown'));
testDocument.dispatchEvent(new Event('selectionchange'));
clock.tick(200);
assert.notCalled(onSelectionChanged);
});
it('does not invoke callback until there is a pause since the last change', () => {
fakeDocument.dispatchEvent(new Event('selectionchange'));
testDocument.dispatchEvent(new Event('selectionchange'));
clock.tick(90);
fakeDocument.dispatchEvent(new Event('selectionchange'));
testDocument.dispatchEvent(new Event('selectionchange'));
clock.tick(90);
assert.notCalled(onSelectionChanged);
clock.tick(20);
......
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