@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,274 @@
1
+ 'use client';
2
+
3
+ import { RefObject, useEffect, useRef } from 'react';
4
+
5
+ /**
6
+ * Snapshot of the topmost visible passage element: its UUID and how far
7
+ * its top edge is from the viewport top. Used to realign scroll position
8
+ * after a layout change (e.g. tab switch with different content widths).
9
+ */
10
+ export interface PassageAnchor {
11
+ uuid: string;
12
+ offsetFromViewport: number;
13
+ }
14
+
15
+ /**
16
+ * Finds the nearest scrollable ancestor of an element,
17
+ * skipping hidden containers (zero clientHeight).
18
+ */
19
+ export function findScrollParent(element: HTMLElement): HTMLElement | null {
20
+ let current = element.parentElement;
21
+ while (current) {
22
+ const { overflowY } = getComputedStyle(current);
23
+ if (
24
+ (overflowY === 'auto' || overflowY === 'scroll') &&
25
+ current.clientHeight > 0
26
+ ) {
27
+ return current;
28
+ }
29
+ current = current.parentElement;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * Finds the topmost visible passage element within the scroll container.
36
+ * Passage elements are NodeViewWrappers with `id` attributes (UUIDs).
37
+ */
38
+ export function capturePassageAnchor(
39
+ scrollContainer: HTMLElement,
40
+ ): PassageAnchor | null {
41
+ const passagesList = scrollContainer.querySelectorAll<HTMLElement>(
42
+ '[data-node-view-wrapper][id]',
43
+ );
44
+ const passages = Array.from(passagesList);
45
+ const containerTop = scrollContainer.getBoundingClientRect().top;
46
+
47
+ for (const el of passages) {
48
+ const rect = el.getBoundingClientRect();
49
+ // Find the first passage whose bottom is below the container top
50
+ // (i.e. at least partially visible)
51
+ if (rect.bottom > containerTop) {
52
+ return {
53
+ uuid: el.id,
54
+ offsetFromViewport: rect.top - containerTop,
55
+ };
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * After a tab switch, finds the same passage element by UUID and adjusts
63
+ * scrollTop so it appears at the same viewport offset as before.
64
+ */
65
+ export function restorePassageAnchor(
66
+ scrollContainer: HTMLElement,
67
+ anchor: PassageAnchor,
68
+ ): void {
69
+ // Use querySelectorAll because Translation and Compare can both contain
70
+ // passages with the same UUID. querySelector would return the first
71
+ // (hidden Translation), so we need to find the visible one.
72
+ const candidatesList = scrollContainer.querySelectorAll<HTMLElement>(
73
+ `[data-node-view-wrapper]#${CSS.escape(anchor.uuid)}`,
74
+ );
75
+ const candidates = Array.from(candidatesList);
76
+ let el: HTMLElement | null = null;
77
+ for (const candidate of candidates) {
78
+ if (candidate.getBoundingClientRect().height > 0) {
79
+ el = candidate;
80
+ break;
81
+ }
82
+ }
83
+ if (!el) return;
84
+
85
+ const containerTop = scrollContainer.getBoundingClientRect().top;
86
+ const currentOffset = el.getBoundingClientRect().top - containerTop;
87
+ const delta = currentOffset - anchor.offsetFromViewport;
88
+ scrollContainer.scrollTop += delta;
89
+ }
90
+
91
+ /** Tabs whose content contains passage elements (data-node-view-wrapper). */
92
+ const PASSAGE_TABS = ['translation', 'compare'];
93
+
94
+ export function usePassageAnchorRestore(
95
+ scrollContainerRef: RefObject<HTMLElement | null>,
96
+ activeTab: string | undefined,
97
+ ): RefObject<PassageAnchor | null> {
98
+ const anchorRef = useRef<PassageAnchor | null>(null);
99
+ const prevTabRef = useRef<string | undefined>(undefined);
100
+
101
+ useEffect(() => {
102
+ if (prevTabRef.current === activeTab) return;
103
+ prevTabRef.current = activeTab;
104
+
105
+ // Only restore when arriving at a passage tab (translation/compare).
106
+ // When switching to front/source, keep the anchor so it's available
107
+ // when the user eventually returns to a passage tab.
108
+ if (!PASSAGE_TABS.includes(activeTab || '')) return;
109
+
110
+ const container = scrollContainerRef.current;
111
+ const anchor = anchorRef.current;
112
+ if (!container || !anchor) return;
113
+
114
+ // Clear inside rAF, not here — useScrollPositionRestore reads the ref
115
+ // synchronously (in its effect, which runs after this one) to decide
116
+ // whether to skip its own restore.
117
+ requestAnimationFrame(() => {
118
+ anchorRef.current = null;
119
+ restorePassageAnchor(container, anchor);
120
+ });
121
+ }, [activeTab, scrollContainerRef]);
122
+
123
+ return anchorRef;
124
+ }
125
+
126
+ /**
127
+ * Normalizes tab names so that tabs which should share scroll position
128
+ * map to the same key (e.g. 'compare' → 'translation').
129
+ */
130
+ function normalizeTabKey(tab: string): string {
131
+ if (tab === 'compare') return 'translation';
132
+ return tab;
133
+ }
134
+
135
+ /** Max time (ms) to wait for content to populate before giving up on restore. */
136
+ const RESTORE_TIMEOUT = 5000;
137
+
138
+ /**
139
+ * Module-level storage for scroll positions, keyed by `panelId:tabKey`.
140
+ * Survives component remounts (which happen on tab switches due to the
141
+ * ThreeColumns dual-render architecture).
142
+ */
143
+ const scrollPositions = new Map<string, number>();
144
+
145
+ /**
146
+ * Watches the scroll container for content changes (via MutationObserver) and
147
+ * restores scrollTop once the container has enough height. Returns a cleanup
148
+ * function that tears down the observer and timeout.
149
+ */
150
+ function waitForContentAndRestore(
151
+ container: HTMLElement,
152
+ targetScroll: number,
153
+ ): () => void {
154
+ // Try immediately — content may already be present (non-paginated tabs)
155
+ container.scrollTop = targetScroll;
156
+ if (Math.abs(container.scrollTop - targetScroll) < 2) {
157
+ return () => { /* no-op */ };
158
+ }
159
+
160
+ let settled = false;
161
+
162
+ const cleanup = () => {
163
+ if (settled) return;
164
+ settled = true;
165
+ observer.disconnect();
166
+ clearTimeout(timeout);
167
+ };
168
+
169
+ const tryRestore = () => {
170
+ if (settled) return;
171
+ requestAnimationFrame(() => {
172
+ if (settled) return;
173
+ container.scrollTop = targetScroll;
174
+ if (Math.abs(container.scrollTop - targetScroll) < 2) {
175
+ cleanup();
176
+ }
177
+ });
178
+ };
179
+
180
+ const observer = new MutationObserver(tryRestore);
181
+ observer.observe(container, {
182
+ childList: true,
183
+ subtree: true,
184
+ characterData: true,
185
+ });
186
+
187
+ // Give up after timeout — content may simply be shorter than the saved position
188
+ const timeout = setTimeout(() => {
189
+ container.scrollTop = targetScroll;
190
+ cleanup();
191
+ }, RESTORE_TIMEOUT);
192
+
193
+ return cleanup;
194
+ }
195
+
196
+ /**
197
+ * Saves scroll position when switching away from a tab and restores it
198
+ * when switching back.
199
+ *
200
+ * Scroll positions are stored in a module-level Map (not a ref) so they
201
+ * survive component remounts — which happen on tab switches because the
202
+ * ThreeColumns layout renders main panel children in both mobile and
203
+ * desktop trees.
204
+ *
205
+ * For tabs with async/paginated content (TipTap editor), uses a MutationObserver
206
+ * to wait for enough content to load before restoring the scroll position.
207
+ *
208
+ * @param panelId - Stable identifier for the panel ('main' or 'right')
209
+ * @param scrollContainerRef - Ref to the scrollable container element
210
+ * @param activeTab - The currently active tab name
211
+ * @param hasHash - When true, skips restore to let hash-based scrolling take over
212
+ */
213
+ export function useScrollPositionRestore(
214
+ panelId: string,
215
+ scrollContainerRef: RefObject<HTMLElement | null>,
216
+ activeTab: string | undefined,
217
+ hasHash: boolean,
218
+ passageAnchorRef?: RefObject<PassageAnchor | null>,
219
+ ) {
220
+ const prevTabRef = useRef<string | undefined>(undefined);
221
+ const cleanupRef = useRef<(() => void) | null>(null);
222
+
223
+ useEffect(() => {
224
+ const currentKey = normalizeTabKey(activeTab || '');
225
+ const prevKey =
226
+ prevTabRef.current !== undefined
227
+ ? normalizeTabKey(prevTabRef.current)
228
+ : undefined;
229
+
230
+ const container = scrollContainerRef.current;
231
+
232
+ // Skip hidden containers (mobile layout on desktop viewport)
233
+ if (container && container.clientHeight === 0) {
234
+ prevTabRef.current = activeTab;
235
+ return;
236
+ }
237
+
238
+ // Cancel any in-progress restore from a previous tab switch
239
+ cleanupRef.current?.();
240
+ cleanupRef.current = null;
241
+
242
+ // Save outgoing tab's scroll position
243
+ if (prevKey !== undefined && prevKey !== currentKey && container) {
244
+ scrollPositions.set(`${panelId}:${prevKey}`, container.scrollTop);
245
+ }
246
+
247
+ // Restore incoming tab's scroll position.
248
+ // Skip when a passage anchor is pending AND we're arriving at a passage
249
+ // tab — the passage anchor restore is more accurate (immune to scrollTop
250
+ // clamping from hidden content) and will handle positioning instead.
251
+ const passageAnchorWillRestore =
252
+ !!passageAnchorRef?.current &&
253
+ PASSAGE_TABS.includes(activeTab || '');
254
+ if (prevKey !== currentKey && !hasHash && !passageAnchorWillRestore && container) {
255
+ const saved = scrollPositions.get(`${panelId}:${currentKey}`) ?? 0;
256
+ if (saved === 0) {
257
+ requestAnimationFrame(() => {
258
+ container.scrollTop = 0;
259
+ });
260
+ } else {
261
+ cleanupRef.current = waitForContentAndRestore(container, saved);
262
+ }
263
+ }
264
+
265
+ prevTabRef.current = activeTab;
266
+ }, [panelId, activeTab, hasHash, scrollContainerRef]);
267
+
268
+ // Cleanup on unmount
269
+ useEffect(() => {
270
+ return () => {
271
+ cleanupRef.current?.();
272
+ };
273
+ }, []);
274
+ }
@@ -0,0 +1,52 @@
1
+ 'use client';
2
+
3
+ import { TohokuCatalogEntry, Work } from '@eightyfourthousand/data-access';
4
+ import { useEffect } from 'react';
5
+
6
+ export const useTohToggle = ({
7
+ toh,
8
+ work,
9
+ }: {
10
+ toh?: TohokuCatalogEntry;
11
+ work: Work;
12
+ }) => {
13
+ useEffect(() => {
14
+ (async () => {
15
+ const sheets = Array.from(document.styleSheets);
16
+ let sheet: CSSStyleSheet | null | undefined = [...sheets].find((s) => {
17
+ const node = s.ownerNode as HTMLElement | null;
18
+ return node?.id === 'dynamic-visibility-rules';
19
+ });
20
+
21
+ if (!sheet) {
22
+ const styleEl = document.createElement('style');
23
+ styleEl.id = 'dynamic-visibility-rules';
24
+ document.head.appendChild(styleEl);
25
+ sheet = styleEl.sheet;
26
+ }
27
+
28
+ if (!sheet) {
29
+ console.warn(
30
+ 'Could not find or create stylesheet for dynamic class toggling.',
31
+ );
32
+ return;
33
+ }
34
+
35
+ // Clear existing rules
36
+ while (sheet.cssRules.length > 0) {
37
+ sheet.deleteRule(0);
38
+ }
39
+
40
+ // Add new rules
41
+ const tohs = work.toh;
42
+ tohs
43
+ .filter((cls) => cls !== toh)
44
+ .forEach((cls) => {
45
+ sheet.insertRule(
46
+ `.${cls} { display: none !important; }`,
47
+ sheet.cssRules.length,
48
+ );
49
+ });
50
+ })();
51
+ }, [toh, work.toh]);
52
+ };
@@ -0,0 +1,11 @@
1
+ export * from './AiSummarizerPage';
2
+ export * from './NavigationProvider';
3
+ export * from './LabeledElement';
4
+ export * from './SuggestRevisionForm';
5
+ export * from './ThreeColumnRenderer';
6
+ export * from './TranslationSkeleton';
7
+ export * from './TranslationTable';
8
+ export * from './bibliography';
9
+ export * from './glossary';
10
+ export * from './titles';
11
+ export * from './types';
@@ -0,0 +1,2 @@
1
+ export * from './generate-metadata';
2
+ export * from './OpenGraphImage';
@@ -0,0 +1,132 @@
1
+ import { ReactNode, SVGProps } from 'react';
2
+ import { cn } from '@eightyfourthousand/lib-utils';
3
+
4
+ export const OrnamentBG = ({ className }: SVGProps<SVGElement>) => {
5
+ return (
6
+ <svg
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ width="38"
9
+ height="20"
10
+ viewBox="0 0 38 20"
11
+ fill="none"
12
+ className={className}
13
+ >
14
+ <path d="M0 0C0 11.04 8.512 20 19 20C29.488 20 38 11.04 38 0H0Z" />
15
+ </svg>
16
+ );
17
+ };
18
+
19
+ export const Ornament = ({ className }: SVGProps<SVGElement>) => {
20
+ return (
21
+ <svg
22
+ xmlns="http://www.w3.org/2000/svg"
23
+ width="36"
24
+ height="19"
25
+ viewBox="0 0 36 19"
26
+ className={className}
27
+ >
28
+ <path d="M21.8 11.28C22.332 11.12 22.864 10.88 23.32 10.56V8H24.84V6.4H23.32V4.8H30.084C28.488 9.2 24.84 12.48 20.28 13.36V1.6H33.884C33.124 10.16 26.284 16.8 18 16.8C9.71602 16.8 2.87602 10.16 2.11602 1.6H15.72V13.36C11.236 12.48 7.51202 9.2 5.91602 4.8H12.68V6.4H11.16V8H12.68V10.64C13.136 10.88 13.668 11.12 14.2 11.36V3.2H3.86402C5.23202 9.76 10.628 14.8 17.24 15.12V0H0.52002C0.52002 10.16 8.34802 18.4 18 18.4C27.652 18.4 35.48 10.16 35.48 0H18.76V15.12C25.372 14.72 30.768 9.76 32.136 3.2H21.8V11.28Z" />
29
+ </svg>
30
+ );
31
+ };
32
+
33
+ export const FramedCard = ({
34
+ children,
35
+ className,
36
+ }: {
37
+ children: ReactNode;
38
+ className?: string;
39
+ }) => {
40
+ const borderColor = `border-navy-50`;
41
+ const bgColor = `bg-navy-25/40`;
42
+ const ornamentTint = `fill-navy-50`;
43
+ const ornamentBgTint = `fill-white/75`;
44
+
45
+ const cornerSize = 2;
46
+ const size = `size-${cornerSize}`;
47
+ const height = `height-${cornerSize}`;
48
+ const width = `width-${cornerSize}`;
49
+ const border = `border border-5 ${borderColor} border-double`;
50
+
51
+ return (
52
+ <div
53
+ className={cn(
54
+ 'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-0',
55
+ className,
56
+ )}
57
+ >
58
+ <div className={cn(size, 'relative')}>
59
+ <div
60
+ className={cn(
61
+ border,
62
+ 'absolute top-1.25 start-1.25 z-2 border-t-transparent border-s-transparent',
63
+ )}
64
+ />
65
+ </div>
66
+ <div
67
+ className={cn(
68
+ height,
69
+ border,
70
+ bgColor,
71
+ 'relative rounded-t-md border-b-transparent',
72
+ )}
73
+ >
74
+ <div className="absolute -top-1.25 h-0 w-full overflow-visible">
75
+ <OrnamentBG className={cn('mx-auto', ornamentBgTint)} />
76
+ </div>
77
+ <div className="absolute -top-1.25 h-0 w-full overflow-visible">
78
+ <Ornament className={cn('mx-auto', ornamentTint)} />
79
+ </div>
80
+ </div>
81
+ <div className={cn(size, 'relative')}>
82
+ <div
83
+ className={cn(
84
+ border,
85
+ 'absolute top-1.25 end-0.75 z-2 border-t-transparent border-e-transparent',
86
+ )}
87
+ />
88
+ </div>
89
+ <div
90
+ className={cn(
91
+ width,
92
+ border,
93
+ bgColor,
94
+ 'rounded-s-md border-e-transparent border-double',
95
+ )}
96
+ />
97
+ <div className={cn('fit-content', bgColor)}>{children}</div>
98
+ <div
99
+ className={cn(
100
+ width,
101
+ border,
102
+ bgColor,
103
+ 'rounded-e-md border-s-transparent',
104
+ )}
105
+ />
106
+ <div className={cn(size, 'relative')}>
107
+ <div
108
+ className={cn(
109
+ border,
110
+ 'absolute bottom-0.75 start-1.25 z-2 border-b-transparent border-s-transparent',
111
+ )}
112
+ />
113
+ </div>
114
+ <div
115
+ className={cn(
116
+ height,
117
+ border,
118
+ bgColor,
119
+ 'rounded-b-md border-t-transparent',
120
+ )}
121
+ />
122
+ <div className={cn(size, 'relative')}>
123
+ <div
124
+ className={cn(
125
+ border,
126
+ 'absolute right-0.75 bottom-0.75 z-2 border-b-transparent border-e-transparent',
127
+ )}
128
+ />
129
+ </div>
130
+ </div>
131
+ );
132
+ };
@@ -0,0 +1,20 @@
1
+ import { TranslationLanguage } from '@eightyfourthousand/data-access';
2
+ import { cn } from '@eightyfourthousand/lib-utils';
3
+
4
+ export const LongTitle = ({
5
+ title,
6
+ language,
7
+ }: {
8
+ title: string;
9
+ language: TranslationLanguage;
10
+ }) => {
11
+ const languageStyles: { [key in TranslationLanguage]?: string } = {
12
+ 'Sa-Ltn': 'italic long-term',
13
+ 'Bo-Ltn': 'italic',
14
+ bo: 'font-tibetan',
15
+ };
16
+
17
+ const textStyle = languageStyles[language] || '';
18
+
19
+ return <div className={cn('py-1.5', textStyle)}>{title}</div>;
20
+ };
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+
3
+ import { Imprint } from '@eightyfourthousand/data-access';
4
+ import { Skeleton } from '@eightyfourthousand/design-system';
5
+ import { TitleDetails } from './TitleDetails';
6
+ import { TitlesCard } from './TitlesCard';
7
+
8
+ export const LongTitles = ({ imprint }: { imprint?: Imprint }) => {
9
+ if (!imprint) {
10
+ return <Skeleton className="h-48 w-full" />;
11
+ }
12
+
13
+ const { mainTitles } = imprint;
14
+ const mainEnTitle = mainTitles?.en || '';
15
+
16
+ return (
17
+ <div className="mx-auto flex w-full flex-col gap-1 px-4 pt-8">
18
+ <TitlesCard
19
+ header={imprint.section}
20
+ main={mainEnTitle}
21
+ footer={imprint.toh}
22
+ canEdit={false}
23
+ />
24
+ <div className="h-8" />
25
+ <TitleDetails imprint={imprint} />
26
+ </div>
27
+ );
28
+ };
@@ -0,0 +1,54 @@
1
+ import { ReactNode } from 'react';
2
+ import { ExtendedTranslationLanguage } from '@eightyfourthousand/data-access';
3
+ import { H2, H3, H4 } from '@eightyfourthousand/design-system';
4
+ import { cva, type VariantProps } from 'class-variance-authority';
5
+ import { cn } from '@eightyfourthousand/lib-utils';
6
+
7
+ export const TITLE_VARIANT_STYLES = {
8
+ en: '',
9
+ bo: 'my-1',
10
+ ja: 'my-1 italic font-light text-muted-foreground',
11
+ zh: 'my-1 italic font-light text-muted-foreground',
12
+ 'Bo-Ltn': 'my-1 italic font-light text-muted-foreground',
13
+ 'Mt-Ltn': 'my-1 italic font-light text-muted-foreground',
14
+ 'Pi-Ltn': 'my-1 italic font-light text-muted-foreground',
15
+ 'Sa-Ltn': 'my-1 italic font-light text-muted-foreground',
16
+ 'Zh-Ltn': 'my-1 italic font-light text-muted-foreground',
17
+ };
18
+
19
+ const titleVariants = cva('', {
20
+ variants: {
21
+ language: TITLE_VARIANT_STYLES,
22
+ },
23
+ defaultVariants: { language: 'en' },
24
+ });
25
+
26
+ export const Title = ({
27
+ children,
28
+ language,
29
+ ...props
30
+ }: {
31
+ children: ReactNode;
32
+ } & VariantProps<typeof titleVariants>) => {
33
+ const components: {
34
+ [key in ExtendedTranslationLanguage]: typeof H2 | typeof H4;
35
+ } = {
36
+ en: H2,
37
+ bo: H3,
38
+ ja: H4,
39
+ zh: H4,
40
+ 'Bo-Ltn': H2,
41
+ 'Mt-Ltn': H2,
42
+ 'Pi-Ltn': H2,
43
+ 'Sa-Ltn': H2,
44
+ 'Zh-Ltn': H2,
45
+ };
46
+
47
+ const Component = (language && components[language]) || H4;
48
+
49
+ return (
50
+ <Component className={cn(titleVariants({ language }))} {...props}>
51
+ {children}
52
+ </Component>
53
+ );
54
+ };
@@ -0,0 +1,47 @@
1
+ import { Imprint } from '@eightyfourthousand/data-access';
2
+ import { LongTitle } from './LongTitle';
3
+
4
+ const DOT = `·` as const;
5
+
6
+ export const TitleDetails = ({ imprint }: { imprint: Imprint }) => {
7
+ const { mainTitles, longTitles, toh } = imprint;
8
+
9
+ const longBoTitle = longTitles?.bo || mainTitles?.bo;
10
+ const longBoLtnTitle = longTitles?.['Bo-Ltn'] || mainTitles?.['Bo-Ltn'];
11
+ const longEnTitle = longTitles?.en || mainTitles?.en;
12
+ const longSaLtnTitle = longTitles?.['Sa-Ltn'] || mainTitles?.['Sa-Ltn'];
13
+
14
+ const translators =
15
+ imprint?.tibetanTranslators
16
+ ?.split(',')
17
+ ?.map((t) => t.trim())
18
+ .filter((t) => !!t) || [];
19
+
20
+ return (
21
+ <div className="pb-4 mx-auto max-2-2xl items-center text-center text-xs font-serif text-foreground/75">
22
+ {longBoTitle && <LongTitle title={longBoTitle} language="bo" />}
23
+ {longBoLtnTitle && <LongTitle title={longBoLtnTitle} language="Bo-Ltn" />}
24
+ {longEnTitle && <LongTitle title={longEnTitle} language="en" />}
25
+ {longSaLtnTitle && <LongTitle title={longSaLtnTitle} language="Sa-Ltn" />}
26
+ {toh && <div className="pt-8">{`${DOT} ${toh} ${DOT}`}</div>}
27
+ {imprint.sourceDescription && <div>{imprint.sourceDescription}</div>}
28
+ {translators.length > 0 && (
29
+ <>
30
+ <div className="pb-1 pt-5 text-xs uppercase">
31
+ Translated into Tibetan by
32
+ </div>
33
+
34
+ <div className="mx-auto flex flex-wrap justify-center gap-x-2">
35
+ <span>{DOT}</span>
36
+ {translators.map((translator, idx) => (
37
+ <div key={`translator-wrapper-${idx}`} className="flex gap-x-2">
38
+ <span key={`${translator}-${idx}`}>{translator}</span>
39
+ <span key={`dot-${idx}`}>{DOT}</span>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </>
44
+ )}
45
+ </div>
46
+ );
47
+ };
@@ -0,0 +1,37 @@
1
+ import { LANGUAGES, Title, Titles } from '@eightyfourthousand/data-access';
2
+ import { Input, Label } from '@eightyfourthousand/design-system';
3
+
4
+ export const TitleForm = ({
5
+ titles = [],
6
+ onChange,
7
+ }: {
8
+ titles?: Titles;
9
+ onChange: (title: Title) => void;
10
+ }) => {
11
+ const titlesByLanguage: { [key: string]: Title } = {};
12
+ titles.forEach((title) => {
13
+ titlesByLanguage[title.language] = title;
14
+ });
15
+
16
+ return (
17
+ <div className="flex flex-col gap-4">
18
+ {LANGUAGES.map((language) => (
19
+ <div key={language} className="flex flex-col gap-2">
20
+ <Label className="uppercase">{language}</Label>
21
+ <Input
22
+ type="text"
23
+ placeholder={`Enter ${language} title`}
24
+ value={titlesByLanguage[language]?.title}
25
+ onChange={(e) =>
26
+ onChange({
27
+ ...titlesByLanguage[language],
28
+ title: e.target.value,
29
+ language,
30
+ })
31
+ }
32
+ />
33
+ </div>
34
+ ))}
35
+ </div>
36
+ );
37
+ };