Commit d28ac796 authored by Lyza Danger Gardner's avatar Lyza Danger Gardner

Remove angular `thread-list` and its supporting modules

- Replace ng-1 `thread-list` with preact `ThreadList`
  (renamed from `ThreadListOmega`)
- Remove SCSS (new `ThreadList` has no CSS)
- Remove `virtual-thread-list`
parent 35e92333
import { mount } from 'enzyme';
import { createElement } from 'preact';
import events from '../../events';
import { act } from 'preact/test-utils';
import ThreadList from '../thread-list-omega';
import { $imports } from '../thread-list-omega';
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('ThreadListOmega', () => {
let fakeDebounce;
let fakeDomUtil;
let fakeMetadata;
let fakeTopThread;
let fakeRootScope;
let fakeScrollContainer;
let fakeStore;
let fakeVisibleThreadsUtil;
function createComponent(props) {
return mount(
<ThreadList
thread={fakeTopThread}
$rootScope={fakeRootScope}
{...props}
/>,
{ attachTo: fakeScrollContainer }
);
}
beforeEach(() => {
fakeDebounce = sinon.stub().returns(() => null);
fakeDomUtil = {
getElementHeightWithMargins: sinon.stub().returns(0),
};
fakeMetadata = {
isHighlight: sinon.stub().returns(false),
isReply: sinon.stub().returns(false),
};
fakeRootScope = {
eventCallbacks: {},
$apply: function (callback) {
callback();
},
$on: function (event, callback) {
if (event === events.BEFORE_ANNOTATION_CREATED) {
this.eventCallbacks[event] = callback;
}
},
$broadcast: sinon.stub(),
};
fakeScrollContainer = document.createElement('div');
fakeScrollContainer.className = 'js-thread-list-scroll-root';
fakeScrollContainer.style.height = '2000px';
document.body.appendChild(fakeScrollContainer);
fakeStore = {
clearSelection: sinon.stub(),
};
fakeTopThread = {
id: 't0',
annotation: { $tag: 'myTag0' },
children: [
{ id: 't1', children: [], annotation: { $tag: 't1' } },
{ id: 't2', children: [], annotation: { $tag: 't2' } },
{ id: 't3', children: [], annotation: { $tag: 't3' } },
{ id: 't4', children: [], annotation: { $tag: 't4' } },
],
};
fakeVisibleThreadsUtil = {
calculateVisibleThreads: sinon.stub().returns({
visibleThreads: fakeTopThread.children,
offscreenUpperHeight: 400,
offscreenLowerHeight: 600,
}),
THREAD_DIMENSION_DEFAULTS: {
defaultHeight: 200,
},
};
$imports.$mock(mockImportedComponents());
$imports.$mock({
'lodash.debounce': fakeDebounce,
'../store/use-store': callback => callback(fakeStore),
'../util/annotation-metadata': fakeMetadata,
'../util/dom': fakeDomUtil,
'../util/visible-threads': fakeVisibleThreadsUtil,
});
});
afterEach(() => {
$imports.$restore();
fakeScrollContainer.remove();
});
it('works', () => {
const wrapper = createComponent();
assert.isTrue(wrapper.find('section').exists());
});
it('calculates visible threads', () => {
createComponent();
assert.calledWith(
fakeVisibleThreadsUtil.calculateVisibleThreads,
fakeTopThread.children,
sinon.match({}),
0,
sinon.match.number
);
});
context('new annotation created in application', () => {
it('attaches a listener for the BEFORE_ANNOTATION_CREATED event', () => {
fakeRootScope.$on = sinon.stub();
createComponent();
assert.calledWith(
fakeRootScope.$on,
events.BEFORE_ANNOTATION_CREATED,
sinon.match.func
);
});
it('clears the current selection in the store', () => {
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.calledOnce(fakeStore.clearSelection);
});
it('does not clear the selection in the store if new annotation is a highlight', () => {
fakeMetadata.isHighlight.returns(true);
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.notCalled(fakeStore.clearSelection);
});
it('does not clear the selection in the store if new annotation is a reply', () => {
fakeMetadata.isReply.returns(true);
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.notCalled(fakeStore.clearSelection);
});
});
context('active scroll to an annotation thread', () => {
let stubbedDocument;
let stubbedFakeScrollContainer;
let fakeScrollTop;
beforeEach(() => {
fakeScrollTop = sinon.stub();
stubbedFakeScrollContainer = sinon
.stub(fakeScrollContainer, 'scrollTop')
.set(fakeScrollTop);
stubbedDocument = sinon
.stub(document, 'querySelector')
.returns(fakeScrollContainer);
});
afterEach(() => {
stubbedFakeScrollContainer.restore();
stubbedDocument.restore();
});
it('should do nothing if there is no active annotation thread to scroll to', () => {
createComponent();
assert.notCalled(fakeScrollTop);
});
it('should do nothing if the annotation thread to scroll to is not in DOM', () => {
createComponent();
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
{ $tag: 'nonexistent' }
);
});
assert.notCalled(fakeScrollTop);
});
it('should set the scroll container `scrollTop` to derived position of thread', () => {
createComponent();
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
fakeTopThread.children[3].annotation
);
});
// The third thread in a collection of threads at default height (200)
// should be at 600px. This setting of `scrollTop` is the only externally-
// observable thing that happens here...
assert.calledWith(fakeScrollTop, 600);
});
});
describe('scroll and resize event handling', () => {
let debouncedFn;
beforeEach(() => {
debouncedFn = sinon.stub();
fakeDebounce.returns(debouncedFn);
});
it('updates scroll position and window height for recalculation on container scroll', () => {
createComponent();
document
.querySelector('.js-thread-list-scroll-root')
.dispatchEvent(new Event('scroll'));
assert.calledOnce(debouncedFn);
});
it('updates scroll position and window height for recalculation on window resize', () => {
createComponent();
window.dispatchEvent(new Event('resize'));
assert.calledOnce(debouncedFn);
});
});
context('when top-level threads change', () => {
it('recalculates thread heights', () => {
const wrapper = createComponent();
// Initial render and effect hooks will trigger calculation twice
fakeDomUtil.getElementHeightWithMargins.resetHistory();
// Let's see it respond to the top-level threads changing
wrapper.setProps({ thread: fakeTopThread });
// It should check the element height for each top-level thread (assuming
// they are all onscreen, which these test threads "are")
assert.equal(
fakeDomUtil.getElementHeightWithMargins.callCount,
fakeTopThread.children.length
);
});
});
it('renders dimensional elements above and below visible threads', () => {
const wrapper = createComponent();
const upperDiv = wrapper.find('div').first();
const lowerDiv = wrapper.find('div').last();
assert.equal(upperDiv.getDOMNode().style.height, '400px');
assert.equal(lowerDiv.getDOMNode().style.height, '600px');
});
it('renders a `ThreadCard` for each visible thread', () => {
const wrapper = createComponent();
const cards = wrapper.find('ThreadCard');
assert.equal(cards.length, fakeTopThread.children.length);
});
it(
'should pass a11y checks',
checkAccessibility({
content: () => {
const wrapper = createComponent();
return wrapper;
},
})
);
});
import { createElement } from 'preact';
import { useEffect, useState } from 'preact/hooks';
import propTypes from 'prop-types';
import debounce from 'lodash.debounce';
import shallowEqual from 'shallowequal';
import events from '../events';
import useStore from '../store/use-store';
import { isHighlight, isReply } from '../util/annotation-metadata';
import { getElementHeightWithMargins } from '../util/dom';
import { withServices } from '../util/service-context';
import {
calculateVisibleThreads,
THREAD_DIMENSION_DEFAULTS,
} from '../util/visible-threads';
import ThreadCard from './thread-card';
function getScrollContainer() {
return document.querySelector('.js-thread-list-scroll-root');
}
/**
* Render a list of threads, but only render those that are in or near the
* current browser viewport.
*/
function ThreadListOmega({ thread, $rootScope }) {
const clearSelection = useStore(store => store.clearSelection);
// Height of the scrollable container. For the moment, this is the same as
// the window height.
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
// Scroll offset of scroll container. This is updated after the scroll
// container is scrolled, with debouncing to limit update frequency.
const [scrollPosition, setScrollPosition] = useState(
getScrollContainer().scrollTop
);
// Map of thread ID to measured height of thread.
const [threadHeights, setThreadHeights] = useState({});
// ID of thread to scroll to after the next render. If the thread is not
// present, the value persists until it can be "consumed".
const [scrollToId, setScrollToId] = useState(null);
const topLevelThreads = thread.children;
const topLevelThreadIds = topLevelThreads.map(t => t.id);
const {
offscreenLowerHeight,
offscreenUpperHeight,
visibleThreads,
} = calculateVisibleThreads(
topLevelThreads,
threadHeights,
scrollPosition,
windowHeight
);
// Listen for when a new annotation is created in the application, and trigger
// a scroll to that annotation's thread card (unless highlight or reply)
useEffect(() => {
const removeListener = $rootScope.$on(
events.BEFORE_ANNOTATION_CREATED,
(event, annotation) => {
if (!isHighlight(annotation) && !isReply(annotation)) {
clearSelection();
setScrollToId(annotation.$tag);
}
}
);
return removeListener;
}, [$rootScope, clearSelection]);
// Effect to scroll a particular thread into view. This is mainly used to
// scroll a newly created annotation into view (as triggered by the
// listener for `BEFORE_ANNOTATION_CREATED`)
useEffect(() => {
if (!scrollToId) {
return;
}
const threadIndex = topLevelThreads.findIndex(t => t.id === scrollToId);
if (threadIndex === -1) {
// Thread is not currently present.
//
// When `scrollToId` is set as a result of a `BEFORE_ANNOTATION_CREATED`
// event, the annotation is not always present in the _next_ render after
// that event is received, but in another render after that. Therefore
// we wait until the annotation appears before "consuming" the scroll-to-id.
return;
}
// Clear `scrollToId` so we don't scroll again after the next render.
setScrollToId(null);
const getThreadHeight = thread =>
threadHeights[thread.id] || THREAD_DIMENSION_DEFAULTS.defaultHeight;
const yOffset = topLevelThreads
.slice(0, threadIndex)
.reduce((total, thread) => total + getThreadHeight(thread), 0);
const scrollContainer = getScrollContainer();
scrollContainer.scrollTop = yOffset;
}, [scrollToId, topLevelThreads, threadHeights]);
// Attach listeners such that whenever the scroll container is scrolled or the
// window resized, a recalculation of visible threads is triggered
useEffect(() => {
const scrollContainer = getScrollContainer();
const updateScrollPosition = debounce(
() => {
setWindowHeight(window.innerHeight);
setScrollPosition(scrollContainer.scrollTop);
},
10,
{ maxWait: 100 }
);
scrollContainer.addEventListener('scroll', updateScrollPosition);
window.addEventListener('resize', updateScrollPosition);
return () => {
scrollContainer.removeEventListener('scroll', updateScrollPosition);
window.removeEventListener('resize', updateScrollPosition);
};
}, []);
// When the set of top-level threads changes, recalculate the real rendered
// heights of thread cards and update `threadHeights` state if there are changes.
useEffect(() => {
let newHeights = {};
for (let id of topLevelThreadIds) {
const threadElement = document.getElementById(id);
if (!threadElement) {
// Thread is currently off screen.
continue;
}
const height = getElementHeightWithMargins(threadElement);
if (height !== null) {
newHeights[id] = height;
}
}
// Functional update of `threadHeights` state: `heights` is previous state
setThreadHeights(heights => {
// Merge existing and new heights.
newHeights = Object.assign({}, heights, newHeights);
// Skip update if no heights actually changed.
if (shallowEqual(heights, newHeights)) {
return heights;
}
return newHeights;
});
}, [topLevelThreadIds]);
return (
<section>
<div style={{ height: offscreenUpperHeight }} />
{visibleThreads.map(child => (
<div id={child.id} key={child.id}>
<ThreadCard thread={child} />
</div>
))}
<div style={{ height: offscreenLowerHeight }} />
</section>
);
}
ThreadListOmega.propTypes = {
/** Should annotations render extra document metadata? */
thread: propTypes.object.isRequired,
/** injected */
$rootScope: propTypes.object.isRequired,
};
ThreadListOmega.injectedProps = ['$rootScope'];
export default withServices(ThreadListOmega);
This diff is collapsed.
......@@ -126,6 +126,7 @@ import ShareAnnotationsPanel from './components/share-annotations-panel';
import SidebarContentError from './components/sidebar-content-error';
import SvgIcon from '../shared/components/svg-icon';
import Thread from './components/thread';
import ThreadList from './components/thread-list';
import ToastMessages from './components/toast-messages';
import TopBar from './components/top-bar';
......@@ -135,8 +136,6 @@ import annotationViewerContent from './components/annotation-viewer-content';
import hypothesisApp from './components/hypothesis-app';
import sidebarContent from './components/sidebar-content';
import streamContent from './components/stream-content';
import threadList from './components/thread-list';
import threadListOmega from './components/thread-list-omega';
// Services.
......@@ -268,8 +267,7 @@ function startAngularApp(config) {
.component('streamContent', streamContent)
.component('svgIcon', wrapComponent(SvgIcon))
.component('thread', wrapComponent(Thread))
.component('threadList', threadList)
.component('threadListOmega', wrapComponent(threadListOmega))
.component('threadList', wrapComponent(ThreadList))
.component('toastMessages', wrapComponent(ToastMessages))
.component('topBar', wrapComponent(TopBar))
......
......@@ -33,15 +33,7 @@
ng-if="!vm.isLoading() && !(vm.selectedAnnotationUnavailable() || vm.selectedGroupUnavailable())">
</search-status-bar>
<thread-list
on-change-collapsed="vm.setCollapsed(id, collapsed)"
on-focus="vm.focus(annotation)"
on-select="vm.scrollTo(annotation)"
show-document-info="false"
ng-if="!vm.selectedGroupUnavailable()"
thread="vm.rootThread()">
</thread-list>
<thread-list thread="vm.rootThread()"></thread-list>
<logged-out-message ng-if="vm.shouldShowLoggedOutMessage()" on-login="vm.onLogin()">
</logged-out-message>
<ul class="thread-list">
<li class="thread-list__spacer"
ng-style="{height: vm.virtualThreadList.offscreenUpperHeight}"></li>
<li ng-repeat="child in vm.virtualThreadList.visibleThreads track by child.id">
<div id="{{child.id}}"
class="thread-list__card"
ng-mouseenter="vm.onFocus({annotation: child.annotation})"
ng-class="{'thread-list__card--theme-clean' : vm.isThemeClean }"
ng-click="vm.onSelect({annotation: child.annotation})"
ng-mouseleave="vm.onFocus({annotation: null})">
<thread thread="child" show-document-info="vm.showDocumentInfo"></thread>
</div>
<hr ng-if="vm.isThemeClean"
class="thread-list__separator--theme-clean" />
</li>
<li class="thread-list__spacer"
ng-style="{height: vm.virtualThreadList.offscreenLowerHeight}"></li>
</ul>
import VirtualThreadList from '../virtual-thread-list';
import { $imports } from '../virtual-thread-list';
describe('VirtualThreadList', function () {
let lastState;
let threadList;
const threadOptions = {};
let fakeScope;
let fakeScrollRoot;
let fakeWindow;
function idRange(start, end) {
const ary = [];
for (let 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(() => {
const fakeDebounce = callback => {
const debounced = () => {
// Update synchronously instead of really debouncing.
callback();
};
debounced.cancel = sinon.stub();
return debounced;
};
$imports.$mock({
'lodash.debounce': fakeDebounce,
});
});
afterEach(() => {
$imports.$restore();
});
beforeEach(function () {
fakeScope = { $digest: sinon.stub() };
fakeScrollRoot = {
scrollTop: 0,
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();
});
},
};
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,
};
threadOptions.scrollRoot = fakeScrollRoot;
const rootThread = { annotation: undefined, children: [] };
threadList = new VirtualThreadList(
fakeScope,
fakeWindow,
rootThread,
threadOptions
);
threadList.on('changed', function (state) {
lastState = state;
});
});
[
{
when: 'scrollRoot is scrolled to top of list',
threads: 100,
scrollOffset: 0,
windowHeight: 300,
expectedVisibleThreads: idRange(0, 5),
expectedHeightAbove: 0,
expectedHeightBelow: 18800,
},
{
when: 'scrollRoot is scrolled to middle of list',
threads: 100,
scrollOffset: 2000,
windowHeight: 300,
expectedVisibleThreads: idRange(5, 15),
expectedHeightAbove: 1000,
expectedHeightBelow: 16800,
},
{
when: 'scrollRoot is scrolled to bottom of list',
threads: 100,
scrollOffset: 18800,
windowHeight: 300,
expectedVisibleThreads: idRange(89, 99),
expectedHeightAbove: 17800,
expectedHeightBelow: 0,
},
].forEach(testCase => {
it(`generates expected state when ${testCase.when}`, () => {
const thread = generateRootThread(testCase.threads);
fakeScrollRoot.scrollTop = testCase.scrollOffset;
fakeWindow.innerHeight = testCase.windowHeight;
threadList.setRootThread(thread);
const visibleIDs = threadIDs(lastState.visibleThreads);
assert.deepEqual(visibleIDs, testCase.expectedVisibleThreads);
assert.equal(
lastState.offscreenUpperHeight,
testCase.expectedHeightAbove
);
assert.equal(
lastState.offscreenLowerHeight,
testCase.expectedHeightBelow
);
});
});
it('recalculates when a window.resize occurs', function () {
lastState = null;
fakeWindow.trigger('resize');
assert.ok(lastState);
});
it('recalculates when a scrollRoot.scroll occurs', function () {
lastState = null;
fakeScrollRoot.trigger('scroll');
assert.ok(lastState);
});
it('recalculates when root thread changes', function () {
threadList.setRootThread({ annotation: undefined, children: [] });
assert.ok(lastState);
});
describe('#setThreadHeight', function () {
[
{
threadHeight: 1000,
expectedVisibleThreads: idRange(0, 1),
},
{
threadHeight: 300,
expectedVisibleThreads: idRange(0, 4),
},
].forEach(testCase => {
it('affects visible threads', () => {
const thread = generateRootThread(10);
fakeWindow.innerHeight = 500;
fakeScrollRoot.scrollTop = 0;
idRange(0, 10).forEach(function (id) {
threadList.setThreadHeight(id, testCase.threadHeight);
});
threadList.setRootThread(thread);
assert.deepEqual(
threadIDs(lastState.visibleThreads),
testCase.expectedVisibleThreads
);
});
});
});
describe('#detach', function () {
it('stops listening to window.resize events', function () {
threadList.detach();
lastState = null;
fakeWindow.trigger('resize');
assert.isNull(lastState);
});
it('stops listening to scrollRoot.scroll events', function () {
threadList.detach();
lastState = null;
fakeScrollRoot.trigger('scroll');
assert.isNull(lastState);
});
});
describe('#yOffsetOf', function () {
[
{
nth: 'first',
index: 0,
offset: 0,
},
{
nth: 'second',
index: 1,
offset: 100,
},
{
nth: 'last',
index: 9,
offset: 900,
},
].forEach(testCase => {
it(`returns ${testCase.offset} as the Y offset of the ${testCase.nth} thread`, () => {
const thread = generateRootThread(10);
threadList.setRootThread(thread);
idRange(0, 10).forEach(function (id) {
threadList.setThreadHeight(id, 100);
});
const id = idRange(testCase.index, testCase.index)[0];
assert.equal(threadList.yOffsetOf(id), testCase.offset);
});
});
});
});
import debounce from 'lodash.debounce';
import EventEmitter from 'tiny-emitter';
/**
* @typedef Options
* @property {Element} [scrollRoot] - The scrollable Element which contains the
* thread list. The set of on-screen threads is determined based on the scroll
* position and height of this element.
*/
/**
* 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.
*/
export default class VirtualThreadList extends EventEmitter {
/*
* @param {Window} container - The Window displaying the list of annotation threads.
* @param {Thread} rootThread - The initial Thread object for the top-level
* threads.
* @param {Options} options
*/
constructor($scope, window_, rootThread, options) {
super();
this._rootThread = rootThread;
// Cache of thread ID -> last-seen height
this._heights = {};
this.window = window_;
this.scrollRoot = options.scrollRoot || document.body;
const debouncedUpdate = debounce(
() => {
this.calculateVisibleThreads();
$scope.$digest();
},
10,
{ maxWait: 100 }
);
this.scrollRoot.addEventListener('scroll', debouncedUpdate);
this.window.addEventListener('resize', debouncedUpdate);
this._detach = function () {
this.scrollRoot.removeEventListener('scroll', debouncedUpdate);
this.window.removeEventListener('resize', debouncedUpdate);
debouncedUpdate.cancel();
};
}
/**
* Detach event listeners and clear any pending timeouts.
*
* This should be invoked when the UI view presenting the virtual thread list
* is torn down.
*/
detach() {
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.
*/
setRootThread(thread) {
if (thread === this._rootThread) {
return;
}
this._rootThread = thread;
this.calculateVisibleThreads();
}
/**
* 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 thread.
*/
setThreadHeight(id, height) {
if (isNaN(height) || height <= 0) {
throw new Error('Invalid thread height %d', height);
}
this._heights[id] = height;
}
_height(id) {
// Default guess of the height required for a threads that have not been
// measured
const DEFAULT_HEIGHT = 200;
return this._heights[id] || DEFAULT_HEIGHT;
}
/** Return the vertical offset of an annotation card from the top of the list. */
yOffsetOf(id) {
const self = this;
const allThreads = this._rootThread.children;
const 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.
*/
calculateVisibleThreads() {
// Space above the viewport in pixels which should be considered 'on-screen'
// when calculating the set of visible threads
const MARGIN_ABOVE = 800;
// Same as MARGIN_ABOVE but for the space below the viewport
const 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.
let offscreenLowerHeight = 0;
// Same as offscreenLowerHeight but for cards above the viewport.
let offscreenUpperHeight = 0;
// List of annotations which are in or near the viewport and need to
// actually be created.
const visibleThreads = [];
const allThreads = this._rootThread.children;
const visibleHeight = this.window.innerHeight;
let usedHeight = 0;
let thread;
for (let i = 0; i < allThreads.length; i++) {
thread = allThreads[i];
const threadHeight = this._height(thread.id);
if (
usedHeight + threadHeight <
this.scrollRoot.scrollTop - MARGIN_ABOVE
) {
// Thread is above viewport
offscreenUpperHeight += threadHeight;
} else if (
usedHeight <
this.scrollRoot.scrollTop + visibleHeight + MARGIN_BELOW
) {
// Thread is either in or close to the viewport
visibleThreads.push(thread);
} else {
// Thread is below viewport
offscreenLowerHeight += threadHeight;
}
usedHeight += threadHeight;
}
this.emit('changed', {
offscreenLowerHeight: offscreenLowerHeight,
offscreenUpperHeight: offscreenUpperHeight,
visibleThreads: visibleThreads,
});
return {
offscreenLowerHeight,
offscreenUpperHeight,
visibleThreads,
};
}
}
@use "../../variables" as var;
.thread-list {
& > * {
// Default spacing between items in the annotation card list
margin-bottom: 0.72em;
}
}
.thread-list__card {
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.1);
border-radius: 2px;
cursor: pointer;
padding: 1em;
background-color: var.$white;
&:hover {
box-shadow: 0px 2px 3px 0px rgba(0, 0, 0, 0.15);
}
}
.thread-list__card--theme-clean {
box-shadow: none;
&:hover {
box-shadow: none;
}
}
.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;
}
.thread-list__separator--theme-clean {
border: 0;
border-top: 1px solid #e1e1e1;
margin: -8px 15px 10px 15px;
}
......@@ -63,7 +63,6 @@
@use './components/tag-list';
@use './components/thread';
@use './components/thread-card';
@use './components/thread-list';
@use './components/toast-messages';
@use './components/tooltip';
@use './components/top-bar';
......
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