@eightyfourthousand/lib-editing 2026.3.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 (304) hide show
  1. package/.babelrc +12 -0
  2. package/.eslintrc.json +18 -0
  3. package/README.md +7 -0
  4. package/jest.config.ts +10 -0
  5. package/package.json +35 -0
  6. package/postcss.config.mjs +9 -0
  7. package/project.json +29 -0
  8. package/src/fixtures/basic/json.ts +396 -0
  9. package/src/fixtures/toh251/json.ts +4913 -0
  10. package/src/fixtures/toh251/passages.ts +2814 -0
  11. package/src/fixtures/types.ts +8 -0
  12. package/src/index.ts +4 -0
  13. package/src/lib/block.ts +226 -0
  14. package/src/lib/components/editor/BlockEditor.tsx +38 -0
  15. package/src/lib/components/editor/EditorBackMatterPage.tsx +95 -0
  16. package/src/lib/components/editor/EditorBodyPage.tsx +87 -0
  17. package/src/lib/components/editor/EditorHeader.tsx +22 -0
  18. package/src/lib/components/editor/EditorLayout.tsx +62 -0
  19. package/src/lib/components/editor/EditorLeftPanelPage.tsx +27 -0
  20. package/src/lib/components/editor/EditorProvider.tsx +399 -0
  21. package/src/lib/components/editor/PaginationProvider.tsx +472 -0
  22. package/src/lib/components/editor/TitlesBuilder.tsx +40 -0
  23. package/src/lib/components/editor/TranslationBuilder.tsx +171 -0
  24. package/src/lib/components/editor/TranslationEditor.tsx +32 -0
  25. package/src/lib/components/editor/extensions/Abbreviation/Abbreviation.ts +133 -0
  26. package/src/lib/components/editor/extensions/Audio/Audio.ts +69 -0
  27. package/src/lib/components/editor/extensions/Bold.ts +43 -0
  28. package/src/lib/components/editor/extensions/Document.ts +8 -0
  29. package/src/lib/components/editor/extensions/DragHandle/DragHandle.ts +429 -0
  30. package/src/lib/components/editor/extensions/EndNoteLink/EndNoteLink.tsx +39 -0
  31. package/src/lib/components/editor/extensions/EndNoteLink/EndNoteLinkHoverContent.tsx +139 -0
  32. package/src/lib/components/editor/extensions/EndNoteLink/EndNoteLinkMark.ts +236 -0
  33. package/src/lib/components/editor/extensions/EndNoteLink/endnote-utils.ts +412 -0
  34. package/src/lib/components/editor/extensions/GlobalConfig.ts +52 -0
  35. package/src/lib/components/editor/extensions/GlossaryInstance/GlossaryInput.tsx +54 -0
  36. package/src/lib/components/editor/extensions/GlossaryInstance/GlossaryInstance.tsx +129 -0
  37. package/src/lib/components/editor/extensions/GlossaryInstance/GlossaryInstanceNode.ts +148 -0
  38. package/src/lib/components/editor/extensions/Heading/Heading.ts +71 -0
  39. package/src/lib/components/editor/extensions/HoverInputField.tsx +54 -0
  40. package/src/lib/components/editor/extensions/Image.ts +18 -0
  41. package/src/lib/components/editor/extensions/Indent.ts +103 -0
  42. package/src/lib/components/editor/extensions/InternalLink/InternalLink.ts +173 -0
  43. package/src/lib/components/editor/extensions/InternalLink/InternalLinkHoverContent.tsx +137 -0
  44. package/src/lib/components/editor/extensions/InternalLink/InternalLinkInput.tsx +71 -0
  45. package/src/lib/components/editor/extensions/InternalLink/index.ts +1 -0
  46. package/src/lib/components/editor/extensions/Italic.ts +50 -0
  47. package/src/lib/components/editor/extensions/LeadingSpace.ts +106 -0
  48. package/src/lib/components/editor/extensions/Line/LineNode.ts +41 -0
  49. package/src/lib/components/editor/extensions/LineGroup/LineGroupNode.ts +124 -0
  50. package/src/lib/components/editor/extensions/Link/Link.ts +65 -0
  51. package/src/lib/components/editor/extensions/Link/LinkHoverContent.tsx +124 -0
  52. package/src/lib/components/editor/extensions/Link/index.ts +1 -0
  53. package/src/lib/components/editor/extensions/List.ts +74 -0
  54. package/src/lib/components/editor/extensions/Mantra/Mantra.ts +88 -0
  55. package/src/lib/components/editor/extensions/Mention/Mention.ts +184 -0
  56. package/src/lib/components/editor/extensions/Mention/MentionHoverContent.tsx +158 -0
  57. package/src/lib/components/editor/extensions/NodeWrapper.tsx +57 -0
  58. package/src/lib/components/editor/extensions/Paragraph/Paragraph.ts +25 -0
  59. package/src/lib/components/editor/extensions/ParagraphIndent.ts +87 -0
  60. package/src/lib/components/editor/extensions/Passage/EditLabel.tsx +57 -0
  61. package/src/lib/components/editor/extensions/Passage/EditorOptions.tsx +29 -0
  62. package/src/lib/components/editor/extensions/Passage/Passage.tsx +238 -0
  63. package/src/lib/components/editor/extensions/Passage/PassageNode.ts +223 -0
  64. package/src/lib/components/editor/extensions/Passage/ReaderOptions.tsx +55 -0
  65. package/src/lib/components/editor/extensions/Passage/ShowAnnotations.tsx +92 -0
  66. package/src/lib/components/editor/extensions/Passage/index.ts +1 -0
  67. package/src/lib/components/editor/extensions/Passage/label.spec.ts +118 -0
  68. package/src/lib/components/editor/extensions/Passage/label.ts +39 -0
  69. package/src/lib/components/editor/extensions/Placeholder.ts +9 -0
  70. package/src/lib/components/editor/extensions/SlashCommand/SlashCommand.ts +65 -0
  71. package/src/lib/components/editor/extensions/SlashCommand/SuggestionList.tsx +109 -0
  72. package/src/lib/components/editor/extensions/SlashCommand/Suggestions.ts +185 -0
  73. package/src/lib/components/editor/extensions/SmallCaps.ts +110 -0
  74. package/src/lib/components/editor/extensions/StarterKit.ts +36 -0
  75. package/src/lib/components/editor/extensions/Subscript.ts +43 -0
  76. package/src/lib/components/editor/extensions/Superscript.ts +43 -0
  77. package/src/lib/components/editor/extensions/Table.ts +32 -0
  78. package/src/lib/components/editor/extensions/TextAlign.ts +5 -0
  79. package/src/lib/components/editor/extensions/TitleMetadata.ts +40 -0
  80. package/src/lib/components/editor/extensions/TitleNode.ts +133 -0
  81. package/src/lib/components/editor/extensions/TitlesNode.ts +102 -0
  82. package/src/lib/components/editor/extensions/Trailer.ts +57 -0
  83. package/src/lib/components/editor/extensions/TranslationDocument.ts +7 -0
  84. package/src/lib/components/editor/extensions/TranslationMetadata.ts +58 -0
  85. package/src/lib/components/editor/extensions/Underline.ts +43 -0
  86. package/src/lib/components/editor/hooks/index.ts +4 -0
  87. package/src/lib/components/editor/hooks/useBlockEditor.ts +53 -0
  88. package/src/lib/components/editor/hooks/useDefaultExtensions.ts +39 -0
  89. package/src/lib/components/editor/hooks/useDirtyStore.ts +33 -0
  90. package/src/lib/components/editor/hooks/useTranslationExtensions.ts +148 -0
  91. package/src/lib/components/editor/index.ts +10 -0
  92. package/src/lib/components/editor/menus/EmptyBubbleMenu.tsx +42 -0
  93. package/src/lib/components/editor/menus/MainBubbleMenu.tsx +51 -0
  94. package/src/lib/components/editor/menus/TranslationBubbleMenu.tsx +57 -0
  95. package/src/lib/components/editor/menus/index.ts +3 -0
  96. package/src/lib/components/editor/menus/selectors/EndNoteSelector.tsx +388 -0
  97. package/src/lib/components/editor/menus/selectors/GlossarySelector.tsx +63 -0
  98. package/src/lib/components/editor/menus/selectors/LinkSelector.tsx +68 -0
  99. package/src/lib/components/editor/menus/selectors/MantraSelector.tsx +119 -0
  100. package/src/lib/components/editor/menus/selectors/NodeSelector.tsx +144 -0
  101. package/src/lib/components/editor/menus/selectors/ParagraphButtons.tsx +68 -0
  102. package/src/lib/components/editor/menus/selectors/SelectorInputField.tsx +68 -0
  103. package/src/lib/components/editor/menus/selectors/TextAlignSelector.tsx +89 -0
  104. package/src/lib/components/editor/menus/selectors/TextButtons.tsx +89 -0
  105. package/src/lib/components/editor/menus/selectors/TranslationNodeSelector.tsx +143 -0
  106. package/src/lib/components/editor/menus/selectors/TranslationTextButtons.tsx +125 -0
  107. package/src/lib/components/editor/menus/selectors/index.ts +5 -0
  108. package/src/lib/components/editor/save-filter.spec.ts +94 -0
  109. package/src/lib/components/editor/save-filter.ts +27 -0
  110. package/src/lib/components/editor/util.ts +304 -0
  111. package/src/lib/components/index.ts +3 -0
  112. package/src/lib/components/reader/ReaderBackMatterPage.tsx +62 -0
  113. package/src/lib/components/reader/ReaderBackMatterPanel.tsx +53 -0
  114. package/src/lib/components/reader/ReaderBodyPage.tsx +46 -0
  115. package/src/lib/components/reader/ReaderBodyPanel.tsx +68 -0
  116. package/src/lib/components/reader/ReaderLayout.tsx +39 -0
  117. package/src/lib/components/reader/ReaderLeftPanel.tsx +8 -0
  118. package/src/lib/components/reader/ReaderLeftPanelPage.tsx +31 -0
  119. package/src/lib/components/reader/TranslationReader.tsx +28 -0
  120. package/src/lib/components/reader/index.ts +2 -0
  121. package/src/lib/components/reader/ssr.ts +3 -0
  122. package/src/lib/components/shared/AiSummarizerPage.tsx +12 -0
  123. package/src/lib/components/shared/BackMatterPanel.tsx +143 -0
  124. package/src/lib/components/shared/BodyPanel.tsx +214 -0
  125. package/src/lib/components/shared/HoverCardProvider.tsx +407 -0
  126. package/src/lib/components/shared/Imprint.tsx +24 -0
  127. package/src/lib/components/shared/LabeledElement.tsx +133 -0
  128. package/src/lib/components/shared/LeftPanel.tsx +65 -0
  129. package/src/lib/components/shared/NavigationContext.ts +64 -0
  130. package/src/lib/components/shared/NavigationProvider.tsx +368 -0
  131. package/src/lib/components/shared/OpenGraphImage.tsx +75 -0
  132. package/src/lib/components/shared/PassageSkeleton.tsx +10 -0
  133. package/src/lib/components/shared/RestrictionWarning.tsx +177 -0
  134. package/src/lib/components/shared/SourceReader.tsx +83 -0
  135. package/src/lib/components/shared/SuggestRevisionForm.tsx +99 -0
  136. package/src/lib/components/shared/TableOfContents.tsx +280 -0
  137. package/src/lib/components/shared/ThreeColumnRenderer.tsx +54 -0
  138. package/src/lib/components/shared/TranslationHeader.tsx +86 -0
  139. package/src/lib/components/shared/TranslationHoverCard.tsx +84 -0
  140. package/src/lib/components/shared/TranslationSkeleton.tsx +16 -0
  141. package/src/lib/components/shared/TranslationTable.tsx +155 -0
  142. package/src/lib/components/shared/bibliography/BibliographyBody.tsx +28 -0
  143. package/src/lib/components/shared/bibliography/BibliographyList.tsx +63 -0
  144. package/src/lib/components/shared/bibliography/index.ts +1 -0
  145. package/src/lib/components/shared/generate-metadata.ts +44 -0
  146. package/src/lib/components/shared/glossary/GlossaryInstanceBody.tsx +144 -0
  147. package/src/lib/components/shared/glossary/GlossaryPaginationProvider.tsx +317 -0
  148. package/src/lib/components/shared/glossary/GlossarySkeleton.tsx +19 -0
  149. package/src/lib/components/shared/glossary/GlossaryTermList.tsx +58 -0
  150. package/src/lib/components/shared/glossary/index.ts +3 -0
  151. package/src/lib/components/shared/hooks/useGlossaryInstanceListener.tsx +42 -0
  152. package/src/lib/components/shared/hooks/useScrollInTab.tsx +43 -0
  153. package/src/lib/components/shared/hooks/useScrollPositionRestore.ts +274 -0
  154. package/src/lib/components/shared/hooks/useTohToggle.tsx +52 -0
  155. package/src/lib/components/shared/index.ts +11 -0
  156. package/src/lib/components/shared/ssr.ts +2 -0
  157. package/src/lib/components/shared/titles/FramedCard.tsx +132 -0
  158. package/src/lib/components/shared/titles/LongTitle.tsx +20 -0
  159. package/src/lib/components/shared/titles/LongTitles.tsx +28 -0
  160. package/src/lib/components/shared/titles/Title.tsx +54 -0
  161. package/src/lib/components/shared/titles/TitleDetails.tsx +47 -0
  162. package/src/lib/components/shared/titles/TitleForm.tsx +37 -0
  163. package/src/lib/components/shared/titles/Titles.tsx +114 -0
  164. package/src/lib/components/shared/titles/TitlesCard.tsx +113 -0
  165. package/src/lib/components/shared/titles/index.ts +8 -0
  166. package/src/lib/components/shared/types.ts +79 -0
  167. package/src/lib/components/ssr.ts +2 -0
  168. package/src/lib/exporters/abbreviation.spec.ts +31 -0
  169. package/src/lib/exporters/abbreviation.ts +22 -0
  170. package/src/lib/exporters/annotation.ts +193 -0
  171. package/src/lib/exporters/audio.spec.ts +77 -0
  172. package/src/lib/exporters/audio.ts +27 -0
  173. package/src/lib/exporters/blockquote.spec.ts +48 -0
  174. package/src/lib/exporters/blockquote.ts +24 -0
  175. package/src/lib/exporters/code.spec.ts +93 -0
  176. package/src/lib/exporters/code.ts +26 -0
  177. package/src/lib/exporters/end-note-link.spec.ts +104 -0
  178. package/src/lib/exporters/end-note-link.ts +35 -0
  179. package/src/lib/exporters/export.ts +12 -0
  180. package/src/lib/exporters/glossary-instance.spec.ts +85 -0
  181. package/src/lib/exporters/glossary-instance.ts +31 -0
  182. package/src/lib/exporters/has-abbreviation.spec.ts +31 -0
  183. package/src/lib/exporters/has-abbreviation.ts +21 -0
  184. package/src/lib/exporters/heading.spec.ts +80 -0
  185. package/src/lib/exporters/heading.ts +28 -0
  186. package/src/lib/exporters/image.spec.ts +48 -0
  187. package/src/lib/exporters/image.ts +25 -0
  188. package/src/lib/exporters/indent.spec.ts +58 -0
  189. package/src/lib/exporters/indent.ts +18 -0
  190. package/src/lib/exporters/index.ts +1 -0
  191. package/src/lib/exporters/internal-link.spec.ts +90 -0
  192. package/src/lib/exporters/internal-link.ts +35 -0
  193. package/src/lib/exporters/italic.spec.ts +84 -0
  194. package/src/lib/exporters/italic.ts +55 -0
  195. package/src/lib/exporters/leading-space.spec.ts +28 -0
  196. package/src/lib/exporters/leading-space.ts +16 -0
  197. package/src/lib/exporters/line-group.spec.ts +48 -0
  198. package/src/lib/exporters/line-group.ts +24 -0
  199. package/src/lib/exporters/line.spec.ts +48 -0
  200. package/src/lib/exporters/line.ts +24 -0
  201. package/src/lib/exporters/link.spec.ts +123 -0
  202. package/src/lib/exporters/link.ts +67 -0
  203. package/src/lib/exporters/list-item.spec.ts +48 -0
  204. package/src/lib/exporters/list-item.ts +24 -0
  205. package/src/lib/exporters/list.spec.ts +82 -0
  206. package/src/lib/exporters/list.ts +31 -0
  207. package/src/lib/exporters/mantra.spec.ts +51 -0
  208. package/src/lib/exporters/mantra.ts +25 -0
  209. package/src/lib/exporters/mention.ts +41 -0
  210. package/src/lib/exporters/paragraph.spec.ts +173 -0
  211. package/src/lib/exporters/paragraph.ts +32 -0
  212. package/src/lib/exporters/quote.spec.ts +56 -0
  213. package/src/lib/exporters/quote.ts +25 -0
  214. package/src/lib/exporters/span.spec.ts +118 -0
  215. package/src/lib/exporters/span.ts +44 -0
  216. package/src/lib/exporters/table-body-data.spec.ts +48 -0
  217. package/src/lib/exporters/table-body-data.ts +24 -0
  218. package/src/lib/exporters/table-body-header.spec.ts +48 -0
  219. package/src/lib/exporters/table-body-header.ts +24 -0
  220. package/src/lib/exporters/table-body-row.spec.ts +48 -0
  221. package/src/lib/exporters/table-body-row.ts +24 -0
  222. package/src/lib/exporters/table.spec.ts +48 -0
  223. package/src/lib/exporters/table.ts +24 -0
  224. package/src/lib/exporters/trailer.spec.ts +48 -0
  225. package/src/lib/exporters/trailer.ts +24 -0
  226. package/src/lib/exporters/util.ts +62 -0
  227. package/src/lib/passage.ts +182 -0
  228. package/src/lib/titles.ts +80 -0
  229. package/src/lib/transformers/abbreviation.spec.ts +87 -0
  230. package/src/lib/transformers/abbreviation.ts +30 -0
  231. package/src/lib/transformers/annotate.ts +146 -0
  232. package/src/lib/transformers/audio.spec.ts +55 -0
  233. package/src/lib/transformers/audio.ts +29 -0
  234. package/src/lib/transformers/blockquote.spec.ts +48 -0
  235. package/src/lib/transformers/blockquote.ts +41 -0
  236. package/src/lib/transformers/code.spec.ts +52 -0
  237. package/src/lib/transformers/code.ts +22 -0
  238. package/src/lib/transformers/deprecated.ts +7 -0
  239. package/src/lib/transformers/end-note-link.spec.ts +56 -0
  240. package/src/lib/transformers/end-note-link.ts +76 -0
  241. package/src/lib/transformers/glossary-instance.spec.ts +55 -0
  242. package/src/lib/transformers/glossary-instance.ts +40 -0
  243. package/src/lib/transformers/has-abbreviation.spec.ts +50 -0
  244. package/src/lib/transformers/has-abbreviation.ts +30 -0
  245. package/src/lib/transformers/heading.spec.ts +62 -0
  246. package/src/lib/transformers/heading.ts +30 -0
  247. package/src/lib/transformers/image.spec.ts +51 -0
  248. package/src/lib/transformers/image.ts +28 -0
  249. package/src/lib/transformers/indent.spec.ts +53 -0
  250. package/src/lib/transformers/indent.ts +17 -0
  251. package/src/lib/transformers/index.ts +33 -0
  252. package/src/lib/transformers/inline-title.spec.ts +59 -0
  253. package/src/lib/transformers/inline-title.ts +34 -0
  254. package/src/lib/transformers/internal-link.spec.ts +67 -0
  255. package/src/lib/transformers/internal-link.ts +65 -0
  256. package/src/lib/transformers/italic.ts +22 -0
  257. package/src/lib/transformers/leading-space.spec.ts +55 -0
  258. package/src/lib/transformers/leading-space.ts +17 -0
  259. package/src/lib/transformers/line-group.spec.ts +48 -0
  260. package/src/lib/transformers/line-group.ts +37 -0
  261. package/src/lib/transformers/line.spec.ts +54 -0
  262. package/src/lib/transformers/line.ts +27 -0
  263. package/src/lib/transformers/link.spec.ts +61 -0
  264. package/src/lib/transformers/link.ts +27 -0
  265. package/src/lib/transformers/list-item.spec.ts +48 -0
  266. package/src/lib/transformers/list-item.ts +37 -0
  267. package/src/lib/transformers/list.spec.ts +58 -0
  268. package/src/lib/transformers/list.ts +42 -0
  269. package/src/lib/transformers/mantra.spec.ts +58 -0
  270. package/src/lib/transformers/mantra.ts +28 -0
  271. package/src/lib/transformers/mention.ts +70 -0
  272. package/src/lib/transformers/paragraph.spec.ts +46 -0
  273. package/src/lib/transformers/paragraph.ts +26 -0
  274. package/src/lib/transformers/quote.spec.ts +42 -0
  275. package/src/lib/transformers/quote.ts +3 -0
  276. package/src/lib/transformers/quoted.ts +3 -0
  277. package/src/lib/transformers/recurse.ts +76 -0
  278. package/src/lib/transformers/reference.ts +3 -0
  279. package/src/lib/transformers/span.spec.ts +68 -0
  280. package/src/lib/transformers/span.ts +78 -0
  281. package/src/lib/transformers/split-at.ts +58 -0
  282. package/src/lib/transformers/split-block.ts +110 -0
  283. package/src/lib/transformers/split-content.ts +67 -0
  284. package/src/lib/transformers/split-insert.ts +76 -0
  285. package/src/lib/transformers/split-marks.ts +42 -0
  286. package/src/lib/transformers/split-node.ts +138 -0
  287. package/src/lib/transformers/table-body-data.spec.ts +44 -0
  288. package/src/lib/transformers/table-body-data.ts +29 -0
  289. package/src/lib/transformers/table-body-header.spec.ts +44 -0
  290. package/src/lib/transformers/table-body-header.ts +29 -0
  291. package/src/lib/transformers/table-body-row.spec.ts +44 -0
  292. package/src/lib/transformers/table-body-row.ts +29 -0
  293. package/src/lib/transformers/table.spec.ts +47 -0
  294. package/src/lib/transformers/table.ts +29 -0
  295. package/src/lib/transformers/trailer.spec.ts +43 -0
  296. package/src/lib/transformers/trailer.ts +26 -0
  297. package/src/lib/transformers/transformer.ts +25 -0
  298. package/src/lib/transformers/unknown.ts +8 -0
  299. package/src/lib/transformers/util.ts +20 -0
  300. package/src/lib/types.ts +10 -0
  301. package/src/ssr.ts +1 -0
  302. package/tsconfig.json +20 -0
  303. package/tsconfig.lib.json +29 -0
  304. package/tsconfig.spec.json +22 -0
@@ -0,0 +1,472 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ ReactNode,
7
+ useRef,
8
+ useState,
9
+ useEffect,
10
+ } from 'react';
11
+ import { Editor } from '@tiptap/react';
12
+ import { TranslationEditorContent } from './TranslationEditor';
13
+ import { useBlockEditor, useTranslationExtensions } from './hooks';
14
+ import type { XmlFragment } from 'yjs';
15
+ import {
16
+ createGraphQLClient,
17
+ getTranslationBlocks,
18
+ getTranslationBlocksAround,
19
+ } from '@eightyfourthousand/client-graphql';
20
+ import type { PanelFilter } from '@eightyfourthousand/data-access';
21
+ import { PassageSkeleton } from '../shared/PassageSkeleton';
22
+ import { isUuid, scrollToElement, useIsMobile } from '@eightyfourthousand/lib-utils';
23
+ import { PanelName, TabName, useNavigation } from '../shared';
24
+ import { LotusPond, SHEET_ANIMATION_DURATION } from '@eightyfourthousand/design-system';
25
+ import { useEditorState } from './EditorProvider';
26
+
27
+ const LOADING_SKELETONS_COUNT = 3;
28
+ const CHUNK_SIZE = 25;
29
+
30
+ /**
31
+ * Insert content in chunks with yielding to browser to prevent long frame blocking
32
+ */
33
+ const insertContentChunked = async (
34
+ editor: Editor,
35
+ pos: number,
36
+ content: TranslationEditorContent,
37
+ ) => {
38
+ if (!Array.isArray(content) || content.length <= CHUNK_SIZE) {
39
+ // Small content - insert all at once
40
+ editor.commands.insertContentAt(pos, content);
41
+ return;
42
+ }
43
+
44
+ // Insert in chunks to avoid blocking the main thread
45
+ for (let i = 0; i < content.length; i += CHUNK_SIZE) {
46
+ const chunk = content.slice(i, i + CHUNK_SIZE);
47
+ const insertPos = i === 0 ? pos : editor.state.doc.content.size;
48
+ editor.commands.insertContentAt(insertPos, chunk);
49
+
50
+ // Yield to browser between chunks (except after last chunk)
51
+ if (i + CHUNK_SIZE < content.length) {
52
+ await new Promise((resolve) => requestAnimationFrame(resolve));
53
+ }
54
+ }
55
+ };
56
+
57
+ interface PaginationContextState {
58
+ endCursor?: string;
59
+ startCursor?: string;
60
+ editor?: Editor;
61
+ }
62
+
63
+ export const PaginationContext = createContext<PaginationContextState>({});
64
+
65
+ export const PaginationProvider = ({
66
+ uuid,
67
+ filter,
68
+ panel,
69
+ tab,
70
+ content,
71
+ fragment,
72
+ isEditable = true,
73
+ onCreate,
74
+ children,
75
+ }: {
76
+ uuid: string;
77
+ filter?: PanelFilter;
78
+ panel: PanelName;
79
+ tab?: TabName;
80
+ content: TranslationEditorContent;
81
+ fragment?: XmlFragment;
82
+ isEditable?: boolean;
83
+ onCreate?: (params: { editor: Editor }) => void;
84
+ children: ReactNode;
85
+ }) => {
86
+ const initialEndCursor = Array.isArray(content)
87
+ ? content.at(-1)?.attrs?.uuid
88
+ : content?.attrs?.uuid;
89
+
90
+ const { setNavigating } = useEditorState();
91
+
92
+ const [startCursor, setStartCursor] = useState<string | undefined>();
93
+ const [endCursor, setEndCursor] = useState<string | undefined>(
94
+ initialEndCursor || undefined,
95
+ );
96
+ const [navCursor, setNavCursor] = useState<string | undefined>();
97
+ const processedNavCursorRef = useRef<string | undefined>(undefined);
98
+ const isNavigatingRef = useRef(false);
99
+
100
+ const [startIsLoading, setStartIsLoading] = useState(false);
101
+ const [endIsLoading, setEndIsLoading] = useState(true);
102
+ const [isEditorReady, setIsEditorReady] = useState(false);
103
+ const loadMoreAtStartRef = useRef<HTMLDivElement>(null);
104
+ const loadMoreAtEndRef = useRef<HTMLDivElement>(null);
105
+ const childrenDivRef = useRef<HTMLDivElement>(null);
106
+ const shouldLoadMoreAtStartRef = useRef(false);
107
+ const shouldLoadMoreAtEndRef = useRef(false);
108
+ const [loadMoreTrigger, setLoadMoreTrigger] = useState(0);
109
+ const dataClient = createGraphQLClient();
110
+
111
+ const { panels, updatePanel, setShowOuterContent } = useNavigation();
112
+ const isMobile = useIsMobile();
113
+
114
+ // Extract hash as a primitive value so we only react to actual hash changes,
115
+ // not to every panels object reference change.
116
+ // When a `tab` is specified, only accept the hash when the panel's active tab
117
+ // matches — this prevents a PaginationProvider for one tab (e.g. endnotes)
118
+ // from reacting to hashes set by a different tab (e.g. glossary).
119
+ const panelHash =
120
+ !tab || panels[panel]?.tab === tab ? panels[panel]?.hash : undefined;
121
+
122
+ const { extensions } = useTranslationExtensions({
123
+ fragment,
124
+ });
125
+
126
+ const { editor } = useBlockEditor({
127
+ extensions,
128
+ content,
129
+ isEditable,
130
+ onCreate: ({ editor }) => {
131
+ setEndIsLoading(false);
132
+ setIsEditorReady(true);
133
+ onCreate?.({ editor });
134
+ },
135
+ });
136
+
137
+ useEffect(() => {
138
+ setShowOuterContent(!startCursor);
139
+ }, [startCursor, setShowOuterContent]);
140
+
141
+ useEffect(() => {
142
+ const div = childrenDivRef.current;
143
+ if (!div) {
144
+ return;
145
+ }
146
+
147
+ setNavCursor(panelHash);
148
+ }, [panelHash]);
149
+
150
+ useEffect(() => {
151
+ const div = childrenDivRef.current;
152
+ if (!div || !navCursor || startIsLoading || endIsLoading) {
153
+ return;
154
+ }
155
+
156
+ // Guard: Don't re-process the same hash
157
+ if (processedNavCursorRef.current === navCursor) {
158
+ return;
159
+ }
160
+
161
+ // Mark as processing IMMEDIATELY (synchronously) to prevent re-entry
162
+ // from effect re-runs while async work is in progress
163
+ processedNavCursorRef.current = navCursor;
164
+
165
+ (async () => {
166
+ isNavigatingRef.current = true;
167
+ setNavigating(true, true, fragment);
168
+ try {
169
+ // On mobile, add a delay to allow panel Sheet animation to complete
170
+ // before attempting to scroll to the element
171
+ if (isMobile) {
172
+ await new Promise((resolve) =>
173
+ setTimeout(resolve, SHEET_ANIMATION_DURATION),
174
+ );
175
+ }
176
+
177
+ let element = div.querySelector<HTMLElement>(
178
+ `#${CSS.escape(navCursor)}`,
179
+ );
180
+
181
+ if (!element && isUuid(navCursor)) {
182
+ const {
183
+ blocks,
184
+ hasMoreBefore,
185
+ hasMoreAfter,
186
+ prevCursor,
187
+ nextCursor,
188
+ } = await getTranslationBlocksAround({
189
+ client: dataClient,
190
+ uuid,
191
+ type: filter,
192
+ passageUuid: navCursor,
193
+ });
194
+
195
+ // Guard: if the UUID doesn't correspond to any passages for this
196
+ // filter (e.g. a cross-tab UUID leaked through), bail out rather
197
+ // than wiping the editor with an empty setContent([]) call.
198
+ if (!blocks || blocks.length === 0) {
199
+ return;
200
+ }
201
+
202
+ // Wait for editor to finish updating
203
+ await new Promise<void>((resolve) => {
204
+ const handleUpdate = () => {
205
+ editor?.off('update', handleUpdate);
206
+ requestAnimationFrame(() => resolve());
207
+ };
208
+
209
+ editor?.on('update', handleUpdate);
210
+ editor?.chain().clearContent().setContent(blocks).run();
211
+ });
212
+ setStartCursor(hasMoreBefore && prevCursor ? prevCursor : undefined);
213
+ setEndCursor(hasMoreAfter && nextCursor ? nextCursor : undefined);
214
+
215
+ // Wait for React to re-render and update the DOM (remove/add skeletons)
216
+ // by waiting until the element's position stabilizes
217
+ await new Promise<void>((resolve) => {
218
+ let stabilityCount = 0;
219
+ let lastTop = -1;
220
+
221
+ const checkStability = () => {
222
+ const el = div.querySelector<HTMLElement>(
223
+ `#${CSS.escape(navCursor)}`,
224
+ );
225
+ if (!el) {
226
+ requestAnimationFrame(checkStability);
227
+ return;
228
+ }
229
+
230
+ const currentTop = el.getBoundingClientRect().top;
231
+
232
+ // Check if position has stabilized (same for 2 consecutive frames)
233
+ if (currentTop === lastTop) {
234
+ stabilityCount++;
235
+ if (stabilityCount >= 2) {
236
+ resolve();
237
+ return;
238
+ }
239
+ } else {
240
+ stabilityCount = 0;
241
+ }
242
+
243
+ lastTop = currentTop;
244
+ requestAnimationFrame(checkStability);
245
+ };
246
+
247
+ requestAnimationFrame(checkStability);
248
+ });
249
+
250
+ element = div.querySelector<HTMLElement>(`#${CSS.escape(navCursor)}`);
251
+ }
252
+
253
+ if (!element) {
254
+ return;
255
+ }
256
+
257
+ await scrollToElement({ element });
258
+
259
+ updatePanel({
260
+ name: panel,
261
+ state: { ...panels[panel], hash: undefined },
262
+ });
263
+ } catch (error) {
264
+ console.error('Navigation failed:', error);
265
+ } finally {
266
+ isNavigatingRef.current = false;
267
+ setNavigating(false);
268
+ // Re-trigger load-more check: the observer may have fired while
269
+ // navigation was in progress (sentinel entered view after content
270
+ // was replaced), but the load-more effect was blocked by the
271
+ // isNavigatingRef guard. Bumping the trigger re-evaluates now
272
+ // that navigation is complete.
273
+ setLoadMoreTrigger((c) => c + 1);
274
+ }
275
+ })();
276
+ }, [
277
+ panel,
278
+ uuid,
279
+ filter,
280
+ navCursor,
281
+ startIsLoading,
282
+ endIsLoading,
283
+ // Intentionally excluding `panels` - we only read its current value when clearing hash.
284
+ // Including it causes infinite loops since updatePanel() creates a new panels object.
285
+ editor,
286
+ dataClient,
287
+ updatePanel,
288
+ isMobile,
289
+ ]);
290
+
291
+ useEffect(() => {
292
+ return () => {
293
+ editor?.destroy();
294
+ };
295
+ }, [editor]);
296
+
297
+ // Set up IntersectionObserver only after the editor has created and rendered
298
+ // initial content. isEditorReady flips to true once and stays true, so the
299
+ // observer is created once and never torn down/recreated during page fetches.
300
+ useEffect(() => {
301
+ if (!isEditorReady) return;
302
+
303
+ const startEl = loadMoreAtStartRef.current;
304
+ const endEl = loadMoreAtEndRef.current;
305
+
306
+ const observer = new IntersectionObserver((entries) => {
307
+ for (const entry of entries) {
308
+ if (entry.target === startEl) {
309
+ shouldLoadMoreAtStartRef.current = entry.isIntersecting;
310
+ } else if (entry.target === endEl) {
311
+ shouldLoadMoreAtEndRef.current = entry.isIntersecting;
312
+ }
313
+ if (entry.isIntersecting) {
314
+ setLoadMoreTrigger((c) => c + 1);
315
+ }
316
+ }
317
+ });
318
+
319
+ if (startEl) observer.observe(startEl);
320
+ if (endEl) observer.observe(endEl);
321
+
322
+ return () => observer.disconnect();
323
+ }, [isEditorReady]);
324
+
325
+ useEffect(() => {
326
+ if (
327
+ endIsLoading ||
328
+ !shouldLoadMoreAtEndRef.current ||
329
+ !endCursor ||
330
+ isNavigatingRef.current
331
+ ) {
332
+ return;
333
+ }
334
+ setEndIsLoading(true);
335
+
336
+ (async () => {
337
+ const {
338
+ blocks,
339
+ hasMoreAfter: hasMore,
340
+ nextCursor,
341
+ } = await getTranslationBlocks({
342
+ client: dataClient,
343
+ uuid,
344
+ type: filter,
345
+ cursor: endCursor,
346
+ });
347
+
348
+ const pos = editor?.state.doc?.content.size;
349
+
350
+ if (pos >= 0 && blocks.length && editor) {
351
+ setNavigating(true);
352
+ await insertContentChunked(editor, pos, blocks);
353
+ setNavigating(false);
354
+ }
355
+
356
+ setEndCursor(hasMore && nextCursor ? nextCursor : undefined);
357
+ setEndIsLoading(false);
358
+ })();
359
+ }, [
360
+ uuid,
361
+ filter,
362
+ endIsLoading,
363
+ editor,
364
+ endCursor,
365
+ dataClient,
366
+ loadMoreTrigger,
367
+ ]);
368
+
369
+ useEffect(() => {
370
+ if (
371
+ startIsLoading ||
372
+ !shouldLoadMoreAtStartRef.current ||
373
+ !startCursor ||
374
+ isNavigatingRef.current
375
+ ) {
376
+ return;
377
+ }
378
+ setStartIsLoading(true);
379
+
380
+ (async () => {
381
+ const { blocks, hasMoreBefore, prevCursor } = await getTranslationBlocks({
382
+ client: dataClient,
383
+ uuid,
384
+ type: filter,
385
+ cursor: startCursor,
386
+ direction: 'backward',
387
+ });
388
+
389
+ const pos = 0;
390
+
391
+ if (blocks.length && editor?.view?.dom) {
392
+ const editorEl = editor.view.dom;
393
+ const scrollContainer =
394
+ editorEl.closest('[data-panel]') || editorEl.parentElement;
395
+ const previousScrollHeight = scrollContainer?.scrollHeight || 0;
396
+ const previousScrollTop = scrollContainer?.scrollTop || 0;
397
+
398
+ // For start insertion, insert all at once to maintain scroll position accuracy.
399
+ // Chunking here would cause scroll jank since we adjust scroll after insertion.
400
+ setNavigating(true);
401
+ editor.commands.insertContentAt(pos, blocks);
402
+ setNavigating(false);
403
+
404
+ requestAnimationFrame(() => {
405
+ const newScrollHeight = scrollContainer?.scrollHeight || 0;
406
+ const deltaHeight = newScrollHeight - previousScrollHeight;
407
+ if (scrollContainer) {
408
+ scrollContainer.scrollTop = previousScrollTop + deltaHeight;
409
+ }
410
+ });
411
+ } else if (blocks.length && editor) {
412
+ // Fallback: insert without scroll preservation when view not ready
413
+ setNavigating(true);
414
+ editor.commands.insertContentAt(pos, blocks);
415
+ setNavigating(false);
416
+ }
417
+
418
+ setStartCursor(hasMoreBefore && prevCursor ? prevCursor : undefined);
419
+ setStartIsLoading(false);
420
+ })();
421
+ }, [
422
+ uuid,
423
+ filter,
424
+ startIsLoading,
425
+ editor,
426
+ startCursor,
427
+ dataClient,
428
+ loadMoreTrigger,
429
+ ]);
430
+
431
+ return (
432
+ <PaginationContext.Provider
433
+ value={{
434
+ startCursor,
435
+ endCursor,
436
+ editor,
437
+ }}
438
+ >
439
+ {startCursor && (
440
+ <div className="flex flex-col gap-4 pt-6">
441
+ {Array.from({ length: LOADING_SKELETONS_COUNT }).map((_, i) => (
442
+ <PassageSkeleton key={i} />
443
+ ))}
444
+ </div>
445
+ )}
446
+ <div ref={loadMoreAtStartRef} className="h-0" />
447
+ <div ref={childrenDivRef}>{children}</div>
448
+ <div ref={loadMoreAtEndRef} className="h-0" />
449
+ {endCursor ? (
450
+ <div className="flex flex-col gap-4 pb-8 pt-6">
451
+ {Array.from({ length: LOADING_SKELETONS_COUNT }).map((_, i) => (
452
+ <PassageSkeleton key={i} />
453
+ ))}
454
+ </div>
455
+ ) : (
456
+ <div className="w-full pt-16 pb-6 @c/sidebar:pt-8">
457
+ <LotusPond className="@c/sidebar:hidden mx-auto w-96" />
458
+ </div>
459
+ )}
460
+ </PaginationContext.Provider>
461
+ );
462
+ };
463
+
464
+ export const usePagination = () => {
465
+ const context = useContext(PaginationContext);
466
+
467
+ if (!context) {
468
+ throw new Error('usePagination must be used within a PaginationProvider');
469
+ }
470
+
471
+ return context;
472
+ };
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+
3
+ import { Imprint, Titles as TitlesData } from '@eightyfourthousand/data-access';
4
+ import { useEffect, useState } from 'react';
5
+ import { useEditorState } from './EditorProvider';
6
+ import { Titles } from '../shared/titles';
7
+ import { TranslationSkeleton } from '../shared/TranslationSkeleton';
8
+
9
+ export const TitlesBuilder = ({
10
+ titles,
11
+ imprint,
12
+ }: {
13
+ titles: TitlesData;
14
+ imprint?: Imprint;
15
+ }) => {
16
+ const [isEditable, setIsEditable] = useState(false);
17
+ const [loading, setLoading] = useState(true);
18
+
19
+ const { canEdit } = useEditorState();
20
+
21
+ useEffect(() => {
22
+ const checkEditable = async () => {
23
+ if (!loading) {
24
+ return;
25
+ }
26
+
27
+ const editable = await canEdit();
28
+
29
+ setIsEditable(editable);
30
+ setLoading(false);
31
+ };
32
+ checkEditable();
33
+ }, [loading, canEdit]);
34
+
35
+ return !loading ? (
36
+ <Titles titles={titles} imprint={imprint} canEdit={isEditable} />
37
+ ) : (
38
+ <TranslationSkeleton />
39
+ );
40
+ };
@@ -0,0 +1,171 @@
1
+ 'use client';
2
+
3
+ import type { TranslationRenderer } from '../shared/types';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ import type { XmlFragment } from 'yjs';
6
+ import { useEditorState } from './EditorProvider';
7
+ import { TranslationEditor } from './TranslationEditor';
8
+ import { TranslationSkeleton, useNavigation } from '../shared';
9
+ import { PaginationProvider } from './PaginationProvider';
10
+ import {
11
+ removeAllEndnoteLinksForPassage,
12
+ syncEndnoteLinkLabelsAcrossEditors,
13
+ } from './extensions/EndNoteLink/endnote-utils';
14
+
15
+ export const TranslationBuilder = ({
16
+ content,
17
+ name,
18
+ className,
19
+ filter,
20
+ panel,
21
+ }: TranslationRenderer) => {
22
+ const [fragment, setFragment] = useState<XmlFragment>();
23
+ const [isObserving, setIsObserving] = useState(false);
24
+ const [isEditable, setIsEditable] = useState(false);
25
+
26
+ const {
27
+ canEdit,
28
+ setEditor,
29
+ getEditor,
30
+ startObserving,
31
+ getFragment,
32
+ isNavigating,
33
+ } = useEditorState();
34
+
35
+ const { uuid } = useNavigation();
36
+
37
+ // Track known passage UUIDs by passage type so we can detect deletions
38
+ // (backspace, merge, etc.) and clean up related marks in other editors.
39
+ // This ref is independent of the Y.js observer's knownUuidsRef, which
40
+ // can be cleared during navigation.
41
+ const passageUuidsByTypeRef = useRef<Record<string, Set<string>>>({});
42
+ const debouncedSyncRef = useRef<ReturnType<typeof setTimeout>>(undefined);
43
+ const pendingDeletedByTypeRef = useRef<Record<string, Set<string>>>({});
44
+
45
+ useEffect(() => {
46
+ (async () => {
47
+ const isEditable = await canEdit();
48
+
49
+ setIsEditable(isEditable);
50
+ if (isEditable) {
51
+ setFragment(getFragment(name));
52
+ }
53
+ })();
54
+ }, [name, canEdit, getFragment]);
55
+
56
+ return content && fragment ? (
57
+ <PaginationProvider
58
+ uuid={uuid}
59
+ panel={panel}
60
+ tab={name}
61
+ filter={filter}
62
+ content={content}
63
+ fragment={fragment}
64
+ isEditable={isEditable}
65
+ onCreate={({ editor }) => {
66
+ editor.commands.setDebug(true);
67
+ setEditor(name, editor);
68
+ if (!isObserving) {
69
+ setIsObserving(true);
70
+ startObserving(name);
71
+ }
72
+
73
+ if (name === 'endnotes') {
74
+ // Initialize known passage UUIDs grouped by type
75
+ const byType: Record<string, Set<string>> = {};
76
+ const doc = editor.state.doc;
77
+ for (let i = 0; i < doc.childCount; i++) {
78
+ const child = doc.child(i);
79
+ if (child.type.name === 'passage' && child.attrs.uuid) {
80
+ const pType = (child.attrs.type as string) || 'unknown';
81
+ (byType[pType] ??= new Set()).add(child.attrs.uuid);
82
+ }
83
+ }
84
+ passageUuidsByTypeRef.current = byType;
85
+
86
+ editor.on('update', () => {
87
+ // Collect current passage UUIDs grouped by type (cheap)
88
+ const currentByType: Record<string, Set<string>> = {};
89
+ const doc = editor.state.doc;
90
+ for (let i = 0; i < doc.childCount; i++) {
91
+ const child = doc.child(i);
92
+ if (child.type.name === 'passage' && child.attrs.uuid) {
93
+ const pType = (child.attrs.type as string) || 'unknown';
94
+ (currentByType[pType] ??= new Set()).add(child.attrs.uuid);
95
+ }
96
+ }
97
+
98
+ // Detect deleted and added passages per type (skip during
99
+ // navigation to avoid false positives from content replacement)
100
+ const deletedByType: Record<string, string[]> = {};
101
+ let hasAdded = false;
102
+ if (!isNavigating()) {
103
+ const prevByType = passageUuidsByTypeRef.current;
104
+ for (const pType of Object.keys(prevByType)) {
105
+ const prev = prevByType[pType];
106
+ const curr = currentByType[pType];
107
+ prev.forEach((uuid) => {
108
+ if (!curr?.has(uuid)) {
109
+ (deletedByType[pType] ??= []).push(uuid);
110
+ }
111
+ });
112
+ }
113
+ if (!hasAdded) {
114
+ outer: for (const pType of Object.keys(currentByType)) {
115
+ const prev = prevByType[pType];
116
+ for (const uuid of currentByType[pType]) {
117
+ if (!prev?.has(uuid)) {
118
+ hasAdded = true;
119
+ break outer;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ // Always update the baseline so the next diff is accurate
127
+ passageUuidsByTypeRef.current = currentByType;
128
+
129
+ const hasDeleted = Object.keys(deletedByType).length > 0;
130
+
131
+ // Accumulate deletions and debounce the expensive sync work
132
+ // so rapid keystrokes don't trigger multiple full traversals
133
+ for (const [pType, uuids] of Object.entries(deletedByType)) {
134
+ const pending = (pendingDeletedByTypeRef.current[pType] ??=
135
+ new Set());
136
+ for (const uuid of uuids) {
137
+ pending.add(uuid);
138
+ }
139
+ }
140
+
141
+ if (hasDeleted || hasAdded) {
142
+ if (debouncedSyncRef.current) {
143
+ clearTimeout(debouncedSyncRef.current);
144
+ }
145
+ debouncedSyncRef.current = setTimeout(() => {
146
+ const pendingEndnotes =
147
+ pendingDeletedByTypeRef.current['endnotes'];
148
+ if (pendingEndnotes?.size) {
149
+ const frontEditor = getEditor('front');
150
+ const translationEditor = getEditor('translation');
151
+ for (const uuid of pendingEndnotes) {
152
+ if (frontEditor)
153
+ removeAllEndnoteLinksForPassage(frontEditor, uuid);
154
+ if (translationEditor)
155
+ removeAllEndnoteLinksForPassage(translationEditor, uuid);
156
+ }
157
+ }
158
+ pendingDeletedByTypeRef.current = {};
159
+ syncEndnoteLinkLabelsAcrossEditors(editor, getEditor);
160
+ }, 150);
161
+ }
162
+ });
163
+ }
164
+ }}
165
+ >
166
+ <TranslationEditor className={className} />
167
+ </PaginationProvider>
168
+ ) : (
169
+ <TranslationSkeleton />
170
+ );
171
+ };