Commit 80b34eef authored by Robert Knight's avatar Robert Knight

Show adder when the selection changes

When using touch input to manipulate the selection there are no
mousedown/mouseup events that we can listen to and the
touchstart/touchend events are not triggered when manipulating the
OS-provided selection handles in the browser.

Instead listen for selectionchange events and show the adder in
response.

To avoid showing the adder every time the selection handle moves which
would be distracting, we instead buffer selectionchange events and only
show them after a pause.

When the user is using mouse input to make a selection, we ignore
selectionchange events so that the adder does not appear until the user
finishes making their selection.
parent b9cd5e3b
......@@ -27,41 +27,103 @@ function listen(src, eventNames) {
}
/**
* Returns an observable of `DOMRange` for the selection in the given
* @p document.
* Buffers events from a source Observable, waiting for a pause of `delay`
* ms with no events before emitting the last value from `src`.
*
* @return Observable<DOMRange|null>
* @param {number} delay
* @param {Observable<T>} src
* @return {Observable<T>}
*/
function selections(document, setTimeout, clearTimeout) {
setTimeout = setTimeout || window.setTimeout;
clearTimeout = clearTimeout || window.clearTimeout;
var events = listen(document, ['ready', 'mouseup']);
function buffer(delay, src) {
return new Observable(function (obs) {
function emitRange() {
var selection = document.getSelection();
if (!selection.rangeCount || selection.getRangeAt(0).collapsed) {
obs.next(null);
} else {
obs.next(selection.getRangeAt(0));
}
var lastValue;
var timeout;
function onNext() {
obs.next(lastValue);
}
var timeout;
var sub = events.subscribe({
next: function () {
// Add a delay before checking the state of the selection because
// the selection is not updated immediately after a 'mouseup' event
// but only on the next tick of the event loop.
timeout = setTimeout(emitRange, 0);
},
var sub = src.subscribe({
next: function (value) {
lastValue = value;
clearTimeout(timeout);
timeout = setTimeout(onNext, delay);
}
});
return function () {
clearTimeout(timeout);
sub.unsubscribe();
clearTimeout(timeout);
};
});
}
/**
* Merges multiple streams of values into a single stream.
*
* @param {Array<Observable>} sources
* @return Observable
*/
function merge(sources) {
return new Observable(function (obs) {
var subs = sources.map(function (src) {
return src.subscribe({
next: function (value) {
obs.next(value);
},
});
});
return function () {
subs.forEach(function (sub) {
sub.unsubscribe();
});
};
});
}
/**
* Returns an observable of `DOMRange` for the selection in the given
* @p document.
*
* The returned stream will emit `null` when the selection is empty.
*
* @return Observable<DOMRange|null>
*/
function selections(document) {
// Get a stream of selection changes that occur whilst the user is not
// making a selection with the mouse.
var isMouseDown;
var selectionEvents = listen(document, ['mousedown', 'mouseup', 'selectionchange'])
.filter(function (event) {
if (event.type === 'mousedown' || event.type === 'mouseup') {
isMouseDown = event.type === 'mousedown';
return false;
} else {
return !isMouseDown;
}
});
var events = merge([
// Add a delay before checking the state of the selection because
// the selection is not updated immediately after a 'mouseup' event
// but only on the next tick of the event loop.
buffer(10, listen(document, ['ready', 'mouseup'])),
// Buffer selection changes to avoid continually emitting events whilst the
// user drags the selection handles on mobile devices
buffer(100, selectionEvents),
]);
return events.map(function () {
var selection = document.getSelection();
if (!selection.rangeCount || selection.getRangeAt(0).collapsed) {
return null;
} else {
return selection.getRangeAt(0);
}
});
}
module.exports = selections;
......@@ -29,34 +29,65 @@ function FakeDocument() {
}
describe('selections', function () {
var clock;
var fakeDocument;
var range;
var rangeSub;
var onSelectionChanged;
beforeEach(function () {
clock = sinon.useFakeTimers();
fakeDocument = new FakeDocument();
onSelectionChanged = sinon.stub();
var fakeSetTimeout = function (fn) { fn(); };
var fakeClearTimeout = function () {};
var ranges = selections(fakeDocument, fakeSetTimeout, fakeClearTimeout);
var ranges = selections(fakeDocument);
rangeSub = ranges.subscribe({next: onSelectionChanged});
});
afterEach(function () {
rangeSub.unsubscribe();
});
unroll('emits the selected range when #event occurs', function (testCase) {
var range = {};
range = {};
fakeDocument.selection = {
rangeCount: 1,
getRangeAt: function (index) {
return index === 0 ? range : null;
},
};
});
afterEach(function () {
rangeSub.unsubscribe();
clock.restore();
});
unroll('emits the selected range when #event occurs', function (testCase) {
fakeDocument.dispatchEvent({type: testCase.event});
clock.tick(testCase.delay);
assert.calledWith(onSelectionChanged, range);
}, [{event: 'mouseup'}, {event: 'ready'}]);
}, [
{event: 'mouseup', delay: 20},
{event: 'ready', delay: 20},
]);
describe('when the selection changes', function () {
it('emits a selection if the mouse is not down', function () {
fakeDocument.dispatchEvent({type: 'selectionchange'});
clock.tick(200);
assert.calledWith(onSelectionChanged, range);
});
it('does not emit a selection if the mouse is down', function () {
fakeDocument.dispatchEvent({type: 'mousedown'});
fakeDocument.dispatchEvent({type: 'selectionchange'});
clock.tick(200);
assert.notCalled(onSelectionChanged);
});
it('does not emit a selection until there is a pause since the last change', function () {
fakeDocument.dispatchEvent({type: 'selectionchange'});
clock.tick(90);
fakeDocument.dispatchEvent({type: 'selectionchange'});
clock.tick(90);
assert.notCalled(onSelectionChanged);
clock.tick(20);
assert.called(onSelectionChanged);
});
});
});
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