@eightyfourthousand/lib-editing 2026.3.0 → 2026.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +7 -7
- package/src/lib/components/editor/EditorBackMatterPage.tsx +3 -1
- package/src/lib/components/editor/EditorHeader.tsx +1 -1
- package/src/lib/components/editor/EditorProvider.tsx +76 -0
- package/src/lib/components/editor/PaginationProvider.tsx +30 -45
- package/src/lib/components/editor/extensions/Heading/Heading.ts +4 -3
- package/src/lib/components/editor/extensions/InternalLink/InternalLink.ts +4 -1
- package/src/lib/components/editor/extensions/Mention/Mention.ts +4 -1
- package/src/lib/components/editor/extensions/Passage/EditorOptions.tsx +2 -2
- package/src/lib/components/editor/extensions/Passage/Passage.tsx +3 -3
- package/src/lib/components/editor/extensions/Passage/ReaderOptions.tsx +3 -3
- package/src/lib/components/editor/extensions/Passage/ShowAnnotations.tsx +3 -3
- package/src/lib/components/editor/extensions/TitlesNode.ts +1 -1
- package/src/lib/components/reader/ReaderBackMatterPage.tsx +1 -0
- package/src/lib/components/reader/ReaderBackMatterPanel.tsx +3 -0
- package/src/lib/components/reader/ReaderBodyPanel.tsx +1 -1
- package/src/lib/components/shared/BackMatterPanel.tsx +17 -10
- package/src/lib/components/shared/BodyPanel.tsx +6 -6
- package/src/lib/components/shared/LabeledElement.tsx +3 -3
- package/src/lib/components/shared/LeftPanel.tsx +3 -3
- package/src/lib/components/shared/NavigationContext.ts +1 -1
- package/src/lib/components/shared/NavigationProvider.tsx +33 -1
- package/src/lib/components/shared/ReaderSearchButton.tsx +110 -0
- package/src/lib/components/shared/SearchReplacePanel.tsx +259 -0
- package/src/lib/components/shared/SourceReader.tsx +13 -4
- package/src/lib/components/shared/SuggestRevisionForm.tsx +1 -1
- package/src/lib/components/shared/TableOfContents.tsx +40 -27
- package/src/lib/components/shared/ThreeColumnRenderer.tsx +16 -9
- package/src/lib/components/shared/TranslationHeader.tsx +2 -63
- package/src/lib/components/shared/bibliography/BibliographyList.tsx +2 -2
- package/src/lib/components/shared/glossary/GlossaryInstanceBody.tsx +15 -5
- package/src/lib/components/shared/glossary/GlossaryPaginationProvider.tsx +17 -56
- package/src/lib/components/shared/glossary/GlossaryTermList.tsx +4 -5
- package/src/lib/components/shared/hooks/usePaginationLoadTriggers.spec.tsx +158 -0
- package/src/lib/components/shared/hooks/usePaginationLoadTriggers.ts +129 -0
- package/src/lib/components/shared/titles/FramedCard.tsx +3 -3
- package/src/lib/components/shared/titles/Titles.tsx +17 -6
- package/src/lib/components/shared/titles/TitlesCard.tsx +6 -6
- package/src/lib/transformers/has-abbreviation.ts +7 -2
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { ThreeColumns } from '@eightyfourthousand/design-system';
|
|
4
|
-
import { ReactNode } from 'react';
|
|
4
|
+
import { ReactNode, useMemo } from 'react';
|
|
5
5
|
import { useNavigation } from './NavigationProvider';
|
|
6
6
|
import { cn } from '@eightyfourthousand/lib-utils';
|
|
7
7
|
import { TranslationHeader } from './TranslationHeader';
|
|
8
|
+
import { ReaderSearchButton } from './ReaderSearchButton';
|
|
9
|
+
import { GatedFeature } from '@eightyfourthousand/lib-instr';
|
|
8
10
|
|
|
9
11
|
export const ThreeColumnRenderer = ({
|
|
10
12
|
withHeader = false,
|
|
@@ -16,6 +18,8 @@ export const ThreeColumnRenderer = ({
|
|
|
16
18
|
const { panels, updatePanel, hasTranslationContent } = useNavigation();
|
|
17
19
|
const rightPanelEnabled = hasTranslationContent;
|
|
18
20
|
|
|
21
|
+
const searchButton = useMemo(() => <ReaderSearchButton />, []);
|
|
22
|
+
|
|
19
23
|
return (
|
|
20
24
|
<div
|
|
21
25
|
className={cn(
|
|
@@ -23,13 +27,16 @@ export const ThreeColumnRenderer = ({
|
|
|
23
27
|
withHeader ? 'pb-20' : '',
|
|
24
28
|
)}
|
|
25
29
|
>
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
30
|
+
<GatedFeature flag='show-reader-header'>
|
|
31
|
+
<div className="pb-2.5">
|
|
32
|
+
<TranslationHeader className="rounded-full shadow-lg" />
|
|
33
|
+
</div>
|
|
34
|
+
</GatedFeature>
|
|
29
35
|
<ThreeColumns
|
|
30
36
|
leftPanelOpen={panels.left.open}
|
|
31
37
|
rightPanelOpen={rightPanelEnabled ? panels.right.open : false}
|
|
32
38
|
rightPanelEnabled={rightPanelEnabled}
|
|
39
|
+
mainPanelActions={searchButton}
|
|
33
40
|
onLeftPanelOpenChange={(open) => {
|
|
34
41
|
updatePanel({
|
|
35
42
|
name: 'left',
|
|
@@ -39,11 +46,11 @@ export const ThreeColumnRenderer = ({
|
|
|
39
46
|
onRightPanelOpenChange={
|
|
40
47
|
rightPanelEnabled
|
|
41
48
|
? (open) => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
updatePanel({
|
|
50
|
+
name: 'right',
|
|
51
|
+
state: { open, tab: panels.right.tab },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
47
54
|
: undefined
|
|
48
55
|
}
|
|
49
56
|
>
|
|
@@ -1,68 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { MiniLogo } from '@eightyfourthousand/design-system';
|
|
4
|
-
import { PassageMatch, SearchButton, SearchResult } from '@eightyfourthousand/lib-search';
|
|
5
4
|
import { useNavigation } from './NavigationProvider';
|
|
6
|
-
import { useCallback } from 'react';
|
|
7
|
-
import { PanelName, PanelState, TabName } from './types';
|
|
8
|
-
import { BodyItemType } from '@eightyfourthousand/data-access';
|
|
9
5
|
import { cn } from '@eightyfourthousand/lib-utils';
|
|
10
6
|
|
|
11
7
|
export const TranslationHeader = ({ className }: { className?: string }) => {
|
|
12
|
-
const { imprint
|
|
8
|
+
const { imprint } = useNavigation();
|
|
13
9
|
|
|
14
|
-
const onResultSelected = useCallback(
|
|
15
|
-
(result: SearchResult) => {
|
|
16
|
-
let side: PanelName = 'main';
|
|
17
|
-
const panelState: PanelState = {
|
|
18
|
-
open: true,
|
|
19
|
-
hash: result.uuid,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const TAB_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, TabName>> = {
|
|
23
|
-
abbreviations: 'abbreviations',
|
|
24
|
-
endnotes: 'endnotes',
|
|
25
|
-
summary: 'front',
|
|
26
|
-
introduction: 'front',
|
|
27
|
-
acknowledgements: 'front',
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const SIDE_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, PanelName>> =
|
|
31
|
-
{
|
|
32
|
-
abbreviations: 'right',
|
|
33
|
-
endnotes: 'right',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
switch (result.type) {
|
|
37
|
-
case 'passage':
|
|
38
|
-
{
|
|
39
|
-
const passage = result as PassageMatch;
|
|
40
|
-
side = SIDE_FOR_PASSAGE_SECTION[passage.section] || 'main';
|
|
41
|
-
panelState.tab =
|
|
42
|
-
TAB_FOR_PASSAGE_SECTION[passage.section] || 'translation';
|
|
43
|
-
}
|
|
44
|
-
break;
|
|
45
|
-
case 'alignment':
|
|
46
|
-
side = 'main';
|
|
47
|
-
panelState.tab = 'compare';
|
|
48
|
-
break;
|
|
49
|
-
case 'bibliography':
|
|
50
|
-
side = 'right';
|
|
51
|
-
panelState.tab = 'bibliography';
|
|
52
|
-
break;
|
|
53
|
-
case 'glossary':
|
|
54
|
-
side = 'right';
|
|
55
|
-
panelState.tab = 'glossary';
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
updatePanel({
|
|
60
|
-
name: side,
|
|
61
|
-
state: panelState,
|
|
62
|
-
});
|
|
63
|
-
},
|
|
64
|
-
[updatePanel],
|
|
65
|
-
);
|
|
66
10
|
return (
|
|
67
11
|
<div
|
|
68
12
|
className={cn(
|
|
@@ -72,15 +16,10 @@ export const TranslationHeader = ({ className }: { className?: string }) => {
|
|
|
72
16
|
>
|
|
73
17
|
<div className="flex gap-2 md:gap-5 min-w-0">
|
|
74
18
|
<MiniLogo className="ms-3 me-1 my-auto" width={32} height={32} />
|
|
75
|
-
<span className="font-serif
|
|
19
|
+
<span className="font-serif truncate text-darkgray text-xs sm:text-sm my-auto flex-shrink">
|
|
76
20
|
{`${imprint?.mainTitles?.en ? `${imprint?.mainTitles?.en}` : ''}${imprint?.section ? ` from ${imprint?.section}` : ''}`}
|
|
77
21
|
</span>
|
|
78
22
|
</div>
|
|
79
|
-
<SearchButton
|
|
80
|
-
workUuid={uuid}
|
|
81
|
-
toh={toh}
|
|
82
|
-
onResultSelected={onResultSelected}
|
|
83
|
-
/>
|
|
84
23
|
</div>
|
|
85
24
|
);
|
|
86
25
|
};
|
|
@@ -22,7 +22,7 @@ export const BibliographyList = ({
|
|
|
22
22
|
return (
|
|
23
23
|
<div ref={ref} className={cn('flex flex-col w-full', className)}>
|
|
24
24
|
<LabeledElement
|
|
25
|
-
className="
|
|
25
|
+
className="mt-2"
|
|
26
26
|
id={'bibliography'}
|
|
27
27
|
label="b."
|
|
28
28
|
>
|
|
@@ -43,7 +43,7 @@ export const BibliographyList = ({
|
|
|
43
43
|
label={`b.${i + 1}`}
|
|
44
44
|
contentType="bibliography"
|
|
45
45
|
>
|
|
46
|
-
<div className="text-
|
|
46
|
+
<div className="text-primary font-sans text-base font-bold">
|
|
47
47
|
{section.heading}
|
|
48
48
|
</div>
|
|
49
49
|
</LabeledElement>
|
|
@@ -8,16 +8,21 @@ import { useCallback, useRef, useState } from 'react';
|
|
|
8
8
|
import { useGlossaryInstanceListener } from '../hooks/useGlossaryInstanceListener';
|
|
9
9
|
import { useNavigation } from '../NavigationProvider';
|
|
10
10
|
import { TAB_FOR_SECTION, PANEL_FOR_SECTION } from '../types';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createGraphQLClient,
|
|
13
|
+
getTermPassages,
|
|
14
|
+
} from '@eightyfourthousand/client-graphql';
|
|
12
15
|
|
|
13
16
|
type PassageItem = { uuid: string; type: string; label: string };
|
|
14
17
|
|
|
15
18
|
export const GlossaryInstanceBody = ({
|
|
16
19
|
instance,
|
|
17
20
|
className,
|
|
21
|
+
isEditor = false,
|
|
18
22
|
}: {
|
|
19
23
|
instance: GlossaryTermInstance;
|
|
20
24
|
className?: string;
|
|
25
|
+
isEditor?: boolean;
|
|
21
26
|
}) => {
|
|
22
27
|
const ref = useRef<HTMLDivElement>(null);
|
|
23
28
|
useGlossaryInstanceListener({ ref });
|
|
@@ -66,10 +71,15 @@ export const GlossaryInstanceBody = ({
|
|
|
66
71
|
return (
|
|
67
72
|
<div className={cn('p-2 flex gap-1 flex-col', className)}>
|
|
68
73
|
{instance.names.english && (
|
|
69
|
-
<div className="text-
|
|
74
|
+
<div className="text-primary font-sans text-base font-bold">
|
|
70
75
|
{instance.names.english}
|
|
71
76
|
</div>
|
|
72
77
|
)}
|
|
78
|
+
{isEditor && instance.names.alternatives && (
|
|
79
|
+
<div className="text-sm text-accent">
|
|
80
|
+
{`Also: ${instance.names.alternatives}`}
|
|
81
|
+
</div>
|
|
82
|
+
)}
|
|
73
83
|
<Ul>
|
|
74
84
|
{instance.names.wylie && (
|
|
75
85
|
<Li className="italic">{instance.names.wylie}</Li>
|
|
@@ -103,7 +113,7 @@ export const GlossaryInstanceBody = ({
|
|
|
103
113
|
{index > 0 && ', '}
|
|
104
114
|
<Button
|
|
105
115
|
variant="link"
|
|
106
|
-
className=
|
|
116
|
+
className="p-0 h-6 font-normal hover:cursor-pointer"
|
|
107
117
|
onClick={() => handlePassageClick(passage)}
|
|
108
118
|
>
|
|
109
119
|
{passage.label || passage.uuid.slice(0, 6)}
|
|
@@ -115,7 +125,7 @@ export const GlossaryInstanceBody = ({
|
|
|
115
125
|
{', '}
|
|
116
126
|
<Button
|
|
117
127
|
variant="link"
|
|
118
|
-
className=
|
|
128
|
+
className="p-0 h-6 font-normal hover:cursor-pointer"
|
|
119
129
|
onClick={loadMore}
|
|
120
130
|
>
|
|
121
131
|
more ›
|
|
@@ -133,7 +143,7 @@ export const GlossaryInstanceBody = ({
|
|
|
133
143
|
href={`/glossary/${instance.authority}`}
|
|
134
144
|
target="_blank"
|
|
135
145
|
rel="noreferrer"
|
|
136
|
-
className="hover:underline decoration-
|
|
146
|
+
className="hover:underline decoration-primary hover:text-primary"
|
|
137
147
|
>
|
|
138
148
|
{'View full entry ›'}
|
|
139
149
|
</a>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { isUuid, scrollToElement } from '@eightyfourthousand/lib-utils';
|
|
20
20
|
import { useNavigation } from '../NavigationProvider';
|
|
21
21
|
import { GlossarySkeleton } from './GlossarySkeleton';
|
|
22
|
+
import { usePaginationLoadTriggers } from '../hooks/usePaginationLoadTriggers';
|
|
22
23
|
|
|
23
24
|
const LOADING_SKELETONS_COUNT = 3;
|
|
24
25
|
|
|
@@ -41,10 +42,12 @@ const GlossaryPaginationContext = createContext<GlossaryPaginationState>({
|
|
|
41
42
|
export const GlossaryPaginationProvider = ({
|
|
42
43
|
workUuid,
|
|
43
44
|
initialPage,
|
|
45
|
+
withAttestations = false,
|
|
44
46
|
children,
|
|
45
47
|
}: {
|
|
46
48
|
workUuid: string;
|
|
47
49
|
initialPage: GlossaryTermsPage;
|
|
50
|
+
withAttestations?: boolean;
|
|
48
51
|
children: ReactNode;
|
|
49
52
|
}) => {
|
|
50
53
|
const [terms, setTerms] = useState(initialPage.terms);
|
|
@@ -62,21 +65,24 @@ export const GlossaryPaginationProvider = ({
|
|
|
62
65
|
const processedNavCursorRef = useRef<string | undefined>(undefined);
|
|
63
66
|
const isNavigatingRef = useRef(false);
|
|
64
67
|
|
|
65
|
-
const loadMoreAtStartRef = useRef<HTMLDivElement>(null);
|
|
66
|
-
const loadMoreAtEndRef = useRef<HTMLDivElement>(null);
|
|
67
68
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
68
|
-
const startCursorRef = useRef(startCursor);
|
|
69
|
-
const endCursorRef = useRef(endCursor);
|
|
70
|
-
const startLoadArmedRef = useRef(true);
|
|
71
|
-
const endLoadArmedRef = useRef(true);
|
|
72
69
|
const handledStartLoadRequestRef = useRef(0);
|
|
73
70
|
const handledEndLoadRequestRef = useRef(0);
|
|
74
|
-
const [startLoadRequest, setStartLoadRequest] = useState(0);
|
|
75
|
-
const [endLoadRequest, setEndLoadRequest] = useState(0);
|
|
76
71
|
|
|
77
72
|
// Stable reference — prevents re-renders from re-triggering load effects
|
|
78
73
|
const dataClient = useMemo(() => createGraphQLClient(), []);
|
|
79
74
|
const { panels, updatePanel } = useNavigation();
|
|
75
|
+
const {
|
|
76
|
+
loadMoreAtStartRef: observedLoadMoreAtStartRef,
|
|
77
|
+
loadMoreAtEndRef,
|
|
78
|
+
startLoadRequest,
|
|
79
|
+
endLoadRequest,
|
|
80
|
+
} = usePaginationLoadTriggers({
|
|
81
|
+
startCursor,
|
|
82
|
+
endCursor,
|
|
83
|
+
startIsLoading,
|
|
84
|
+
endIsLoading,
|
|
85
|
+
});
|
|
80
86
|
|
|
81
87
|
// Only accept hash when right panel is on glossary tab
|
|
82
88
|
const panelHash =
|
|
@@ -93,14 +99,6 @@ export const GlossaryPaginationProvider = ({
|
|
|
93
99
|
}
|
|
94
100
|
}, [panelHash]);
|
|
95
101
|
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
startCursorRef.current = startCursor;
|
|
98
|
-
}, [startCursor]);
|
|
99
|
-
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
endCursorRef.current = endCursor;
|
|
102
|
-
}, [endCursor]);
|
|
103
|
-
|
|
104
102
|
// Hash navigation: navigate to a specific glossary term
|
|
105
103
|
useEffect(() => {
|
|
106
104
|
if (!navCursor || startIsLoading || endIsLoading) return;
|
|
@@ -130,6 +128,7 @@ export const GlossaryPaginationProvider = ({
|
|
|
130
128
|
client: dataClient,
|
|
131
129
|
uuid: workUuid,
|
|
132
130
|
termUuid: navCursor,
|
|
131
|
+
withAttestations,
|
|
133
132
|
});
|
|
134
133
|
|
|
135
134
|
if (page.terms.length === 0) return;
|
|
@@ -165,45 +164,6 @@ export const GlossaryPaginationProvider = ({
|
|
|
165
164
|
})();
|
|
166
165
|
}, [navCursor, startIsLoading, endIsLoading, workUuid]);
|
|
167
166
|
|
|
168
|
-
// IntersectionObserver for load-more sentinels
|
|
169
|
-
useEffect(() => {
|
|
170
|
-
const startEl = loadMoreAtStartRef.current;
|
|
171
|
-
const endEl = loadMoreAtEndRef.current;
|
|
172
|
-
|
|
173
|
-
const observer = new IntersectionObserver((entries) => {
|
|
174
|
-
for (const entry of entries) {
|
|
175
|
-
if (entry.target === startEl && !entry.isIntersecting) {
|
|
176
|
-
startLoadArmedRef.current = true;
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (entry.target === endEl && !entry.isIntersecting) {
|
|
181
|
-
endLoadArmedRef.current = true;
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (entry.target === startEl && startCursorRef.current) {
|
|
186
|
-
if (!startLoadArmedRef.current) {
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
startLoadArmedRef.current = false;
|
|
190
|
-
setStartLoadRequest((c) => c + 1);
|
|
191
|
-
} else if (entry.target === endEl && endCursorRef.current) {
|
|
192
|
-
if (!endLoadArmedRef.current) {
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
endLoadArmedRef.current = false;
|
|
196
|
-
setEndLoadRequest((c) => c + 1);
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
if (startEl) observer.observe(startEl);
|
|
202
|
-
if (endEl) observer.observe(endEl);
|
|
203
|
-
|
|
204
|
-
return () => observer.disconnect();
|
|
205
|
-
}, []);
|
|
206
|
-
|
|
207
167
|
// Load more at end (forward pagination)
|
|
208
168
|
useEffect(() => {
|
|
209
169
|
if (
|
|
@@ -225,6 +185,7 @@ export const GlossaryPaginationProvider = ({
|
|
|
225
185
|
uuid: workUuid,
|
|
226
186
|
cursor: endCursor,
|
|
227
187
|
direction: 'forward',
|
|
188
|
+
withAttestations,
|
|
228
189
|
});
|
|
229
190
|
|
|
230
191
|
if (page.terms.length > 0) {
|
|
@@ -298,7 +259,7 @@ export const GlossaryPaginationProvider = ({
|
|
|
298
259
|
endIsLoading,
|
|
299
260
|
}}
|
|
300
261
|
>
|
|
301
|
-
<div ref={
|
|
262
|
+
<div ref={observedLoadMoreAtStartRef} className="h-0" />
|
|
302
263
|
<div ref={contentRef}>{children}</div>
|
|
303
264
|
<div ref={loadMoreAtEndRef} className="h-0" />
|
|
304
265
|
{(endCursor || (endIsLoading && terms.length === 0)) && (
|
|
@@ -11,8 +11,10 @@ const LOADING_SKELETONS_COUNT = 3;
|
|
|
11
11
|
|
|
12
12
|
export const GlossaryTermList = ({
|
|
13
13
|
className,
|
|
14
|
+
isEditor = false,
|
|
14
15
|
}: {
|
|
15
16
|
className?: string;
|
|
17
|
+
isEditor?: boolean;
|
|
16
18
|
}) => {
|
|
17
19
|
const { terms, startCursor, endIsLoading } = useGlossaryPagination();
|
|
18
20
|
const isNavigationLoading = endIsLoading && terms.length === 0;
|
|
@@ -21,11 +23,7 @@ export const GlossaryTermList = ({
|
|
|
21
23
|
|
|
22
24
|
return (
|
|
23
25
|
<div className={cn('flex flex-col w-full', className)}>
|
|
24
|
-
<LabeledElement
|
|
25
|
-
className="@c/sidebar:mt-5 mt-6"
|
|
26
|
-
id={'glossary'}
|
|
27
|
-
label="g."
|
|
28
|
-
>
|
|
26
|
+
<LabeledElement className="mt-2" id={'glossary'} label="g.">
|
|
29
27
|
<SectionTitle>Glossary</SectionTitle>
|
|
30
28
|
</LabeledElement>
|
|
31
29
|
<div className="mt-3 flex flex-col gap-6">
|
|
@@ -48,6 +46,7 @@ export const GlossaryTermList = ({
|
|
|
48
46
|
>
|
|
49
47
|
<GlossaryInstanceBody
|
|
50
48
|
instance={instance}
|
|
49
|
+
isEditor={isEditor}
|
|
51
50
|
className="p-0 @c/sidebar:-mt-0.5"
|
|
52
51
|
/>
|
|
53
52
|
</LabeledElement>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { usePaginationLoadTriggers } from './usePaginationLoadTriggers';
|
|
3
|
+
|
|
4
|
+
class MockIntersectionObserver {
|
|
5
|
+
static instances: MockIntersectionObserver[] = [];
|
|
6
|
+
|
|
7
|
+
callback: IntersectionObserverCallback;
|
|
8
|
+
observedElements = new Set<Element>();
|
|
9
|
+
|
|
10
|
+
constructor(callback: IntersectionObserverCallback) {
|
|
11
|
+
this.callback = callback;
|
|
12
|
+
MockIntersectionObserver.instances.push(this);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
observe = (element: Element) => {
|
|
16
|
+
this.observedElements.add(element);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
disconnect = () => {
|
|
20
|
+
this.observedElements.clear();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
unobserve = (element: Element) => {
|
|
24
|
+
this.observedElements.delete(element);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const emitIntersection = ({
|
|
29
|
+
target,
|
|
30
|
+
isIntersecting,
|
|
31
|
+
}: {
|
|
32
|
+
target: Element;
|
|
33
|
+
isIntersecting: boolean;
|
|
34
|
+
}) => {
|
|
35
|
+
const observer = MockIntersectionObserver.instances.at(-1);
|
|
36
|
+
|
|
37
|
+
if (!observer) {
|
|
38
|
+
throw new Error('No IntersectionObserver instance was created.');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
act(() => {
|
|
42
|
+
observer.callback(
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
target,
|
|
46
|
+
isIntersecting,
|
|
47
|
+
} as IntersectionObserverEntry,
|
|
48
|
+
],
|
|
49
|
+
observer as unknown as IntersectionObserver,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const HookHarness = ({
|
|
55
|
+
enabled = true,
|
|
56
|
+
startCursor,
|
|
57
|
+
endCursor,
|
|
58
|
+
startIsLoading = false,
|
|
59
|
+
endIsLoading = false,
|
|
60
|
+
}: {
|
|
61
|
+
enabled?: boolean;
|
|
62
|
+
startCursor?: string;
|
|
63
|
+
endCursor?: string;
|
|
64
|
+
startIsLoading?: boolean;
|
|
65
|
+
endIsLoading?: boolean;
|
|
66
|
+
}) => {
|
|
67
|
+
const {
|
|
68
|
+
loadMoreAtStartRef,
|
|
69
|
+
loadMoreAtEndRef,
|
|
70
|
+
startLoadRequest,
|
|
71
|
+
endLoadRequest,
|
|
72
|
+
} = usePaginationLoadTriggers({
|
|
73
|
+
enabled,
|
|
74
|
+
startCursor,
|
|
75
|
+
endCursor,
|
|
76
|
+
startIsLoading,
|
|
77
|
+
endIsLoading,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
<div data-testid="start-ref" ref={loadMoreAtStartRef} />
|
|
83
|
+
<div data-testid="end-ref" ref={loadMoreAtEndRef} />
|
|
84
|
+
<div data-testid="start-count">{startLoadRequest}</div>
|
|
85
|
+
<div data-testid="end-count">{endLoadRequest}</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
describe('usePaginationLoadTriggers', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
MockIntersectionObserver.instances = [];
|
|
93
|
+
global.IntersectionObserver =
|
|
94
|
+
MockIntersectionObserver as unknown as typeof IntersectionObserver;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('fires at most one end request until the sentinel leaves and re-enters', () => {
|
|
98
|
+
render(<HookHarness endCursor="end-1" />);
|
|
99
|
+
|
|
100
|
+
const endSentinel = screen.getByTestId('end-ref');
|
|
101
|
+
|
|
102
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
103
|
+
expect(screen.getByTestId('end-count').textContent).toBe('1');
|
|
104
|
+
|
|
105
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
106
|
+
expect(screen.getByTestId('end-count').textContent).toBe('1');
|
|
107
|
+
|
|
108
|
+
emitIntersection({ target: endSentinel, isIntersecting: false });
|
|
109
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
110
|
+
expect(screen.getByTestId('end-count').textContent).toBe('2');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('fires at most one start request until the sentinel leaves and re-enters', () => {
|
|
114
|
+
render(<HookHarness startCursor="start-1" />);
|
|
115
|
+
|
|
116
|
+
const startSentinel = screen.getByTestId('start-ref');
|
|
117
|
+
|
|
118
|
+
emitIntersection({ target: startSentinel, isIntersecting: true });
|
|
119
|
+
expect(screen.getByTestId('start-count').textContent).toBe('1');
|
|
120
|
+
|
|
121
|
+
emitIntersection({ target: startSentinel, isIntersecting: true });
|
|
122
|
+
expect(screen.getByTestId('start-count').textContent).toBe('1');
|
|
123
|
+
|
|
124
|
+
emitIntersection({ target: startSentinel, isIntersecting: false });
|
|
125
|
+
emitIntersection({ target: startSentinel, isIntersecting: true });
|
|
126
|
+
expect(screen.getByTestId('start-count').textContent).toBe('2');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('does not observe until enabled', () => {
|
|
130
|
+
const { rerender } = render(<HookHarness enabled={false} endCursor="end-1" />);
|
|
131
|
+
|
|
132
|
+
expect(MockIntersectionObserver.instances).toHaveLength(0);
|
|
133
|
+
|
|
134
|
+
rerender(<HookHarness enabled endCursor="end-1" />);
|
|
135
|
+
|
|
136
|
+
expect(MockIntersectionObserver.instances).toHaveLength(1);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('does not queue another end request from visibility churn during loading', () => {
|
|
140
|
+
const { rerender } = render(<HookHarness endCursor="end-1" />);
|
|
141
|
+
|
|
142
|
+
const endSentinel = screen.getByTestId('end-ref');
|
|
143
|
+
|
|
144
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
145
|
+
expect(screen.getByTestId('end-count').textContent).toBe('1');
|
|
146
|
+
|
|
147
|
+
rerender(<HookHarness endCursor="end-1" endIsLoading />);
|
|
148
|
+
|
|
149
|
+
emitIntersection({ target: endSentinel, isIntersecting: false });
|
|
150
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
151
|
+
expect(screen.getByTestId('end-count').textContent).toBe('1');
|
|
152
|
+
|
|
153
|
+
rerender(<HookHarness endCursor="end-1" />);
|
|
154
|
+
emitIntersection({ target: endSentinel, isIntersecting: false });
|
|
155
|
+
emitIntersection({ target: endSentinel, isIntersecting: true });
|
|
156
|
+
expect(screen.getByTestId('end-count').textContent).toBe('2');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export const usePaginationLoadTriggers = ({
|
|
6
|
+
enabled = true,
|
|
7
|
+
startCursor,
|
|
8
|
+
endCursor,
|
|
9
|
+
startIsLoading = false,
|
|
10
|
+
endIsLoading = false,
|
|
11
|
+
}: {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
startCursor?: string;
|
|
14
|
+
endCursor?: string;
|
|
15
|
+
startIsLoading?: boolean;
|
|
16
|
+
endIsLoading?: boolean;
|
|
17
|
+
}) => {
|
|
18
|
+
const loadMoreAtStartRef = useRef<HTMLDivElement>(null);
|
|
19
|
+
const loadMoreAtEndRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
const startCursorRef = useRef(startCursor);
|
|
21
|
+
const endCursorRef = useRef(endCursor);
|
|
22
|
+
const startIsLoadingRef = useRef(startIsLoading);
|
|
23
|
+
const endIsLoadingRef = useRef(endIsLoading);
|
|
24
|
+
const startLoadArmedRef = useRef(true);
|
|
25
|
+
const endLoadArmedRef = useRef(true);
|
|
26
|
+
const startIsIntersectingRef = useRef(false);
|
|
27
|
+
const endIsIntersectingRef = useRef(false);
|
|
28
|
+
const [startLoadRequest, setStartLoadRequest] = useState(0);
|
|
29
|
+
const [endLoadRequest, setEndLoadRequest] = useState(0);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
startCursorRef.current = startCursor;
|
|
33
|
+
}, [startCursor]);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
endCursorRef.current = endCursor;
|
|
37
|
+
}, [endCursor]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
startIsLoadingRef.current = startIsLoading;
|
|
41
|
+
|
|
42
|
+
if (!startIsLoading && !startIsIntersectingRef.current) {
|
|
43
|
+
startLoadArmedRef.current = true;
|
|
44
|
+
}
|
|
45
|
+
}, [startIsLoading]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
endIsLoadingRef.current = endIsLoading;
|
|
49
|
+
|
|
50
|
+
if (!endIsLoading && !endIsIntersectingRef.current) {
|
|
51
|
+
endLoadArmedRef.current = true;
|
|
52
|
+
}
|
|
53
|
+
}, [endIsLoading]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!enabled) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startEl = loadMoreAtStartRef.current;
|
|
61
|
+
const endEl = loadMoreAtEndRef.current;
|
|
62
|
+
|
|
63
|
+
const observer = new IntersectionObserver((entries) => {
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (entry.target === startEl) {
|
|
66
|
+
startIsIntersectingRef.current = entry.isIntersecting;
|
|
67
|
+
|
|
68
|
+
if (!entry.isIntersecting) {
|
|
69
|
+
if (!startIsLoadingRef.current) {
|
|
70
|
+
startLoadArmedRef.current = true;
|
|
71
|
+
}
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (startIsLoadingRef.current || !startCursorRef.current) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!startLoadArmedRef.current) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
startLoadArmedRef.current = false;
|
|
84
|
+
setStartLoadRequest((current) => current + 1);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (entry.target === endEl) {
|
|
89
|
+
endIsIntersectingRef.current = entry.isIntersecting;
|
|
90
|
+
|
|
91
|
+
if (!entry.isIntersecting) {
|
|
92
|
+
if (!endIsLoadingRef.current) {
|
|
93
|
+
endLoadArmedRef.current = true;
|
|
94
|
+
}
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (endIsLoadingRef.current || !endCursorRef.current) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!endLoadArmedRef.current) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
endLoadArmedRef.current = false;
|
|
107
|
+
setEndLoadRequest((current) => current + 1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (startEl) {
|
|
113
|
+
observer.observe(startEl);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (endEl) {
|
|
117
|
+
observer.observe(endEl);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return () => observer.disconnect();
|
|
121
|
+
}, [enabled]);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
loadMoreAtStartRef,
|
|
125
|
+
loadMoreAtEndRef,
|
|
126
|
+
startLoadRequest,
|
|
127
|
+
endLoadRequest,
|
|
128
|
+
};
|
|
129
|
+
};
|