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