Commit 536ba5e0 authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3285 from hypothesis/virtualize-thread-list

New threading 4/N - Virtualize the thread list
parents d59f8070 d0876bbd
...@@ -17,6 +17,14 @@ function AnnotationViewerController ( ...@@ -17,6 +17,14 @@ function AnnotationViewerController (
$location.path('/stream').search('q', query); $location.path('/stream').search('q', query);
}; };
rootThread.on('changed', function (thread) {
$scope.virtualThreadList = {
visibleThreads: thread.children,
offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px',
};
});
$scope.rootThread = function () { $scope.rootThread = function () {
return rootThread.thread(); return rootThread.thread();
}; };
......
...@@ -183,6 +183,7 @@ module.exports = angular.module('h', [ ...@@ -183,6 +183,7 @@ module.exports = angular.module('h', [
.value('AnnotationUISync', require('./annotation-ui-sync')) .value('AnnotationUISync', require('./annotation-ui-sync'))
.value('Discovery', require('./discovery')) .value('Discovery', require('./discovery'))
.value('ExcerptOverflowMonitor', require('./directive/excerpt-overflow-monitor')) .value('ExcerptOverflowMonitor', require('./directive/excerpt-overflow-monitor'))
.value('VirtualThreadList', require('./virtual-thread-list'))
.value('raven', require('./raven')) .value('raven', require('./raven'))
.value('settings', settings) .value('settings', settings)
.value('time', require('./time')) .value('time', require('./time'))
......
...@@ -268,15 +268,12 @@ function AnnotationController( ...@@ -268,15 +268,12 @@ function AnnotationController(
// are empty // are empty
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, deleteIfNewAndEmpty); $rootScope.$on(events.BEFORE_ANNOTATION_CREATED, deleteIfNewAndEmpty);
// Call `onDestroy()` when this AnnotationController's scope is removed. // Call `onDestroy()` when the component is destroyed.
$scope.$on('$destroy', onDestroy); $scope.$on('$destroy', onDestroy);
// Call `onGroupFocused()` whenever the currently-focused group changes. // Call `onGroupFocused()` whenever the currently-focused group changes.
$scope.$on(events.GROUP_FOCUSED, onGroupFocused); $scope.$on(events.GROUP_FOCUSED, onGroupFocused);
// Call `onUserChanged()` whenever the user logs in or out.
$scope.$on(events.USER_CHANGED, onUserChanged);
// New annotations (just created locally by the client, rather then // New annotations (just created locally by the client, rather then
// received from the server) have some fields missing. Add them. // received from the server) have some fields missing. Add them.
domainModel.user = domainModel.user || session.state.userid; domainModel.user = domainModel.user || session.state.userid;
...@@ -322,34 +319,28 @@ function AnnotationController( ...@@ -322,34 +319,28 @@ function AnnotationController(
} }
function onDestroy() { function onDestroy() {
// If the annotation component is destroyed whilst the annotation is being
// edited, persist temporary state so that we can restore it if the
// annotation editor is later recreated.
//
// The annotation component may be destroyed when switching accounts,
// when switching groups or when the component is scrolled off-screen.
if (vm.editing()) {
saveToDrafts(drafts, domainModel, vm);
}
if (vm.cancelTimestampRefresh) { if (vm.cancelTimestampRefresh) {
vm.cancelTimestampRefresh(); vm.cancelTimestampRefresh();
} }
} }
function onGroupFocused() { function onGroupFocused() {
if (vm.editing()) {
saveToDrafts(drafts, domainModel, vm);
}
// New annotations move to the new group, when a new group is focused. // New annotations move to the new group, when a new group is focused.
if (isNew(domainModel)) { if (isNew(domainModel)) {
domainModel.group = groups.focused().id; domainModel.group = groups.focused().id;
} }
} }
function onUserChanged(event, args) {
// If the user creates an annotation while signed out and then signs in
// we want those annotations to still be in the sidebar after sign in.
// So we need to save a draft of the annotation here on sign in because
// app.coffee / the routing code is about to destroy all the
// AnnotationController instances and only the ones that have saved drafts
// will be re-created.
if (vm.editing() && session.state.userid) {
saveToDrafts(drafts, domainModel, vm);
}
}
/** Save this annotation if it's a new highlight. /** Save this annotation if it's a new highlight.
* *
* The highlight will be saved to the server if the user is logged in, * The highlight will be saved to the server if the user is logged in,
......
...@@ -1271,7 +1271,7 @@ describe('annotation', function() { ...@@ -1271,7 +1271,7 @@ describe('annotation', function() {
}); });
}); });
describe('onGroupFocused()', function() { describe('when component is destroyed', function () {
it('if the annotation is being edited it updates drafts', function() { it('if the annotation is being edited it updates drafts', function() {
var parts = createDirective(); var parts = createDirective();
parts.controller.isPrivate = true; parts.controller.isPrivate = true;
...@@ -1283,25 +1283,25 @@ describe('annotation', function() { ...@@ -1283,25 +1283,25 @@ describe('annotation', function() {
}); });
fakeDrafts.update = sinon.stub(); fakeDrafts.update = sinon.stub();
$rootScope.$broadcast(events.GROUP_FOCUSED); parts.scope.$broadcast('$destroy');
assert.calledWith( assert.calledWith(
fakeDrafts.update, fakeDrafts.update,
parts.annotation, {isPrivate:true, tags:[], text:'unsaved-text'}); parts.annotation, {isPrivate:true, tags:[], text:'unsaved-text'});
}); });
it('if the annotation isn\'t being edited it doesn\'t update drafts', it('if the annotation isn\'t being edited it doesn\'t update drafts', function() {
function() { var parts = createDirective();
var parts = createDirective(); parts.controller.isPrivate = true;
parts.controller.isPrivate = true; fakeDrafts.update = sinon.stub();
fakeDrafts.update = sinon.stub();
$rootScope.$broadcast(events.GROUP_FOCUSED); parts.scope.$broadcast('$destroy');
assert.notCalled(fakeDrafts.update); assert.notCalled(fakeDrafts.update);
} });
); });
describe('onGroupFocused()', function() {
it('updates domainModel.group if the annotation is new', function () { it('updates domainModel.group if the annotation is new', function () {
var annotation = fixtures.newAnnotation(); var annotation = fixtures.newAnnotation();
annotation.group = 'old-group-id'; annotation.group = 'old-group-id';
......
'use strict'; 'use strict';
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
var buildThread = require('./build-thread'); var buildThread = require('./build-thread');
var events = require('./events'); var events = require('./events');
var metadata = require('./annotation-metadata'); var metadata = require('./annotation-metadata');
...@@ -37,7 +40,8 @@ var sortFns = { ...@@ -37,7 +40,8 @@ var sortFns = {
* The root thread is then displayed by viewer.html * The root thread is then displayed by viewer.html
*/ */
// @ngInject // @ngInject
module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { function RootThread($rootScope, annotationUI, searchFilter, viewFilter) {
var self = this;
var thread; var thread;
/** /**
...@@ -72,6 +76,7 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -72,6 +76,7 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
sortCompareFn: sortFn, sortCompareFn: sortFn,
filterFn: filterFn, filterFn: filterFn,
}); });
self.emit('changed', thread);
} }
rebuildRootThread(); rebuildRootThread();
annotationUI.subscribe(rebuildRootThread); annotationUI.subscribe(rebuildRootThread);
...@@ -112,19 +117,20 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) { ...@@ -112,19 +117,20 @@ module.exports = function ($rootScope, annotationUI, searchFilter, viewFilter) {
annotationUI.removeAnnotations(annotations); annotationUI.removeAnnotations(annotations);
}); });
return { /**
/** * Rebuild the conversation thread based on the currently loaded annotations
* Rebuild the conversation thread based on the currently loaded annotations * and search/sort/filter settings.
* and search/sort/filter settings. */
*/ this.rebuild = rebuildRootThread;
rebuild: rebuildRootThread,
/**
/** * Returns the current root conversation thread.
* Returns the current root conversation thread. * @return {Thread}
* @return {Thread} */
*/ this.thread = function () {
thread: function () { return thread;
return thread;
},
}; };
}; }
inherits(RootThread, EventEmitter);
module.exports = RootThread;
...@@ -56,6 +56,14 @@ module.exports = class StreamController ...@@ -56,6 +56,14 @@ module.exports = class StreamController
$scope.forceVisible = (id) -> $scope.forceVisible = (id) ->
annotationUI.setForceVisible(id, true) annotationUI.setForceVisible(id, true)
rootThread.on('changed', (thread) ->
$scope.virtualThreadList = {
visibleThreads: thread.children,
offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px',
};
);
$scope.isStream = true $scope.isStream = true
$scope.sortOptions = ['Newest', 'Oldest'] $scope.sortOptions = ['Newest', 'Oldest']
$scope.sort.name = 'Newest' $scope.sort.name = 'Newest'
......
'use strict'; 'use strict';
var angular = require('angular'); var angular = require('angular');
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
function FakeRootThread() {
this.thread = sinon.stub();
}
inherits(FakeRootThread, EventEmitter);
describe('AnnotationViewerController', function () { describe('AnnotationViewerController', function () {
...@@ -30,9 +37,7 @@ describe('AnnotationViewerController', function () { ...@@ -30,9 +37,7 @@ describe('AnnotationViewerController', function () {
search: {}, search: {},
}, },
annotationUI: {}, annotationUI: {},
rootThread: { rootThread: new FakeRootThread(),
thread: sinon.stub(),
},
streamer: opts.streamer || { setConfig: function () {} }, streamer: opts.streamer || { setConfig: function () {} },
store: opts.store || { store: opts.store || {
AnnotationResource: { get: sinon.spy() }, AnnotationResource: { get: sinon.spy() },
...@@ -52,6 +57,7 @@ describe('AnnotationViewerController', function () { ...@@ -52,6 +57,7 @@ describe('AnnotationViewerController', function () {
}, },
annotationMapper: opts.annotationMapper || { loadAnnotations: sinon.spy() }, annotationMapper: opts.annotationMapper || { loadAnnotations: sinon.spy() },
}; };
inherits(locals.rootThread, EventEmitter);
locals.ctrl = getControllerService()( locals.ctrl = getControllerService()(
'AnnotationViewerController', locals); 'AnnotationViewerController', locals);
return locals; return locals;
......
EventEmitter = require('tiny-emitter')
inherits = require('inherits')
{module, inject} = angular.mock {module, inject} = angular.mock
class FakeRootThread extends EventEmitter
constructor: () ->
this.thread = sinon.stub()
describe 'StreamController', -> describe 'StreamController', ->
$controller = null $controller = null
$scope = null $scope = null
...@@ -70,9 +77,7 @@ describe 'StreamController', -> ...@@ -70,9 +77,7 @@ describe 'StreamController', ->
getFilter: sandbox.stub() getFilter: sandbox.stub()
} }
fakeRootThread = { fakeRootThread = new FakeRootThread()
thread: sandbox.stub()
}
$provide.value 'annotationMapper', fakeAnnotationMapper $provide.value 'annotationMapper', fakeAnnotationMapper
$provide.value 'annotationUI', fakeAnnotationUI $provide.value 'annotationUI', fakeAnnotationUI
......
'use strict';
var proxyquire = require('proxyquire');
var VirtualThreadList = proxyquire('../virtual-thread-list', {
'lodash.debounce': function (fn) {
// Make debounced functions execute immediately
return fn;
},
});
var util = require('./util');
var unroll = util.unroll;
describe('VirtualThreadList', function () {
var lastState;
var threadList;
var fakeScope;
var fakeWindow;
function idRange(start, end) {
var ary = [];
for (var i=start; i <= end; i++) {
ary.push('t' + i.toString());
}
return ary;
}
function threadIDs(threads) {
return threads.map(function (thread) { return thread.id; });
}
function generateRootThread(count) {
return {
annotation: undefined,
children: idRange(0, count-1).map(function (id) {
return {id: id, annotation: undefined, children: []};
}),
};
}
beforeEach(function () {
fakeScope = {$digest: sinon.stub()};
fakeWindow = {
listeners: {},
addEventListener: function (event, listener) {
this.listeners[event] = this.listeners[event] || [];
this.listeners[event].push(listener);
},
removeEventListener: function (event, listener) {
this.listeners[event] = this.listeners[event].filter(function (fn) {
return fn !== listener;
});
},
trigger: function (event) {
this.listeners[event].forEach(function (cb) {
cb();
});
},
innerHeight: 100,
pageYOffset: 0,
};
var rootThread = {annotation: undefined, children: []};
threadList = new VirtualThreadList(fakeScope, fakeWindow, rootThread);
threadList.on('changed', function (state) {
lastState = state;
});
});
unroll('generates expected state when #when', function (testCase) {
var thread = generateRootThread(testCase.threads);
fakeWindow.pageYOffset = testCase.scrollOffset;
fakeWindow.innerHeight = testCase.windowHeight;
threadList.setRootThread(thread);
var visibleIDs = threadIDs(lastState.visibleThreads);
assert.deepEqual(visibleIDs, testCase.expectedVisibleThreads);
assert.equal(lastState.offscreenUpperHeight, testCase.expectedHeightAbove);
assert.equal(lastState.offscreenLowerHeight, testCase.expectedHeightBelow);
},[{
when: 'window is scrolled to top of list',
threads: 100,
scrollOffset: 0,
windowHeight: 300,
expectedVisibleThreads: idRange(0, 5),
expectedHeightAbove: 0,
expectedHeightBelow: 18800,
},{
when: 'window is scrolled to middle of list',
threads: 100,
scrollOffset: 2000,
windowHeight: 300,
expectedVisibleThreads: idRange(5, 15),
expectedHeightAbove: 1000,
expectedHeightBelow: 16800,
},{
when: 'window is scrolled to bottom of list',
threads: 100,
scrollOffset: 18800,
windowHeight: 300,
expectedVisibleThreads: idRange(89, 99),
expectedHeightAbove: 17800,
expectedHeightBelow: 0,
}]);
unroll('recalculates when a window.#event occurs', function (testCase) {
lastState = null;
fakeWindow.trigger(testCase.event);
assert.ok(lastState);
},[{
event: 'resize',
},{
event: 'scroll',
}]);
it('recalculates when root thread changes', function () {
threadList.setRootThread({annotation: undefined, children: []});
assert.ok(lastState);
});
describe('#setThreadHeight', function () {
unroll('affects visible threads', function (testCase) {
var thread = generateRootThread(10);
fakeWindow.innerHeight = 500;
fakeWindow.pageYOffset = 0;
idRange(0,10).forEach(function (id) {
threadList.setThreadHeight(id, testCase.threadHeight);
});
threadList.setRootThread(thread);
assert.deepEqual(threadIDs(lastState.visibleThreads),
testCase.expectedVisibleThreads);
},[{
threadHeight: 1000,
expectedVisibleThreads: idRange(0,1),
},{
threadHeight: 300,
expectedVisibleThreads: idRange(0,4),
}]);
});
describe('#detach', function () {
unroll('stops listening to window.#event events', function (testCase) {
threadList.detach();
lastState = null;
fakeWindow.trigger(testCase.event);
assert.isNull(lastState);
},[{
event: 'resize',
},{
event: 'scroll',
}]);
});
describe('#yOffsetOf', function () {
unroll('returns #offset as the Y offset of the #nth thread', function (testCase) {
var thread = generateRootThread(10);
threadList.setRootThread(thread);
idRange(0, 10).forEach(function (id) {
threadList.setThreadHeight(id, 100);
});
var id = idRange(testCase.index, testCase.index)[0];
assert.equal(threadList.yOffsetOf(id), testCase.offset);
}, [{
nth: 'first',
index: 0,
offset: 0,
},{
nth: 'second',
index: 1,
offset: 100,
},{
nth: 'last',
index: 9,
offset: 900,
}]);
});
});
...@@ -26,6 +26,23 @@ function FakeSearchClient(resource, opts) { ...@@ -26,6 +26,23 @@ function FakeSearchClient(resource, opts) {
} }
inherits(FakeSearchClient, EventEmitter); inherits(FakeSearchClient, EventEmitter);
function FakeRootThread() {
this.thread = sinon.stub().returns({
totalChildren: 0,
});
}
inherits(FakeRootThread, EventEmitter);
function FakeVirtualThreadList() {
this.setRootThread = sinon.stub();
this.setThreadHeight = sinon.stub();
this.detach = sinon.stub();
this.yOffsetOf = function () {
return 100;
};
}
inherits(FakeVirtualThreadList, EventEmitter);
describe('WidgetController', function () { describe('WidgetController', function () {
var $rootScope; var $rootScope;
var $scope; var $scope;
...@@ -88,13 +105,7 @@ describe('WidgetController', function () { ...@@ -88,13 +105,7 @@ describe('WidgetController', function () {
focus: sinon.stub(), focus: sinon.stub(),
}; };
fakeRootThread = { fakeRootThread = new FakeRootThread();
thread: sinon.stub().returns({
totalChildren: 0,
}),
setSearchQuery: sinon.stub(),
sortBy: sinon.stub(),
};
fakeSettings = { fakeSettings = {
annotations: 'test', annotations: 'test',
...@@ -104,6 +115,7 @@ describe('WidgetController', function () { ...@@ -104,6 +115,7 @@ describe('WidgetController', function () {
SearchResource: {}, SearchResource: {},
}; };
$provide.value('VirtualThreadList', FakeVirtualThreadList);
$provide.value('annotationMapper', fakeAnnotationMapper); $provide.value('annotationMapper', fakeAnnotationMapper);
$provide.value('annotationUI', annotationUI); $provide.value('annotationUI', annotationUI);
$provide.value('crossframe', fakeCrossFrame); $provide.value('crossframe', fakeCrossFrame);
...@@ -273,6 +285,21 @@ describe('WidgetController', function () { ...@@ -273,6 +285,21 @@ describe('WidgetController', function () {
}); });
describe('when a new annotation is created', function () { describe('when a new annotation is created', function () {
var windowScroll;
var cardListTopEl;
beforeEach(function () {
$scope.clearSelection = sinon.stub();
windowScroll = sinon.stub(window, 'scroll');
cardListTopEl = $('<div class="js-thread-list-top"></div>');
cardListTopEl.appendTo(document.body);
});
afterEach(function () {
windowScroll.restore();
cardListTopEl.remove();
});
/** /**
* It should clear any selection that exists in the sidebar before * It should clear any selection that exists in the sidebar before
* creating a new annotation. Otherwise the new annotation with its * creating a new annotation. Otherwise the new annotation with its
...@@ -280,24 +307,26 @@ describe('WidgetController', function () { ...@@ -280,24 +307,26 @@ describe('WidgetController', function () {
* not part of the selection. * not part of the selection.
*/ */
it('clears the selection', function () { it('clears the selection', function () {
$scope.clearSelection = sinon.stub();
$rootScope.$emit('beforeAnnotationCreated', {}); $rootScope.$emit('beforeAnnotationCreated', {});
assert.called($scope.clearSelection); assert.called($scope.clearSelection);
}); });
it('does not clear the selection if the new annotation is a highlight', function () { it('does not clear the selection if the new annotation is a highlight', function () {
$scope.clearSelection = sinon.stub();
$rootScope.$emit('beforeAnnotationCreated', {$highlight: true}); $rootScope.$emit('beforeAnnotationCreated', {$highlight: true});
assert.notCalled($scope.clearSelection); assert.notCalled($scope.clearSelection);
}); });
it('does not clear the selection if the new annotation is a reply', function () { it('does not clear the selection if the new annotation is a reply', function () {
$scope.clearSelection = sinon.stub();
$rootScope.$emit('beforeAnnotationCreated', { $rootScope.$emit('beforeAnnotationCreated', {
references: ['parent-id'] references: ['parent-id']
}); });
assert.notCalled($scope.clearSelection); assert.notCalled($scope.clearSelection);
}); });
it('scrolls the viewport to the new annotation', function () {
$rootScope.$emit('beforeAnnotationCreated', {$$tag: '123'});
assert.called(windowScroll);
});
}); });
describe('direct linking messages', function () { describe('direct linking messages', function () {
......
'use strict';
var EventEmitter = require('tiny-emitter');
var debounce = require('lodash.debounce');
var inherits = require('inherits');
/**
* VirtualThreadList is a helper for virtualizing the annotation thread list.
*
* 'Virtualizing' the thread list improves UI performance by only creating
* annotation cards for annotations which are either in or near the viewport.
*
* Reducing the number of annotation cards that are actually created optimizes
* the initial population of the list, since annotation cards are big components
* that are expensive to create and consume a lot of memory. For Angular
* applications this also helps significantly with UI responsiveness by limiting
* the number of watchers (functions created by template expressions or
* '$scope.$watch' calls) that have to be run on every '$scope.$digest()' cycle.
*
* @param {Window} container - The Window displaying the list of annotation threads.
* @param {Thread} rootThread - The initial Thread object for the top-level
* threads.
*/
function VirtualThreadList($scope, window_, rootThread) {
var self = this;
this._rootThread = rootThread;
// Cache of thread ID -> last-seen height
this._heights = {};
this.window = window_;
var debouncedUpdate = debounce(function () {
self._updateVisibleThreads();
$scope.$digest();
}, 20);
this.window.addEventListener('scroll', debouncedUpdate);
this.window.addEventListener('resize', debouncedUpdate);
this._detach = function () {
this.window.removeEventListener('scroll', debouncedUpdate);
this.window.removeEventListener('resize', debouncedUpdate);
};
}
inherits(VirtualThreadList, EventEmitter);
/**
* Detach event listeners and clear any pending timeouts.
*
* This should be invoked when the UI view presenting the virtual thread list
* is torn down.
*/
VirtualThreadList.prototype.detach = function () {
this._detach();
};
/**
* Sets the root thread containing all conversations matching the current
* filters.
*
* This should be called with the current Thread object whenever the set of
* matching annotations changes.
*/
VirtualThreadList.prototype.setRootThread = function (thread) {
this._rootThread = thread;
this._updateVisibleThreads();
};
/**
* Sets the actual height for a thread.
*
* When calculating the amount of space required for offscreen threads,
* the actual or 'last-seen' height is used if known. Otherwise an estimate
* is used.
*
* @param {string} id - The annotation ID or $$tag
* @param {number?} height - The height of the annotation or undefined to
* revert to the default height for this thread.
*/
VirtualThreadList.prototype.setThreadHeight = function (id, height) {
this._heights[id] = height;
};
VirtualThreadList.prototype._height = function (id) {
// Default guess of the height required for a threads that have not been
// measured
var DEFAULT_HEIGHT = 200;
return this._heights[id] || DEFAULT_HEIGHT;
};
/** Return the vertical offset of an annotation card from the top of the list. */
VirtualThreadList.prototype.yOffsetOf = function (id) {
var self = this;
var allThreads = this._rootThread.children;
var matchIndex = allThreads.findIndex(function (thread) {
return thread.id === id;
});
if (matchIndex === -1) {
return 0;
}
return allThreads.slice(0, matchIndex).reduce(function (offset, thread) {
return offset + self._height(thread.id);
}, 0);
};
/**
* Recalculates the set of visible threads and estimates of the amount of space
* required for offscreen threads above and below the viewport.
*
* Emits a `changed` event with the recalculated set of visible threads.
*/
VirtualThreadList.prototype._updateVisibleThreads = function () {
// Space above the viewport in pixels which should be considered 'on-screen'
// when calculating the set of visible threads
var MARGIN_ABOVE = 800;
// Same as MARGIN_ABOVE but for the space below the viewport
var MARGIN_BELOW = 800;
// Estimated height in pixels of annotation cards which are below the
// viewport and not actually created. This is used to create an empty spacer
// element below visible cards in order to give the list's scrollbar the
// correct dimensions.
var offscreenLowerHeight = 0;
// Same as offscreenLowerHeight but for cards above the viewport.
var offscreenUpperHeight = 0;
// List of annotations which are in or near the viewport and need to
// actually be created.
var visibleThreads = [];
var allThreads = this._rootThread.children;
var visibleHeight = this.window.innerHeight;
var usedHeight = 0;
var thread;
for (var i = 0; i < allThreads.length; i++) {
thread = allThreads[i];
var threadHeight = this._height(thread.id);
if (usedHeight + threadHeight < this.window.pageYOffset - MARGIN_ABOVE) {
// Thread is above viewport
offscreenUpperHeight += threadHeight;
} else if (usedHeight <
this.window.pageYOffset + visibleHeight + MARGIN_BELOW) {
// Thread is either in or close to the viewport
visibleThreads.push(allThreads[i]);
} else {
// Thread is below viewport
offscreenLowerHeight += threadHeight;
}
usedHeight += threadHeight;
}
this.emit('changed', {
offscreenLowerHeight: offscreenLowerHeight,
offscreenUpperHeight: offscreenUpperHeight,
visibleThreads: visibleThreads,
});
};
module.exports = VirtualThreadList;
'use strict'; 'use strict';
var SearchClient = require('./search-client');
var events = require('./events'); var events = require('./events');
var memoize = require('./util/memoize'); var memoize = require('./util/memoize');
var SearchClient = require('./search-client'); var scopeTimeout = require('./util/scope-timeout');
function firstKey(object) { function firstKey(object) {
for (var k in object) { for (var k in object) {
...@@ -32,8 +33,49 @@ function groupIDFromSelection(selection, results) { ...@@ -32,8 +33,49 @@ function groupIDFromSelection(selection, results) {
// @ngInject // @ngInject
module.exports = function WidgetController( module.exports = function WidgetController(
$scope, $rootScope, annotationUI, crossframe, annotationMapper, $scope, $rootScope, annotationUI, crossframe, annotationMapper,
drafts, groups, rootThread, settings, streamer, streamFilter, store drafts, groups, rootThread, settings, streamer, streamFilter, store,
VirtualThreadList
) { ) {
function getThreadHeight(id) {
var threadElement = document.getElementById(id);
if (!threadElement) {
return;
}
// Get the height of the element inside the border-box, excluding
// top and bottom margins.
var elementHeight = threadElement.getBoundingClientRect().height;
var style = window.getComputedStyle(threadElement);
// Get the bottom margin of the element. style.margin{Side} will return
// values of the form 'Npx', from which we extract 'N'.
var marginHeight = parseFloat(style.marginTop) +
parseFloat(style.marginBottom);
return elementHeight + marginHeight;
}
var visibleThreads = new VirtualThreadList($scope, window, rootThread.thread());
visibleThreads.on('changed', function (state) {
$scope.virtualThreadList = {
visibleThreads: state.visibleThreads,
offscreenUpperHeight: state.offscreenUpperHeight + 'px',
offscreenLowerHeight: state.offscreenLowerHeight + 'px',
};
scopeTimeout($scope, function () {
state.visibleThreads.forEach(function (thread) {
visibleThreads.setThreadHeight(thread.id, getThreadHeight(thread.id));
});
}, 50);
});
rootThread.on('changed', function (thread) {
visibleThreads.setRootThread(thread);
});
$scope.$on('$destroy', function () {
visibleThreads.detach();
});
$scope.sortOptions = ['Newest', 'Oldest', 'Location']; $scope.sortOptions = ['Newest', 'Oldest', 'Location'];
...@@ -291,10 +333,41 @@ module.exports = function WidgetController( ...@@ -291,10 +333,41 @@ module.exports = function WidgetController(
return rootThread.thread().totalChildren; return rootThread.thread().totalChildren;
}; };
/**
* Return the offset between the top of the window and the top of the
* first annotation card.
*/
function cardListYOffset() {
var cardListTopEl = document.querySelector('.js-thread-list-top');
return cardListTopEl.getBoundingClientRect().top + window.pageYOffset;
}
/** Scroll the annotation with a given ID or $$tag into view. */
function scrollIntoView(id) {
var estimatedYOffset = visibleThreads.yOffsetOf(id);
var estimatedPos = estimatedYOffset - cardListYOffset();
window.scroll(0, estimatedPos);
// As a result of scrolling the sidebar, the heights of some of the cards
// above `id` might change because the initial estimate will be replaced by
// the actual known height after a card is rendered.
//
// So we wait briefly after the view is scrolled then check whether the
// estimated Y offset changed and if so, trigger scrolling again.
scopeTimeout($scope, function () {
var newYOffset = visibleThreads.yOffsetOf(id);
if (newYOffset !== estimatedYOffset) {
scrollIntoView(id);
}
}, 200);
}
$rootScope.$on(events.BEFORE_ANNOTATION_CREATED, 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;
} }
$scope.clearSelection(); $scope.clearSelection();
scrollIntoView(data.$$tag);
}); });
}; };
$thread-padding: $annotation-card-left-padding; $thread-padding: $annotation-card-left-padding;
.stream-list { .thread-list {
& > * { & > * {
margin-bottom: .72em; margin-bottom: .72em;
} }
...@@ -10,6 +10,13 @@ $thread-padding: $annotation-card-left-padding; ...@@ -10,6 +10,13 @@ $thread-padding: $annotation-card-left-padding;
} }
} }
.thread-list__spacer {
// This is a hidden element which is used to reserve space for off-screen
// threads, so it should not occupy any space other than that set via its
// 'height' inline style property.
margin: 0;
}
.annotation-unavailable-message { .annotation-unavailable-message {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
......
...@@ -11,6 +11,10 @@ ...@@ -11,6 +11,10 @@
right: 0; right: 0;
top: 0; top: 0;
z-index: 5; z-index: 5;
// Force top-bar onto a new compositor layer so that it does not judder when
// the window is scrolled.
transform: translate3d(0,0,0);
} }
.top-bar__inner { .top-bar__inner {
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
(See gh2642 for rationale for 'ng-show="true"') (See gh2642 for rationale for 'ng-show="true"')
--> -->
<ul class="stream-list ng-hide" <ul class="thread-list ng-hide"
ng-show="true" ng-show="true"
window-scroll="loadMore(20)"> window-scroll="loadMore(20)">
<search-status-bar <search-status-bar
...@@ -30,13 +30,15 @@ ...@@ -30,13 +30,15 @@
You do not have permission to see this annotation You do not have permission to see this annotation
</p> </p>
</li> </li>
<li id="{{vm.id}}" <li class="thread-list__spacer js-thread-list-top"
ng-style="{height: virtualThreadList.offscreenUpperHeight}"></li>
<li id="{{child.id}}"
class="annotation-card thread" class="annotation-card thread"
ng-class="{'js-hover': hasFocus(child.annotation)}" ng-class="{'js-hover': hasFocus(child.annotation)}"
ng-mouseenter="focus(child.annotation)" ng-mouseenter="focus(child.annotation)"
ng-click="scrollTo(child.annotation)" ng-click="scrollTo(child.annotation)"
ng-mouseleave="focus()" ng-mouseleave="focus()"
ng-repeat="child in rootThread().children track by child.id"> ng-repeat="child in virtualThreadList.visibleThreads track by child.id">
<annotation-thread <annotation-thread
thread="child" thread="child"
show-document-info="::!isSidebar" show-document-info="::!isSidebar"
...@@ -44,6 +46,8 @@ ...@@ -44,6 +46,8 @@
on-force-visible="forceVisible(thread)"> on-force-visible="forceVisible(thread)">
</annotation-thread> </annotation-thread>
</li> </li>
<li class="thread-list__spacer"
ng-style="{height: virtualThreadList.offscreenLowerHeight}"></li>
<loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()" <loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()"
on-login="login()" ng-cloak> on-login="login()" ng-cloak>
</loggedout-message> </loggedout-message>
......
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