@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.
Files changed (40) hide show
  1. package/README.md +4 -2
  2. package/package.json +7 -7
  3. package/src/lib/components/editor/EditorBackMatterPage.tsx +3 -1
  4. package/src/lib/components/editor/EditorHeader.tsx +1 -1
  5. package/src/lib/components/editor/EditorProvider.tsx +76 -0
  6. package/src/lib/components/editor/PaginationProvider.tsx +30 -45
  7. package/src/lib/components/editor/extensions/Heading/Heading.ts +4 -3
  8. package/src/lib/components/editor/extensions/InternalLink/InternalLink.ts +4 -1
  9. package/src/lib/components/editor/extensions/Mention/Mention.ts +4 -1
  10. package/src/lib/components/editor/extensions/Passage/EditorOptions.tsx +2 -2
  11. package/src/lib/components/editor/extensions/Passage/Passage.tsx +3 -3
  12. package/src/lib/components/editor/extensions/Passage/ReaderOptions.tsx +3 -3
  13. package/src/lib/components/editor/extensions/Passage/ShowAnnotations.tsx +3 -3
  14. package/src/lib/components/editor/extensions/TitlesNode.ts +1 -1
  15. package/src/lib/components/reader/ReaderBackMatterPage.tsx +1 -0
  16. package/src/lib/components/reader/ReaderBackMatterPanel.tsx +3 -0
  17. package/src/lib/components/reader/ReaderBodyPanel.tsx +1 -1
  18. package/src/lib/components/shared/BackMatterPanel.tsx +17 -10
  19. package/src/lib/components/shared/BodyPanel.tsx +6 -6
  20. package/src/lib/components/shared/LabeledElement.tsx +3 -3
  21. package/src/lib/components/shared/LeftPanel.tsx +3 -3
  22. package/src/lib/components/shared/NavigationContext.ts +1 -1
  23. package/src/lib/components/shared/NavigationProvider.tsx +33 -1
  24. package/src/lib/components/shared/ReaderSearchButton.tsx +110 -0
  25. package/src/lib/components/shared/SearchReplacePanel.tsx +259 -0
  26. package/src/lib/components/shared/SourceReader.tsx +13 -4
  27. package/src/lib/components/shared/SuggestRevisionForm.tsx +1 -1
  28. package/src/lib/components/shared/TableOfContents.tsx +40 -27
  29. package/src/lib/components/shared/ThreeColumnRenderer.tsx +16 -9
  30. package/src/lib/components/shared/TranslationHeader.tsx +2 -63
  31. package/src/lib/components/shared/bibliography/BibliographyList.tsx +2 -2
  32. package/src/lib/components/shared/glossary/GlossaryInstanceBody.tsx +15 -5
  33. package/src/lib/components/shared/glossary/GlossaryPaginationProvider.tsx +17 -56
  34. package/src/lib/components/shared/glossary/GlossaryTermList.tsx +4 -5
  35. package/src/lib/components/shared/hooks/usePaginationLoadTriggers.spec.tsx +158 -0
  36. package/src/lib/components/shared/hooks/usePaginationLoadTriggers.ts +129 -0
  37. package/src/lib/components/shared/titles/FramedCard.tsx +3 -3
  38. package/src/lib/components/shared/titles/Titles.tsx +17 -6
  39. package/src/lib/components/shared/titles/TitlesCard.tsx +6 -6
  40. 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
- <div className="pb-2.5">
27
- <TranslationHeader className="rounded-full shadow-lg" />
28
- </div>
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
- updatePanel({
43
- name: 'right',
44
- state: { open, tab: panels.right.tab },
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, uuid, toh, updatePanel } = useNavigation();
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 font-light truncate text-darkgray text-xs sm:text-sm my-auto flex-shrink">
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="@c/sidebar:mt-5 mt-6"
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-secondary font-sans text-base font-bold">
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 { createGraphQLClient, getTermPassages } from '@eightyfourthousand/client-graphql';
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-secondary font-sans text-base font-bold">
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='p-0 h-6 font-normal hover:cursor-pointer'
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='p-0 h-6 font-normal hover:cursor-pointer'
128
+ className="p-0 h-6 font-normal hover:cursor-pointer"
119
129
  onClick={loadMore}
120
130
  >
121
131
  more &rsaquo;
@@ -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-slate hover:text-slate"
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={loadMoreAtStartRef} className="h-0" />
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
+ };