Commit 66a5da9a authored by Robert Knight's avatar Robert Knight Committed by Nick Stenning

Improve logic for scrolling sidebar to annotation with a given ID (#3360)

The previous logic contained several errors and omissions which could
result in the card not appearing at the top of the viewport after
scrolling.

 1. If the set of visible threads changed multiple times before the timeout
    that triggered re-measuring of visible thread heights changed, then
    a measured height could be replaced with 0.

    Add checks to catch this.

 2. Account for the target scroll offset changing as a result of
    the document height increasing after the actual heights of
    cards become known and consequently the maximum possible
    scroll offset changes.

 3. Correct calculation of the position that we need to scroll the
    window to in order to have a card positioned at the top
    of the window. For the first card in the list, the position
    should be 0 (as returned by visibleThreads.yOffsetOf(...)),
    and for the Nth card it should be the total height of the N-1
    previous cards.
parent 4f194af8
...@@ -286,18 +286,14 @@ describe('WidgetController', function () { ...@@ -286,18 +286,14 @@ describe('WidgetController', function () {
describe('when a new annotation is created', function () { describe('when a new annotation is created', function () {
var windowScroll; var windowScroll;
var cardListTopEl;
beforeEach(function () { beforeEach(function () {
$scope.clearSelection = sinon.stub(); $scope.clearSelection = sinon.stub();
windowScroll = sinon.stub(window, 'scroll'); windowScroll = sinon.stub(window, 'scroll');
cardListTopEl = $('<div class="js-thread-list-top"></div>');
cardListTopEl.appendTo(document.body);
}); });
afterEach(function () { afterEach(function () {
windowScroll.restore(); windowScroll.restore();
cardListTopEl.remove();
}); });
/** /**
......
...@@ -75,10 +75,12 @@ VirtualThreadList.prototype.setRootThread = function (thread) { ...@@ -75,10 +75,12 @@ VirtualThreadList.prototype.setRootThread = function (thread) {
* is used. * is used.
* *
* @param {string} id - The annotation ID or $$tag * @param {string} id - The annotation ID or $$tag
* @param {number?} height - The height of the annotation or undefined to * @param {number} height - The height of the annotation thread.
* revert to the default height for this thread.
*/ */
VirtualThreadList.prototype.setThreadHeight = function (id, height) { VirtualThreadList.prototype.setThreadHeight = function (id, height) {
if (isNaN(height) || height <= 0) {
throw new Error('Invalid thread height %d', height);
}
this._heights[id] = height; this._heights[id] = height;
}; };
......
...@@ -36,6 +36,11 @@ module.exports = function WidgetController( ...@@ -36,6 +36,11 @@ module.exports = function WidgetController(
drafts, groups, rootThread, settings, streamer, streamFilter, store, drafts, groups, rootThread, settings, streamer, streamFilter, store,
VirtualThreadList VirtualThreadList
) { ) {
/**
* Returns the height of the thread for an annotation if it exists in the view
* or undefined otherwise.
*/
function getThreadHeight(id) { function getThreadHeight(id) {
var threadElement = document.getElementById(id); var threadElement = document.getElementById(id);
if (!threadElement) { if (!threadElement) {
...@@ -56,6 +61,10 @@ module.exports = function WidgetController( ...@@ -56,6 +61,10 @@ module.exports = function WidgetController(
return elementHeight + marginHeight; return elementHeight + marginHeight;
} }
// `visibleThreads` keeps track of the subset of all threads matching the
// current filters which are in or near the viewport and the view then renders
// only those threads, using placeholders above and below the visible threads
// to reserve space for threads which are not actually rendered.
var visibleThreads = new VirtualThreadList($scope, window, rootThread.thread()); var visibleThreads = new VirtualThreadList($scope, window, rootThread.thread());
visibleThreads.on('changed', function (state) { visibleThreads.on('changed', function (state) {
$scope.virtualThreadList = { $scope.virtualThreadList = {
...@@ -66,7 +75,11 @@ module.exports = function WidgetController( ...@@ -66,7 +75,11 @@ module.exports = function WidgetController(
scopeTimeout($scope, function () { scopeTimeout($scope, function () {
state.visibleThreads.forEach(function (thread) { state.visibleThreads.forEach(function (thread) {
visibleThreads.setThreadHeight(thread.id, getThreadHeight(thread.id)); var height = getThreadHeight(thread.id);
if (!height) {
return;
}
visibleThreads.setThreadHeight(thread.id, height);
}); });
}, 50); }, 50);
}); });
...@@ -334,29 +347,33 @@ module.exports = function WidgetController( ...@@ -334,29 +347,33 @@ module.exports = function WidgetController(
}; };
/** /**
* Return the offset between the top of the window and the top of the * Return the vertical scroll offset for the document in order to position the
* first annotation card. * annotation thread with a given `id` or $$tag at the top-left corner
* of the view.
*/ */
function cardListYOffset() { function scrollOffset(id) {
var cardListTopEl = document.querySelector('.js-thread-list-top'); var maxYOffset = document.body.clientHeight - window.innerHeight;
return cardListTopEl.getBoundingClientRect().top + window.pageYOffset; return Math.min(maxYOffset, visibleThreads.yOffsetOf(id));
} }
/** Scroll the annotation with a given ID or $$tag into view. */ /** Scroll the annotation with a given ID or $$tag into view. */
function scrollIntoView(id) { function scrollIntoView(id) {
var estimatedYOffset = visibleThreads.yOffsetOf(id); var estimatedYOffset = scrollOffset(id);
var estimatedPos = estimatedYOffset - cardListYOffset(); window.scroll(0, estimatedYOffset);
window.scroll(0, estimatedPos);
// As a result of scrolling the sidebar, the heights of some of the cards // As a result of scrolling the sidebar, the target scroll offset for
// above `id` might change because the initial estimate will be replaced by // annotation `id` might have changed as a result of:
// the actual known height after a card is rendered. //
// 1. Heights of some cards above `id` changing from an initial estimate to
// an actual measured height after the card is rendered.
// 2. The height of the document changing as a result of any cards heights'
// changing. This may affect the scroll offset if the original target
// was near to the bottom of the list.
// //
// So we wait briefly after the view is scrolled then check whether the // So we wait briefly after the view is scrolled then check whether the
// estimated Y offset changed and if so, trigger scrolling again. // estimated Y offset changed and if so, trigger scrolling again.
scopeTimeout($scope, function () { scopeTimeout($scope, function () {
var newYOffset = visibleThreads.yOffsetOf(id); var newYOffset = scrollOffset(id);
if (newYOffset !== estimatedYOffset) { if (newYOffset !== estimatedYOffset) {
scrollIntoView(id); scrollIntoView(id);
} }
......
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