Commit 0a734296 authored by Robert Knight's avatar Robert Knight

Display chapter headings above cards in sidebar

Visually group ebook annotations by chapter in the sidebar by displaying
chapter headings above groups of annotations from a particular chapter.

For each thread a "heading key" is extracted, which is currently the EPUB
Content Document's CFI, taken from the "EPUBContentSelector" selector. For other
annotation types we could use a different key in future. When rendering threads
a heading is displayed above each thread where the key is different than the
previously rendered thread.

To avoid adding complexity to the virtualization calculations, the headings are
rendered as part of the first thread in the group, and so the height of the
heading element is included in the measured height for that thread.
parent 34f6ea06
......@@ -3,6 +3,7 @@ import classnames from 'classnames';
import debounce from 'lodash.debounce';
import { ListenerCollection } from '../../shared/listener-collection';
import type { Annotation, EPUBContentSelector } from '../../types/api';
import type { Thread } from '../helpers/build-thread';
import {
calculateVisibleThreads,
......@@ -33,6 +34,45 @@ export type ThreadListProps = {
threads: Thread[];
};
/**
* Find the selector identifying the document segment which an annotation
* belongs to.
*/
function getSegmentSelector(ann: Annotation): EPUBContentSelector | undefined {
return ann.target[0].selector?.find(s => s.type === 'EPUBContentSelector') as
| EPUBContentSelector
| undefined;
}
/**
* Return a key that identifies the document section to which an annotation
* belongs.
*/
function headingKey(thread: Thread): string | null {
if (!thread.annotation) {
return null;
}
const chapter = getSegmentSelector(thread.annotation);
return chapter?.cfi ?? null;
}
/** Build a map of heading key (see {@link headingKey}) to section heading. */
function headingMap(threads: Thread[]): Map<string, string> {
const headings = new Map();
for (const thread of threads) {
if (!thread.annotation) {
continue;
}
const selector = getSegmentSelector(thread.annotation);
if (selector?.title) {
headings.set(selector.cfi, selector.title);
}
}
return headings;
}
/**
* Render a list of threads.
*
......@@ -81,7 +121,8 @@ export default function ThreadList({ threads }: ThreadListProps) {
};
}, []);
// Map of thread ID to measured height of thread.
// Map of thread ID to measured height of thread. The height of each thread
// includes any headings displayed immediately above it.
const [threadHeights, setThreadHeights] = useState(() => new Map());
// ID of thread to scroll to after the next render. If the thread is not
......@@ -115,6 +156,25 @@ export default function ThreadList({ threads }: ThreadListProps) {
return newAnnotations[newAnnotations.length - 1].$tag;
})();
// Compute the heading to display above each thread.
//
// We compute the map based on the full list of threads, not just the rendered
// ones, so the association doesn't change while scrolling.
const headings = useMemo(() => {
let prevHeadingKey: string | null = null;
const headingForKey = headingMap(threads);
const headings = new Map();
for (const thread of threads) {
const key = headingKey(thread);
if (key && prevHeadingKey !== key) {
prevHeadingKey = key;
headings.set(thread, headingForKey.get(key) ?? 'Untitled chapter');
}
}
return headings;
}, [threads]);
// Scroll to newly created annotations and replies.
//
// nb. If there are multiple unsaved annotations and the newest one is saved
......@@ -209,6 +269,11 @@ export default function ThreadList({ threads }: ThreadListProps) {
id={child.id}
key={child.id}
>
{headings.get(child) && (
<h2 className="text-md text-grey-7 font-bold pt-3 pb-2">
{headings.get(child)}
</h2>
)}
<ThreadCard thread={child} />
</div>
))}
......
......@@ -26,6 +26,19 @@ describe('ThreadList', () => {
return wrapper;
}
function createThread(tag) {
const target = [
{
source: 'https://example.com',
},
];
return {
id: tag,
children: [],
annotation: { $tag: tag, target },
};
}
beforeEach(() => {
wrappers = [];
fakeDomUtil = {
......@@ -44,21 +57,22 @@ describe('ThreadList', () => {
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' } },
createThread('t1'),
createThread('t2'),
createThread('t3'),
createThread('t4'),
],
};
fakeVisibleThreadsUtil = {
calculateVisibleThreads: sinon.stub().returns({
// nb. Use `callsFake` here rather than `returns` to always use
// latest `fakeTopThread.children` reference.
calculateVisibleThreads: sinon.stub().callsFake(() => ({
visibleThreads: fakeTopThread.children,
offscreenUpperHeight: 400,
offscreenLowerHeight: 600,
}),
})),
THREAD_DIMENSION_DEFAULTS: {
defaultHeight: 200,
},
......@@ -302,6 +316,73 @@ describe('ThreadList', () => {
assert.equal(cards.length, fakeTopThread.children.length);
});
describe('chapter headings', () => {
const addThread = (cfi, title) => {
const id = `t${fakeTopThread.children.length + 1}`;
const thread = createThread(id);
thread.annotation.target[0].selector = [
{
type: 'EPUBContentSelector',
cfi,
title,
},
];
fakeTopThread.children.push(thread);
};
const getHeading = container => {
const heading = container.find('h2');
return heading.exists() ? heading.text() : null;
};
beforeEach(() => {
fakeTopThread.children = [];
});
it('renders section headings above first annotation from each section', () => {
// Add two groups of annotations.
addThread('/2/4', 'Chapter One');
addThread('/2/4', 'Chapter One');
addThread('/2/6', 'Chapter Two');
addThread('/2/6', 'Chapter Two');
// When annotations are sorted by date, rather than location, headings
// may be repeated.
addThread('/2/4', 'Chapter One');
addThread('/2/4', 'Chapter One');
const wrapper = createComponent();
const containers = wrapper.find('[data-testid="thread-card-container"]');
assert.equal(containers.length, fakeTopThread.children.length);
assert.equal(getHeading(containers.at(0)), 'Chapter One');
assert.equal(getHeading(containers.at(1)), null);
assert.equal(getHeading(containers.at(2)), 'Chapter Two');
assert.equal(getHeading(containers.at(3)), null);
assert.equal(getHeading(containers.at(4)), 'Chapter One');
assert.equal(getHeading(containers.at(5)), null);
});
it('uses last non-empty heading for each chapter', () => {
// Add annotations for same chapter but with different captured headings.
addThread('/2/4', 'Chapter 1');
addThread('/2/4', 'Chapter One');
addThread('/2/4', undefined);
// Add an annotation for a different chapter with no associated heading.
addThread('/2/8', undefined);
const wrapper = createComponent();
const containers = wrapper.find('[data-testid="thread-card-container"]');
assert.equal(containers.length, fakeTopThread.children.length);
assert.equal(getHeading(containers.at(0)), 'Chapter One');
assert.equal(getHeading(containers.at(1)), null);
assert.equal(getHeading(containers.at(2)), null);
assert.equal(getHeading(containers.at(3)), 'Untitled chapter');
});
});
it('does not error if thread heights cannot be measured', () => {
// Render the `ThreadList` unconnected to a document. This will prevent
// it from being able to measure the height of rendered threads.
......
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