@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
|
@@ -97,12 +97,12 @@ export const LabeledElement = ({
|
|
|
97
97
|
) : (
|
|
98
98
|
<>
|
|
99
99
|
<DropdownMenuItem onSelect={toggle}>
|
|
100
|
-
<BookmarkIcon className="text-
|
|
100
|
+
<BookmarkIcon className="text-primary" />
|
|
101
101
|
{isBookmarked ? 'Remove Bookmark' : 'Add Bookmark'}
|
|
102
102
|
</DropdownMenuItem>
|
|
103
103
|
<DropdownMenuSeparator />
|
|
104
104
|
<DropdownMenuItem onSelect={copyLink}>
|
|
105
|
-
<CopyIcon className="text-
|
|
105
|
+
<CopyIcon className="text-primary" /> Copy Link
|
|
106
106
|
</DropdownMenuItem>
|
|
107
107
|
<DropdownMenuSeparator />
|
|
108
108
|
<DropdownMenuItem
|
|
@@ -111,7 +111,7 @@ export const LabeledElement = ({
|
|
|
111
111
|
setShowRevisionForm(true);
|
|
112
112
|
}}
|
|
113
113
|
>
|
|
114
|
-
<MessageSquareIcon className="text-
|
|
114
|
+
<MessageSquareIcon className="text-primary" /> Suggest Revision
|
|
115
115
|
</DropdownMenuItem>
|
|
116
116
|
</>
|
|
117
117
|
)}
|
|
@@ -35,7 +35,7 @@ export const LeftPanel = ({
|
|
|
35
35
|
updatePanel({ name: 'left', state: { open: true, tab } });
|
|
36
36
|
}}
|
|
37
37
|
defaultValue="toc"
|
|
38
|
-
className="w-full gap-0 @container/sidebar"
|
|
38
|
+
className="w-full gap-0 @container/sidebar h-full flex flex-col"
|
|
39
39
|
>
|
|
40
40
|
<div className="sticky top-0 pt-1 pb-2 z-10 w-full rounded-t bg-background overflow-x-auto text-center">
|
|
41
41
|
<TabsList
|
|
@@ -47,8 +47,8 @@ export const LeftPanel = ({
|
|
|
47
47
|
<TabsTrigger value="toc">Table of Contents</TabsTrigger>
|
|
48
48
|
</TabsList>
|
|
49
49
|
</div>
|
|
50
|
-
<div className="
|
|
51
|
-
<div className="overflow-auto
|
|
50
|
+
<div className="flex-1 min-h-0">
|
|
51
|
+
<div className="overflow-auto h-full bg-surface">
|
|
52
52
|
<div className="rounded px-2 pb-8 max-w-readable mx-auto">
|
|
53
53
|
<TabsContent value="toc" className="px-2 mt-1.5">
|
|
54
54
|
<TableOfContents
|
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
Work,
|
|
10
10
|
} from '@eightyfourthousand/data-access';
|
|
11
11
|
import { createContext, useContext } from 'react';
|
|
12
|
-
import { PanelName, PanelsState, PanelState
|
|
12
|
+
import { PanelName, PanelsState, PanelState } from './types';
|
|
13
13
|
|
|
14
14
|
export interface NavigationState {
|
|
15
15
|
uuid: string;
|
|
@@ -27,14 +27,16 @@ import {
|
|
|
27
27
|
import { ReadonlyURLSearchParams, useSearchParams } from 'next/navigation';
|
|
28
28
|
import {
|
|
29
29
|
PANEL_NAMES,
|
|
30
|
+
PANEL_FOR_SECTION,
|
|
30
31
|
PanelName,
|
|
31
32
|
PanelsState,
|
|
32
33
|
PanelState,
|
|
34
|
+
TAB_FOR_SECTION,
|
|
33
35
|
TabName,
|
|
34
36
|
} from './types';
|
|
35
37
|
import { HoverCardProvider } from './HoverCardProvider';
|
|
36
38
|
import { GatedFeature, useFeatureFlagEnabled } from '@eightyfourthousand/lib-instr';
|
|
37
|
-
import { useIsMobile } from '@eightyfourthousand/lib-utils';
|
|
39
|
+
import { isXmlId, useIsMobile } from '@eightyfourthousand/lib-utils';
|
|
38
40
|
import { RestrictionWarning } from './RestrictionWarning';
|
|
39
41
|
import { NavigationContext, DEFAULT_PANELS } from './NavigationContext';
|
|
40
42
|
|
|
@@ -267,6 +269,36 @@ export const NavigationProvider = ({
|
|
|
267
269
|
window.history.replaceState(null, '', newUrl);
|
|
268
270
|
}, [toh, panels]);
|
|
269
271
|
|
|
272
|
+
// On initial load, check for an XML ID in the URL hash and resolve it to a passage UUID
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
275
|
+
if (!hash || !isXmlId(hash)) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Remove hash from URL immediately
|
|
280
|
+
window.history.replaceState(
|
|
281
|
+
null,
|
|
282
|
+
'',
|
|
283
|
+
`${window.location.pathname}${window.location.search}`,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
(async () => {
|
|
287
|
+
const passage = await getPassage({ client: graphqlClient, xmlId: hash });
|
|
288
|
+
if (!passage) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const panel = PANEL_FOR_SECTION[passage.type] ?? 'main';
|
|
293
|
+
const tab = TAB_FOR_SECTION[passage.type] ?? 'translation';
|
|
294
|
+
|
|
295
|
+
updatePanel({
|
|
296
|
+
name: panel,
|
|
297
|
+
state: { open: true, tab, hash: passage.uuid },
|
|
298
|
+
});
|
|
299
|
+
})();
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
270
302
|
useEffect(() => {
|
|
271
303
|
if (!uuid || !toh) {
|
|
272
304
|
return;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { BodyItemType } from '@eightyfourthousand/data-access';
|
|
4
|
+
import {
|
|
5
|
+
PassageMatch,
|
|
6
|
+
SearchButton,
|
|
7
|
+
SearchResult,
|
|
8
|
+
} from '@eightyfourthousand/lib-search';
|
|
9
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
10
|
+
import { useEditorState } from '../editor/EditorProvider';
|
|
11
|
+
import { useNavigation } from './NavigationProvider';
|
|
12
|
+
import { SearchReplacePanel } from './SearchReplacePanel';
|
|
13
|
+
import { PanelName, PanelState, TabName } from './types';
|
|
14
|
+
|
|
15
|
+
export const ReaderSearchButton = () => {
|
|
16
|
+
const { uuid, toh, updatePanel } = useNavigation();
|
|
17
|
+
const { applyReplacedPassages, canEdit, dirtyStore } = useEditorState();
|
|
18
|
+
const [canReplace, setCanReplace] = useState(false);
|
|
19
|
+
const isDirty = useSyncExternalStore(
|
|
20
|
+
dirtyStore.subscribe.bind(dirtyStore),
|
|
21
|
+
dirtyStore.getSnapshot.bind(dirtyStore),
|
|
22
|
+
() => false,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let active = true;
|
|
27
|
+
|
|
28
|
+
(async () => {
|
|
29
|
+
const editable = await canEdit();
|
|
30
|
+
if (active) {
|
|
31
|
+
setCanReplace(editable);
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
active = false;
|
|
37
|
+
};
|
|
38
|
+
}, [canEdit]);
|
|
39
|
+
|
|
40
|
+
const onResultSelected = useCallback(
|
|
41
|
+
(result: SearchResult) => {
|
|
42
|
+
let side: PanelName = 'main';
|
|
43
|
+
const panelState: PanelState = {
|
|
44
|
+
open: true,
|
|
45
|
+
hash: result.uuid,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const TAB_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, TabName>> = {
|
|
49
|
+
abbreviations: 'abbreviations',
|
|
50
|
+
endnotes: 'endnotes',
|
|
51
|
+
summary: 'front',
|
|
52
|
+
introduction: 'front',
|
|
53
|
+
acknowledgements: 'front',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const SIDE_FOR_PASSAGE_SECTION: Partial<Record<BodyItemType, PanelName>> =
|
|
57
|
+
{
|
|
58
|
+
abbreviations: 'right',
|
|
59
|
+
endnotes: 'right',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
switch (result.type) {
|
|
63
|
+
case 'passage':
|
|
64
|
+
{
|
|
65
|
+
const passage = result as PassageMatch;
|
|
66
|
+
side = SIDE_FOR_PASSAGE_SECTION[passage.section] || 'main';
|
|
67
|
+
panelState.tab =
|
|
68
|
+
TAB_FOR_PASSAGE_SECTION[passage.section] || 'translation';
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
case 'alignment':
|
|
72
|
+
side = 'main';
|
|
73
|
+
panelState.tab = 'compare';
|
|
74
|
+
break;
|
|
75
|
+
case 'bibliography':
|
|
76
|
+
side = 'right';
|
|
77
|
+
panelState.tab = 'bibliography';
|
|
78
|
+
break;
|
|
79
|
+
case 'glossary':
|
|
80
|
+
side = 'right';
|
|
81
|
+
panelState.tab = 'glossary';
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updatePanel({
|
|
86
|
+
name: side,
|
|
87
|
+
state: panelState,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
[updatePanel],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<SearchButton
|
|
95
|
+
workUuid={uuid}
|
|
96
|
+
toh={toh}
|
|
97
|
+
onResultSelected={onResultSelected}
|
|
98
|
+
renderActions={(searchContext) => (
|
|
99
|
+
<SearchReplacePanel
|
|
100
|
+
canReplace={canReplace}
|
|
101
|
+
replaceDisabledReason={
|
|
102
|
+
isDirty ? 'Save changes before using search and replace.' : undefined
|
|
103
|
+
}
|
|
104
|
+
onPassagesReplaced={applyReplacedPassages}
|
|
105
|
+
searchContext={searchContext}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createGraphQLClient,
|
|
5
|
+
replace,
|
|
6
|
+
type ReplacedPassage,
|
|
7
|
+
} from '@eightyfourthousand/client-graphql';
|
|
8
|
+
import {
|
|
9
|
+
Button,
|
|
10
|
+
Collapsible,
|
|
11
|
+
CollapsibleContent,
|
|
12
|
+
CollapsibleTrigger,
|
|
13
|
+
Input,
|
|
14
|
+
} from '@eightyfourthousand/design-system';
|
|
15
|
+
import type { SearchActionContext } from '@eightyfourthousand/lib-search';
|
|
16
|
+
import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react';
|
|
17
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
18
|
+
|
|
19
|
+
const REPLACE_CHUNK_SIZE = 200;
|
|
20
|
+
|
|
21
|
+
export interface SearchReplacePanelProps {
|
|
22
|
+
canReplace: boolean;
|
|
23
|
+
onPassagesReplaced?: (passages: ReplacedPassage[]) => Promise<void> | void;
|
|
24
|
+
replaceDisabledReason?: string;
|
|
25
|
+
searchContext: SearchActionContext;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SearchReplacePanel = ({
|
|
29
|
+
canReplace,
|
|
30
|
+
onPassagesReplaced,
|
|
31
|
+
replaceDisabledReason,
|
|
32
|
+
searchContext,
|
|
33
|
+
}: SearchReplacePanelProps) => {
|
|
34
|
+
const client = useMemo(() => createGraphQLClient(), []);
|
|
35
|
+
const [replaceOpen, setReplaceOpen] = useState(false);
|
|
36
|
+
const [replaceQuery, setReplaceQuery] = useState('');
|
|
37
|
+
const [replacing, setReplacing] = useState(false);
|
|
38
|
+
|
|
39
|
+
const canRunReplace =
|
|
40
|
+
canReplace &&
|
|
41
|
+
!replaceDisabledReason &&
|
|
42
|
+
!!searchContext.searchQuery &&
|
|
43
|
+
!!replaceQuery &&
|
|
44
|
+
searchContext.passageOccurrences.length > 0 &&
|
|
45
|
+
!replacing;
|
|
46
|
+
const canStepBackward =
|
|
47
|
+
replaceOpen && searchContext.activeOccurrenceIndex > 0;
|
|
48
|
+
const canStepForward =
|
|
49
|
+
replaceOpen &&
|
|
50
|
+
searchContext.activeOccurrenceIndex <
|
|
51
|
+
searchContext.passageOccurrences.length - 1;
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
searchContext.setShouldScrollActiveOccurrence(replaceOpen);
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
searchContext.setShouldScrollActiveOccurrence(false);
|
|
58
|
+
};
|
|
59
|
+
}, [replaceOpen, searchContext]);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!replaceOpen || searchContext.passageOccurrences.length === 0) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
67
|
+
if (event.key === 'ArrowUp') {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
searchContext.moveActiveOccurrence('previous');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (event.key === 'ArrowDown') {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
searchContext.moveActiveOccurrence('next');
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
79
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
80
|
+
}, [replaceOpen, searchContext]);
|
|
81
|
+
|
|
82
|
+
if (!canReplace || !searchContext.searchQuery) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const runReplace = async ({ replaceAll }: { replaceAll: boolean }) => {
|
|
87
|
+
if (!canRunReplace) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setReplacing(true);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (replaceAll) {
|
|
95
|
+
const targetUuids = searchContext.passages.map((passage) => passage.uuid);
|
|
96
|
+
if (targetUuids.length === 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const allReplacedPassages: ReplacedPassage[] = [];
|
|
101
|
+
for (let i = 0; i < targetUuids.length; i += REPLACE_CHUNK_SIZE) {
|
|
102
|
+
const chunk = targetUuids.slice(i, i + REPLACE_CHUNK_SIZE);
|
|
103
|
+
const response = await replace({
|
|
104
|
+
client,
|
|
105
|
+
searchText: searchContext.searchQuery,
|
|
106
|
+
replaceText: replaceQuery,
|
|
107
|
+
targetUuids: chunk,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (!response.success) {
|
|
111
|
+
console.error(`Replace failed: ${response.error ?? 'unknown error'}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
allReplacedPassages.push(...response.passages);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await onPassagesReplaced?.(allReplacedPassages);
|
|
119
|
+
await searchContext.refreshSearch({ nextSelection: { kind: 'index', index: 0 } });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Single replace: target only the active passage and use its exact position as the cursor.
|
|
124
|
+
const activeOccurrence = searchContext.activeOccurrence;
|
|
125
|
+
if (!activeOccurrence) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const response = await replace({
|
|
130
|
+
client,
|
|
131
|
+
searchText: searchContext.searchQuery,
|
|
132
|
+
replaceText: replaceQuery,
|
|
133
|
+
targetUuids: [activeOccurrence.passageUuid],
|
|
134
|
+
cursorPassageUuid: activeOccurrence.passageUuid,
|
|
135
|
+
cursorStart: activeOccurrence.start,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
if (!response.success) {
|
|
139
|
+
console.error(`Replace failed: ${response.error ?? 'unknown error'}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await onPassagesReplaced?.(response.passages);
|
|
144
|
+
|
|
145
|
+
const nextSelection =
|
|
146
|
+
response.nextPassageUuid != null && response.nextOccurrenceStart != null
|
|
147
|
+
? {
|
|
148
|
+
kind: 'cursor' as const,
|
|
149
|
+
passageUuid: response.nextPassageUuid,
|
|
150
|
+
start: response.nextOccurrenceStart,
|
|
151
|
+
}
|
|
152
|
+
: {
|
|
153
|
+
kind: 'index' as const,
|
|
154
|
+
index: Math.min(
|
|
155
|
+
searchContext.activeOccurrenceIndex + 1,
|
|
156
|
+
Math.max(searchContext.passageOccurrences.length - 1, 0),
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
await searchContext.refreshSearch({ nextSelection });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
console.error('Replace failed:', error);
|
|
163
|
+
} finally {
|
|
164
|
+
setReplacing(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<Collapsible
|
|
170
|
+
open={replaceOpen}
|
|
171
|
+
onOpenChange={setReplaceOpen}
|
|
172
|
+
className="bg-background border rounded-lg px-4 py-3 text-foreground"
|
|
173
|
+
>
|
|
174
|
+
<CollapsibleTrigger asChild>
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="group/collapsible flex w-full items-center justify-start gap-2 text-left text-sm font-medium cursor-pointer"
|
|
178
|
+
>
|
|
179
|
+
<ChevronRightIcon className="size-4 transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
|
180
|
+
<span>Replace</span>
|
|
181
|
+
</button>
|
|
182
|
+
</CollapsibleTrigger>
|
|
183
|
+
<CollapsibleContent className="pt-3">
|
|
184
|
+
<div className="flex flex-col">
|
|
185
|
+
<Input
|
|
186
|
+
id="replace-query"
|
|
187
|
+
placeholder="Replace with..."
|
|
188
|
+
value={replaceQuery}
|
|
189
|
+
onChange={(e) => {
|
|
190
|
+
const nextValue = e.target.value;
|
|
191
|
+
if (nextValue === replaceQuery) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
setReplaceQuery(nextValue);
|
|
195
|
+
if (nextValue) {
|
|
196
|
+
searchContext.scrollActiveOccurrenceIntoView();
|
|
197
|
+
}
|
|
198
|
+
}}
|
|
199
|
+
/>
|
|
200
|
+
<div className="flex flex-wrap items-center gap-2 text-sm text-secondary-foreground">
|
|
201
|
+
<span>
|
|
202
|
+
{searchContext.passageOccurrences.length > 0 &&
|
|
203
|
+
searchContext.activePassageUuid
|
|
204
|
+
? `Occurrence ${searchContext.activeOccurrenceIndex + 1} of ${searchContext.passageOccurrences.length} in ${searchContext.activePassageLabel || searchContext.activePassageUuid}`
|
|
205
|
+
: 'No exact passage occurrences available for replacement.'}
|
|
206
|
+
</span>
|
|
207
|
+
{replaceDisabledReason && <span>{replaceDisabledReason}</span>}
|
|
208
|
+
</div>
|
|
209
|
+
<div className="text-sm text-muted-foreground pb-4">
|
|
210
|
+
Replace is case sensitive and is only applied to passages.
|
|
211
|
+
</div>
|
|
212
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
213
|
+
<Button
|
|
214
|
+
size="sm"
|
|
215
|
+
disabled={!canRunReplace}
|
|
216
|
+
onClick={() => void runReplace({ replaceAll: false })}
|
|
217
|
+
>
|
|
218
|
+
{replacing ? 'Replacing…' : 'Replace'}
|
|
219
|
+
</Button>
|
|
220
|
+
<Button
|
|
221
|
+
size="sm"
|
|
222
|
+
variant="outline"
|
|
223
|
+
disabled={!canRunReplace}
|
|
224
|
+
onClick={() => void runReplace({ replaceAll: true })}
|
|
225
|
+
>
|
|
226
|
+
Replace all
|
|
227
|
+
</Button>
|
|
228
|
+
<div className="ml-auto flex items-center gap-2">
|
|
229
|
+
<Button
|
|
230
|
+
size="icon"
|
|
231
|
+
variant="ghost"
|
|
232
|
+
className="size-8"
|
|
233
|
+
disabled={!canStepBackward}
|
|
234
|
+
onClick={() => {
|
|
235
|
+
searchContext.moveActiveOccurrence('previous');
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<ChevronRightIcon className="size-4 rotate-[-90deg]" />
|
|
239
|
+
<span className="sr-only">Previous occurrence</span>
|
|
240
|
+
</Button>
|
|
241
|
+
<Button
|
|
242
|
+
size="icon"
|
|
243
|
+
variant="ghost"
|
|
244
|
+
className="size-8"
|
|
245
|
+
disabled={!canStepForward}
|
|
246
|
+
onClick={() => {
|
|
247
|
+
searchContext.moveActiveOccurrence('next');
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<ChevronDownIcon className="size-4" />
|
|
251
|
+
<span className="sr-only">Next occurrence</span>
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</CollapsibleContent>
|
|
257
|
+
</Collapsible>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import { useInView } from 'motion/react';
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
5
|
import { useNavigation } from './NavigationProvider';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createGraphQLClient,
|
|
8
|
+
getWorkFolios,
|
|
9
|
+
} from '@eightyfourthousand/client-graphql';
|
|
7
10
|
import type { Folio } from '@eightyfourthousand/data-access';
|
|
8
11
|
import { LabeledElement } from './LabeledElement';
|
|
9
12
|
import { PassageSkeleton } from './PassageSkeleton';
|
|
@@ -61,9 +64,15 @@ export const SourceReader = () => {
|
|
|
61
64
|
className="mt-0.5"
|
|
62
65
|
contentType="source"
|
|
63
66
|
>
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
{folio.content ? (
|
|
68
|
+
<div className="leading-7 min-h-8 font-tibetan text-lg 2xl:whitespace-pre-wrap whitespace-normal">
|
|
69
|
+
{folio.content}
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
<div className="h-12 text-center text-muted-foreground">
|
|
73
|
+
[blank]
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
67
76
|
</LabeledElement>
|
|
68
77
|
))}
|
|
69
78
|
<div ref={loadMoreRef} className="h-0" />
|
|
@@ -58,7 +58,7 @@ export const SuggestRevisionForm = ({
|
|
|
58
58
|
return (
|
|
59
59
|
<div className="p-1">
|
|
60
60
|
<p className="text-sm font-semibold">Suggest a Revision</p>
|
|
61
|
-
<p className="text-
|
|
61
|
+
<p className="text-primary text-xs capitalize py-2">
|
|
62
62
|
{parseToh(toh)} · {type} {label}
|
|
63
63
|
</p>
|
|
64
64
|
<form onSubmit={handleSubmit}>
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
Separator,
|
|
15
15
|
} from '@eightyfourthousand/design-system';
|
|
16
16
|
import { cn, parseToh } from '@eightyfourthousand/lib-utils';
|
|
17
|
-
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
17
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
18
18
|
import { useNavigation } from './NavigationProvider';
|
|
19
19
|
import {
|
|
20
20
|
PanelName,
|
|
@@ -59,38 +59,51 @@ export const TableOfContentsSection = ({
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
return (
|
|
62
|
-
<Accordion type="multiple" className={cn(depth && 'pl-
|
|
63
|
-
{node.children.map((child) => {
|
|
62
|
+
<Accordion type="multiple" className={cn(depth && 'pl-5')}>
|
|
63
|
+
{node.children.map((child, index) => {
|
|
64
64
|
// 'The Translation' is always bold
|
|
65
65
|
const maybeBold =
|
|
66
66
|
child.content === 'The Translation' ? 'font-bold' : '';
|
|
67
|
+
// Show dividers above and below the top-level introduction section
|
|
68
|
+
const isIntroduction =
|
|
69
|
+
depth === 0 && child.section === 'introduction';
|
|
70
|
+
const prevChild = node.children[index - 1];
|
|
71
|
+
const prevWasIntroduction =
|
|
72
|
+
depth === 0 && prevChild?.section === 'introduction';
|
|
73
|
+
const showDividerBefore =
|
|
74
|
+
isIntroduction && !prevWasIntroduction;
|
|
67
75
|
return child.children?.length ? (
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
<React.Fragment key={child.uuid}>
|
|
77
|
+
{showDividerBefore && <Separator className="my-4" />}
|
|
78
|
+
<AccordionItem
|
|
79
|
+
value={child.uuid}
|
|
80
|
+
className="border-b-0"
|
|
81
|
+
>
|
|
82
|
+
<AccordionTrigger className={cn(baseStyle, className)}>
|
|
83
|
+
<span
|
|
84
|
+
className={cn('line-clamp-2', maybeBold)}
|
|
85
|
+
onClick={() => onClick(child)}
|
|
86
|
+
>
|
|
87
|
+
{child.content}
|
|
88
|
+
</span>
|
|
89
|
+
</AccordionTrigger>
|
|
90
|
+
<AccordionContent className="border-l border-dotted py-0">
|
|
91
|
+
<TableOfContentsSection node={child} depth={depth + 1} />
|
|
92
|
+
</AccordionContent>
|
|
93
|
+
</AccordionItem>
|
|
94
|
+
</React.Fragment>
|
|
95
|
+
) : (
|
|
96
|
+
<React.Fragment key={child.uuid}>
|
|
97
|
+
{showDividerBefore && <Separator className="my-4" />}
|
|
98
|
+
<div className={baseStyle}>
|
|
74
99
|
<span
|
|
75
100
|
className={cn('line-clamp-2', maybeBold)}
|
|
76
101
|
onClick={() => onClick(child)}
|
|
77
102
|
>
|
|
78
103
|
{child.content}
|
|
79
104
|
</span>
|
|
80
|
-
</
|
|
81
|
-
|
|
82
|
-
<TableOfContentsSection node={child} depth={depth + 1} />
|
|
83
|
-
</AccordionContent>
|
|
84
|
-
</AccordionItem>
|
|
85
|
-
) : (
|
|
86
|
-
<div key={child.uuid} className={baseStyle}>
|
|
87
|
-
<span
|
|
88
|
-
className={cn('line-clamp-2', maybeBold)}
|
|
89
|
-
onClick={() => onClick(child)}
|
|
90
|
-
>
|
|
91
|
-
{child.content}
|
|
92
|
-
</span>
|
|
93
|
-
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</React.Fragment>
|
|
94
107
|
);
|
|
95
108
|
})}
|
|
96
109
|
</Accordion>
|
|
@@ -216,7 +229,7 @@ export const TableOfContents = ({
|
|
|
216
229
|
return (
|
|
217
230
|
<div>
|
|
218
231
|
<div className={cn(baseStyle, 'flex gap-2 mt-6')}>
|
|
219
|
-
<div className="size-5 text-primary
|
|
232
|
+
<div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
|
|
220
233
|
<LibraryBigIcon />
|
|
221
234
|
</div>
|
|
222
235
|
<span className="my-auto font-light">
|
|
@@ -224,13 +237,13 @@ export const TableOfContents = ({
|
|
|
224
237
|
</span>
|
|
225
238
|
</div>
|
|
226
239
|
<div className={cn(baseStyle, 'flex gap-2')}>
|
|
227
|
-
<div className="size-5 text-primary
|
|
240
|
+
<div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
|
|
228
241
|
<BookOpenIcon />
|
|
229
242
|
</div>
|
|
230
|
-
<span className="my-auto font-
|
|
243
|
+
<span className="my-auto font-bold">{title}</span>
|
|
231
244
|
</div>
|
|
232
245
|
<div className="flex gap-2">
|
|
233
|
-
<div className="size-5 text-primary
|
|
246
|
+
<div className="size-5 text-primary [&_svg]:stroke-1 [&_svg]:size-5 my-auto">
|
|
234
247
|
<HashIcon />
|
|
235
248
|
</div>
|
|
236
249
|
{work.toh.length > 1 ? (
|