Commit b9cd5e3b authored by Robert Knight's avatar Robert Knight

Refactor selection change detection to use observables

In preparation for some more complex handling of document events in
order to show the adder when the selection changes on mobile, refactor
the processing of events to use observables.

The zen-observable package is used as a lightweight implementation of
the proposed ES2017
[Observable](https://github.com/zenparsing/es-observable) API.
parent 69451ba1
...@@ -9,6 +9,7 @@ $ = Annotator.$ ...@@ -9,6 +9,7 @@ $ = Annotator.$
adder = require('./adder') adder = require('./adder')
highlighter = require('./highlighter') highlighter = require('./highlighter')
rangeUtil = require('./range-util') rangeUtil = require('./range-util')
selections = require('./selections')
animationPromise = (fn) -> animationPromise = (fn) ->
return new Promise (resolve, reject) -> return new Promise (resolve, reject) ->
...@@ -47,7 +48,15 @@ module.exports = class Guest extends Annotator ...@@ -47,7 +48,15 @@ module.exports = class Guest extends Annotator
constructor: (element, options) -> constructor: (element, options) ->
super super
self = this
this.adderCtrl = new adder.Adder(@adder[0]) this.adderCtrl = new adder.Adder(@adder[0])
this.selections = selections(document).subscribe
next: (range) ->
if range
self._onSelection(range)
else
self._onClearSelection()
this.anchors = [] this.anchors = []
cfOptions = cfOptions =
...@@ -133,6 +142,7 @@ module.exports = class Guest extends Annotator ...@@ -133,6 +142,7 @@ module.exports = class Guest extends Annotator
destroy: -> destroy: ->
$('#annotator-dynamic-style').remove() $('#annotator-dynamic-style').remove()
this.selections.unsubscribe()
@adder.remove() @adder.remove()
@element.find('.annotator-hl').each -> @element.find('.annotator-hl').each ->
...@@ -332,33 +342,21 @@ module.exports = class Guest extends Annotator ...@@ -332,33 +342,21 @@ module.exports = class Guest extends Annotator
tags = (a.$$tag for a in annotations) tags = (a.$$tag for a in annotations)
@crossframe?.call('focusAnnotations', tags) @crossframe?.call('focusAnnotations', tags)
onSuccessfulSelection: (event, immediate) -> _onSelection: (range) ->
unless event? @selectedRanges = [range]
throw "Called onSuccessfulSelection without an event!"
unless event.ranges?
throw "Called onSuccessulSelection with an event with missing ranges!"
@selectedRanges = event.ranges
Annotator.$('.annotator-toolbar .h-icon-note') Annotator.$('.annotator-toolbar .h-icon-note')
.attr('title', 'New Annotation') .attr('title', 'New Annotation')
.removeClass('h-icon-note') .removeClass('h-icon-note')
.addClass('h-icon-annotate'); .addClass('h-icon-annotate');
# Do we want immediate annotation? selection = Annotator.Util.getGlobal().getSelection()
if immediate isBackwards = rangeUtil.isSelectionBackwards(selection)
# Create an annotation focusRect = rangeUtil.selectionFocusRect(selection)
@onAdderClick event {left, top, arrowDirection} = this.adderCtrl.target(focusRect, isBackwards)
else this.adderCtrl.showAt(left, top, arrowDirection)
# Show the adder button
selection = Annotator.Util.getGlobal().getSelection() _onClearSelection: () ->
isBackwards = rangeUtil.isSelectionBackwards(selection)
focusRect = rangeUtil.selectionFocusRect(selection)
{left, top, arrowDirection} = this.adderCtrl.target(focusRect, isBackwards)
this.adderCtrl.showAt(left, top, arrowDirection)
true
onFailedSelection: (event) ->
this.adderCtrl.hide() this.adderCtrl.hide()
@selectedRanges = [] @selectedRanges = []
......
...@@ -24,9 +24,6 @@ Annotator.Plugin.Toolbar = require('./plugin/toolbar'); ...@@ -24,9 +24,6 @@ Annotator.Plugin.Toolbar = require('./plugin/toolbar');
Annotator.Plugin.PDF = require('./plugin/pdf'); Annotator.Plugin.PDF = require('./plugin/pdf');
require('../vendor/annotator.document'); // Does not export the plugin :( require('../vendor/annotator.document'); // Does not export the plugin :(
// Selection plugins
Annotator.Plugin.TextSelection = require('./plugin/textselection');
// Cross-frame communication // Cross-frame communication
Annotator.Plugin.CrossFrame = require('./plugin/cross-frame'); Annotator.Plugin.CrossFrame = require('./plugin/cross-frame');
Annotator.Plugin.CrossFrame.AnnotationSync = require('../annotation-sync'); Annotator.Plugin.CrossFrame.AnnotationSync = require('../annotation-sync');
......
Annotator = require('annotator')
$ = Annotator.$
# This plugin implements the UI code for creating text annotations
module.exports = class TextSelection extends Annotator.Plugin
pluginInit: ->
# Register the event handlers required for creating a selection
$(document).bind({
"touchend": @checkForEndSelection
"mouseup": @checkForEndSelection
"ready": @checkForEndSelection
})
null
destroy: ->
$(document).unbind({
"touchend": @checkForEndSelection
"mouseup": @checkForEndSelection
"ready": @checkForEndSelection
})
super
# This is called when the mouse is released.
# Checks to see if a selection been made on mouseup and if so,
# calls Annotator's onSuccessfulSelection method.
#
# event - The event triggered this. Usually it's a mouseup Event,
# but that's not necessary.
#
# Returns nothing.
checkForEndSelection: (event = {}) =>
callback = ->
# Get the currently selected ranges.
selection = Annotator.Util.getGlobal().getSelection()
ranges = for i in [0...selection.rangeCount]
r = selection.getRangeAt(0)
if r.collapsed then continue else r
if ranges.length
event.ranges = ranges
@annotator.onSuccessfulSelection event
else
@annotator.onFailedSelection event
# Run callback after the current event loop tick
# so that the mouseup event can update the document selection.
setTimeout callback, 0
'use strict';
var Observable = require('zen-observable');
/**
* Returns an observable of events emitted by `src`.
*
* @param {EventTarget} src - The event source.
* @param {Array<string>} eventNames - List of events to subscribe to
*/
function listen(src, eventNames) {
return new Observable(function (observer) {
var onNext = function (event) {
observer.next(event);
};
eventNames.forEach(function (event) {
src.addEventListener(event, onNext);
});
return function () {
eventNames.forEach(function (event) {
src.removeEventListener(event, onNext);
});
};
});
}
/**
* Returns an observable of `DOMRange` for the selection in the given
* @p document.
*
* @return Observable<DOMRange|null>
*/
function selections(document, setTimeout, clearTimeout) {
setTimeout = setTimeout || window.setTimeout;
clearTimeout = clearTimeout || window.clearTimeout;
var events = listen(document, ['ready', 'mouseup']);
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 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);
},
});
return function () {
clearTimeout(timeout);
sub.unsubscribe();
};
});
}
module.exports = selections;
'use strict';
var unroll = require('../../test/util').unroll;
var selections = require('../selections');
function FakeDocument() {
var listeners = {};
return {
getSelection: function () {
return this.selection;
},
addEventListener: function (name, listener) {
listeners[name] = (listeners[name] || []).concat(listener);
},
removeEventListener: function (name, listener) {
listeners[name] = listeners[name].filter(function (lis) {
return lis !== listener;
});
},
dispatchEvent: function (event) {
listeners[event.type].forEach(function (fn) { fn(event); });
},
};
}
describe('selections', function () {
var fakeDocument;
var rangeSub;
var onSelectionChanged;
beforeEach(function () {
fakeDocument = new FakeDocument();
onSelectionChanged = sinon.stub();
var fakeSetTimeout = function (fn) { fn(); };
var fakeClearTimeout = function () {};
var ranges = selections(fakeDocument, fakeSetTimeout, fakeClearTimeout);
rangeSub = ranges.subscribe({next: onSelectionChanged});
});
afterEach(function () {
rangeSub.unsubscribe();
});
unroll('emits the selected range when #event occurs', function (testCase) {
var range = {};
fakeDocument.selection = {
rangeCount: 1,
getRangeAt: function (index) {
return index === 0 ? range : null;
},
};
fakeDocument.dispatchEvent({type: testCase.event});
assert.calledWith(onSelectionChanged, range);
}, [{event: 'mouseup'}, {event: 'ready'}]);
});
...@@ -67,7 +67,8 @@ ...@@ -67,7 +67,8 @@
"unorm": "^1.3.3", "unorm": "^1.3.3",
"vinyl": "^1.1.1", "vinyl": "^1.1.1",
"watchify": "^3.7.0", "watchify": "^3.7.0",
"whatwg-fetch": "^0.10.1" "whatwg-fetch": "^0.10.1",
"zen-observable": "^0.2.1"
}, },
"devDependencies": { "devDependencies": {
"chai": "^3.5.0", "chai": "^3.5.0",
......
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