Commit 8cddfa09 authored by Nick Stenning's avatar Nick Stenning Committed by GitHub

Merge pull request #93 from hypothesis/thread-list

Extract virtualized thread list into its own component
parents e9259a97 81cb27ee
...@@ -41,16 +41,8 @@ function AnnotationViewerController ( ...@@ -41,16 +41,8 @@ function AnnotationViewerController (
$location.path('/stream').search('q', query); $location.path('/stream').search('q', query);
}; };
function thread() {
return rootThread.thread(annotationUI.getState());
}
annotationUI.subscribe(function () { annotationUI.subscribe(function () {
$scope.virtualThreadList = { $scope.rootThread = rootThread.thread(annotationUI.getState());
visibleThreads: thread().children,
offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px',
};
}); });
$scope.setCollapsed = function (id, collapsed) { $scope.setCollapsed = function (id, collapsed) {
......
...@@ -163,6 +163,7 @@ module.exports = angular.module('h', [ ...@@ -163,6 +163,7 @@ module.exports = angular.module('h', [
.directive('spinner', require('./directive/spinner')) .directive('spinner', require('./directive/spinner'))
.directive('statusButton', require('./directive/status-button')) .directive('statusButton', require('./directive/status-button'))
.directive('tagEditor', require('./directive/tag-editor')) .directive('tagEditor', require('./directive/tag-editor'))
.directive('threadList', require('./directive/thread-list'))
.directive('timestamp', require('./directive/timestamp')) .directive('timestamp', require('./directive/timestamp'))
.directive('topBar', require('./directive/top-bar')) .directive('topBar', require('./directive/top-bar'))
.directive('windowScroll', require('./directive/window-scroll')) .directive('windowScroll', require('./directive/window-scroll'))
......
'use strict';
var angular = require('angular');
var EventEmitter = require('tiny-emitter');
var inherits = require('inherits');
var immutable = require('seamless-immutable');
var events = require('../../events');
var threadList = require('../thread-list');
var util = require('./util');
var annotFixtures = immutable({
annotation: {$$tag: 't1', id: '1', text: 'text'},
reply: {
$$tag: 't2',
id: '2',
references: ['1'],
text: 'areply',
},
highlight: {$highlight: true, $$tag: 't3', id: '3'},
});
var threadFixtures = immutable({
thread: {
children: [{
id: annotFixtures.annotation.id,
annotation: annotFixtures.annotation,
children: [{
id: annotFixtures.reply.id,
annotation: annotFixtures.reply,
children: [],
visible: true,
}],
visible: true,
},{
id: annotFixtures.highlight.id,
annotation: annotFixtures.highlight,
}],
},
});
var fakeVirtualThread;
function FakeVirtualThreadList($scope, $window, rootThread) {
fakeVirtualThread = this; // eslint-disable-line consistent-this
var thread = rootThread;
this.setRootThread = function (_thread) {
thread = _thread;
};
this.notify = function () {
this.emit('changed', {
offscreenLowerHeight: 10,
offscreenUpperHeight: 20,
visibleThreads: thread.children,
});
};
this.detach = sinon.stub();
this.yOffsetOf = function () {
return 42;
};
}
inherits(FakeVirtualThreadList, EventEmitter);
describe('threadList', function () {
function createThreadList(inputs) {
var defaultInputs = {
thread: threadFixtures.thread,
onClearSelection: sinon.stub(),
onForceVisible: sinon.stub(),
onFocus: sinon.stub(),
onSelect: sinon.stub(),
onSetCollapsed: sinon.stub(),
};
var element = util.createDirective(document, 'threadList',
Object.assign({}, defaultInputs, inputs));
return element;
}
before(function () {
angular.module('app', [])
.directive('threadList', threadList);
});
beforeEach(function () {
angular.mock.module('app', {
VirtualThreadList: FakeVirtualThreadList,
});
});
it('displays the children of the root thread', function () {
var element = createThreadList();
fakeVirtualThread.notify();
element.scope.$digest();
var children = element[0].querySelectorAll('annotation-thread');
assert.equal(children.length, 2);
});
describe('when a new annotation is created', function () {
var scrollSpy;
beforeEach(function () {
scrollSpy = sinon.stub(window, 'scroll');
});
afterEach(function () {
scrollSpy.restore();
});
it('scrolls the annotation into view', function () {
var element = createThreadList();
var annot = annotFixtures.annotation;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annot);
assert.called(scrollSpy);
});
it('does not scroll the annotation into view if it is a reply', function () {
var element = createThreadList();
var reply = annotFixtures.reply;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, reply);
assert.notCalled(scrollSpy);
});
it('does not scroll the annotation into view if it is a highlight', function () {
var element = createThreadList();
var highlight = annotFixtures.highlight;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, highlight);
assert.notCalled(scrollSpy);
});
it('clears the selection', function () {
var inputs = { onClearSelection: sinon.stub() };
var element = createThreadList(inputs);
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED,
annotFixtures.annotation);
assert.called(inputs.onClearSelection);
});
});
it('calls onFocus() when the user hovers an annotation', function () {
var inputs = {
onFocus: {
args: ['annotation'],
callback: sinon.stub(),
},
};
var element = createThreadList(inputs);
fakeVirtualThread.notify();
element.scope.$digest();
var annotation = element[0].querySelector('.thread-list__card');
util.sendEvent(annotation, 'mouseover');
assert.calledWithMatch(inputs.onFocus.callback,
sinon.match(annotFixtures.annotation));
});
it('calls onSelect() when a user clicks an annotation', function () {
var inputs = {
onSelect: {
args: ['annotation'],
callback: sinon.stub(),
},
};
var element = createThreadList(inputs);
fakeVirtualThread.notify();
element.scope.$digest();
var annotation = element[0].querySelector('.thread-list__card');
util.sendEvent(annotation, 'click');
assert.calledWithMatch(inputs.onSelect.callback,
sinon.match(annotFixtures.annotation));
});
});
'use strict';
var events = require('../events');
var metadata = require('../annotation-metadata');
/**
* Component which displays a virtualized list of annotation threads.
*/
var scopeTimeout = require('../util/scope-timeout');
/**
* Returns the height of the thread for an annotation if it exists in the view
* or undefined otherwise.
*/
function getThreadHeight(id) {
var threadElement = document.getElementById(id);
if (!threadElement) {
return null;
}
// 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;
}
// @ngInject
function ThreadListController($scope, VirtualThreadList) {
// `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 self = this;
var visibleThreads = new VirtualThreadList($scope, window, this.thread);
visibleThreads.on('changed', function (state) {
self.virtualThreadList = {
visibleThreads: state.visibleThreads,
offscreenUpperHeight: state.offscreenUpperHeight + 'px',
offscreenLowerHeight: state.offscreenLowerHeight + 'px',
};
scopeTimeout($scope, function () {
state.visibleThreads.forEach(function (thread) {
var height = getThreadHeight(thread.id);
if (!height) {
return;
}
visibleThreads.setThreadHeight(thread.id, height);
});
}, 50);
});
/**
* Return the vertical scroll offset for the document in order to position the
* annotation thread with a given `id` or $$tag at the top-left corner
* of the view.
*/
function scrollOffset(id) {
// Note: This assumes that the element occupies the entire height of the
// containing document. It would be preferable if only the contents of the
// <thread-list> itself scrolled.
var maxYOffset = document.body.clientHeight - window.innerHeight;
return Math.min(maxYOffset, visibleThreads.yOffsetOf(id));
}
/** Scroll the annotation with a given ID or $$tag into view. */
function scrollIntoView(id) {
var estimatedYOffset = scrollOffset(id);
window.scroll(0, estimatedYOffset);
// As a result of scrolling the sidebar, the target scroll offset for
// annotation `id` might have changed as a result of:
//
// 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
// estimated Y offset changed and if so, trigger scrolling again.
scopeTimeout($scope, function () {
var newYOffset = scrollOffset(id);
if (newYOffset !== estimatedYOffset) {
scrollIntoView(id);
}
}, 200);
}
$scope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, annotation) {
if (annotation.$highlight || metadata.isReply(annotation)) {
return;
}
self.onClearSelection();
scrollIntoView(annotation.$$tag);
});
this.$onChanges = function (changes) {
if (changes.thread) {
visibleThreads.setRootThread(changes.thread.currentValue);
}
};
this.$onDestroy = function () {
visibleThreads.detach();
};
}
module.exports = function () {
return {
bindToController: true,
controller: ThreadListController,
controllerAs: 'vm',
restrict: 'E',
scope: {
/** The root thread to be displayed by the thread list. */
thread: '<',
showDocumentInfo: '<',
/**
* Called when the user clicks a link to show an annotation that does not
* match the current filter.
*/
onForceVisible: '&',
/** Called when the user focuses an annotation by hovering it. */
onFocus: '&',
/** Called when a user selects an annotation. */
onSelect: '&',
/** Called when a user toggles the expansion state of an annotation thread. */
onChangeCollapsed: '&',
/** Called to clear the current selection. */
onClearSelection: '&',
},
template: require('../../../templates/client/thread_list.html'),
};
};
...@@ -62,15 +62,8 @@ module.exports = class StreamController ...@@ -62,15 +62,8 @@ module.exports = class StreamController
update: (q) -> $location.search({q: q}) update: (q) -> $location.search({q: q})
} }
thread = ->
rootThread.thread(annotationUI.getState())
annotationUI.subscribe( -> annotationUI.subscribe( ->
$scope.virtualThreadList = { $scope.rootThread = rootThread.thread(annotationUI.getState())
visibleThreads: thread().children,
offscreenUpperHeight: '0px',
offscreenLowerHeight: '0px',
};
); );
# Sort the stream so that the newest annotations are at the top # Sort the stream so that the newest annotations are at the top
......
...@@ -36,16 +36,6 @@ function FakeRootThread() { ...@@ -36,16 +36,6 @@ function FakeRootThread() {
} }
inherits(FakeRootThread, EventEmitter); 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;
...@@ -122,7 +112,6 @@ describe('WidgetController', function () { ...@@ -122,7 +112,6 @@ describe('WidgetController', function () {
search: sinon.stub(), search: sinon.stub(),
}; };
$provide.value('VirtualThreadList', FakeVirtualThreadList);
$provide.value('annotationMapper', fakeAnnotationMapper); $provide.value('annotationMapper', fakeAnnotationMapper);
$provide.value('crossframe', fakeCrossFrame); $provide.value('crossframe', fakeCrossFrame);
$provide.value('drafts', fakeDrafts); $provide.value('drafts', fakeDrafts);
...@@ -307,47 +296,6 @@ describe('WidgetController', function () { ...@@ -307,47 +296,6 @@ describe('WidgetController', function () {
}); });
}); });
describe('when a new annotation is created', function () {
var windowScroll;
beforeEach(function () {
$scope.clearSelection = sinon.stub();
windowScroll = sinon.stub(window, 'scroll');
});
afterEach(function () {
windowScroll.restore();
});
/**
* It should clear any selection that exists in the sidebar before
* creating a new annotation. Otherwise the new annotation with its
* form open for the user to type in won't be visible because it's
* not part of the selection.
*/
it('clears the selection', function () {
$rootScope.$broadcast('beforeAnnotationCreated', {});
assert.called($scope.clearSelection);
});
it('does not clear the selection if the new annotation is a highlight', function () {
$rootScope.$broadcast('beforeAnnotationCreated', {$highlight: true});
assert.notCalled($scope.clearSelection);
});
it('does not clear the selection if the new annotation is a reply', function () {
$rootScope.$broadcast('beforeAnnotationCreated', {
references: ['parent-id'],
});
assert.notCalled($scope.clearSelection);
});
it('scrolls the viewport to the new annotation', function () {
$rootScope.$broadcast('beforeAnnotationCreated', {$$tag: '123'});
assert.called(windowScroll);
});
});
describe('direct linking messages', function () { describe('direct linking messages', function () {
beforeEach(function () { beforeEach(function () {
......
...@@ -4,7 +4,6 @@ var SearchClient = require('./search-client'); ...@@ -4,7 +4,6 @@ var SearchClient = require('./search-client');
var events = require('./events'); var events = require('./events');
var memoize = require('./util/memoize'); var memoize = require('./util/memoize');
var metadata = require('./annotation-metadata'); var metadata = require('./annotation-metadata');
var scopeTimeout = require('./util/scope-timeout');
var tabCounts = require('./tab-counts'); var tabCounts = require('./tab-counts');
var uiConstants = require('./ui-constants'); var uiConstants = require('./ui-constants');
...@@ -36,46 +35,16 @@ function groupIDFromSelection(selection, results) { ...@@ -36,46 +35,16 @@ function groupIDFromSelection(selection, results) {
// @ngInject // @ngInject
module.exports = function WidgetController( module.exports = function WidgetController(
$scope, annotationUI, crossframe, annotationMapper, drafts, $scope, annotationUI, crossframe, annotationMapper, drafts,
features, groups, rootThread, settings, streamer, streamFilter, store, features, groups, rootThread, settings, streamer, streamFilter, store
VirtualThreadList
) { ) {
/**
* Returns the height of the thread for an annotation if it exists in the view
* or undefined otherwise.
*/
function getThreadHeight(id) {
var threadElement = document.getElementById(id);
if (!threadElement) {
return null;
}
// 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;
}
function thread() { function thread() {
return rootThread.thread(annotationUI.getState()); return rootThread.thread(annotationUI.getState());
} }
// `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, thread());
var unsubscribeAnnotationUI = annotationUI.subscribe(function () { var unsubscribeAnnotationUI = annotationUI.subscribe(function () {
var state = annotationUI.getState(); var state = annotationUI.getState();
visibleThreads.setRootThread(thread()); $scope.rootThread = thread();
$scope.selectedTab = state.selectedTab; $scope.selectedTab = state.selectedTab;
var counts = tabCounts(state.annotations, { var counts = tabCounts(state.annotations, {
...@@ -92,28 +61,6 @@ module.exports = function WidgetController( ...@@ -92,28 +61,6 @@ module.exports = function WidgetController(
$scope.$on('$destroy', unsubscribeAnnotationUI); $scope.$on('$destroy', unsubscribeAnnotationUI);
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) {
var height = getThreadHeight(thread.id);
if (!height) {
return;
}
visibleThreads.setThreadHeight(thread.id, height);
});
}, 50);
});
$scope.$on('$destroy', function () {
visibleThreads.detach();
});
function annotationExists(id) { function annotationExists(id) {
return annotationUI.getState().annotations.some(function (annot) { return annotationUI.getState().annotations.some(function (annot) {
return annot.id === id; return annot.id === id;
...@@ -346,13 +293,6 @@ module.exports = function WidgetController( ...@@ -346,13 +293,6 @@ module.exports = function WidgetController(
$scope.focus = focusAnnotation; $scope.focus = focusAnnotation;
$scope.scrollTo = scrollToAnnotation; $scope.scrollTo = scrollToAnnotation;
$scope.hasFocus = function (annotation) {
if (!annotation || !annotationUI.getState().focusedAnnotationMap) {
return false;
}
return annotation.$$tag in annotationUI.getState().focusedAnnotationMap;
};
$scope.selectedAnnotationCount = function () { $scope.selectedAnnotationCount = function () {
var selection = annotationUI.getState().selectedAnnotationMap; var selection = annotationUI.getState().selectedAnnotationMap;
if (!selection) { if (!selection) {
...@@ -404,46 +344,4 @@ module.exports = function WidgetController( ...@@ -404,46 +344,4 @@ module.exports = function WidgetController(
$scope.topLevelThreadCount = function () { $scope.topLevelThreadCount = function () {
return thread().totalChildren; return thread().totalChildren;
}; };
/**
* Return the vertical scroll offset for the document in order to position the
* annotation thread with a given `id` or $$tag at the top-left corner
* of the view.
*/
function scrollOffset(id) {
var maxYOffset = document.body.clientHeight - window.innerHeight;
return Math.min(maxYOffset, visibleThreads.yOffsetOf(id));
}
/** Scroll the annotation with a given ID or $$tag into view. */
function scrollIntoView(id) {
var estimatedYOffset = scrollOffset(id);
window.scroll(0, estimatedYOffset);
// As a result of scrolling the sidebar, the target scroll offset for
// annotation `id` might have changed as a result of:
//
// 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
// estimated Y offset changed and if so, trigger scrolling again.
scopeTimeout($scope, function () {
var newYOffset = scrollOffset(id);
if (newYOffset !== estimatedYOffset) {
scrollIntoView(id);
}
}, 200);
}
$scope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, data) {
if (data.$highlight || (data.references && data.references.length > 0)) {
return;
}
$scope.clearSelection();
scrollIntoView(data.$$tag);
});
}; };
@import "mixins/icons"; @import "mixins/icons";
//ANNOTATION CARD//////////////////////////////// // Highlight quote of annotation whenever its thread is hovered
.thread-list__card:hover .annotation-quote {
.annotation-card { border-left: $highlight 3px solid;
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.10); color: $grey-5;
border-radius: 2px;
cursor: pointer;
padding: $layout-h-margin;
background-color: $white;
}
.annotation-card:hover {
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15);
.annotation-header__user {
color: $grey-7;
}
.annotation-quote {
border-left: $highlight 3px solid;
color: $grey-5;
}
} }
// When hovering a top-level annotation, show the footer in a hovered state. // When hovering a top-level annotation, show the footer in a hovered state.
// When hovering a reply (at any level), show the reply's own footer in // When hovering a reply (at any level), show the reply's own footer in
// a hovered state and also the footer of the top-level annotation. // a hovered state and also the footer of the top-level annotation.
.annotation-card:hover > .annotation, .thread-list__card:hover > .annotation,
.annotation:hover { .annotation:hover {
.annotation-replies__link, .annotation-replies__link,
.annotation-replies__count, .annotation-replies__count,
......
...@@ -27,6 +27,7 @@ $base-line-height: 20px; ...@@ -27,6 +27,7 @@ $base-line-height: 20px;
@import './simple-search'; @import './simple-search';
@import './spinner'; @import './spinner';
@import './tags-input'; @import './tags-input';
@import './thread-list';
@import './tooltip'; @import './tooltip';
@import './top-bar'; @import './top-bar';
...@@ -108,20 +109,6 @@ body { ...@@ -108,20 +109,6 @@ body {
} }
} }
.thread-list {
& > * {
// Default spacing between items in the annotation card list
margin-bottom: .72em;
}
}
.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;
......
.thread-list {
& > * {
// Default spacing between items in the annotation card list
margin-bottom: .72em;
}
}
.thread-list__card {
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.10);
border-radius: 2px;
cursor: pointer;
padding: $layout-h-margin;
background-color: $white;
&:hover {
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15);
}
}
.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;
}
<ul class="thread-list">
<li class="thread-list__spacer"
ng-style="{height: vm.virtualThreadList.offscreenUpperHeight}"></li>
<li id="{{child.id}}"
class="thread-list__card"
ng-mouseenter="vm.onFocus({annotation: child.annotation})"
ng-click="vm.onSelect({annotation: child.annotation})"
ng-mouseleave="vm.onFocus({annotation: null})"
ng-repeat="child in vm.virtualThreadList.visibleThreads track by child.id">
<annotation-thread
thread="child"
show-document-info="vm.showDocumentInfo"
on-change-collapsed="vm.onChangeCollapsed({id: id, collapsed: collapsed})"
on-force-visible="vm.onForceVisible({thread: thread})">
</annotation-thread>
</li>
<li class="thread-list__spacer"
ng-style="{height: vm.virtualThreadList.offscreenLowerHeight}"></li>
</ul>
...@@ -8,62 +8,49 @@ ...@@ -8,62 +8,49 @@
total-orphans="totalOrphans"> total-orphans="totalOrphans">
</selection-tabs> </selection-tabs>
<!-- Annotation thread view <search-status-bar
ng-show="!isLoading()"
ng-if="!isStream"
filter-active="!!search.query()"
filter-match-count="visibleCount()"
on-clear-selection="clearSelection()"
search-query="search ? search.query : ''"
selection-count="selectedAnnotationCount()"
total-count="topLevelThreadCount()"
selected-tab="selectedTab"
total-annotations="totalAnnotations"
total-notes="totalNotes">
</search-status-bar>
(See gh2642 for rationale for 'ng-show="true"') <div class="annotation-unavailable-message"
--> ng-if="selectedAnnotationUnavailable()">
<ul class="thread-list ng-hide" <div class="annotation-unavailable-message__icon"></div>
ng-show="true" <p class="annotation-unavailable-message__label">
window-scroll="loadMore(20)"> <span ng-if="auth.status === 'logged-out'">
<search-status-bar This annotation is not available.
ng-show="!isLoading()" <br>
ng-if="!isStream" You may need to
filter-active="!!search.query()" <a class="loggedout-message__link" href="" ng-click="login()">log in</a>
filter-match-count="visibleCount()" to see it.
</span>
<span ng-if="auth.status === 'logged-in'">
You do not have permission to view this annotation.
</span>
</p>
</div>
<span window-scroll="loadMore(20)">
<thread-list
on-change-collapsed="setCollapsed(id, collapsed)"
on-clear-selection="clearSelection()" on-clear-selection="clearSelection()"
search-query="search ? search.query : ''" on-focus="focus(annotation)"
selection-count="selectedAnnotationCount()" on-force-visible="forceVisible(thread)"
total-count="topLevelThreadCount()" on-select="scrollTo(annotation)"
selected-tab="selectedTab" show-document-info="::!isSidebar"
total-annotations="totalAnnotations" thread="rootThread">
total-notes="totalNotes"> </thread-list>
</search-status-bar> </span>
<li class="annotation-unavailable-message"
ng-if="selectedAnnotationUnavailable()"> <loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()"
<div class="annotation-unavailable-message__icon"></div> on-login="login()" ng-cloak>
<p class="annotation-unavailable-message__label"> </loggedout-message>
<span ng-if="auth.status === 'logged-out'">
This annotation is not available.
<br>
You may need to
<a class="loggedout-message__link" href="" ng-click="login()">log in</a>
to see it.
</span>
<span ng-if="auth.status === 'logged-in'">
You do not have permission to view this annotation.
</span>
</p>
</li>
<li class="thread-list__spacer"
ng-style="{height: virtualThreadList.offscreenUpperHeight}"></li>
<li id="{{child.id}}"
class="annotation-card"
ng-class="{'js-hover': hasFocus(child.annotation)}"
ng-mouseenter="focus(child.annotation)"
ng-click="scrollTo(child.annotation)"
ng-mouseleave="focus()"
ng-repeat="child in virtualThreadList.visibleThreads track by child.id">
<annotation-thread
thread="child"
show-document-info="::!isSidebar"
on-change-collapsed="setCollapsed(id, collapsed)"
on-force-visible="forceVisible(thread)">
</annotation-thread>
</li>
<li class="thread-list__spacer"
ng-style="{height: virtualThreadList.offscreenLowerHeight}"></li>
<loggedout-message ng-if="isSidebar && shouldShowLoggedOutMessage()"
on-login="login()" ng-cloak>
</loggedout-message>
</ul>
<!-- / Thread view -->
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