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 angular from 'angular';
import EventEmitter from 'tiny-emitter';
import { mount } from 'enzyme';
import { createElement } from 'preact';
import * as util from './angular-util';
import events from '../../events';
import threadList, { $imports } from '../thread-list';
import immutable from '../../util/immutable';
const 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' },
});
const 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,
},
],
},
});
import { act } from 'preact/test-utils';
import ThreadList from '../thread-list';
import { $imports } from '../thread-list';
import { checkAccessibility } from '../../../test-util/accessibility';
import mockImportedComponents from '../../../test-util/mock-imported-components';
describe('ThreadList', () => {
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 }
);
}
let fakeVirtualThread;
let fakeStore;
const fakeSettings = {};
beforeEach(() => {
fakeDebounce = sinon.stub().returns(() => null);
fakeDomUtil = {
getElementHeightWithMargins: sinon.stub().returns(0),
};
fakeMetadata = {
isHighlight: sinon.stub().returns(false),
isReply: sinon.stub().returns(false),
};
class FakeVirtualThreadList extends EventEmitter {
constructor($scope, $window, rootThread, options) {
super();
fakeRootScope = {
eventCallbacks: {},
fakeVirtualThread = this; // eslint-disable-line consistent-this
$apply: function (callback) {
callback();
},
let thread = rootThread;
$on: function (event, callback) {
if (event === events.BEFORE_ANNOTATION_CREATED) {
this.eventCallbacks[event] = callback;
}
},
this.options = options;
this.setRootThread = function (_thread) {
thread = _thread;
$broadcast: sinon.stub(),
};
this.notify = function () {
this.emit('changed', {
offscreenLowerHeight: 10,
offscreenUpperHeight: 20,
visibleThreads: thread.children,
});
};
this.detach = sinon.stub();
this.yOffsetOf = function () {
return 42;
fakeScrollContainer = document.createElement('div');
fakeScrollContainer.className = 'js-thread-list-scroll-root';
fakeScrollContainer.style.height = '2000px';
document.body.appendChild(fakeScrollContainer);
fakeStore = {
clearSelection: sinon.stub(),
};
this.calculateVisibleThreads = () => {
return {
offscreenLowerHeight: 10,
offscreenUpperHeight: 20,
visibleThreads: thread.children,
};
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' } },
],
};
}
}
describe('threadList', function () {
let threadListContainers;
function createThreadList(inputs) {
const defaultInputs = {
thread: threadFixtures.thread,
onForceVisible: sinon.stub(),
onFocus: sinon.stub(),
onSelect: sinon.stub(),
onSetCollapsed: sinon.stub(),
fakeVisibleThreadsUtil = {
calculateVisibleThreads: sinon.stub().returns({
visibleThreads: fakeTopThread.children,
offscreenUpperHeight: 400,
offscreenLowerHeight: 600,
}),
THREAD_DIMENSION_DEFAULTS: {
defaultHeight: 200,
},
};
// Create a scrollable container for the `<thread-list>` so that scrolling
// can be tested.
const parentEl = document.createElement('div');
parentEl.classList.add('js-thread-list-scroll-root');
parentEl.style.overflow = 'scroll';
parentEl.style.height = '100px';
// Add an element inside the scrollable container which is much taller than
// the container, so that it actually becomes scrollable.
const tallDiv = document.createElement('div');
tallDiv.style.height = '1000px';
parentEl.appendChild(tallDiv);
document.body.appendChild(parentEl);
// Create the `<thread-list>` instance
const element = util.createDirective(
document,
'threadList',
Object.assign({}, defaultInputs, inputs),
{}, // initialScope
'', // initialHtml
{ parentElement: parentEl }
);
$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,
});
});
element.parentEl = parentEl;
afterEach(() => {
$imports.$restore();
fakeScrollContainer.remove();
});
threadListContainers.push(parentEl);
it('works', () => {
const wrapper = createComponent();
return element;
}
assert.isTrue(wrapper.find('section').exists());
});
before(function () {
fakeStore = {
clearSelection: sinon.stub(),
};
it('calculates visible threads', () => {
createComponent();
angular.module('app', []).component('threadList', threadList);
assert.calledWith(
fakeVisibleThreadsUtil.calculateVisibleThreads,
fakeTopThread.children,
sinon.match({}),
0,
sinon.match.number
);
});
beforeEach(function () {
angular.mock.module('app', {
settings: fakeSettings,
store: fakeStore,
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
);
});
threadListContainers = [];
$imports.$mock({
'../virtual-thread-list': FakeVirtualThreadList,
it('clears the current selection in the store', () => {
createComponent();
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED]({}, {});
assert.calledOnce(fakeStore.clearSelection);
});
});
afterEach(function () {
$imports.$restore();
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]({}, {});
threadListContainers.forEach(function (el) {
el.remove();
assert.notCalled(fakeStore.clearSelection);
});
});
it('shows the clean theme when settings contains the clean theme option', function () {
angular.mock.module('app', {
VirtualThreadList: FakeVirtualThreadList,
settings: { theme: 'clean' },
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);
});
const element = createThreadList();
fakeVirtualThread.notify();
element.scope.$digest();
assert.equal(
element[0].querySelectorAll('.thread-list__card--theme-clean').length,
element[0].querySelectorAll('thread').length
);
});
it('displays the children of the root thread', function () {
const element = createThreadList();
fakeVirtualThread.notify();
element.scope.$digest();
const children = element[0].querySelectorAll('thread');
assert.equal(children.length, 2);
});
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);
});
describe('when a new annotation is created', function () {
it('scrolls the annotation into view', function () {
const element = createThreadList();
element.parentEl.scrollTop = 500;
afterEach(() => {
stubbedFakeScrollContainer.restore();
stubbedDocument.restore();
});
const annot = annotFixtures.annotation;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, annot);
it('should do nothing if there is no active annotation thread to scroll to', () => {
createComponent();
// Check that the thread list was scrolled up to make the new annotation
// visible.
assert.isBelow(element.parentEl.scrollTop, 100);
assert.notCalled(fakeScrollTop);
});
it('does not scroll the annotation into view if it is a reply', function () {
const element = createThreadList();
element.parentEl.scrollTop = 500;
it('should do nothing if the annotation thread to scroll to is not in DOM', () => {
createComponent();
const reply = annotFixtures.reply;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, reply);
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
{ $tag: 'nonexistent' }
);
});
// Check that the thread list was not scrolled
assert.equal(element.parentEl.scrollTop, 500);
assert.notCalled(fakeScrollTop);
});
it('does not scroll the annotation into view if it is a highlight', function () {
const element = createThreadList();
element.parentEl.scrollTop = 500;
it('should set the scroll container `scrollTop` to derived position of thread', () => {
createComponent();
const highlight = annotFixtures.highlight;
element.scope.$broadcast(events.BEFORE_ANNOTATION_CREATED, highlight);
act(() => {
fakeRootScope.eventCallbacks[events.BEFORE_ANNOTATION_CREATED](
{},
fakeTopThread.children[3].annotation
);
});
// Check that the thread list was not scrolled
assert.equal(element.parentEl.scrollTop, 500);
// 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);
});
});
it('clears the selection', function () {
const element = createThreadList();
element.scope.$broadcast(
events.BEFORE_ANNOTATION_CREATED,
annotFixtures.annotation
);
assert.called(fakeStore.clearSelection);
describe('scroll and resize event handling', () => {
let debouncedFn;
beforeEach(() => {
debouncedFn = sinon.stub();
fakeDebounce.returns(debouncedFn);
});
});
it('calls onFocus() when the user hovers an annotation', function () {
const inputs = {
onFocus: {
args: ['annotation'],
callback: sinon.stub(),
},
};
const element = createThreadList(inputs);
fakeVirtualThread.notify();
element.scope.$digest();
it('updates scroll position and window height for recalculation on container scroll', () => {
createComponent();
document
.querySelector('.js-thread-list-scroll-root')
.dispatchEvent(new Event('scroll'));
const cardElement = element[0].querySelector('.thread-list__card');
cardElement.dispatchEvent(new Event('mouseover'));
assert.calledOnce(debouncedFn);
});
assert.calledWithMatch(
inputs.onFocus.callback,
sinon.match(annotFixtures.annotation)
);
});
it('updates scroll position and window height for recalculation on window resize', () => {
createComponent();
window.dispatchEvent(new Event('resize'));
it('calls onSelect() when a user clicks an annotation', function () {
const inputs = {
onSelect: {
args: ['annotation'],
callback: sinon.stub(),
},
};
const element = createThreadList(inputs);
fakeVirtualThread.notify();
element.scope.$digest();
assert.calledOnce(debouncedFn);
});
});
const cardElement = element[0].querySelector('.thread-list__card');
cardElement.dispatchEvent(new Event('click'));
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
);
});
});
assert.calledWithMatch(
inputs.onSelect.callback,
sinon.match(annotFixtures.annotation)
);
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('uses the correct scroll root', function () {
createThreadList();
const scrollRoot = fakeVirtualThread.options.scrollRoot;
assert.isTrue(scrollRoot.classList.contains('js-thread-list-scroll-root'));
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);
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 * as metadata from '../util/annotation-metadata';
import VirtualThreadList from '../virtual-thread-list';
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');
}
/**
* Component which displays a virtualized list of annotation threads.
* Render a list of threads, but only render those that are in or near the
* current browser viewport.
*/
function ThreadList({ 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;
}
import scopeTimeout from '../util/scope-timeout';
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;
}
/**
* Returns the height of the thread for an annotation if it exists in the view
* or `null` otherwise.
*/
function getThreadHeight(id) {
const threadElement = document.getElementById(id);
if (!threadElement) {
return null;
}
// Note: `getComputedStyle` may return `null` in Firefox if the iframe is
// hidden. See https://bugzilla.mozilla.org/show_bug.cgi?id=548397
const style = window.getComputedStyle(threadElement);
if (!style) {
return null;
}
// Get the height of the element inside the border-box, excluding
// top and bottom margins.
const elementHeight = threadElement.getBoundingClientRect().height;
// Get the bottom margin of the element. style.margin{Side} will return
// values of the form 'Npx', from which we extract 'N'.
const marginHeight =
parseFloat(style.marginTop) + parseFloat(style.marginBottom);
return elementHeight + marginHeight;
}
// Clear `scrollToId` so we don't scroll again after the next render.
setScrollToId(null);
// @ngInject
function ThreadListController($element, $scope, settings, store) {
// `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.
const self = this;
// `scrollRoot` is the `Element` to scroll when scrolling a given thread into
// view.
//
// For now there is only one `<thread-list>` instance in the whole
// application so we simply require the scroll root to be annotated with a
// specific class. A more generic mechanism was removed due to issues in
// Firefox. See https://github.com/hypothesis/client/issues/341
this.scrollRoot = document.querySelector('.js-thread-list-scroll-root');
this.isThemeClean = settings.theme === 'clean';
const options = { scrollRoot: this.scrollRoot };
let visibleThreads;
this.$onInit = () => {
visibleThreads = new VirtualThreadList(
$scope,
window,
this.thread,
options
);
// Calculate the visible threads.
onVisibleThreadsChanged(visibleThreads.calculateVisibleThreads());
// Subscribe onVisibleThreadsChanged to the visibleThreads 'changed' event
// after calculating visible threads, to avoid an undesired second call to
// onVisibleThreadsChanged that occurs from the emission of the 'changed'
// event during the visibleThreads calculation.
visibleThreads.on('changed', onVisibleThreadsChanged);
};
/**
* Update which threads are visible in the virtualThreadsList.
*
* @param {Object} state the new state of the virtualThreadsList
*/
function onVisibleThreadsChanged(state) {
self.virtualThreadList = {
visibleThreads: state.visibleThreads,
offscreenUpperHeight: state.offscreenUpperHeight + 'px',
offscreenLowerHeight: state.offscreenLowerHeight + 'px',
};
const getThreadHeight = thread =>
threadHeights[thread.id] || THREAD_DIMENSION_DEFAULTS.defaultHeight;
scopeTimeout(
$scope,
function () {
state.visibleThreads.forEach(function (thread) {
const 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) {
const maxYOffset =
self.scrollRoot.scrollHeight - self.scrollRoot.clientHeight;
return Math.min(maxYOffset, visibleThreads.yOffsetOf(id));
}
/** Scroll the annotation with a given ID or $tag into view. */
function scrollIntoView(id) {
const estimatedYOffset = scrollOffset(id);
self.scrollRoot.scrollTop = 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 () {
const newYOffset = scrollOffset(id);
if (newYOffset !== estimatedYOffset) {
scrollIntoView(id);
}
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);
},
200
10,
{ maxWait: 100 }
);
}
$scope.$on(events.BEFORE_ANNOTATION_CREATED, function (event, annotation) {
if (annotation.$highlight || metadata.isReply(annotation)) {
return;
}
store.clearSelection();
scrollIntoView(annotation.$tag);
});
scrollContainer.addEventListener('scroll', updateScrollPosition);
window.addEventListener('resize', updateScrollPosition);
this.$onChanges = function (changes) {
if (changes.thread && visibleThreads) {
visibleThreads.setRootThread(changes.thread.currentValue);
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;
}
}
};
this.$onDestroy = function () {
visibleThreads.detach();
};
// 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>
);
}
export default {
controller: ThreadListController,
controllerAs: 'vm',
bindings: {
/** The root thread to be displayed by the thread list. */
thread: '<',
showDocumentInfo: '<',
/** 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: '&',
},
template: require('../templates/thread-list.html'),
ThreadList.propTypes = {
/** Should annotations render extra document metadata? */
thread: propTypes.object.isRequired,
/** injected */
$rootScope: propTypes.object.isRequired,
};
ThreadList.injectedProps = ['$rootScope'];
export default withServices(ThreadList);
......@@ -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