diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml
index 4210f71956..93052ddad2 100644
--- a/entry_types/scrolled/config/locales/de.yml
+++ b/entry_types/scrolled/config/locales/de.yml
@@ -1926,6 +1926,17 @@ de:
review:
add_comment: Kommentar hinzufügen
cancel_add_comment: Abbrechen
+ comment_toolbar: Kommentare
+ filter:
+ label: Kommentare filtern
+ unresolved: Ungelöst
+ all: Alle
+ previous_comment: Vorheriger Kommentar
+ next_comment: Nächster Kommentar
+ comment_count:
+ zero: Keine Kommentare
+ one: 1 Kommentar
+ other: '%{count} Kommentare'
select_content_element: Zum Kommentieren auswählen
select_section: Abschnitt zum Kommentieren auswählen
select_text_to_comment: Text zum Kommentieren auswählen
diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml
index 3e7b8f3a24..acf8f3da58 100644
--- a/entry_types/scrolled/config/locales/en.yml
+++ b/entry_types/scrolled/config/locales/en.yml
@@ -1755,6 +1755,17 @@ en:
review:
add_comment: Add comment
cancel_add_comment: Cancel
+ comment_toolbar: Comments
+ filter:
+ label: Filter comments
+ unresolved: Unresolved
+ all: All
+ previous_comment: Previous comment
+ next_comment: Next comment
+ comment_count:
+ zero: No comments
+ one: 1 comment
+ other: '%{count} comments'
select_content_element: Select to comment
select_section: Select section to comment
select_text_to_comment: Select text to comment
diff --git a/entry_types/scrolled/package/spec/contentElements/textBlock/editor-spec.js b/entry_types/scrolled/package/spec/contentElements/textBlock/editor-spec.js
index e746be46aa..7956218d44 100644
--- a/entry_types/scrolled/package/spec/contentElements/textBlock/editor-spec.js
+++ b/entry_types/scrolled/package/spec/contentElements/textBlock/editor-spec.js
@@ -129,48 +129,4 @@ describe('contentElements/textBlock/editor', () => {
});
});
- describe('#compareRanges', () => {
- function range(anchor, focus) {
- return {
- anchor: {path: [anchor[0], anchor[1]], offset: anchor[2]},
- focus: {path: [focus[0], focus[1]], offset: focus[2]}
- };
- }
-
- it('orders ranges by start block', () => {
- const earlier = range([0, 0, 0], [0, 0, 5]);
- const later = range([1, 0, 0], [1, 0, 5]);
-
- expect(type.compareRanges(earlier, later)).toBeLessThan(0);
- expect(type.compareRanges(later, earlier)).toBeGreaterThan(0);
- });
-
- it('orders ranges within the same block by offset', () => {
- const earlier = range([0, 0, 2], [0, 0, 4]);
- const later = range([0, 0, 6], [0, 0, 8]);
-
- expect(type.compareRanges(earlier, later)).toBeLessThan(0);
- });
-
- it('uses the smaller of anchor and focus as the start point', () => {
- const reversed = range([0, 0, 8], [0, 0, 2]);
- const forward = range([0, 0, 5], [0, 0, 6]);
-
- expect(type.compareRanges(reversed, forward)).toBeLessThan(0);
- });
-
- it('returns 0 for equal ranges', () => {
- const r = range([0, 0, 0], [0, 0, 5]);
-
- expect(type.compareRanges(r, r)).toBe(0);
- });
-
- it('sorts range-less subjects last', () => {
- const r = range([0, 0, 0], [0, 0, 5]);
-
- expect(type.compareRanges(undefined, r)).toBeGreaterThan(0);
- expect(type.compareRanges(r, undefined)).toBeLessThan(0);
- expect(type.compareRanges(undefined, undefined)).toBe(0);
- });
- });
});
diff --git a/entry_types/scrolled/package/spec/contentElements/textBlock/review-spec.js b/entry_types/scrolled/package/spec/contentElements/textBlock/review-spec.js
new file mode 100644
index 0000000000..6ac080a81f
--- /dev/null
+++ b/entry_types/scrolled/package/spec/contentElements/textBlock/review-spec.js
@@ -0,0 +1,49 @@
+import 'contentElements/textBlock/review';
+import {review} from 'review';
+
+describe('contentElements/textBlock/review', () => {
+ const compareRanges = review.contentElementTypes.findCompareRanges('textBlock');
+
+ function range(anchor, focus) {
+ return {
+ anchor: {path: [anchor[0], anchor[1]], offset: anchor[2]},
+ focus: {path: [focus[0], focus[1]], offset: focus[2]}
+ };
+ }
+
+ it('orders ranges by start block', () => {
+ const earlier = range([0, 0, 0], [0, 0, 5]);
+ const later = range([1, 0, 0], [1, 0, 5]);
+
+ expect(compareRanges(earlier, later)).toBeLessThan(0);
+ expect(compareRanges(later, earlier)).toBeGreaterThan(0);
+ });
+
+ it('orders ranges within the same block by offset', () => {
+ const earlier = range([0, 0, 2], [0, 0, 4]);
+ const later = range([0, 0, 6], [0, 0, 8]);
+
+ expect(compareRanges(earlier, later)).toBeLessThan(0);
+ });
+
+ it('uses the smaller of anchor and focus as the start point', () => {
+ const reversed = range([0, 0, 8], [0, 0, 2]);
+ const forward = range([0, 0, 5], [0, 0, 6]);
+
+ expect(compareRanges(reversed, forward)).toBeLessThan(0);
+ });
+
+ it('returns 0 for equal ranges', () => {
+ const r = range([0, 0, 0], [0, 0, 5]);
+
+ expect(compareRanges(r, r)).toBe(0);
+ });
+
+ it('sorts range-less subjects last', () => {
+ const r = range([0, 0, 0], [0, 0, 5]);
+
+ expect(compareRanges(undefined, r)).toBeGreaterThan(0);
+ expect(compareRanges(r, undefined)).toBeLessThan(0);
+ expect(compareRanges(undefined, undefined)).toBe(0);
+ });
+});
diff --git a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js
index e80551e145..8ba388a135 100644
--- a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js
+++ b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import {act} from '@testing-library/react';
import {editor} from 'pageflow-scrolled/editor';
+import {review} from 'pageflow-scrolled/review';
import {EntryCommentsView} from 'editor/views/EntryCommentsView';
import styles from 'editor/views/EntryCommentsView.module.css';
@@ -14,7 +15,7 @@ describe('EntryCommentsView', () => {
const {createEntry} = useEditorGlobals();
beforeAll(() => {
- editor.contentElementTypes.register('fixture', {
+ review.contentElementTypes.register('fixture', {
compareRanges: (a, b) => (a?.start ?? 0) - (b?.start ?? 0)
});
});
diff --git a/entry_types/scrolled/package/spec/editor/views/SelectionCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/SelectionCommentsView-spec.js
index 7c0a873dc1..335af9431a 100644
--- a/entry_types/scrolled/package/spec/editor/views/SelectionCommentsView-spec.js
+++ b/entry_types/scrolled/package/spec/editor/views/SelectionCommentsView-spec.js
@@ -2,6 +2,7 @@ import '@testing-library/jest-dom/extend-expect';
import userEvent from '@testing-library/user-event';
import {editor} from 'pageflow-scrolled/editor';
+import {review} from 'pageflow-scrolled/review';
import {SelectionCommentsView} from 'editor/views/SelectionCommentsView';
import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers';
@@ -12,7 +13,7 @@ describe('SelectionCommentsView', () => {
const {createEntry} = useEditorGlobals();
beforeAll(() => {
- editor.contentElementTypes.register('fixture', {
+ review.contentElementTypes.register('fixture', {
compareRanges: (a, b) => (a?.start ?? 0) - (b?.start ?? 0)
});
});
diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentDisplayFilter-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentDisplayFilter-spec.js
new file mode 100644
index 0000000000..b91f4fb491
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentDisplayFilter-spec.js
@@ -0,0 +1,131 @@
+import '@testing-library/jest-dom/extend-expect';
+import {fireEvent, within} from '@testing-library/react';
+
+import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting';
+
+describe('comment display filter', () => {
+ useCommentingPageObjects();
+
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+ });
+
+ it('expands resolved comments in the popover when showing all', () => {
+ const entry = renderEntry({
+ seed: {contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]},
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: '2026-01-01',
+ comments: [{id: 10, body: 'Resolved comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ fireEvent.click(entry.getCommentFilterButton('all'));
+ fireEvent.click(entry.getAllCommentBadges()[0]);
+
+ expect(entry.getByText('Resolved comment')).toBeInTheDocument();
+ expect(entry.queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument();
+ });
+
+ it('shows the unresolved and total comment counts in the segments', () => {
+ const entry = renderEntry({
+ seed: {
+ contentElements: [
+ {typeName: 'withTestId', configuration: {testId: 5}},
+ {typeName: 'withTestId', configuration: {testId: 6}},
+ {typeName: 'withTestId', configuration: {testId: 7}}
+ ]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'a', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: null,
+ comments: [{id: 11, body: 'b', creatorName: 'Bob', creatorId: 2}]},
+ {id: 3, subjectType: 'ContentElement', subjectId: 3, resolvedAt: '2026-01-01',
+ comments: [{id: 12, body: 'c', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ expect(within(entry.getCommentFilterButton('unresolved')).getByText('2'))
+ .toBeInTheDocument();
+ expect(within(entry.getCommentFilterButton('all')).getByText('3'))
+ .toBeInTheDocument();
+ });
+
+ it('shows unresolved comments with the unresolved segment active by default', () => {
+ const entry = renderEntryWithResolvedThread();
+
+ expect(entry.queryAllCommentBadges()).toHaveLength(0);
+ expect(entry.getCommentFilterButton('unresolved'))
+ .toHaveAttribute('aria-pressed', 'true');
+ expect(entry.getCommentFilterButton('all'))
+ .toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('shows resolved comments when selecting all', () => {
+ const entry = renderEntryWithResolvedThread();
+
+ fireEvent.click(entry.getCommentFilterButton('all'));
+
+ expect(entry.queryAllCommentBadges()).toHaveLength(1);
+ expect(entry.getCommentFilterButton('all'))
+ .toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('shows both resolved and unresolved comments when showing all', () => {
+ const entry = renderEntry({
+ seed: {
+ contentElements: [
+ {typeName: 'withTestId', configuration: {testId: 5}},
+ {typeName: 'withTestId', configuration: {testId: 6}}
+ ]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'Open', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: '2026-01-01',
+ comments: [{id: 11, body: 'Done', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ expect(entry.queryAllCommentBadges()).toHaveLength(1);
+
+ fireEvent.click(entry.getCommentFilterButton('all'));
+
+ expect(entry.queryAllCommentBadges()).toHaveLength(2);
+ });
+
+ it('hides resolved comments again when selecting unresolved', () => {
+ const entry = renderEntryWithResolvedThread();
+
+ fireEvent.click(entry.getCommentFilterButton('all'));
+ expect(entry.queryAllCommentBadges()).toHaveLength(1);
+
+ fireEvent.click(entry.getCommentFilterButton('unresolved'));
+
+ expect(entry.queryAllCommentBadges()).toHaveLength(0);
+ });
+
+ function renderEntryWithResolvedThread() {
+ return renderEntry({
+ seed: {
+ contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: '2026-01-01',
+ comments: [{id: 10, body: 'Done', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+ }
+});
diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigation-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigation-spec.js
new file mode 100644
index 0000000000..5fb69dbed2
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigation-spec.js
@@ -0,0 +1,156 @@
+import '@testing-library/jest-dom/extend-expect';
+import {fireEvent, within} from '@testing-library/react';
+
+import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting';
+
+describe('comment navigation', () => {
+ useCommentingPageObjects();
+
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+ });
+
+ it('shows a dash in the position indicator before stepping', () => {
+ const entry = renderEntryWithTwoThreads();
+
+ expect(within(entry.getCommentToolbar()).getByText('– /')).toBeInTheDocument();
+ });
+
+ it('opens each thread popover when cycling forward', () => {
+ const entry = renderEntryWithTwoThreads();
+
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ expect(entry.getByText('First comment')).toBeInTheDocument();
+
+ fireEvent.click(next);
+ expect(entry.getByText('Second comment')).toBeInTheDocument();
+ expect(entry.queryByText('First comment')).not.toBeInTheDocument();
+ });
+
+ it('wraps to the last thread when cycling backward from the start', () => {
+ const entry = renderEntryWithTwoThreads();
+
+ fireEvent.click(entry.getPreviousCommentButton());
+
+ expect(entry.getByText('Second comment')).toBeInTheDocument();
+ });
+
+ it('disables navigation when there are no comments for the filter', () => {
+ const entry = renderEntry({
+ seed: {contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]},
+ commenting: {currentUser: {id: 42, name: 'Alice'}, commentThreads: []}
+ });
+
+ expect(entry.getNextCommentButton()).toBeDisabled();
+ expect(entry.getPreviousCommentButton()).toBeDisabled();
+ });
+
+ it('skips resolved comments while navigating', () => {
+ const entry = renderEntry({
+ seed: {
+ contentElements: [
+ {typeName: 'withTestId', configuration: {testId: 5}},
+ {typeName: 'withTestId', configuration: {testId: 6}},
+ {typeName: 'withTestId', configuration: {testId: 7}}
+ ]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: '2026-01-01',
+ comments: [{id: 11, body: 'Resolved comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 3, subjectType: 'ContentElement', subjectId: 3, resolvedAt: null,
+ comments: [{id: 12, body: 'Third comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ expect(entry.getByText('First comment')).toBeInTheDocument();
+
+ fireEvent.click(next);
+ expect(entry.getByText('Third comment')).toBeInTheDocument();
+ expect(entry.queryByText('Resolved comment')).not.toBeInTheDocument();
+ });
+
+ it('shows the current position while stepping', () => {
+ const entry = renderEntryWithTwoThreads();
+
+ const toolbar = entry.getCommentToolbar();
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ expect(within(toolbar).getByText('1 /')).toBeInTheDocument();
+
+ fireEvent.click(next);
+ expect(within(toolbar).getByText('2 /')).toBeInTheDocument();
+ });
+
+ it('highlights the current thread when stepping within one element', () => {
+ const entry = renderEntryWithTwoThreadsOnOneElement();
+
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ expect(entry.getByText('First thread').closest('[aria-current="true"]')).not.toBeNull();
+ expect(entry.getByText('Second thread').closest('[aria-current="true"]')).toBeNull();
+
+ fireEvent.click(next);
+ expect(entry.getByText('Second thread').closest('[aria-current="true"]')).not.toBeNull();
+ expect(entry.getByText('First thread').closest('[aria-current="true"]')).toBeNull();
+ });
+
+ it('keeps the popover open when pressing a toolbar button', () => {
+ const entry = renderEntryWithTwoThreadsOnOneElement();
+
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ expect(entry.getByText('First thread')).toBeInTheDocument();
+
+ fireEvent.mouseDown(next);
+
+ expect(entry.getByText('First thread')).toBeInTheDocument();
+ });
+
+ function renderEntryWithTwoThreadsOnOneElement() {
+ return renderEntry({
+ seed: {contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]},
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'First thread', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 11, body: 'Second thread', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+ }
+
+ function renderEntryWithTwoThreads() {
+ return renderEntry({
+ seed: {
+ contentElements: [
+ {typeName: 'withTestId', configuration: {testId: 5}},
+ {typeName: 'withTestId', configuration: {testId: 6}}
+ ]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: null,
+ comments: [{id: 11, body: 'Second comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+ }
+});
diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js
new file mode 100644
index 0000000000..f19d94be5e
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationExcursions-spec.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import {fireEvent} from '@testing-library/react';
+
+import {frontend} from 'frontend';
+import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting';
+
+describe('comment navigation across excursions', () => {
+ useCommentingPageObjects();
+
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+
+ frontend.widgetTypes.register('excursionOverlay', {
+ component: ({children}) =>
{children}
+ });
+ });
+
+ const seed = {
+ widgets: [{typeName: 'excursionOverlay', role: 'excursion'}],
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 1, storylineId: 1, configuration: {}},
+ {id: 2, storylineId: 2, configuration: {title: 'excursion'}}
+ ],
+ sections: [
+ {id: 1, permaId: 100, chapterId: 1},
+ {id: 2, permaId: 200, chapterId: 2}
+ ],
+ contentElements: [
+ {id: 1, permaId: 1, sectionId: 1, typeName: 'withTestId', configuration: {testId: 5}},
+ {id: 2, permaId: 2, sectionId: 2, typeName: 'withTestId', configuration: {testId: 6}}
+ ]
+ };
+
+ it('activates the excursion and opens the popover when navigating to its comment', () => {
+ const entry = renderEntry({
+ seed,
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 2, resolvedAt: null,
+ comments: [{id: 10, body: 'Excursion comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ expect(entry.queryByText('Excursion comment')).not.toBeInTheDocument();
+
+ fireEvent.click(entry.getNextCommentButton());
+
+ expect(entry.getByText('Excursion comment')).toBeInTheDocument();
+ });
+
+ it('returns from the excursion when navigating onward to a main storyline comment', () => {
+ const entry = renderEntry({
+ seed,
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'Main comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: null,
+ comments: [{id: 11, body: 'Excursion comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+
+ const next = entry.getNextCommentButton();
+
+ fireEvent.click(next);
+ fireEvent.click(next);
+ expect(entry.getByText('Excursion comment')).toBeInTheDocument();
+
+ fireEvent.click(next);
+
+ expect(entry.queryByText('Excursion comment')).not.toBeInTheDocument();
+ expect(entry.getByText('Main comment')).toBeInTheDocument();
+ });
+});
diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationSelection-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationSelection-spec.js
new file mode 100644
index 0000000000..9c443b7ba4
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentNavigationSelection-spec.js
@@ -0,0 +1,70 @@
+import '@testing-library/jest-dom/extend-expect';
+import {fireEvent} from '@testing-library/react';
+
+import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting';
+import styles from 'frontend/commenting/FloatingToolbar.module.css';
+
+describe('comment navigation coupled with selection', () => {
+ useCommentingPageObjects();
+
+ beforeEach(() => {
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+ });
+
+ it('moves the position indicator to the clicked subject', () => {
+ const entry = renderEntryWithThreeThreads();
+
+ fireEvent.click(entry.getAllCommentBadges()[1]);
+
+ const toolbar = entry.getCommentToolbar();
+
+ expect(toolbar.querySelector(`.${styles.count}`)).toHaveTextContent(/^2\s*\//);
+ });
+
+ it('continues navigation forward from the clicked subject', () => {
+ const entry = renderEntryWithThreeThreads();
+
+ fireEvent.click(entry.getAllCommentBadges()[1]);
+ expect(entry.getByText('Second comment')).toBeInTheDocument();
+
+ fireEvent.click(entry.getNextCommentButton());
+
+ expect(entry.getByText('Third comment')).toBeInTheDocument();
+ expect(entry.queryByText('Second comment')).not.toBeInTheDocument();
+ });
+
+ it('continues navigation backward from the clicked subject', () => {
+ const entry = renderEntryWithThreeThreads();
+
+ fireEvent.click(entry.getAllCommentBadges()[1]);
+ expect(entry.getByText('Second comment')).toBeInTheDocument();
+
+ fireEvent.click(entry.getPreviousCommentButton());
+
+ expect(entry.getByText('First comment')).toBeInTheDocument();
+ expect(entry.queryByText('Second comment')).not.toBeInTheDocument();
+ });
+
+ function renderEntryWithThreeThreads() {
+ return renderEntry({
+ seed: {
+ contentElements: [
+ {typeName: 'withTestId', configuration: {testId: 5}},
+ {typeName: 'withTestId', configuration: {testId: 6}},
+ {typeName: 'withTestId', configuration: {testId: 7}}
+ ]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, resolvedAt: null,
+ comments: [{id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 2, resolvedAt: null,
+ comments: [{id: 11, body: 'Second comment', creatorName: 'Bob', creatorId: 2}]},
+ {id: 3, subjectType: 'ContentElement', subjectId: 3, resolvedAt: null,
+ comments: [{id: 12, body: 'Third comment', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ });
+ }
+});
diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentingBadges-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentingBadges-spec.js
index e31aaf1ec9..c075762a66 100644
--- a/entry_types/scrolled/package/spec/frontend/commenting/features/commentingBadges-spec.js
+++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentingBadges-spec.js
@@ -76,4 +76,52 @@ describe('commenting badges', () => {
expect(entry.getByText('On the section')).toBeInTheDocument();
});
+
+ it('closes the thread list when clicking outside', () => {
+ const entry = renderEntry({
+ seed: {
+ sections: [{id: 1, permaId: 10}],
+ contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'Section', subjectId: 10, comments: [
+ {id: 10, body: 'On the section', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ });
+
+ fireEvent.click(entry.getAllCommentBadges()[0]);
+ expect(entry.getByText('On the section')).toBeInTheDocument();
+
+ fireEvent.mouseDown(document.body);
+
+ expect(entry.queryByText('On the section')).not.toBeInTheDocument();
+ });
+
+ it('closes the thread list when pressing Escape', () => {
+ const entry = renderEntry({
+ seed: {
+ sections: [{id: 1, permaId: 10}],
+ contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]
+ },
+ commenting: {
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'Section', subjectId: 10, comments: [
+ {id: 10, body: 'On the section', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ });
+
+ fireEvent.click(entry.getAllCommentBadges()[0]);
+ expect(entry.getByText('On the section')).toBeInTheDocument();
+
+ fireEvent.keyDown(document, {key: 'Escape'});
+
+ expect(entry.queryByText('On the section')).not.toBeInTheDocument();
+ });
});
diff --git a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js
index 46a0026935..a35e0112d4 100644
--- a/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js
+++ b/entry_types/scrolled/package/spec/frontend/useActiveExcursion-spec.js
@@ -1,15 +1,24 @@
import {useActiveExcursion} from 'frontend/useActiveExcursion';
-import {useEntryStructure} from 'entryState';
import {renderHookInEntry} from 'support';
import {changeLocationHash} from 'support/changeLocationHash';
import {act} from '@testing-library/react';
-describe('useActiveExcursion', () => {
- beforeEach(() => window.location.hash = '#initial');
+const mockScrollToTarget = jest.fn();
+
+jest.mock('frontend/useScrollTarget', () => ({
+ ...jest.requireActual('frontend/useScrollTarget'),
+ useScrollToTarget: () => mockScrollToTarget
+}));
+
+describe('ActiveExcursionProvider', () => {
+ beforeEach(() => {
+ window.location.hash = '#initial';
+ mockScrollToTarget.mockClear();
+ });
it('returns undefined by default', () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
}
});
@@ -19,7 +28,7 @@ describe('useActiveExcursion', () => {
});
it('returns excursion with slug matching hash', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -41,7 +50,7 @@ describe('useActiveExcursion', () => {
});
it('returns undefined when hash matches main chapter slug', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -63,7 +72,7 @@ describe('useActiveExcursion', () => {
});
it('supports resetting active excursion', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -90,7 +99,7 @@ describe('useActiveExcursion', () => {
});
it('supports activating excursion via section id', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -120,7 +129,7 @@ describe('useActiveExcursion', () => {
});
it('returns to initial hash after activating different excursions', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -155,7 +164,7 @@ describe('useActiveExcursion', () => {
});
it('can return to initial empty hash', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -184,7 +193,7 @@ describe('useActiveExcursion', () => {
});
it('activates excursion when hash matches section permaId pattern', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -210,7 +219,7 @@ describe('useActiveExcursion', () => {
});
it('returns undefined when hash matches section permaId from main storyline', async () => {
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -238,7 +247,7 @@ describe('useActiveExcursion', () => {
it('activates excursion on initial render when hash matches chapter slug', () => {
window.location.hash = '#excursion';
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -258,7 +267,7 @@ describe('useActiveExcursion', () => {
it('activates excursion on initial render when hash matches section permaId pattern', () => {
window.location.hash = '#section-501';
- const {result} = renderHookInEntry(() => useActiveExcursion(useEntryStructure()), {
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
seed: {
storylines: [
{id: 1, configuration: {main: true}},
@@ -284,273 +293,236 @@ describe('useActiveExcursion', () => {
afterEach(() => jest.useRealTimers());
it('calls scrollToTarget when navigating from main to excursion section', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 11, permaId: 502},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 11, permaId: 502},
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-502'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toMatchObject({title: 'excursion'});
- expect(scrollToTarget).toHaveBeenCalledWith({id: 102});
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 102});
});
it('calls scrollToTarget when navigating from excursion to main section', async () => {
window.location.hash = '#excursion';
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- ]
- }
+
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-500'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toBeUndefined();
- expect(scrollToTarget).toHaveBeenCalledWith({id: 100});
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 100});
});
it('calls scrollToTarget when navigating from excursion A to excursion B', async () => {
window.location.hash = '#excursion1';
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion1'}},
- {id: 12, storylineId: 2, configuration: {title: 'excursion2'}}
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 12, permaId: 502},
- {id: 103, chapterId: 12, permaId: 503}
- ]
- }
+
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion1'}},
+ {id: 12, storylineId: 2, configuration: {title: 'excursion2'}}
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 12, permaId: 502},
+ {id: 103, chapterId: 12, permaId: 503}
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-503'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toMatchObject({title: 'excursion2'});
- expect(scrollToTarget).toHaveBeenCalledWith({id: 103});
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 103});
});
it('delays scroll by 500ms', async () => {
- const scrollToTarget = jest.fn();
-
- renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 11, permaId: 502},
- ]
- }
+ renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 11, permaId: 502},
+ ]
}
- );
+ });
act(() => {
changeLocationHash('#section-502');
});
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(499);
});
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(1);
});
- expect(scrollToTarget).toHaveBeenCalledWith({id: 102});
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 102});
});
it('does not call scrollToTarget when navigating within main storyline', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 103, chapterId: 10, permaId: 503},
- {id: 101, chapterId: 11, permaId: 501},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 103, chapterId: 10, permaId: 503},
+ {id: 101, chapterId: 11, permaId: 501},
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-503'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toBeUndefined();
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
});
it('does not call scrollToTarget when navigating within same excursion', async () => {
window.location.hash = '#excursion';
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 11, permaId: 502},
- ]
- }
+
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 11, permaId: 502},
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-502'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toMatchObject({title: 'excursion'});
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
});
it('does not call scrollToTarget when hash does not match anything', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ ]
}
- );
+ });
act(() => changeLocationHash('#nonexistent'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toBeUndefined();
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
});
it('scrolls again after returning from excursion and re-entering', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 11, permaId: 502},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 11, permaId: 502},
+ ]
}
- );
+ });
// Enter excursion (non-first section)
act(() => changeLocationHash('#section-502'));
act(() => jest.advanceTimersByTime(500));
- expect(scrollToTarget).toHaveBeenCalledWith({id: 102});
- scrollToTarget.mockClear();
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 102});
+ mockScrollToTarget.mockClear();
// Return from excursion
act(() => result.current.returnFromExcursion());
@@ -561,70 +533,60 @@ describe('useActiveExcursion', () => {
act(() => changeLocationHash('#section-502'));
act(() => jest.advanceTimersByTime(500));
- expect(scrollToTarget).toHaveBeenCalledWith({id: 102});
+ expect(mockScrollToTarget).toHaveBeenCalledWith({id: 102});
});
it('does not scroll when navigating to excursion via slug', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ ]
}
- );
+ });
act(() => changeLocationHash('#excursion'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toMatchObject({title: 'excursion'});
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
});
it('does not scroll when navigating to first section of excursion', async () => {
- const scrollToTarget = jest.fn();
-
- const {result} = renderHookInEntry(
- () => useActiveExcursion(useEntryStructure(), {scrollToTarget}),
- {
- seed: {
- storylines: [
- {id: 1, configuration: {main: true}},
- {id: 2}
- ],
- chapters: [
- {id: 10, storylineId: 1, configuration: {title: 'intro'}},
- {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
- ],
- sections: [
- {id: 100, chapterId: 10, permaId: 500},
- {id: 101, chapterId: 11, permaId: 501},
- {id: 102, chapterId: 11, permaId: 502},
- ]
- }
+ const {result} = renderHookInEntry(() => useActiveExcursion(), {
+ seed: {
+ storylines: [
+ {id: 1, configuration: {main: true}},
+ {id: 2}
+ ],
+ chapters: [
+ {id: 10, storylineId: 1, configuration: {title: 'intro'}},
+ {id: 11, storylineId: 2, configuration: {title: 'excursion'}},
+ ],
+ sections: [
+ {id: 100, chapterId: 10, permaId: 500},
+ {id: 101, chapterId: 11, permaId: 501},
+ {id: 102, chapterId: 11, permaId: 502},
+ ]
}
- );
+ });
act(() => changeLocationHash('#section-501'));
act(() => jest.advanceTimersByTime(500));
const {activeExcursion} = result.current;
expect(activeExcursion).toMatchObject({title: 'excursion'});
- expect(scrollToTarget).not.toHaveBeenCalled();
+ expect(mockScrollToTarget).not.toHaveBeenCalled();
});
});
});
diff --git a/entry_types/scrolled/package/spec/review/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js
index 7268552a4f..9ba5b123fe 100644
--- a/entry_types/scrolled/package/spec/review/ThreadList-spec.js
+++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import {useFakeTranslations} from 'pageflow/testHelpers';
import {ThreadList} from 'review/ThreadList';
+import {ScrollHighlightedThreadIntoViewProvider} from 'review/scrollHighlightedThreadIntoView';
import {renderWithReviewState} from 'support/renderWithReviewState';
describe('ThreadList', () => {
@@ -179,11 +180,13 @@ describe('ThreadList', () => {
expect(onThreadClick).toHaveBeenCalledWith(expect.objectContaining({id: 7}));
});
- it('scrolls the highlighted thread into view', () => {
+ it('scrolls the highlighted thread into view within the scroll context', () => {
const {getByText} = renderWithReviewState(
- ,
+
+
+ ,
{
commentThreads: [
{id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
@@ -202,6 +205,26 @@ describe('ThreadList', () => {
.toBe(getByText('second').closest('[aria-current="true"]'));
});
+ it('does not scroll the highlighted thread into view outside the scroll context', () => {
+ renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 10, body: 'first', creatorName: 'Alice', creatorId: 1}
+ ]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 20, body: 'second', creatorName: 'Bob', creatorId: 2}
+ ]}
+ ]
+ }
+ );
+
+ expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled();
+ });
+
it('orders threads via compareRanges when provided', () => {
const compareRanges = (a, b) => a.start - b.start;
@@ -944,4 +967,55 @@ describe('ThreadList', () => {
expect(getAllByPlaceholderText('Reply...')).toHaveLength(2);
});
+
+ it('expands resolved threads by default when expandResolved is set', () => {
+ const {getByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ resolvedAt: null,
+ comments: [{id: 10, body: 'Active thread', creatorName: 'Alice', creatorId: 1}]},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10,
+ resolvedAt: '2026-04-09T10:00:00Z',
+ comments: [{id: 20, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ );
+
+ expect(getByText('Active thread')).toBeInTheDocument();
+ expect(getByText('Resolved thread')).toBeInTheDocument();
+ });
+
+ it('shows resolved threads instead of the new form when all are resolved and expandResolved is set', () => {
+ const {getByText, queryByPlaceholderText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ resolvedAt: '2026-04-09T10:00:00Z',
+ comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ );
+
+ expect(getByText('Resolved thread')).toBeInTheDocument();
+ expect(queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument();
+ });
+
+ it('still auto-shows the new form for only-resolved threads without expandResolved', () => {
+ const {getByPlaceholderText, queryByText} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10,
+ resolvedAt: '2026-04-09T10:00:00Z',
+ comments: [{id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}]}
+ ]
+ }
+ );
+
+ expect(getByPlaceholderText('Add a comment...')).toBeInTheDocument();
+ expect(queryByText('Resolved thread')).not.toBeInTheDocument();
+ });
});
diff --git a/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js b/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js
index f088df649b..c87050b838 100644
--- a/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js
+++ b/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom/extend-expect';
import {ThreadsBadge} from 'review/ThreadsBadge';
import {renderWithReviewState} from 'support/renderWithReviewState';
+import badgeStyles from 'review/Badge.module.css';
describe('ThreadsBadge', () => {
it('does not display count for single thread', () => {
@@ -61,6 +62,34 @@ describe('ThreadsBadge', () => {
expect(container).toBeEmptyDOMElement();
});
+ it('renders the badge as resolved when all shown threads are resolved', () => {
+ const {getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []}
+ ]
+ }
+ );
+
+ expect(getByRole('status')).toHaveClass(badgeStyles.resolved);
+ });
+
+ it('does not render the badge as resolved when an unresolved thread remains', () => {
+ const {getByRole} = renderWithReviewState(
+ ,
+ {
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: '2026-04-09T10:00:00Z', comments: []},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []}
+ ]
+ }
+ );
+
+ expect(getByRole('status')).not.toHaveClass(badgeStyles.resolved);
+ });
+
it('renders nothing when no threads exist for subject', () => {
const {container} = renderWithReviewState(
diff --git a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js
index 20130e3dba..828e268395 100644
--- a/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js
+++ b/entry_types/scrolled/package/spec/review/useLocatedCommentThreads-spec.js
@@ -2,6 +2,7 @@ import React from 'react';
import {useLocatedCommentThreads} from 'review/useLocatedCommentThreads';
import {ReviewStateProvider} from 'review/ReviewStateProvider';
+import {review} from 'review/api';
import {renderHookInEntry} from 'support';
@@ -77,6 +78,24 @@ describe('useLocatedCommentThreads', () => {
expect(result.current.chapters[1].threadCount).toBe(1);
});
+ it("orders a content element's threads by the type's compareRanges", () => {
+ review.contentElementTypes.register('textBlock', {
+ compareRanges: (a, b) => a - b
+ });
+
+ const {result} = renderLocatedCommentThreads([
+ {id: 1, subjectType: 'ContentElement', subjectId: 10001, subjectRange: 2, comments: []},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10001, subjectRange: 1, comments: []}
+ ]);
+
+ const element = result.current.chapters[0].sections[0].contentElements[0];
+
+ expect(element.threads.map(t => t.id)).toEqual([2, 1]);
+ expect(result.current.threads.map(t => t.id)).toEqual([2, 1]);
+
+ review.contentElementTypes.types = {};
+ });
+
it('provides a flat list of threads in document order, section before its elements', () => {
const {result} = renderLocatedCommentThreads([
{id: 1, subjectType: 'ContentElement', subjectId: 10001, comments: []},
diff --git a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js
index 95799971e6..2768ad6000 100644
--- a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js
+++ b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js
@@ -23,12 +23,17 @@ export function renderEntry({
return {
...result,
+ getCommentToolbar: () => result.getByRole('group', {name: 'Comments'}),
getAddCommentButton: () => result.getByRole('button', {name: 'Add comment'}),
getCancelAddCommentButton: () => result.getByRole('button', {name: 'Cancel add comment'}),
getNewThreadInput: () => result.getByPlaceholderText('Add a comment...'),
queryNewThreadInput: () => result.queryByPlaceholderText('Add a comment...'),
getAllCommentBadges: () => result.getAllByRole('status'),
- queryAllCommentBadges: () => result.queryAllByRole('status')
+ queryAllCommentBadges: () => result.queryAllByRole('status'),
+ getCommentFilterButton: resolution =>
+ result.getByRole('button', {name: resolution === 'all' ? 'All' : 'Unresolved'}),
+ getPreviousCommentButton: () => result.getByRole('button', {name: 'Previous comment'}),
+ getNextCommentButton: () => result.getByRole('button', {name: 'Next comment'})
};
}
@@ -44,9 +49,16 @@ export function useCommentingPageObjects() {
useFakeTranslations({
'pageflow_scrolled.review.add_comment': 'Add comment',
'pageflow_scrolled.review.cancel_add_comment': 'Cancel add comment',
+ 'pageflow_scrolled.review.comment_toolbar': 'Comments',
+ 'pageflow_scrolled.review.comment_count': '%{count} comments',
'pageflow_scrolled.review.select_content_element': 'Select to comment',
'pageflow_scrolled.review.select_section': 'Select section to comment',
- 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...'
+ 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...',
+ 'pageflow_scrolled.review.filter.label': 'Filter comments',
+ 'pageflow_scrolled.review.filter.unresolved': 'Unresolved',
+ 'pageflow_scrolled.review.filter.all': 'All',
+ 'pageflow_scrolled.review.previous_comment': 'Previous comment',
+ 'pageflow_scrolled.review.next_comment': 'Next comment'
});
usePageObjects();
diff --git a/entry_types/scrolled/package/src/contentElements/editor.js b/entry_types/scrolled/package/src/contentElements/editor.js
index 6467ec3eb0..608f0c9057 100644
--- a/entry_types/scrolled/package/src/contentElements/editor.js
+++ b/entry_types/scrolled/package/src/contentElements/editor.js
@@ -19,3 +19,5 @@ import './question/editor'
import './counter/editor'
import './quote/editor'
import './infoTable/editor'
+
+import './review';
diff --git a/entry_types/scrolled/package/src/contentElements/review.js b/entry_types/scrolled/package/src/contentElements/review.js
new file mode 100644
index 0000000000..12792af7bd
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/review.js
@@ -0,0 +1 @@
+import './textBlock/review';
diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js
index cb6381d8b1..3e3237d240 100644
--- a/entry_types/scrolled/package/src/contentElements/textBlock/editor.js
+++ b/entry_types/scrolled/package/src/contentElements/textBlock/editor.js
@@ -158,13 +158,6 @@ editor.contentElementTypes.register('textBlock', {
return getValue(configuration).length;
},
- compareRanges(a, b) {
- if (!a && !b) return 0;
- if (!a) return 1;
- if (!b) return -1;
- return Point.compare(rangeStart(a), rangeStart(b));
- },
-
handleDestroy(contentElement) {
const transientState = contentElement.get('transientState') || {};
@@ -202,10 +195,6 @@ function shiftPoint(point, delta) {
return {...point, path: [point.path[0] + delta, ...point.path.slice(1)]};
}
-function rangeStart(range) {
- return Point.isBefore(range.anchor, range.focus) ? range.anchor : range.focus;
-}
-
function endOfBlock(value, blockIndex) {
const [lastNode, lastPath] = Node.last({children: value}, [blockIndex]);
return {path: lastPath, offset: lastNode.text.length};
diff --git a/entry_types/scrolled/package/src/contentElements/textBlock/review.js b/entry_types/scrolled/package/src/contentElements/textBlock/review.js
new file mode 100644
index 0000000000..02835221ca
--- /dev/null
+++ b/entry_types/scrolled/package/src/contentElements/textBlock/review.js
@@ -0,0 +1,16 @@
+import {Point} from 'slate';
+
+import {review} from 'pageflow-scrolled/review';
+
+review.contentElementTypes.register('textBlock', {
+ compareRanges(a, b) {
+ if (!a && !b) return 0;
+ if (!a) return 1;
+ if (!b) return -1;
+ return Point.compare(rangeStart(a), rangeStart(b));
+ }
+});
+
+function rangeStart(range) {
+ return Point.isBefore(range.anchor, range.focus) ? range.anchor : range.focus;
+}
diff --git a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js
index 2c4c5c06b0..811d4b4f98 100644
--- a/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js
+++ b/entry_types/scrolled/package/src/editor/api/ContentElementTypeRegistry.js
@@ -83,10 +83,6 @@ export class ContentElementTypeRegistry {
return this.contentElementTypes[typeName]?.pictogram;
}
- findCompareRanges(typeName) {
- return this.contentElementTypes[typeName]?.compareRanges;
- }
-
groupedByCategory() {
const result = [];
const categoriesByName = {};
diff --git a/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js b/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js
index 0b5b16f1cd..b5483808c8 100644
--- a/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js
+++ b/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js
@@ -2,7 +2,7 @@ import React, {useEffect} from 'react';
import I18n from 'i18n-js';
import {EntryStateProvider, useEntryStateDispatch, watchCollections} from 'pageflow-scrolled/entryState';
-import {ThreadList, useLocatedCommentThreads} from 'pageflow-scrolled/review';
+import {ThreadList, useLocatedCommentThreads, review} from 'pageflow-scrolled/review';
import {ReviewView} from './ReviewView';
import defaultPictogram from './images/defaultPictogram.svg';
@@ -148,7 +148,7 @@ function ContentElementGroup({
const {permaId, type, threads} = contentElement;
const label = I18n.t(`pageflow_scrolled.editor.content_elements.${type}.name`);
const pictogram = editor.contentElementTypes.findPictogram(type) || defaultPictogram;
- const compareRanges = editor.contentElementTypes.findCompareRanges(type);
+ const compareRanges = review.contentElementTypes.findCompareRanges(type);
const isSelected = selectedSubject?.subjectType === 'ContentElement' &&
selectedSubject.id === contentElement.id;
diff --git a/entry_types/scrolled/package/src/editor/views/ReviewView.js b/entry_types/scrolled/package/src/editor/views/ReviewView.js
index b8f64c4a5f..dc5f74d630 100644
--- a/entry_types/scrolled/package/src/editor/views/ReviewView.js
+++ b/entry_types/scrolled/package/src/editor/views/ReviewView.js
@@ -2,7 +2,11 @@ import React from 'react';
import ReactDOM from 'react-dom';
import Marionette from 'backbone.marionette';
-import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review';
+import {
+ ReviewStateProvider,
+ ReviewMessageHandler,
+ ScrollHighlightedThreadIntoViewProvider
+} from 'pageflow-scrolled/review';
import styles from './ReviewView.module.css';
@@ -41,7 +45,9 @@ export const ReviewView = Marionette.ItemView.extend({
const session = this.options.entry.reviewSession;
ReactDOM.render(
- {this.renderContent(this.props())}
+
+ {this.renderContent(this.props())}
+
,
this._containerEl()
);
diff --git a/entry_types/scrolled/package/src/editor/views/SelectionCommentsView.js b/entry_types/scrolled/package/src/editor/views/SelectionCommentsView.js
index c2a367055b..07a1ab1c68 100644
--- a/entry_types/scrolled/package/src/editor/views/SelectionCommentsView.js
+++ b/entry_types/scrolled/package/src/editor/views/SelectionCommentsView.js
@@ -1,6 +1,6 @@
import React from 'react';
-import {ThreadList} from 'pageflow-scrolled/review';
+import {ThreadList, review} from 'pageflow-scrolled/review';
import {ReviewView} from './ReviewView';
import styles from './SelectionCommentsView.module.css';
@@ -21,7 +21,7 @@ export const SelectionCommentsView = ReviewView.extend({
},
props() {
- const {entry, editor} = this.options;
+ const {entry} = this.options;
const subject = entry.get('selectedCommentsSubject');
// Resolve the model from the current subject rather than the cached
@@ -41,7 +41,7 @@ export const SelectionCommentsView = ReviewView.extend({
subjectType: 'ContentElement',
subjectId: model.get('permaId'),
threadIds: model.transientState.get('commentThreadIdsAtSelection'),
- compareRanges: typeName && editor.contentElementTypes.findCompareRanges(typeName),
+ compareRanges: typeName && review.contentElementTypes.findCompareRanges(typeName),
highlightedThreadId: entry.get('highlightedThreadId'),
onThreadClick: thread => entry.trigger('selectCommentThread', thread.id)
};
diff --git a/entry_types/scrolled/package/src/frontend/Content.js b/entry_types/scrolled/package/src/frontend/Content.js
index b9434433cc..a692cb6d87 100644
--- a/entry_types/scrolled/package/src/frontend/Content.js
+++ b/entry_types/scrolled/package/src/frontend/Content.js
@@ -26,11 +26,7 @@ export const Content = extensible('Content', function Content(props) {
const entryStructure = useEntryStructure();
const scrollToTarget = useScrollToTarget();
- const {
- activeExcursion,
- activateExcursionOfSection,
- returnFromExcursion
- } = useActiveExcursion(entryStructure, {scrollToTarget});
+ const {activeExcursion, activateExcursionOfSection, returnFromExcursion} = useActiveExcursion();
const [currentSectionIndex, setCurrentSectionIndexState] = useCurrentSectionIndexState();
const [currentExcursionSectionIndex, setCurrentExcursionSectionIndex] = useState(0);
diff --git a/entry_types/scrolled/package/src/frontend/RootProviders.js b/entry_types/scrolled/package/src/frontend/RootProviders.js
index b4dfc0fbab..7e014720f1 100644
--- a/entry_types/scrolled/package/src/frontend/RootProviders.js
+++ b/entry_types/scrolled/package/src/frontend/RootProviders.js
@@ -14,6 +14,7 @@ import {ConsentProvider} from './thirdPartyConsent';
import {CurrentSectionProvider} from './useCurrentChapter';
import {ExtensionsProvider} from './extensionRegistry';
import {ScrollTargetEmitterProvider} from './useScrollTarget';
+import {ActiveExcursionProvider} from './useActiveExcursion';
export function RootProviders({seed, consent = consentApi, children}) {
return (
@@ -29,7 +30,9 @@ export function RootProviders({seed, consent = consentApi, children}) {
- {children}
+
+ {children}
+
diff --git a/entry_types/scrolled/package/src/frontend/commenting/CommentDisplayFilterProvider.js b/entry_types/scrolled/package/src/frontend/commenting/CommentDisplayFilterProvider.js
new file mode 100644
index 0000000000..1faeb73176
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/CommentDisplayFilterProvider.js
@@ -0,0 +1,22 @@
+import React, {createContext, useContext, useMemo, useState} from 'react';
+
+const CommentDisplayFilterContext = createContext({
+ resolution: 'unresolved',
+ setResolution: () => {}
+});
+
+export function CommentDisplayFilterProvider({children}) {
+ const [resolution, setResolution] = useState('unresolved');
+
+ const value = useMemo(() => ({resolution, setResolution}), [resolution]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCommentDisplayFilter() {
+ return useContext(CommentDisplayFilterContext);
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js
index c36930e432..d8d07e27c4 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js
@@ -8,6 +8,7 @@ import {useCommentThreads, useCommentHighlights, decorateCommentHighlights, useR
import {PlainEditableText, renderElement, renderLeaf} from '../EditableText';
import {useContentElementAttributes} from '../useContentElementAttributes';
import {useAddCommentMode} from './AddCommentModeProvider';
+import {useCommentDisplayFilter} from './CommentDisplayFilterProvider';
import {useSelectedSubject} from './SelectedSubjectProvider';
import {AddCommentHint} from './AddCommentHint';
import {PopoversColumn} from './PopoversColumn';
@@ -40,10 +41,11 @@ function CommentingEditableText({
const {contentElementPermaId} = useContentElementAttributes();
const {active, deactivate, preselect, clearPreselection} = useAddCommentMode();
const {subjectRange, select} = useSelectedSubject('ContentElement', contentElementPermaId);
+ const {resolution} = useCommentDisplayFilter();
const threads = useCommentThreads({
subjectType: 'ContentElement',
subjectId: contentElementPermaId,
- resolution: 'unresolved'
+ resolution
});
const highlights = useCommentHighlights(threads, subjectRange);
diff --git a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
index a75ba008a4..3341a4fdd8 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
@@ -4,6 +4,7 @@ import {useEntryMetadata} from 'pageflow-scrolled/entryState';
import {createReviewSession} from 'pageflow/review';
import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review';
import {AddCommentModeProvider} from './AddCommentModeProvider';
+import {CommentDisplayFilterProvider} from './CommentDisplayFilterProvider';
import {SelectedSubjectProvider} from './SelectedSubjectProvider';
import {FloatingToolbar} from './FloatingToolbar';
@@ -11,12 +12,14 @@ export function EntryDecorator({commentingInitialState, children}) {
return (
-
-
- {children}
-
-
-
+
+
+
+ {children}
+
+
+
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
index 87b76743e6..2886249e77 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js
@@ -1,14 +1,103 @@
import React from 'react';
+import classNames from 'classnames';
+import {useLocatedCommentThreads} from 'pageflow-scrolled/review';
import {useI18n} from '../i18n';
import {useAddCommentMode} from './AddCommentModeProvider';
+import {useCommentDisplayFilter} from './CommentDisplayFilterProvider';
+import {useCommentNavigation} from './SelectedSubjectProvider';
import AddCommentIcon from './images/addComment.svg';
import CancelCommentIcon from './images/cancelComment.svg';
+import ChevronIcon from './images/chevron.svg';
import styles from './FloatingToolbar.module.css';
export function FloatingToolbar() {
const {t} = useI18n({locale: 'ui'});
+
+ return (
+
+ );
+}
+
+function PositionIndicator() {
+ const {t} = useI18n({locale: 'ui'});
+ const {count, position} = useCommentNavigation();
+
+ return (
+
+ {position || '–'} /
+
+ );
+}
+
+function NavigationArrows() {
+ const {t} = useI18n({locale: 'ui'});
+ const {count, goToPrevious, goToNext} = useCommentNavigation();
+
+ return (
+
+
+
+
+ );
+}
+
+const resolutions = ['unresolved', 'all'];
+
+function ResolutionToggleButton() {
+ const {t} = useI18n({locale: 'ui'});
+ const {resolution, setResolution} = useCommentDisplayFilter();
+ const {threads} = useLocatedCommentThreads();
+
+ const counts = {
+ unresolved: threads.filter(thread => !thread.resolvedAt).length,
+ all: threads.length
+ };
+
+ return (
+
+ {resolutions.map(value => (
+
+ ))}
+
+ );
+}
+
+function AddCommentButton() {
+ const {t} = useI18n({locale: 'ui'});
const {active, toggle} = useAddCommentMode();
const Icon = active ? CancelCommentIcon : AddCommentIcon;
@@ -17,14 +106,12 @@ export function FloatingToolbar() {
: 'pageflow_scrolled.review.add_comment');
return (
-
-
-
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
index cf63497b7c..adfcccc6c0 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
+++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.module.css
@@ -1,20 +1,114 @@
.toolbar {
position: fixed;
- bottom: 0;
- right: 0;
+ bottom: space(3);
+ right: space(3);
z-index: 1100;
- padding: space(2);
+ display: flex;
+ align-items: center;
+ gap: space(2);
+ padding: space(1);
+ background: var(--ui-primary-color);
+ border: 1px solid var(--ui-on-primary-color-lightest);
+ border-radius: rounded(lg);
+ box-shadow: var(--ui-box-shadow-md);
+}
+
+.navigation {
+ display: flex;
+ align-items: center;
+ gap: space(1);
}
.button {
font-family: var(--ui-font-family);
color: var(--ui-on-primary-color);
- background: var(--ui-primary-color);
+ background: transparent;
border: none;
- border-radius: rounded(md);
- padding: space(2);
+ border-radius: rounded(sm);
+ padding: space(1);
cursor: pointer;
- box-shadow: var(--ui-box-shadow-md);
display: flex;
align-items: center;
}
+
+.button:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+
+.count,
+.segmented {
+ font-size: size(3.5);
+}
+
+.count {
+ font-family: var(--ui-font-family);
+ font-variant-numeric: tabular-nums;
+ color: var(--ui-on-primary-color-light);
+ padding: 0 space(1);
+ margin: 0 space(-1) 0 space(2);
+ white-space: nowrap;
+}
+
+.chevronUp {
+ transform: rotate(180deg);
+}
+
+.chevronDown {
+ transform: rotate(0deg);
+}
+
+.segmented {
+ display: flex;
+ align-items: center;
+ gap: space(1);
+ border: solid 1px var(--ui-on-primary-color-lightest);
+ border-radius: rounded(md);
+}
+
+.segment {
+ font-family: var(--ui-font-family);
+ display: flex;
+ align-items: center;
+ gap: space(1);
+ color: var(--ui-on-primary-color-light);
+ background: transparent;
+ border: none;
+ padding: space(2) space(2);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.segment:first-child {
+ border-top-left-radius: rounded(md);
+ border-bottom-left-radius: rounded(md);
+}
+
+.segment:last-child {
+ border-top-right-radius: rounded(md);
+ border-bottom-right-radius: rounded(md);
+}
+
+.segment[aria-pressed='true'] {
+ color: var(--ui-on-primary-color);
+ background: var(--ui-on-primary-color-lightest);
+}
+
+.segmentCount {
+ font-variant-numeric: tabular-nums;
+ border-radius: rounded(sm);
+ padding: 0 space(1);
+ background: var(--ui-on-primary-color-lightest);
+}
+
+.segment[aria-pressed='true'] .segmentCount {
+ background: var(--ui-on-primary-color-lighter);
+}
+
+.addButton {
+ color: var(--ui-on-accent-color);
+ background: var(--ui-accent-color);
+ border-radius: rounded(md);
+ padding: space(2);
+ margin-left: space(2);
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/Popover.js b/entry_types/scrolled/package/src/frontend/commenting/Popover.js
index 9b503420cb..fec3bae330 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/Popover.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/Popover.js
@@ -1,4 +1,4 @@
-import React, {useEffect} from 'react';
+import React, {useEffect, useState} from 'react';
import {
useFloating, FloatingPortal,
offset, flip, shift, autoUpdate
@@ -6,6 +6,7 @@ import {
import {ThreadsBadge, ThreadList} from 'pageflow-scrolled/review';
import {useFloatingPortalRoot} from '../FloatingPortalRootProvider';
+import {useCommentDisplayFilter} from './CommentDisplayFilterProvider';
import {useSelectedSubject} from './SelectedSubjectProvider';
import styles from './Popover.module.css';
@@ -14,12 +15,65 @@ export function Popover({
subjectType, subjectId, subjectRange,
placement = 'bottom-start', strategy = 'absolute', hideNewTopicButton
}) {
- const {isSelected, showNewForm, select, clearSelection} =
+ const {isSelected, showNewForm, select, clearSelection, highlightedThreadId} =
useSelectedSubject(subjectType, subjectId, subjectRange);
+ const {resolution} = useCommentDisplayFilter();
+ const [reference, setReference] = useState(null);
+
+ // Scroll into view when navigation lands on this subject. Only
+ // navigation sets a highlighted thread (clicking a badge does not), so
+ // this never fires on plain selection. Re-firing while stepping
+ // between a subject's threads targets the same badge, so it is a
+ // no-op; a popover mounting late (after its excursion is activated)
+ // scrolls once its reference becomes available.
+ useEffect(() => {
+ if (isSelected && highlightedThreadId != null) {
+ reference?.scrollIntoView({block: 'center', behavior: 'smooth'});
+ }
+ }, [isSelected, highlightedThreadId, reference]);
+
+ function handleBadgeClick() {
+ if (isSelected) {
+ clearSelection();
+ }
+ else {
+ select();
+ }
+ }
+
+ return (
+
+
+ {isSelected &&
+ }
+
+ );
+}
+
+function OpenThreadList({
+ reference, subjectType, subjectId, subjectRange,
+ placement, strategy, showNewForm, hideNewTopicButton, highlightedThreadId, expandResolved, onDismiss
+}) {
const portalRoot = useFloatingPortalRoot();
const {refs, floatingStyles} = useFloating({
- open: isSelected,
+ open: true,
+ elements: {reference},
placement,
strategy,
middleware: [
@@ -33,29 +87,19 @@ export function Popover({
whileElementsMounted: autoUpdate
});
- function handleBadgeClick() {
- if (isSelected) {
- clearSelection();
- }
- else {
- select();
- }
- }
-
useEffect(() => {
- if (!isSelected) return;
-
function handleClick(event) {
- if (refs.reference.current?.contains(event.target)) return;
+ if (reference?.contains(event.target)) return;
if (refs.floating.current?.contains(event.target)) return;
if (event.target.closest('[data-comment-highlight]')) return;
+ if (event.target.closest('[data-comment-toolbar]')) return;
- clearSelection();
+ onDismiss();
}
function handleKeyDown(event) {
if (event.key === 'Escape') {
- clearSelection();
+ onDismiss();
}
}
@@ -66,28 +110,22 @@ export function Popover({
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKeyDown);
};
- }, [isSelected, clearSelection, refs.reference, refs.floating]);
+ }, [reference, refs.floating, onDismiss]);
return (
-
-
+
+
- {isSelected &&
-
-
-
-
- }
-
+ highlightedThreadId={highlightedThreadId}
+ expandResolved={expandResolved}
+ showNewForm={showNewForm}
+ hideNewTopicButton={hideNewTopicButton} />
+
+
);
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js
index b0688d0115..64da981d2b 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import {useCommentThreads} from 'pageflow-scrolled/review';
import {useI18n} from '../i18n';
import {useAddCommentMode} from './AddCommentModeProvider';
+import {useCommentDisplayFilter} from './CommentDisplayFilterProvider';
import {useSelectedSubject} from './SelectedSubjectProvider';
import {Popover} from './Popover';
@@ -13,10 +14,11 @@ import styles from './SectionDecorator.module.css';
export function SectionDecorator({section, children}) {
const {active} = useAddCommentMode();
const {isSelected} = useSelectedSubject('Section', section.permaId);
+ const {resolution} = useCommentDisplayFilter();
const threads = useCommentThreads({
subjectType: 'Section',
subjectId: section.permaId,
- resolution: 'unresolved'
+ resolution
});
const hasThreads = threads.length > 0;
diff --git a/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js
index 9f509ef91e..7a87719b7a 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js
@@ -1,31 +1,101 @@
import React, {createContext, useCallback, useContext, useMemo, useState} from 'react';
+import {useLocatedCommentThreads} from 'pageflow-scrolled/review';
+import {useActiveExcursion} from '../useActiveExcursion';
+import {useCommentDisplayFilter} from './CommentDisplayFilterProvider';
+
const SelectedSubjectContext = createContext({
selectedSubject: null,
setSelectedSubject: () => {},
clearSelection: () => {}
});
+const CommentNavigationContext = createContext({
+ count: 0,
+ position: 0,
+ goToNext: () => {},
+ goToPrevious: () => {}
+});
+
export function SelectedSubjectProvider({children}) {
+ const {resolution} = useCommentDisplayFilter();
+ const {chapters} = useLocatedCommentThreads();
+ const {activateExcursionOfSection, returnFromExcursion} = useActiveExcursion();
+
const [selectedSubject, setSelectedSubject] = useState(null);
+ const targets = useMemo(
+ () => navigableTargets(chapters, resolution),
+ [chapters, resolution]
+ );
+
const clearSelection = useCallback(() => {
setSelectedSubject(null);
}, []);
- const value = useMemo(() => ({
+ const goTo = useCallback(step => {
+ if (targets.length === 0) {
+ return;
+ }
+
+ const current = currentTargetIndex(targets, selectedSubject);
+ const next = current < 0
+ ? (step > 0 ? 0 : targets.length - 1)
+ : (current + step + targets.length) % targets.length;
+
+ const target = targets[next];
+
+ // Activate the excursion the target lives in (or leave the current
+ // one) before selecting it, so its popover can mount and open. Only
+ // needed when moving to a different subject.
+ if (!selectedSubject || subjectKey(selectedSubject) !== target.key) {
+ if (target.excursion) {
+ activateExcursionOfSection({id: target.sectionId});
+ }
+ else {
+ returnFromExcursion();
+ }
+ }
+
+ setSelectedSubject({
+ subjectType: target.subjectType,
+ subjectId: target.subjectId,
+ subjectRange: target.subjectRange,
+ highlightedThreadId: target.threadId
+ });
+ }, [targets, selectedSubject, activateExcursionOfSection, returnFromExcursion]);
+
+ const position = useMemo(
+ () => currentTargetIndex(targets, selectedSubject) + 1,
+ [targets, selectedSubject]
+ );
+
+ const selection = useMemo(() => ({
selectedSubject,
setSelectedSubject,
clearSelection
}), [selectedSubject, clearSelection]);
+ const navigation = useMemo(() => ({
+ count: targets.length,
+ position,
+ goToNext: () => goTo(1),
+ goToPrevious: () => goTo(-1)
+ }), [targets.length, position, goTo]);
+
return (
-
- {children}
+
+
+ {children}
+
);
}
+export function useCommentNavigation() {
+ return useContext(CommentNavigationContext);
+}
+
export function useSelectedSubject(subjectType, subjectId, subjectRange) {
const {selectedSubject, setSelectedSubject, clearSelection} = useContext(SelectedSubjectContext);
@@ -40,5 +110,61 @@ export function useSelectedSubject(subjectType, subjectId, subjectRange) {
return {isSelected, hasSelection: !!selectedSubject, select, clearSelection,
showNewForm: isSelected && selectedSubject.showNewForm,
- subjectRange: isSelected ? selectedSubject.subjectRange : undefined};
+ subjectRange: isSelected ? selectedSubject.subjectRange : undefined,
+ highlightedThreadId: isSelected ? selectedSubject.highlightedThreadId ?? null : null};
+}
+
+// The cursor the arrows step from: the highlighted thread when one is
+// set by navigation, otherwise the selected subject's first thread (so
+// arrows continue from a clicked badge), otherwise nothing.
+function currentTargetIndex(targets, selectedSubject) {
+ if (!selectedSubject) {
+ return -1;
+ }
+
+ if (selectedSubject.highlightedThreadId != null) {
+ return targets.findIndex(target => target.threadId === selectedSubject.highlightedThreadId);
+ }
+
+ const key = subjectKey(selectedSubject);
+ return targets.findIndex(target => target.key === key);
+}
+
+function navigableTargets(chapters, resolution) {
+ const targets = [];
+
+ chapters.forEach(chapter => {
+ chapter.sections.forEach(section => {
+ const location = {sectionId: section.id, excursion: chapter.isExcursion};
+
+ pushTargets(targets, section.threads, location);
+ section.contentElements.forEach(contentElement => {
+ pushTargets(targets, contentElement.threads, location);
+ });
+ });
+ });
+
+ return targets.filter(target => matchesResolution(target, resolution));
+}
+
+function pushTargets(targets, threads, location) {
+ threads.forEach(thread => targets.push({
+ key: subjectKey(thread),
+ subjectType: thread.subjectType,
+ subjectId: thread.subjectId,
+ subjectRange: thread.subjectRange,
+ threadId: thread.id,
+ resolved: !!thread.resolvedAt,
+ ...location
+ }));
+}
+
+function matchesResolution(target, resolution) {
+ return resolution === 'all' ||
+ (resolution === 'unresolved' && !target.resolved) ||
+ (resolution === 'resolved' && target.resolved);
+}
+
+function subjectKey({subjectType, subjectId, subjectRange}) {
+ return `${subjectType}:${subjectId}:${subjectRange ? JSON.stringify(subjectRange) : ''}`;
}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/extensions.js b/entry_types/scrolled/package/src/frontend/commenting/extensions.js
index da73844007..f06cbd32f4 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/extensions.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/extensions.js
@@ -1,3 +1,5 @@
+import '../../contentElements/review';
+
import {EntryDecorator} from './EntryDecorator';
import {SectionDecorator} from './SectionDecorator';
import {ContentElementDecorator} from './ContentElementDecorator';
diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/chevron.svg b/entry_types/scrolled/package/src/frontend/commenting/images/chevron.svg
new file mode 100644
index 0000000000..a96f8096bc
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/images/chevron.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/frontend/useActiveExcursion.js b/entry_types/scrolled/package/src/frontend/useActiveExcursion.js
index c65a2f1ce2..3fddee1a2d 100644
--- a/entry_types/scrolled/package/src/frontend/useActiveExcursion.js
+++ b/entry_types/scrolled/package/src/frontend/useActiveExcursion.js
@@ -1,6 +1,18 @@
-import {useCallback, useEffect, useState, useMemo, useRef} from 'react';
+import React, {createContext, useCallback, useContext, useEffect, useState, useMemo, useRef} from 'react';
+
+import {useEntryStructure} from 'pageflow-scrolled/entryState';
+import {useScrollToTarget} from './useScrollTarget';
+
+const ActiveExcursionContext = createContext({
+ activeExcursion: undefined,
+ activateExcursionOfSection: () => {},
+ returnFromExcursion: () => {}
+});
+
+export function ActiveExcursionProvider({children}) {
+ const entryStructure = useEntryStructure();
+ const scrollToTarget = useScrollToTarget();
-export function useActiveExcursion(entryStructure, {scrollToTarget} = {}) {
const [activeExcursionId, setActiveExcursionId] = useState();
const [scrollTarget, setScrollTarget] = useState();
const returnUrlRef = useRef(null);
@@ -109,7 +121,20 @@ export function useActiveExcursion(entryStructure, {scrollToTarget} = {}) {
const activeExcursion = useMemo(() => {
return entryStructure.excursions.find(excursion => excursion.id === activeExcursionId);
- }, [entryStructure, activeExcursionId])
+ }, [entryStructure, activeExcursionId]);
+
+ const value = useMemo(
+ () => ({activeExcursion, activateExcursionOfSection, returnFromExcursion}),
+ [activeExcursion, activateExcursionOfSection, returnFromExcursion]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
- return {activeExcursion, activateExcursionOfSection, returnFromExcursion};
+export function useActiveExcursion() {
+ return useContext(ActiveExcursionContext);
}
diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js
index c06bd5a68c..5527fcf0ab 100644
--- a/entry_types/scrolled/package/src/review/Thread.js
+++ b/entry_types/scrolled/package/src/review/Thread.js
@@ -5,6 +5,7 @@ import {useI18n} from '../frontend/i18n';
import {AvatarStack} from './Avatar';
import {Comment} from './Comment';
import {ReplyForm} from './ReplyForm';
+import {useScrollHighlightedThreadIntoView} from './scrollHighlightedThreadIntoView';
import ChevronIcon from './images/chevron.svg';
import ResolveIcon from './images/resolve.svg';
@@ -18,12 +19,13 @@ export function Thread({thread, collapsed, onToggle, onResolve, onClick, highlig
const repliesCollapsed = collapsed && replies.length > 0;
const ref = useRef();
+ const scrollHighlightedIntoView = useScrollHighlightedThreadIntoView();
useEffect(() => {
- if (highlighted && ref.current) {
+ if (scrollHighlightedIntoView && highlighted && ref.current) {
ref.current.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
- }, [highlighted]);
+ }, [scrollHighlightedIntoView, highlighted]);
return (
{!showNewForm && !hideNewTopicButton &&
@@ -78,7 +81,7 @@ export function ThreadList({subjectType, subjectId, subjectRange, filter, compar
{resolvedThreads.length > 0 &&
);
}
-
-function sortByRange(threads, compareRanges) {
- if (!compareRanges) return threads;
- return [...threads].sort((a, b) => compareRanges(a.subjectRange, b.subjectRange));
-}
diff --git a/entry_types/scrolled/package/src/review/ThreadsBadge.js b/entry_types/scrolled/package/src/review/ThreadsBadge.js
index 6179cfa013..754e958e6e 100644
--- a/entry_types/scrolled/package/src/review/ThreadsBadge.js
+++ b/entry_types/scrolled/package/src/review/ThreadsBadge.js
@@ -3,12 +3,15 @@ import React, {useCallback} from 'react';
import {useCommentThreads} from './ReviewStateProvider';
import {Badge} from './Badge';
-export function ThreadsBadge({subjectType, subjectId, subjectRange, onClick, mode}) {
- const threads = useCommentThreads({subjectType, subjectId, subjectRange, resolution: 'unresolved'});
+export function ThreadsBadge({subjectType, subjectId, subjectRange, onClick, mode, resolution = 'unresolved'}) {
+ const threads = useCommentThreads({subjectType, subjectId, subjectRange, resolution});
+ const unresolvedThreads = useCommentThreads({subjectType, subjectId, subjectRange, resolution: 'unresolved'});
const handleClick = useCallback(() => {
if (onClick) onClick(threads);
}, [onClick, threads]);
- return
;
+ const resolved = threads.length > 0 && unresolvedThreads.length === 0;
+
+ return
;
}
diff --git a/entry_types/scrolled/package/src/review/api/ContentElementTypeRegistry.js b/entry_types/scrolled/package/src/review/api/ContentElementTypeRegistry.js
new file mode 100644
index 0000000000..b589e9fb39
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/api/ContentElementTypeRegistry.js
@@ -0,0 +1,18 @@
+// Holds per content element type behavior needed to display comment
+// threads. Available both in the editor and in the frontend commenting
+// mode. Kept separate from the editor and frontend registries so comment
+// ordering logic (comparing Slate ranges) can be shared by both without
+// pulling Slate into the always-on frontend bundle.
+export class ContentElementTypeRegistry {
+ constructor() {
+ this.types = {};
+ }
+
+ register(typeName, options) {
+ this.types[typeName] = options;
+ }
+
+ findCompareRanges(typeName) {
+ return this.types[typeName]?.compareRanges;
+ }
+}
diff --git a/entry_types/scrolled/package/src/review/api/index.js b/entry_types/scrolled/package/src/review/api/index.js
new file mode 100644
index 0000000000..8ec21f2c2c
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/api/index.js
@@ -0,0 +1,5 @@
+import {ContentElementTypeRegistry} from './ContentElementTypeRegistry';
+
+export const review = {
+ contentElementTypes: new ContentElementTypeRegistry()
+};
diff --git a/entry_types/scrolled/package/src/review/index.js b/entry_types/scrolled/package/src/review/index.js
index 21fe72da8f..abe5b7ca22 100644
--- a/entry_types/scrolled/package/src/review/index.js
+++ b/entry_types/scrolled/package/src/review/index.js
@@ -1,3 +1,4 @@
+export {review} from './api';
export {ReviewStateProvider, useCommentThreads, useCommentThread} from './ReviewStateProvider';
export {useLocatedCommentThreads} from './useLocatedCommentThreads';
export {ReviewMessageHandler} from './ReviewMessageHandler';
@@ -5,6 +6,7 @@ export {ThreadsBadge} from './ThreadsBadge';
export {Badge} from './Badge';
export {ThreadList} from './ThreadList';
export {Thread} from './Thread';
+export {ScrollHighlightedThreadIntoViewProvider} from './scrollHighlightedThreadIntoView';
export {NewThreadForm} from './NewThreadForm';
export {postCreateCommentThreadMessage, postUpdateThreadMessage} from './postMessage';
export {useCommentHighlights, decorateCommentHighlights} from './commentHighlights';
diff --git a/entry_types/scrolled/package/src/review/scrollHighlightedThreadIntoView.js b/entry_types/scrolled/package/src/review/scrollHighlightedThreadIntoView.js
new file mode 100644
index 0000000000..5c045f1146
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/scrollHighlightedThreadIntoView.js
@@ -0,0 +1,20 @@
+import React, {createContext, useContext} from 'react';
+
+const ScrollHighlightedThreadIntoViewContext = createContext(false);
+
+// Editor sidebar panels (see ReviewView) render their thread lists in a
+// scrollable container and want the highlighted thread scrolled into
+// view. The preview popover positions itself near its badge and manages
+// its own scrolling, so it leaves this off (the default) to avoid
+// fighting that.
+export function ScrollHighlightedThreadIntoViewProvider({children}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useScrollHighlightedThreadIntoView() {
+ return useContext(ScrollHighlightedThreadIntoViewContext);
+}
diff --git a/entry_types/scrolled/package/src/review/sortByRange.js b/entry_types/scrolled/package/src/review/sortByRange.js
new file mode 100644
index 0000000000..aee36decf0
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/sortByRange.js
@@ -0,0 +1,4 @@
+export function sortByRange(threads, compareRanges) {
+ if (!compareRanges) return threads;
+ return [...threads].sort((a, b) => compareRanges(a.subjectRange, b.subjectRange));
+}
diff --git a/entry_types/scrolled/package/src/review/useLocatedCommentThreads.js b/entry_types/scrolled/package/src/review/useLocatedCommentThreads.js
index a72db984de..d632efdeee 100644
--- a/entry_types/scrolled/package/src/review/useLocatedCommentThreads.js
+++ b/entry_types/scrolled/package/src/review/useLocatedCommentThreads.js
@@ -2,7 +2,9 @@ import {useMemo} from 'react';
import {useEntryStructureWithContentElements} from 'pageflow-scrolled/entryState';
+import {review} from './api';
import {useCommentThreads} from './ReviewStateProvider';
+import {sortByRange} from './sortByRange';
/**
* Joins the comment threads from the review state with the entry
@@ -25,8 +27,11 @@ export function useLocatedCommentThreads() {
const locatedThreads = new Set();
const threads = [];
- const take = (subjectType, subjectId) => {
- const subjectThreads = threadsBySubject[subjectKey(subjectType, subjectId)] || [];
+ const take = (subjectType, subjectId, compareRanges) => {
+ const subjectThreads = sortByRange(
+ threadsBySubject[subjectKey(subjectType, subjectId)] || [],
+ compareRanges
+ );
subjectThreads.forEach(thread => locatedThreads.add(thread));
threads.push(...subjectThreads);
return subjectThreads;
@@ -38,7 +43,11 @@ export function useLocatedCommentThreads() {
threads: take('Section', section.permaId),
contentElements: section.contentElements.map(contentElement => ({
...contentElement,
- threads: take('ContentElement', contentElement.permaId)
+ threads: take(
+ 'ContentElement',
+ contentElement.permaId,
+ review.contentElementTypes.findCompareRanges(contentElement.type)
+ )
}))
}));