Commit 18198d1f authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3108 from hypothesis/256-focus-selected-annot-on-load

Switch to appropriate group and scroll to direct linked annotation when sidebar loads
parents cf1eabf9 abd2c79b
...@@ -4,19 +4,7 @@ var angular = require('angular'); ...@@ -4,19 +4,7 @@ var angular = require('angular');
var proxyquire = require('proxyquire'); var proxyquire = require('proxyquire');
var util = require('./util'); var util = require('./util');
var noCallThru = require('../../test/util').noCallThru;
/**
* Disable calling through to the original module for a stub.
*
* By default proxyquire will call through to the original module
* for any methods not provided by a stub. This function disables
* this behavior for a stub and returns the input stub.
*
* This prevents unintended usage of the original dependency.
*/
function noCallThru(stub) {
return Object.assign(stub, {'@noCallThru':true});
}
describe('markdown', function () { describe('markdown', function () {
function isHidden(element) { function isHidden(element) {
...@@ -49,8 +37,8 @@ describe('markdown', function () { ...@@ -49,8 +37,8 @@ describe('markdown', function () {
before(function () { before(function () {
angular.module('app', ['ngSanitize']) angular.module('app', ['ngSanitize'])
.directive('markdown', proxyquire('../markdown', { .directive('markdown', proxyquire('../markdown', noCallThru({
angular: noCallThru(angular), angular: angular,
katex: { katex: {
renderToString: function (input) { renderToString: function (input) {
return 'math:' + input.replace(/$$/g, ''); return 'math:' + input.replace(/$$/g, '');
...@@ -62,8 +50,7 @@ describe('markdown', function () { ...@@ -62,8 +50,7 @@ describe('markdown', function () {
toggleSpanStyle: mockFormattingCommand, toggleSpanStyle: mockFormattingCommand,
LinkType: require('../../markdown-commands').LinkType, LinkType: require('../../markdown-commands').LinkType,
}, },
'@noCallThru': true, })))
}))
.filter('converter', function () { .filter('converter', function () {
return function (input) { return function (input) {
return 'rendered:' + input; return 'rendered:' + input;
......
'use strict';
/** /**
* This module defines the set of global events that are dispatched * This module defines the set of global events that are dispatched
* on $rootScope * on $rootScope
*/ */
module.exports = { module.exports = {
/** Broadcast when the currently selected group changes */ // Session state changes
GROUP_FOCUSED: 'groupFocused',
/** Broadcast when the list of groups changes */ /** The list of groups changed */
GROUPS_CHANGED: 'groupsChanged', GROUPS_CHANGED: 'groupsChanged',
/** Broadcast when the signed-in user changes */ /** The signed-in user changed */
USER_CHANGED: 'userChanged', USER_CHANGED: 'userChanged',
/** Broadcast when the session state is updated. /**
* This event is NOT broadcast after the initial session load. * The session state was updated.
*/ */
SESSION_CHANGED: 'sessionChanged', SESSION_CHANGED: 'sessionChanged',
// UI state changes
/** The currently selected group changed */
GROUP_FOCUSED: 'groupFocused',
// Annotation events
/** A new annotation has been created locally. */
BEFORE_ANNOTATION_CREATED: 'beforeAnnotationCreated',
/** Annotations were anchored in a connected document. */
ANNOTATIONS_SYNCED: 'sync',
/** An annotation was created on the server and assigned an ID. */
ANNOTATION_CREATED: 'annotationCreated',
/** An annotation was either deleted or unloaded. */
ANNOTATION_DELETED: 'annotationDeleted',
/** A set of annotations were loaded from the server. */
ANNOTATIONS_LOADED: 'annotationsLoaded',
}; };
...@@ -25,7 +25,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -25,7 +25,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
function all() { function all() {
return session.state.groups || []; return session.state.groups || [];
}; }
// Return the full object for the group with the given id. // Return the full object for the group with the given id.
function get(id) { function get(id) {
...@@ -35,7 +35,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -35,7 +35,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
return gs[i]; return gs[i];
} }
} }
}; }
/** Leave the group with the given ID. /** Leave the group with the given ID.
* Returns a promise which resolves when the action completes. * Returns a promise which resolves when the action completes.
...@@ -51,7 +51,7 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -51,7 +51,7 @@ function groups(localStorage, session, settings, $rootScope, $http) {
// by optimistically updating the session state // by optimistically updating the session state
return response; return response;
}; }
/** Return the currently focused group. If no group is explicitly focused we /** Return the currently focused group. If no group is explicitly focused we
...@@ -72,12 +72,15 @@ function groups(localStorage, session, settings, $rootScope, $http) { ...@@ -72,12 +72,15 @@ function groups(localStorage, session, settings, $rootScope, $http) {
/** Set the group with the passed id as the currently focused group. */ /** Set the group with the passed id as the currently focused group. */
function focus(id) { function focus(id) {
var g = get(id); var prevFocused = focused();
if (g) { var g = get(id);
focusedGroup = g; if (g) {
localStorage.setItem(STORAGE_KEY, g.id); focusedGroup = g;
$rootScope.$broadcast(events.GROUP_FOCUSED, g.id); localStorage.setItem(STORAGE_KEY, g.id);
} if (prevFocused.id !== g.id) {
$rootScope.$broadcast(events.GROUP_FOCUSED, g.id);
}
}
} }
// reset the focused group if the user leaves it // reset the focused group if the user leaves it
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
// ES2015 polyfills // ES2015 polyfills
require('core-js/es6/promise'); require('core-js/es6/promise');
require('core-js/fn/array/find');
require('core-js/fn/object/assign'); require('core-js/fn/object/assign');
// URL constructor, required by IE 10/11, // URL constructor, required by IE 10/11,
......
'use strict';
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
/**
* Client for the Hypothesis search API.
*
* SearchClient handles paging through results, canceling search etc.
*
* @param {Object} resource - ngResource class instance for the /search API
* @param {Object} opts - Search options
* @constructor
*/
function SearchClient(resource, opts) {
opts = opts || {};
var DEFAULT_CHUNK_SIZE = 200;
this._resource = resource;
this._chunkSize = opts.chunkSize || DEFAULT_CHUNK_SIZE;
if (typeof opts.incremental !== 'undefined') {
this._incremental = opts.incremental;
} else {
this._incremental = true;
}
this._canceled = false;
}
inherits(SearchClient, EventEmitter);
SearchClient.prototype._getBatch = function (query, offset) {
var searchQuery = Object.assign({
limit: this._chunkSize,
offset: offset,
sort: 'created',
order: 'asc',
_separate_replies: true,
}, query);
var self = this;
this._resource.get(searchQuery).$promise.then(function (results) {
if (self._canceled) {
return;
}
var chunk = results.rows.concat(results.replies || []);
if (self._incremental) {
self.emit('results', chunk);
} else {
self._results = self._results.concat(chunk);
}
var nextOffset = offset + results.rows.length;
if (results.total > nextOffset) {
self._getBatch(query, nextOffset);
} else {
if (!self._incremental) {
self.emit('results', self._results);
}
self.emit('end');
}
}).catch(function (err) {
if (self._canceled) {
return;
}
self.emit('error', err);
}).then(function () {
if (self._canceled) {
return;
}
self.emit('end');
});
};
/**
* Perform a search against the Hypothesis API.
*
* Emits a 'results' event with an array of annotations as they become
* available (in incremental mode) or when all annotations are available
* (in non-incremental mode).
*
* Emits an 'error' event if the search fails.
* Emits an 'end' event once the search completes.
*/
SearchClient.prototype.get = function (query) {
this._results = [];
this._getBatch(query, 0);
};
/**
* Cancel the current search and emit the 'end' event.
* No further events will be emitted after this.
*/
SearchClient.prototype.cancel = function () {
this._canceled = true;
this.emit('end');
};
module.exports = SearchClient;
...@@ -45,7 +45,7 @@ describe('groups', function() { ...@@ -45,7 +45,7 @@ describe('groups', function() {
} }
} }
}; };
fakeHttp = sandbox.stub() fakeHttp = sandbox.stub();
}); });
afterEach(function () { afterEach(function () {
...@@ -134,7 +134,7 @@ describe('groups', function() { ...@@ -134,7 +134,7 @@ describe('groups', function() {
}); });
}); });
describe('.focus() method', function() { describe('.focus()', function() {
it('sets the focused group to the named group', function() { it('sets the focused group to the named group', function() {
var s = service(); var s = service();
s.focus('id2'); s.focus('id2');
...@@ -155,6 +155,20 @@ describe('groups', function() { ...@@ -155,6 +155,20 @@ describe('groups', function() {
assert.calledWithMatch(fakeLocalStorage.setItem, sinon.match.any, 'id3'); assert.calledWithMatch(fakeLocalStorage.setItem, sinon.match.any, 'id3');
}); });
it('emits the GROUP_FOCUSED event if the focused group changed', function () {
var s = service();
s.focus('id3');
assert.calledWith(fakeRootScope.$broadcast, events.GROUP_FOCUSED, 'id3');
});
it('does not emit GROUP_FOCUSED if the focused group did not change', function () {
var s = service();
s.focus('id3');
fakeRootScope.$broadcast = sinon.stub();
s.focus('id3');
assert.notCalled(fakeRootScope.$broadcast);
});
}); });
describe('.leave()', function () { describe('.leave()', function () {
......
'use strict'; 'use strict';
var proxyquire = require('proxyquire'); var proxyquire = require('proxyquire');
var noCallThru = require('./util').noCallThru;
function noCallThru(stub) {
return Object.assign(stub, {'@noCallThru':true});
}
function fakeExceptionData(scriptURL) { function fakeExceptionData(scriptURL) {
return { return {
...@@ -51,10 +48,10 @@ describe('raven', function () { ...@@ -51,10 +48,10 @@ describe('raven', function () {
Raven.setDataCallback(fakeAngularTransformer); Raven.setDataCallback(fakeAngularTransformer);
}); });
raven = proxyquire('../raven', { raven = proxyquire('../raven', noCallThru({
'raven-js': noCallThru(fakeRavenJS), 'raven-js': fakeRavenJS,
'raven-js/plugins/angular': noCallThru(fakeAngularPlugin), 'raven-js/plugins/angular': fakeAngularPlugin,
}); }));
}); });
describe('.install()', function () { describe('.install()', function () {
......
'use strict';
var SearchClient = require('../search-client');
function await(emitter, event) {
return new Promise(function (resolve) {
emitter.on(event, resolve);
});
}
describe('SearchClient', function () {
var RESULTS = [
{id: 'one'},
{id: 'two'},
{id: 'three'},
{id: 'four'},
];
var fakeResource;
beforeEach(function () {
fakeResource = {
get: sinon.spy(function (params) {
return {
$promise: Promise.resolve({
rows: RESULTS.slice(params.offset,
params.offset + params.limit),
total: RESULTS.length,
}),
};
}),
};
});
it('emits "results"', function () {
var client = new SearchClient(fakeResource);
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onResults, RESULTS);
});
});
it('emits "results" with chunks in incremental mode', function () {
var client = new SearchClient(fakeResource, {chunkSize: 2});
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onResults, RESULTS.slice(0,2));
assert.calledWith(onResults, RESULTS.slice(2,4));
});
});
it('emits "results" once in non-incremental mode', function () {
var client = new SearchClient(fakeResource,
{chunkSize: 2, incremental: false});
var onResults = sinon.stub();
client.on('results', onResults);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledOnce(onResults);
assert.calledWith(onResults, RESULTS);
});
});
it('does not emit "results" if canceled', function () {
var client = new SearchClient(fakeResource);
var onResults = sinon.stub();
var onEnd = sinon.stub();
client.on('results', onResults);
client.on('end', onEnd);
client.get({uri: 'http://example.com'});
client.cancel();
return Promise.resolve().then(function () {
assert.notCalled(onResults);
assert.called(onEnd);
});
});
it('emits "error" event if search fails', function () {
var err = new Error('search failed');
fakeResource.get = function () {
return {
$promise: Promise.reject(err),
};
};
var client = new SearchClient(fakeResource);
var onError = sinon.stub();
client.on('error', onError);
client.get({uri: 'http://example.com'});
return await(client, 'end').then(function () {
assert.calledWith(onError, err);
});
});
});
'use strict';
/**
* Utility function for use with 'proxyquire' that prevents calls to
* stubs 'calling through' to the _original_ dependency if a particular
* function or property is not set on a stub, which is proxyquire's default
* but usually undesired behavior.
*
* See https://github.com/thlorenz/proxyquireify#nocallthru
*
* Usage:
* var moduleUnderTest = proxyquire('./module-under-test', noCallThru({
* './dependency-foo': fakeFoo,
* }));
*
* @param {Object} stubs - A map of dependency paths to stubs, or a single
* stub.
*/
function noCallThru(stubs) {
// This function is trivial but serves as documentation for why
// '@noCallThru' is used.
return Object.assign(stubs, {'@noCallThru':true});
}
module.exports = {
noCallThru: noCallThru,
};
angular = require('angular') angular = require('angular')
mail = require('./vendor/jwz') mail = require('./vendor/jwz')
events = require('./events')
# The threading service provides the model for the currently loaded # The threading service provides the model for the currently loaded
# set of annotations, structured as a tree of annotations and replies. # set of annotations, structured as a tree of annotations and replies.
...@@ -31,10 +32,10 @@ module.exports = class Threading ...@@ -31,10 +32,10 @@ module.exports = class Threading
# Create a root container. # Create a root container.
@root = mail.messageContainer() @root = mail.messageContainer()
$rootScope.$on('beforeAnnotationCreated', this.beforeAnnotationCreated) $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, this.beforeAnnotationCreated)
$rootScope.$on('annotationCreated', this.annotationCreated) $rootScope.$on(events.ANNOTATION_CREATED, this.annotationCreated)
$rootScope.$on('annotationDeleted', this.annotationDeleted) $rootScope.$on(events.ANNOTATION_DELETED, this.annotationDeleted)
$rootScope.$on('annotationsLoaded', this.annotationsLoaded) $rootScope.$on(events.ANNOTATIONS_LOADED, this.annotationsLoaded)
# TODO: Refactor the jwz API for progressive updates. # TODO: Refactor the jwz API for progressive updates.
# Right now the idTable is wiped when `messageThread.thread()` is called and # Right now the idTable is wiped when `messageThread.thread()` is called and
......
'use strict'; 'use strict';
var angular = require('angular');
var events = require('./events'); var events = require('./events');
var SearchClient = require('./search-client');
/**
* Returns the group ID of the first annotation in `results` whose
* ID is a key in `selection`.
*/
function groupIDFromSelection(selection, results) {
var id = Object.keys(selection)[0];
var annot = results.find(function (annot) {
return annot.id === id;
});
if (!annot) {
return;
}
return annot.group;
}
// @ngInject // @ngInject
module.exports = function WidgetController( module.exports = function WidgetController(
...@@ -12,78 +26,165 @@ module.exports = function WidgetController( ...@@ -12,78 +26,165 @@ module.exports = function WidgetController(
$scope.threadRoot = threading.root; $scope.threadRoot = threading.root;
$scope.sortOptions = ['Newest', 'Oldest', 'Location']; $scope.sortOptions = ['Newest', 'Oldest', 'Location'];
var DEFAULT_CHUNK_SIZE = 200; function focusAnnotation(annotation) {
var loaded = []; var highlights = [];
if (annotation) {
highlights = [annotation.$$tag];
}
crossframe.call('focusAnnotations', highlights);
}
function scrollToAnnotation(annotation) {
if (!annotation) {
return;
}
crossframe.call('scrollToAnnotation', annotation.$$tag);
}
/**
* Returns the Annotation object for the first annotation in the
* selected annotation set. Note that 'first' refers to the order
* of annotations passed to annotationUI when selecting annotations,
* not the order in which they appear in the document.
*/
function firstSelectedAnnotation() {
if (annotationUI.selectedAnnotationMap) {
var id = Object.keys(annotationUI.selectedAnnotationMap)[0];
return threading.idTable[id] && threading.idTable[id].message;
} else {
return null;
}
}
var _resetAnnotations = function () { function _resetAnnotations() {
// Unload all the annotations // Unload all the annotations
annotationMapper.unloadAnnotations(threading.annotationList()); annotationMapper.unloadAnnotations(threading.annotationList());
// Reload all the drafts // Reload all the drafts
threading.thread(drafts.unsaved()); threading.thread(drafts.unsaved());
}; }
var searchClients = [];
var _loadAnnotationsFrom = function (query, offset) { function _loadAnnotationsFor(uri, group) {
var queryCore = { var searchClient = new SearchClient(store.SearchResource, {
limit: $scope.chunkSize || DEFAULT_CHUNK_SIZE, // If no group is specified, we are fetching annotations from
offset: offset, // all groups in order to find out which group contains the selected
sort: 'created', // annotation, therefore we need to load all chunks before processing
order: 'asc', // the results
group: groups.focused().id incremental: !!group,
}; });
var q = angular.extend(queryCore, query); searchClients.push(searchClient);
q._separate_replies = true; searchClient.on('results', function (results) {
if (annotationUI.hasSelectedAnnotations()) {
store.SearchResource.get(q, function (results) { // Focus the group containing the selected annotation and filter
var total = results.total; // annotations to those from this group
offset += results.rows.length; var groupID = groupIDFromSelection(annotationUI.selectedAnnotationMap,
if (offset < total) { results);
_loadAnnotationsFrom(query, offset); if (!groupID) {
// If the selected annotation is not available, fall back to
// loading annotations for the currently focused group
groupID = groups.focused().id;
}
results = results.filter(function (result) {
return result.group === groupID;
});
groups.focus(groupID);
} }
annotationMapper.loadAnnotations(results.rows, results.replies); if (results.length) {
annotationMapper.loadAnnotations(results);
}
});
searchClient.on('end', function () {
// Remove client from list of active search clients
searchClients.splice(searchClients.indexOf(searchClient), 1);
});
searchClient.get({uri: uri, group: group});
}
/**
* Load annotations for all URLs associated with @p frames.
*
* @param {Array<{uri:string}>} frames - Hypothesis client frames
* to load annotations for.
*/
function loadAnnotations(frames) {
_resetAnnotations();
searchClients.forEach(function (client) {
client.cancel();
}); });
};
var loadAnnotations = function (frames) { var urls = frames.reduce(function (urls, frame) {
for (var i = 0, f; i < frames.length; i++) { if (urls.indexOf(frame.uri) !== -1) {
f = frames[i]; return urls;
var ref; } else {
if (ref = f.uri, loaded.indexOf(ref) >= 0) { return urls.concat(frame.uri);
continue;
} }
loaded.push(f.uri); }, []);
_loadAnnotationsFrom({uri: f.uri}, 0);
// If there is no selection, load annotations only for the focused group.
//
// If there is a selection, we load annotations for all groups, find out
// which group the first selected annotation is in and then filter the
// results on the client by that group.
//
// In the common case where the total number of annotations on
// a page that are visible to the user is not greater than
// the batch size, this saves an extra roundtrip to the server
// to fetch the selected annotation in order to determine which group
// it is in before fetching the remaining annotations.
var group = annotationUI.hasSelectedAnnotations() ?
null : groups.focused().id;
for (var i=0; i < urls.length; i++) {
_loadAnnotationsFor(urls[i], group);
} }
if (loaded.length > 0) { if (urls.length > 0) {
streamFilter.resetFilter().addClause('/uri', 'one_of', loaded); streamFilter.resetFilter().addClause('/uri', 'one_of', urls);
streamer.setConfig('filter', {filter: streamFilter.getFilter()}); streamer.setConfig('filter', {filter: streamFilter.getFilter()});
} }
}; }
// When a direct-linked annotation is successfully anchored in the page,
// focus and scroll to it
$rootScope.$on(events.ANNOTATIONS_SYNCED, function (event, tags) {
var selectedAnnot = firstSelectedAnnotation();
if (!selectedAnnot) {
return;
}
var matchesSelection = tags.some(function (tag) {
return tag.tag === selectedAnnot.$$tag;
});
if (!matchesSelection) {
return;
}
focusAnnotation(selectedAnnot);
scrollToAnnotation(selectedAnnot);
});
$scope.$on(events.GROUP_FOCUSED, function () { $scope.$on(events.GROUP_FOCUSED, function () {
_resetAnnotations(annotationMapper, drafts, threading); // The focused group may be changed during loading annotations (in which
loaded = []; // case, searchClients.length > 0), as a result of switching to the group
// containing the selected annotation.
//
// In that case, we don't want to trigger reloading annotations again.
if (searchClients.length) {
return;
}
annotationUI.clearSelectedAnnotations();
return loadAnnotations(crossframe.frames); return loadAnnotations(crossframe.frames);
}); });
// Watch anything that may require us to reload annotations.
$scope.$watchCollection(function () { $scope.$watchCollection(function () {
return crossframe.frames; return crossframe.frames;
}, loadAnnotations); }, loadAnnotations);
$scope.focus = function (annotation) { $scope.focus = focusAnnotation;
var highlights = []; $scope.scrollTo = scrollToAnnotation;
if (angular.isObject(annotation)) {
highlights = [annotation.$$tag];
}
return crossframe.call('focusAnnotations', highlights);
};
$scope.scrollTo = function (annotation) {
if (angular.isObject(annotation)) {
return crossframe.call('scrollToAnnotation', annotation.$$tag);
}
};
$scope.hasFocus = function (annotation) { $scope.hasFocus = function (annotation) {
if (!annotation || !$scope.focusedAnnotations) { if (!annotation || !$scope.focusedAnnotations) {
...@@ -92,7 +193,7 @@ module.exports = function WidgetController( ...@@ -92,7 +193,7 @@ module.exports = function WidgetController(
return annotation.$$tag in $scope.focusedAnnotations; return annotation.$$tag in $scope.focusedAnnotations;
}; };
$rootScope.$on('beforeAnnotationCreated', function (event, data) { $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, data) {
if (data.$highlight || (data.references && data.references.length > 0)) { if (data.$highlight || (data.references && data.references.length > 0)) {
return; return;
} }
......
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