@semiont/react-ui 0.5.25 → 0.5.27

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 (267) hide show
  1. package/README.md +28 -4
  2. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js +677 -0
  3. package/dist/PdfAnnotationCanvas.client-EVUPTUWZ.js.map +1 -0
  4. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css +185 -0
  5. package/dist/PdfAnnotationCanvas.client-TGDPPGXJ.css.map +1 -0
  6. package/dist/{ar-CULRHLNG.js → ar-OU3DQGC6.js} +65 -7
  7. package/dist/ar-OU3DQGC6.js.map +1 -0
  8. package/dist/{bn-HYR7CEL2.js → bn-PHWCR6AZ.js} +65 -7
  9. package/dist/bn-PHWCR6AZ.js.map +1 -0
  10. package/dist/chunk-5VDTWRXJ.js +287 -0
  11. package/dist/chunk-5VDTWRXJ.js.map +1 -0
  12. package/dist/{chunk-TG2XQEC4.js → chunk-BTUG3VJJ.js} +18 -298
  13. package/dist/chunk-BTUG3VJJ.js.map +1 -0
  14. package/dist/{chunk-NH6L2GIL.js → chunk-PCRRBU7H.js} +65 -7
  15. package/dist/chunk-PCRRBU7H.js.map +1 -0
  16. package/dist/{cs-YBRWY7NH.js → cs-NEXYZ62I.js} +65 -7
  17. package/dist/cs-NEXYZ62I.js.map +1 -0
  18. package/dist/{da-PHDEOBV3.js → da-FAT43YCU.js} +65 -7
  19. package/dist/da-FAT43YCU.js.map +1 -0
  20. package/dist/{de-O4UCCVLJ.js → de-KRQ3WBCU.js} +65 -7
  21. package/dist/de-KRQ3WBCU.js.map +1 -0
  22. package/dist/{el-MVGO6WZV.js → el-Q4IU3EKC.js} +65 -7
  23. package/dist/el-Q4IU3EKC.js.map +1 -0
  24. package/dist/{en-2KQILFKQ.js → en-2KADE7N4.js} +6 -2
  25. package/dist/{es-USSLXQY7.js → es-2IO3R2MK.js} +65 -7
  26. package/dist/es-2IO3R2MK.js.map +1 -0
  27. package/dist/{fa-WMAREFWY.js → fa-Q5GOQFXX.js} +65 -7
  28. package/dist/fa-Q5GOQFXX.js.map +1 -0
  29. package/dist/{fi-B3NBNOTS.js → fi-R6NMRWBE.js} +65 -7
  30. package/dist/fi-R6NMRWBE.js.map +1 -0
  31. package/dist/{fr-TXYK5T5W.js → fr-QY4D5E6J.js} +65 -7
  32. package/dist/fr-QY4D5E6J.js.map +1 -0
  33. package/dist/{he-JCJACW5D.js → he-X6Y77XHY.js} +65 -7
  34. package/dist/he-X6Y77XHY.js.map +1 -0
  35. package/dist/{hi-VOQLZNJU.js → hi-XL7IOEQM.js} +65 -7
  36. package/dist/hi-XL7IOEQM.js.map +1 -0
  37. package/dist/{id-BFN5RZNZ.js → id-QZ4ZZS67.js} +65 -7
  38. package/dist/id-QZ4ZZS67.js.map +1 -0
  39. package/dist/index.css +241 -508
  40. package/dist/index.css.map +1 -1
  41. package/dist/index.d.ts +155 -54
  42. package/dist/index.js +1815 -1642
  43. package/dist/index.js.map +1 -1
  44. package/dist/integrations/css-modules-helper.js +1 -3
  45. package/dist/integrations/css-modules-helper.js.map +1 -1
  46. package/dist/{it-TWWDHKKM.js → it-ME2Q7OB6.js} +65 -7
  47. package/dist/it-ME2Q7OB6.js.map +1 -0
  48. package/dist/{ja-YRQLEBFM.js → ja-HX7QASRI.js} +65 -7
  49. package/dist/ja-HX7QASRI.js.map +1 -0
  50. package/dist/{ko-PG6B3CRB.js → ko-EQO5AHAV.js} +65 -7
  51. package/dist/ko-EQO5AHAV.js.map +1 -0
  52. package/dist/{ms-7EZECVKM.js → ms-UILFSF3I.js} +65 -7
  53. package/dist/ms-UILFSF3I.js.map +1 -0
  54. package/dist/{nl-HP753YAP.js → nl-NNI45PMK.js} +65 -7
  55. package/dist/nl-NNI45PMK.js.map +1 -0
  56. package/dist/{no-JUD5WX3N.js → no-MAL5F6P6.js} +65 -7
  57. package/dist/no-MAL5F6P6.js.map +1 -0
  58. package/dist/{pl-RVUL4CBM.js → pl-4RMY6JAJ.js} +65 -7
  59. package/dist/pl-4RMY6JAJ.js.map +1 -0
  60. package/dist/{pt-RPOVD4QM.js → pt-VQH2X3QQ.js} +65 -7
  61. package/dist/pt-VQH2X3QQ.js.map +1 -0
  62. package/dist/{ro-DMYWQTRD.js → ro-XXCQO2OG.js} +65 -7
  63. package/dist/ro-XXCQO2OG.js.map +1 -0
  64. package/dist/{sv-DMZ4FGXD.js → sv-7Z6EJ6U4.js} +65 -7
  65. package/dist/sv-7Z6EJ6U4.js.map +1 -0
  66. package/dist/test-utils.css +1 -1
  67. package/dist/test-utils.css.map +1 -1
  68. package/dist/test-utils.d.ts +1 -1
  69. package/dist/test-utils.js +5 -3
  70. package/dist/test-utils.js.map +1 -1
  71. package/dist/{th-4HVBJBQ2.js → th-L3DXXYVQ.js} +65 -7
  72. package/dist/th-L3DXXYVQ.js.map +1 -0
  73. package/dist/{tr-6BDPU5ML.js → tr-P3DFVLFJ.js} +65 -7
  74. package/dist/tr-P3DFVLFJ.js.map +1 -0
  75. package/dist/{uk-TBZBT2TL.js → uk-7C5D3PXS.js} +65 -7
  76. package/dist/uk-7C5D3PXS.js.map +1 -0
  77. package/dist/{vi-UMKZKZL3.js → vi-ZLTLEO7P.js} +65 -7
  78. package/dist/vi-ZLTLEO7P.js.map +1 -0
  79. package/dist/{zh-YBJWJJYB.js → zh-5FHVWDIN.js} +65 -7
  80. package/dist/zh-5FHVWDIN.js.map +1 -0
  81. package/package.json +7 -7
  82. package/src/components/AssistProgress.tsx +144 -134
  83. package/src/components/{EntityFoundLog.tsx → ItemFoundLog.tsx} +12 -7
  84. package/src/components/ProtectedErrorBoundary.css +3 -1
  85. package/src/components/StatusDisplay.css +35 -35
  86. package/src/components/Toast.css +3 -1
  87. package/src/components/__tests__/AssistProgress.test.tsx +216 -148
  88. package/src/components/annotation/annotation-entries.css +0 -4
  89. package/src/components/annotation/annotations.css +1 -1
  90. package/src/components/annotation/references.css +0 -4
  91. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  92. package/src/components/image-annotation/SvgDrawingCanvas.tsx +1 -1
  93. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +7 -9
  94. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +1 -2
  95. package/src/components/layout/Header.css +0 -4
  96. package/src/components/layout/LeftSidebar.css +0 -4
  97. package/src/components/modals/ConfigureGenerationStep.tsx +109 -48
  98. package/src/components/modals/ConfigureSearchStep.tsx +23 -40
  99. package/src/components/modals/ReferenceWizardModal.tsx +55 -16
  100. package/src/components/modals/ResourceGenerateModal.tsx +24 -8
  101. package/src/components/modals/ResourceSearchModal.tsx +1 -1
  102. package/src/components/modals/SearchModal.css +5 -17
  103. package/src/components/modals/SearchModal.tsx +1 -1
  104. package/src/components/modals/SearchResultsStep.tsx +2 -20
  105. package/src/components/modals/WizardFooter.tsx +63 -0
  106. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +255 -0
  107. package/src/components/modals/__tests__/ConfigureSearchStep.test.tsx +72 -0
  108. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +256 -0
  109. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +1 -0
  110. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +6 -2
  111. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +6 -2
  112. package/src/components/modals/__tests__/WizardFooter.test.tsx +235 -0
  113. package/src/components/modals/modals.css +16 -42
  114. package/src/components/navigation/NavigationTabs.css +0 -20
  115. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +162 -6
  116. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +623 -232
  117. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +764 -7
  118. package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
  119. package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
  120. package/src/components/resource/AnnotateView.tsx +16 -5
  121. package/src/components/resource/AnnotationHistory.tsx +2 -0
  122. package/src/components/resource/BrowseView.tsx +15 -4
  123. package/src/components/resource/ResourceViewer.tsx +23 -24
  124. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +50 -0
  125. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +2 -0
  126. package/src/components/resource/__tests__/BrowseView.test.tsx +29 -3
  127. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +47 -3
  128. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
  129. package/src/components/resource/annotate-renderers.tsx +10 -1
  130. package/src/components/resource/browse-renderers.tsx +10 -1
  131. package/src/components/resource/event-formatting.ts +0 -3
  132. package/src/components/resource/panels/AssessmentEntry.tsx +1 -1
  133. package/src/components/resource/panels/AssessmentPanel.css +5 -6
  134. package/src/components/resource/panels/AssistSection.css +68 -271
  135. package/src/components/resource/panels/AssistSection.tsx +4 -4
  136. package/src/components/resource/panels/AssistShell.tsx +12 -4
  137. package/src/components/resource/panels/CollaborationPanel.css +47 -22
  138. package/src/components/resource/panels/CollaborationPanel.tsx +93 -2
  139. package/src/components/resource/panels/CommentEntry.tsx +1 -1
  140. package/src/components/resource/panels/CommentsPanel.css +0 -16
  141. package/src/components/resource/panels/HighlightEntry.tsx +1 -1
  142. package/src/components/resource/panels/HighlightPanel.css +0 -7
  143. package/src/components/resource/panels/JsonLdPanel.css +0 -4
  144. package/src/components/resource/panels/ReferenceEntry.tsx +1 -1
  145. package/src/components/resource/panels/ReferencesPanel.css +0 -12
  146. package/src/components/resource/panels/ReferencesPanel.tsx +12 -20
  147. package/src/components/resource/panels/ResourceInfoPanel.css +0 -32
  148. package/src/components/resource/panels/ResourceInfoPanel.tsx +1 -1
  149. package/src/components/resource/panels/StatisticsPanel.css +0 -20
  150. package/src/components/resource/panels/TagEntry.tsx +1 -1
  151. package/src/components/resource/panels/TaggingPanel.css +0 -4
  152. package/src/components/resource/panels/TaggingPanel.tsx +2 -10
  153. package/src/components/resource/panels/UnifiedAnnotationsPanel.css +0 -4
  154. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +0 -1
  155. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +48 -50
  156. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +11 -6
  157. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +89 -0
  158. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +1 -2
  159. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +0 -1
  160. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +44 -36
  161. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +0 -1
  162. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +1 -1
  163. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +34 -53
  164. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +37 -1
  165. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +0 -1
  166. package/src/components/settings/SettingsPanel.css +0 -4
  167. package/src/features/auth/auth.css +9 -33
  168. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +25 -1
  169. package/src/features/resource-compose/state/compose-page-state-unit.ts +11 -2
  170. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +55 -0
  171. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +29 -0
  172. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +53 -3
  173. package/src/features/resource-discovery/state/discover-state-unit.ts +29 -8
  174. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +26 -0
  175. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +42 -21
  176. package/src/features/resource-viewer/hooks/__tests__/useResourceViewedReport.test.tsx +63 -0
  177. package/src/features/resource-viewer/hooks/useResourceViewedReport.ts +33 -0
  178. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +2 -2
  179. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +1 -1
  180. package/src/styles/core/buttons.css +3 -1
  181. package/src/styles/core/forms.css +0 -21
  182. package/src/styles/core/indicators.css +0 -4
  183. package/src/styles/core/inputs.css +1 -5
  184. package/src/styles/core/progress.css +2 -34
  185. package/src/styles/core/sliders.css +0 -4
  186. package/src/styles/core/toggles.css +0 -4
  187. package/src/styles/features/compose.css +1 -1
  188. package/src/styles/features/resource-discovery.css +13 -0
  189. package/src/styles/features/resource-viewer.css +1 -21
  190. package/src/styles/features/static-pages.css +11 -11
  191. package/src/styles/motivations/motivation-assessment.css +2 -2
  192. package/src/styles/motivations/motivation-comment.css +2 -2
  193. package/src/styles/motivations/motivation-reference.css +2 -2
  194. package/src/styles/panels/history-panel.css +0 -40
  195. package/src/styles/panels/user-panel.css +0 -3
  196. package/src/styles/patterns/panel-helpers.css +0 -27
  197. package/src/styles/patterns/panel-sections.css +0 -59
  198. package/src/styles/patterns/panels-base.css +4 -36
  199. package/src/styles/utilities/motion.css +15 -15
  200. package/src/styles/variables.css +29 -1
  201. package/translations/ar.json +59 -5
  202. package/translations/bn.json +59 -5
  203. package/translations/cs.json +59 -5
  204. package/translations/da.json +59 -5
  205. package/translations/de.json +59 -5
  206. package/translations/el.json +59 -5
  207. package/translations/en.json +59 -5
  208. package/translations/es.json +59 -5
  209. package/translations/fa.json +59 -5
  210. package/translations/fi.json +59 -5
  211. package/translations/fr.json +59 -5
  212. package/translations/he.json +59 -5
  213. package/translations/hi.json +59 -5
  214. package/translations/id.json +59 -5
  215. package/translations/it.json +59 -5
  216. package/translations/ja.json +59 -5
  217. package/translations/ko.json +59 -5
  218. package/translations/ms.json +59 -5
  219. package/translations/nl.json +59 -5
  220. package/translations/no.json +59 -5
  221. package/translations/pl.json +59 -5
  222. package/translations/pt.json +59 -5
  223. package/translations/ro.json +59 -5
  224. package/translations/sv.json +59 -5
  225. package/translations/th.json +59 -5
  226. package/translations/tr.json +59 -5
  227. package/translations/uk.json +59 -5
  228. package/translations/vi.json +59 -5
  229. package/translations/zh.json +59 -5
  230. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js +0 -462
  231. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +0 -1
  232. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
  233. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
  234. package/dist/ar-CULRHLNG.js.map +0 -1
  235. package/dist/bn-HYR7CEL2.js.map +0 -1
  236. package/dist/chunk-NH6L2GIL.js.map +0 -1
  237. package/dist/chunk-TG2XQEC4.js.map +0 -1
  238. package/dist/chunk-XUDKYAVC.js +0 -21
  239. package/dist/chunk-XUDKYAVC.js.map +0 -1
  240. package/dist/cs-YBRWY7NH.js.map +0 -1
  241. package/dist/da-PHDEOBV3.js.map +0 -1
  242. package/dist/de-O4UCCVLJ.js.map +0 -1
  243. package/dist/el-MVGO6WZV.js.map +0 -1
  244. package/dist/es-USSLXQY7.js.map +0 -1
  245. package/dist/fa-WMAREFWY.js.map +0 -1
  246. package/dist/fi-B3NBNOTS.js.map +0 -1
  247. package/dist/fr-TXYK5T5W.js.map +0 -1
  248. package/dist/he-JCJACW5D.js.map +0 -1
  249. package/dist/hi-VOQLZNJU.js.map +0 -1
  250. package/dist/id-BFN5RZNZ.js.map +0 -1
  251. package/dist/it-TWWDHKKM.js.map +0 -1
  252. package/dist/ja-YRQLEBFM.js.map +0 -1
  253. package/dist/ko-PG6B3CRB.js.map +0 -1
  254. package/dist/ms-7EZECVKM.js.map +0 -1
  255. package/dist/nl-HP753YAP.js.map +0 -1
  256. package/dist/no-JUD5WX3N.js.map +0 -1
  257. package/dist/pl-RVUL4CBM.js.map +0 -1
  258. package/dist/pt-RPOVD4QM.js.map +0 -1
  259. package/dist/ro-DMYWQTRD.js.map +0 -1
  260. package/dist/sv-DMZ4FGXD.js.map +0 -1
  261. package/dist/th-4HVBJBQ2.js.map +0 -1
  262. package/dist/tr-6BDPU5ML.js.map +0 -1
  263. package/dist/uk-TBZBT2TL.js.map +0 -1
  264. package/dist/vi-UMKZKZL3.js.map +0 -1
  265. package/dist/zh-YBJWJJYB.js.map +0 -1
  266. package/src/integrations/tailwind-plugin.cjs +0 -224
  267. /package/dist/{en-2KQILFKQ.js.map → en-2KADE7N4.js.map} +0 -0
@@ -6,6 +6,8 @@ import { resourceId as toResourceId } from '@semiont/core';
6
6
  import { toViewportAnchorRect } from '../../lib/anchor-rect';
7
7
  import { createFragmentSelector, anchorRuns, isTextRun, textUnder } from '@semiont/core';
8
8
  import { rectsForPage } from './rects-for-page';
9
+ import { estimateSlotHeight } from './estimate-slot-height';
10
+ import { useTranslations } from '../../contexts/TranslationContext';
9
11
  import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
10
12
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
11
13
  import {
@@ -22,6 +24,55 @@ import './PdfAnnotationCanvas.css';
22
24
 
23
25
  export type DrawingMode = 'rectangle' | 'circle' | 'polygon' | null;
24
26
 
27
+ /**
28
+ * How the document's pages are laid out.
29
+ *
30
+ * `paged` is one page with Previous/Next. `scroll` is a virtualized column:
31
+ * every page gets a slot so the scrollbar tells the truth about the
32
+ * document's length, but only the pages near the viewport are mounted.
33
+ *
34
+ * Declared explicitly rather than inferred from `drawingMode`, which does NOT
35
+ * distinguish the modes — AnnotateView passes `drawingMode={null}` whenever no
36
+ * motivation is selected, so keying layout on it would flip a reader between
37
+ * scrolling and paged views as they picked up and put down a tool.
38
+ * See .plans/PDF-CONTINUOUS-SCROLL.md D3.
39
+ */
40
+ export type PageLayout = 'paged' | 'scroll';
41
+
42
+ /** How far outside the viewport a page starts loading, and stays loaded. */
43
+ const PRELOAD_MARGIN = '100% 0px';
44
+
45
+ /** Width of one page rectangle in the strip, in CSS px. Height follows the
46
+ * document's own aspect ratio, so the strip looks like the document — wide
47
+ * enough to carry a three-digit page number, which is what makes the strip
48
+ * useful rather than merely positional on a long document. */
49
+ const STRIP_PAGE_WIDTH = 30;
50
+
51
+ /**
52
+ * The axis pages advance along.
53
+ *
54
+ * ONE source for two consumers: the column scrolls along it, and the page
55
+ * strip runs parallel to it. A strip that ran across the scroll direction
56
+ * would read as a control for a movement the document does not make.
57
+ *
58
+ * Constant today because scrolling is vertical. The horizontal-scrolling
59
+ * setting turns this into a value read from preferences — both consumers
60
+ * follow automatically, which is the point of routing them through one name.
61
+ */
62
+ const SCROLL_AXIS: 'vertical' | 'horizontal' = 'vertical';
63
+
64
+ /**
65
+ * Scroll an element into view where the environment supports it.
66
+ *
67
+ * Same posture as this file's guarded observers: jsdom implements no layout
68
+ * and therefore no `scrollIntoView`, and a viewer that throws in a test
69
+ * harness (or an exotic embedding host) is worse than one that simply does
70
+ * not scroll.
71
+ */
72
+ function scrollElementIntoView(el: Element | null | undefined, options: ScrollIntoViewOptions): void {
73
+ if (typeof el?.scrollIntoView === 'function') el.scrollIntoView(options);
74
+ }
75
+
25
76
  /**
26
77
  * Get color for annotation based on motivation
27
78
  */
@@ -44,134 +95,70 @@ function getMotivationColor(motivation: SelectionMotivation | null): { stroke: s
44
95
  }
45
96
  }
46
97
 
47
- interface PdfAnnotationCanvasProps {
48
- pdfUrl: string;
49
- /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
98
+ interface PdfPageViewProps {
99
+ doc: PDFDocumentProxy;
100
+ pageNumber: number;
101
+ /** Raster scale. Display-only: the overlay's geometry never reads it. */
102
+ scale: number;
50
103
  resourceUri: string;
51
- existingAnnotations?: Annotation[];
104
+ existingAnnotations: Annotation[];
52
105
  drawingMode: DrawingMode;
53
106
  selectedMotivation?: SelectionMotivation | null;
54
107
  session?: SemiontSession | null | undefined;
55
108
  hoveredAnnotationId?: string | null;
56
109
  selectedAnnotationId?: string | null;
57
- hoverDelayMs?: number;
110
+ hoverDelayMs: number;
111
+ /** The document-wide server map, fetched once per resource by the parent. */
112
+ fetchResourceAnchored: () => Promise<AnchoredText | null>;
58
113
  }
59
114
 
60
115
  /**
61
- * PDF annotation canvas with page navigation and rectangle drawing
116
+ * One rendered page: its raster, its text map, its annotation overlay, and
117
+ * the drag that draws on it.
62
118
  *
63
- * @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
64
- * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
65
- * @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
119
+ * Everything a page needs lives HERE rather than in the parent, which is what
120
+ * makes a scrolling column possible: mounting a page loads it, and unmounting
121
+ * releases it. The raster is a data-URL string, so the last reference going
122
+ * away IS the memory release — no eviction bookkeeping, no object-URL revoke.
123
+ * The drag lives here too because it needs this page's display dimensions and
124
+ * this page's text; a drag can no more span pages than a rectangle can.
66
125
  */
67
- export function PdfAnnotationCanvas({
68
- pdfUrl,
126
+ function PdfPageView({
127
+ doc,
128
+ pageNumber,
129
+ scale,
69
130
  resourceUri,
70
- existingAnnotations = [],
131
+ existingAnnotations,
71
132
  drawingMode,
72
133
  selectedMotivation,
73
134
  session,
74
135
  hoveredAnnotationId,
75
136
  selectedAnnotationId,
76
- hoverDelayMs = 150
77
- }: PdfAnnotationCanvasProps) {
78
- // PDF state
79
- const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
80
- const [numPages, setNumPages] = useState<number>(0);
81
- const [pageNumber, setPageNumber] = useState(1);
137
+ hoverDelayMs,
138
+ fetchResourceAnchored,
139
+ }: PdfPageViewProps) {
82
140
  const [pageImageUrl, setPageImageUrl] = useState<string | null>(null);
83
- const [isLoading, setIsLoading] = useState(true);
84
- const [error, setError] = useState<string | null>(null);
85
141
  const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);
86
142
  /**
87
- * The current page's text and per-run geometry, read once when the page
88
- * loads so a drag can be quoted without a round trip. Null while the page
89
- * is loading; `items` is empty on a scanned page, which has no text layer
90
- * for the browser to read.
143
+ * This page's text and per-run geometry, read once when the page loads so a
144
+ * drag can be quoted without a round trip. Null while loading; `items` is
145
+ * empty on a scanned page, which has no text layer for the browser to read.
91
146
  */
92
147
  const [pageAnchored, setPageAnchored] = useState<AnchoredText | null>(null);
93
148
  const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
94
- const [scale] = useState(1.5); // Fixed scale for better quality
149
+ /** Translation KEY, not text: `t` is a new closure each render, so keeping
150
+ * it out of the load effect's deps is what stops a reload loop. */
151
+ const [errorKey, setErrorKey] = useState<string | null>(null);
152
+ const t = useTranslations('PdfViewer');
95
153
 
96
- // Drawing state
97
154
  const [isDrawing, setIsDrawing] = useState(false);
98
155
  const [selection, setSelection] = useState<CanvasRectangle | null>(null);
99
156
 
100
- const containerRef = useRef<HTMLDivElement>(null);
101
157
  const imageRef = useRef<HTMLImageElement>(null);
102
158
 
103
- // Load PDF document on mount
104
159
  useEffect(() => {
105
160
  let cancelled = false;
106
161
 
107
- async function loadPdf() {
108
- try {
109
- setIsLoading(true);
110
- setError(null);
111
-
112
- const doc = await loadPdfDocument(pdfUrl);
113
-
114
- if (cancelled) return;
115
-
116
- setPdfDoc(doc);
117
- setNumPages(doc.numPages);
118
- setIsLoading(false);
119
- } catch (err) {
120
- if (cancelled) return;
121
-
122
- console.error('Error loading PDF:', err);
123
- setError('Failed to load PDF');
124
- setIsLoading(false);
125
- }
126
- }
127
-
128
- loadPdf();
129
-
130
- return () => {
131
- cancelled = true;
132
- };
133
- }, [pdfUrl]);
134
-
135
- /**
136
- * The server-derived map, fetched once per document rather than once per
137
- * page-load effect (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one
138
- * artifact covering every page — so re-reading it from the per-page effect
139
- * meant a full refetch and re-decode on every page turn; on a 400-page scan
140
- * that is one decode per interaction instead of one per document.
141
- *
142
- * The cache holds the in-flight promise so concurrent page loads share one
143
- * fetch. Answers cache — including "no map" and a stored decline, which are
144
- * definitive — but a transport failure clears the entry, so the next page
145
- * load retries instead of pinning the whole document to geometry-only.
146
- */
147
- const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
148
- const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
149
- if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
150
- const cached = resourceAnchoredRef.current;
151
- if (cached && cached.uri === resourceUri) return cached.outcome;
152
-
153
- const uri = resourceUri;
154
- const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
155
- (served) => (served && !('declined' in served) ? served : null),
156
- () => {
157
- if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
158
- return null;
159
- },
160
- );
161
- resourceAnchoredRef.current = { uri, outcome };
162
- return outcome;
163
- }, [session, resourceUri]);
164
-
165
- // Load current page when page number changes
166
- useEffect(() => {
167
- if (!pdfDoc) return;
168
-
169
- let cancelled = false;
170
- const doc = pdfDoc;
171
-
172
- // Never quote the previous page's text under this page's rectangles.
173
- setPageAnchored(null);
174
-
175
162
  /**
176
163
  * The map a rectangle on this page quotes from, or null when there is
177
164
  * none to be had.
@@ -194,7 +181,7 @@ export function PdfAnnotationCanvas({
194
181
  // No runs means a scanned page: the characters exist only as pixels
195
182
  // and pdf.js has nothing to give. The server derived a map at ingest,
196
183
  // so ask for it rather than leaving the annotation anonymous.
197
- // Whole-resource — served once per document via the cache above
184
+ // Whole-resource — served once per document by the parent's cache —
198
185
  // and `textUnder` filters by page: the same shape the native branch
199
186
  // produces, so nothing downstream branches.
200
187
  //
@@ -204,8 +191,7 @@ export function PdfAnnotationCanvas({
204
191
  // this existed. The served record is the full extraction outcome
205
192
  // (PERSIST-ANCHORS D1); a stored decline means extraction ran and
206
193
  // found nothing to anchor — for this canvas the same degradation as
207
- // no map at all. A success outcome IS the anchoring shape, plus
208
- // provenance this canvas does not read.
194
+ // no map at all.
209
195
  return await fetchResourceAnchored();
210
196
  } catch {
211
197
  return null;
@@ -215,15 +201,11 @@ export function PdfAnnotationCanvas({
215
201
  async function loadPage() {
216
202
  try {
217
203
  const page = await doc.getPage(pageNumber);
218
-
219
204
  if (cancelled) return;
220
205
 
221
206
  // Get page dimensions (at scale 1.0)
222
207
  const viewport = page.getViewport({ scale: 1.0 });
223
- setPageDimensions({
224
- width: viewport.width,
225
- height: viewport.height
226
- });
208
+ setPageDimensions({ width: viewport.width, height: viewport.height });
227
209
 
228
210
  // Anchoring and rendering are independent, and only one of them the
229
211
  // reader is waiting on. Sequencing them put a network round-trip in
@@ -245,7 +227,7 @@ export function PdfAnnotationCanvas({
245
227
  if (cancelled) return;
246
228
 
247
229
  console.error('Error loading page:', err);
248
- setError('Failed to load page');
230
+ setErrorKey('pageLoadFailed');
249
231
  }
250
232
  }
251
233
 
@@ -254,7 +236,7 @@ export function PdfAnnotationCanvas({
254
236
  return () => {
255
237
  cancelled = true;
256
238
  };
257
- }, [pdfDoc, pageNumber, scale, fetchResourceAnchored]);
239
+ }, [doc, pageNumber, scale, fetchResourceAnchored]);
258
240
 
259
241
  // Update display dimensions on resize
260
242
  useEffect(() => {
@@ -371,7 +353,7 @@ export function PdfAnnotationCanvas({
371
353
  });
372
354
 
373
355
  if (hit) {
374
- session?.client.browse.click(hit.annId, hit.annotation.motivation, hitRect);
356
+ session?.client.browse.click(hit.annId, hitRect);
375
357
  setIsDrawing(false);
376
358
  setSelection(null);
377
359
  return;
@@ -438,8 +420,8 @@ export function PdfAnnotationCanvas({
438
420
  // It will be cleared when drawingMode changes or user starts new selection
439
421
  }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri, pageAnchored]);
440
422
 
441
- // Every FragmentSelector rect on the current page — one per line for a
442
- // multi-line (multi-selector) annotation, exactly one for a manual annotation.
423
+ // Every FragmentSelector rect on this page — one per line for a multi-line
424
+ // (multi-selector) annotation, exactly one for a manual annotation.
443
425
  const pageRects = rectsForPage(existingAnnotations, pageNumber);
444
426
 
445
427
  // Hover handlers with currentHover guard and dwell delay
@@ -451,150 +433,559 @@ export function PdfAnnotationCanvas({
451
433
  // Calculate motivation color
452
434
  const { stroke, fill } = getMotivationColor(selectedMotivation ?? null);
453
435
 
454
- if (error) {
455
- return <div className="semiont-pdf-annotation-canvas__error">{error}</div>;
436
+ if (errorKey) {
437
+ return <div className="semiont-pdf-annotation-canvas__error">{t(errorKey)}</div>;
456
438
  }
457
439
 
458
440
  return (
459
- <div className="semiont-pdf-annotation-canvas">
460
- {isLoading && <div className="semiont-pdf-annotation-canvas__loading">Loading PDF...</div>}
461
-
462
- <div
463
- ref={containerRef}
464
- className="semiont-pdf-annotation-canvas__container"
465
- style={{ display: isLoading ? 'none' : undefined }}
466
- onMouseDown={handleMouseDown}
467
- onMouseMove={handleMouseMove}
468
- onMouseUp={handleMouseUp}
469
- onMouseLeave={() => {
470
- if (isDrawing) {
471
- setIsDrawing(false);
472
- setSelection(null);
473
- }
474
- }}
475
- data-drawing-mode={drawingMode || 'none'}
476
- >
477
- {/* PDF page rendered as image */}
478
- {pageImageUrl && (
479
- <img
480
- ref={imageRef}
481
- src={pageImageUrl}
482
- alt={`PDF page ${pageNumber}`}
483
- className="semiont-pdf-annotation-canvas__image"
484
- draggable={false}
485
- style={{ pointerEvents: 'none' }}
486
- onLoad={() => {
487
- // Use double RAF to ensure layout is complete even in onLoad
441
+ <div
442
+ className="semiont-pdf-annotation-canvas__container"
443
+ onMouseDown={handleMouseDown}
444
+ onMouseMove={handleMouseMove}
445
+ onMouseUp={handleMouseUp}
446
+ onMouseLeave={() => {
447
+ if (isDrawing) {
448
+ setIsDrawing(false);
449
+ setSelection(null);
450
+ }
451
+ }}
452
+ data-drawing-mode={drawingMode || 'none'}
453
+ >
454
+ {/* PDF page rendered as image */}
455
+ {pageImageUrl && (
456
+ <img
457
+ ref={imageRef}
458
+ src={pageImageUrl}
459
+ alt={`PDF page ${pageNumber}`}
460
+ className="semiont-pdf-annotation-canvas__image"
461
+ draggable={false}
462
+ style={{ pointerEvents: 'none' }}
463
+ onLoad={() => {
464
+ // Use double RAF to ensure layout is complete even in onLoad
465
+ requestAnimationFrame(() => {
488
466
  requestAnimationFrame(() => {
489
- requestAnimationFrame(() => {
490
- if (imageRef.current) {
491
- setDisplayDimensions({
492
- width: imageRef.current.clientWidth,
493
- height: imageRef.current.clientHeight
494
- });
495
- }
496
- });
467
+ if (imageRef.current) {
468
+ setDisplayDimensions({
469
+ width: imageRef.current.clientWidth,
470
+ height: imageRef.current.clientHeight
471
+ });
472
+ }
497
473
  });
498
- }}
499
- />
500
- )}
474
+ });
475
+ }}
476
+ />
477
+ )}
478
+
479
+ {/* SVG overlay for annotations */}
480
+ {displayDimensions && pageDimensions && (
481
+ <div className="semiont-pdf-annotation-canvas__overlay-container">
482
+ <div className="semiont-pdf-annotation-canvas__overlay">
483
+ <svg
484
+ className="semiont-pdf-annotation-canvas__svg"
485
+ width={displayDimensions.width}
486
+ height={displayDimensions.height}
487
+ >
488
+ {/* Render existing annotations for this page */}
489
+ {pageRects.map(r => {
490
+ const rect = pdfToCanvasCoordinates(r.coord, pageDimensions.height, 1.0);
491
+
492
+ // Scale to display coordinates
493
+ const scaleX = displayDimensions.width / pageDimensions.width;
494
+ const scaleY = displayDimensions.height / pageDimensions.height;
495
+
496
+ const isHovered = r.annId === hoveredAnnotationId;
497
+ const isSelected = r.annId === selectedAnnotationId;
498
+
499
+ // Colour by the annotation's own motivation (not the toolbar's).
500
+ const annMotivation = r.annotation.motivation as SelectionMotivation | null;
501
+ const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);
502
+
503
+ return (
504
+ <rect
505
+ key={`${r.annId}:${r.selectorIndex}`}
506
+ x={rect.x * scaleX}
507
+ y={rect.y * scaleY}
508
+ width={rect.width * scaleX}
509
+ height={rect.height * scaleY}
510
+ stroke={annStroke}
511
+ strokeWidth={isSelected ? 4 : isHovered ? 3 : 2}
512
+ fill={annFill}
513
+ style={{
514
+ pointerEvents: 'auto',
515
+ cursor: 'pointer',
516
+ opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
517
+ }}
518
+ onClick={(e) => session?.client.browse.click(r.annId, e.currentTarget.getBoundingClientRect())}
519
+ onMouseEnter={() => handleMouseEnter(r.annId)}
520
+ onMouseLeave={handleMouseLeave}
521
+ />
522
+ );
523
+ })}
524
+
525
+ {/* Render current selection while drawing or awaiting save */}
526
+ {selection && (() => {
527
+ const rectX = Math.min(selection.startX, selection.endX);
528
+ const rectY = Math.min(selection.startY, selection.endY);
529
+ const rectWidth = Math.abs(selection.endX - selection.startX);
530
+ const rectHeight = Math.abs(selection.endY - selection.startY);
531
+
532
+ // PDF only supports rectangle shapes (FragmentSelector with viewrect)
533
+ // Circle/polygon are disabled in the UI for PDF media types
534
+ return (
535
+ <rect
536
+ x={rectX}
537
+ y={rectY}
538
+ width={rectWidth}
539
+ height={rectHeight}
540
+ stroke={stroke}
541
+ strokeWidth={2}
542
+ strokeDasharray="5,5"
543
+ fill={fill}
544
+ pointerEvents="none"
545
+ />
546
+ );
547
+ })()}
548
+ </svg>
549
+ </div>
550
+ </div>
551
+ )}
552
+ </div>
553
+ );
554
+ }
555
+
556
+ interface PdfAnnotationCanvasProps {
557
+ pdfUrl: string;
558
+ /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
559
+ resourceUri: string;
560
+ existingAnnotations?: Annotation[];
561
+ drawingMode: DrawingMode;
562
+ selectedMotivation?: SelectionMotivation | null;
563
+ session?: SemiontSession | null | undefined;
564
+ hoveredAnnotationId?: string | null;
565
+ selectedAnnotationId?: string | null;
566
+ hoverDelayMs?: number;
567
+ /** `paged` (default) or the virtualized `scroll` column. See `PageLayout`. */
568
+ pageLayout?: PageLayout;
569
+ }
570
+
571
+ /**
572
+ * PDF annotation canvas with page navigation and rectangle drawing
573
+ *
574
+ * @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
575
+ * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
576
+ * @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
577
+ */
578
+ export function PdfAnnotationCanvas({
579
+ pdfUrl,
580
+ resourceUri,
581
+ existingAnnotations = [],
582
+ drawingMode,
583
+ selectedMotivation,
584
+ session,
585
+ hoveredAnnotationId,
586
+ selectedAnnotationId,
587
+ hoverDelayMs = 150,
588
+ pageLayout = 'paged'
589
+ }: PdfAnnotationCanvasProps) {
590
+ // PDF state
591
+ const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
592
+ const [numPages, setNumPages] = useState<number>(0);
593
+ const [pageNumber, setPageNumber] = useState(1);
594
+ const [isLoading, setIsLoading] = useState(true);
595
+ const [errorKey, setErrorKey] = useState<string | null>(null);
596
+ const t = useTranslations('PdfViewer');
597
+ /**
598
+ * Page 1's shape, for reserving space in slots that have not mounted yet:
599
+ * its aspect ratio and its raster width. NOT its raster height — the image
600
+ * renders under `max-width: 100%; height: auto`, so its displayed height
601
+ * depends on the column's width, and reserving raster pixels made the
602
+ * column's height lurch on every mount (S1b).
603
+ * See .plans/PDF-CONTINUOUS-SCROLL.md D4 + S1b.
604
+ */
605
+ const [pageShape, setPageShape] = useState<{ aspect: number; rasterWidth: number } | null>(null);
606
+ /** Measured inner width of the column — the other half of the reservation. */
607
+ const [columnWidth, setColumnWidth] = useState<number | null>(null);
608
+ const columnRef = useRef<HTMLDivElement>(null);
609
+ const [scale] = useState(1.5); // Fixed scale for better quality
610
+
611
+ /** Pages currently intersecting the viewport (plus the preload margin). */
612
+ const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set());
613
+ /** Pages genuinely in view — observed with NO preload margin, so it answers
614
+ * "what is the reader looking at" rather than "what should be loaded".
615
+ * Drives the indicator and the rail; never the mount window. */
616
+ const [onscreenPages, setOnscreenPages] = useState<Set<number>>(new Set());
617
+ const slotRefs = useRef(new Map<number, HTMLElement>());
618
+ const observerRef = useRef<IntersectionObserver | null>(null);
619
+ const onscreenRef = useRef<IntersectionObserver | null>(null);
620
+
621
+ // Load PDF document on mount
622
+ useEffect(() => {
623
+ let cancelled = false;
624
+
625
+ async function loadPdf() {
626
+ try {
627
+ setIsLoading(true);
628
+ setErrorKey(null);
629
+
630
+ const doc = await loadPdfDocument(pdfUrl);
501
631
 
502
- {/* SVG overlay for annotations */}
503
- {displayDimensions && pageDimensions && (
504
- <div className="semiont-pdf-annotation-canvas__overlay-container">
505
- <div className="semiont-pdf-annotation-canvas__overlay">
506
- <svg
507
- className="semiont-pdf-annotation-canvas__svg"
508
- width={displayDimensions.width}
509
- height={displayDimensions.height}
632
+ if (cancelled) return;
633
+
634
+ setPdfDoc(doc);
635
+ setNumPages(doc.numPages);
636
+ setIsLoading(false);
637
+
638
+ // One extra getPage, for slot sizing (D4). Failure is not fatal:
639
+ // unsized slots still scroll, just less faithfully.
640
+ try {
641
+ const first = await doc.getPage(1);
642
+ if (cancelled) return;
643
+ const natural = first.getViewport({ scale: 1.0 });
644
+ setPageShape({
645
+ aspect: natural.height / natural.width,
646
+ rasterWidth: first.getViewport({ scale }).width,
647
+ });
648
+ } catch {
649
+ /* leave unsized — an unsized slot is stable; a mis-sized one moves */
650
+ }
651
+ } catch (err) {
652
+ if (cancelled) return;
653
+
654
+ console.error('Error loading PDF:', err);
655
+ setErrorKey('loadFailed');
656
+ setIsLoading(false);
657
+ }
658
+ }
659
+
660
+ loadPdf();
661
+
662
+ return () => {
663
+ cancelled = true;
664
+ };
665
+ }, [pdfUrl, scale]);
666
+
667
+ /**
668
+ * The server-derived map, fetched once per document rather than once per
669
+ * page (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one artifact
670
+ * covering every page — so re-reading it per page meant a full refetch and
671
+ * re-decode on every page turn; on a 400-page scan that is one decode per
672
+ * interaction instead of one per document. Living on the parent is also
673
+ * what lets a scrolling column mount many pages against a single fetch.
674
+ *
675
+ * The cache holds the in-flight promise so concurrent page loads share one
676
+ * fetch. Answers cache — including "no map" and a stored decline, which are
677
+ * definitive — but a transport failure clears the entry, so the next page
678
+ * load retries instead of pinning the whole document to geometry-only.
679
+ */
680
+ const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
681
+ const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
682
+ if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
683
+ const cached = resourceAnchoredRef.current;
684
+ if (cached && cached.uri === resourceUri) return cached.outcome;
685
+
686
+ const uri = resourceUri;
687
+ const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
688
+ (served) => (served && !('declined' in served) ? served : null),
689
+ () => {
690
+ if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
691
+ return null;
692
+ },
693
+ );
694
+ resourceAnchoredRef.current = { uri, outcome };
695
+ return outcome;
696
+ }, [session, resourceUri]);
697
+
698
+ // The column's width decides every page's displayed height, so the
699
+ // reservation cannot be computed without it.
700
+ useEffect(() => {
701
+ if (pageLayout !== 'scroll') return;
702
+ const measure = () => {
703
+ if (columnRef.current) setColumnWidth(columnRef.current.clientWidth);
704
+ };
705
+ measure();
706
+ let observer: ResizeObserver | null = null;
707
+ try {
708
+ observer = new ResizeObserver(measure);
709
+ if (columnRef.current) observer.observe(columnRef.current);
710
+ } catch {
711
+ window.addEventListener('resize', measure);
712
+ }
713
+ return () => {
714
+ if (observer) observer.disconnect();
715
+ else window.removeEventListener('resize', measure);
716
+ };
717
+ }, [pageLayout]);
718
+
719
+ const slotHeight = estimateSlotHeight(columnWidth, pageShape?.rasterWidth ?? null, pageShape?.aspect ?? null)
720
+ // Before the column has been measured there is still a sane reservation:
721
+ // the raster's own height. Wrong in a narrow column, but every slot is
722
+ // wrong by the SAME amount, so the column is at least internally
723
+ // consistent until the first measurement lands.
724
+ ?? (pageShape ? Math.round(pageShape.rasterWidth * pageShape.aspect) : null);
725
+
726
+ // The mount window. Slots report their own visibility; a page is mounted
727
+ // while its slot intersects (widened by PRELOAD_MARGIN so the next page is
728
+ // ready before it is reached), and unmounting is what frees its raster.
729
+ useEffect(() => {
730
+ if (pageLayout !== 'scroll' || numPages === 0) return;
731
+
732
+ let observer: IntersectionObserver | null = null;
733
+ try {
734
+ observer = new IntersectionObserver(
735
+ (entries) => {
736
+ setVisiblePages((prev) => {
737
+ const next = new Set(prev);
738
+ for (const entry of entries) {
739
+ const page = Number((entry.target as HTMLElement).dataset.page);
740
+ if (!page) continue;
741
+ if (entry.isIntersecting) next.add(page);
742
+ else next.delete(page);
743
+ }
744
+ return next;
745
+ });
746
+ },
747
+ { rootMargin: PRELOAD_MARGIN },
748
+ );
749
+ } catch {
750
+ // No IntersectionObserver: mount every page rather than none. Correct,
751
+ // simply not virtualized — the same posture as the ResizeObserver
752
+ // fallback in PdfPageView.
753
+ setVisiblePages(new Set(Array.from({ length: numPages }, (_, i) => i + 1)));
754
+ return;
755
+ }
756
+
757
+ observerRef.current = observer;
758
+ for (const el of slotRefs.current.values()) observer.observe(el);
759
+
760
+ // A SECOND observer, with no preload margin, answers a different question:
761
+ // which pages the reader can actually see. Two observers rather than one
762
+ // set doing both jobs, because widening the mount window must never move
763
+ // the page indicator.
764
+ let onscreen: IntersectionObserver | null = null;
765
+ try {
766
+ onscreen = new IntersectionObserver((entries) => {
767
+ setOnscreenPages((prev) => {
768
+ const next = new Set(prev);
769
+ for (const entry of entries) {
770
+ const page = Number((entry.target as HTMLElement).dataset.page);
771
+ if (!page) continue;
772
+ if (entry.isIntersecting) next.add(page);
773
+ else next.delete(page);
774
+ }
775
+ return next;
776
+ });
777
+ });
778
+ onscreenRef.current = onscreen;
779
+ for (const el of slotRefs.current.values()) onscreen.observe(el);
780
+ } catch {
781
+ // Without an observer there is no notion of "on screen"; fall back to
782
+ // page 1 so the indicator names something rather than nothing.
783
+ setOnscreenPages(new Set([1]));
784
+ }
785
+
786
+ return () => {
787
+ observer.disconnect();
788
+ observerRef.current = null;
789
+ onscreen?.disconnect();
790
+ onscreenRef.current = null;
791
+ };
792
+ }, [pageLayout, numPages]);
793
+
794
+ const registerSlot = useCallback((page: number) => (el: HTMLDivElement | null) => {
795
+ const previous = slotRefs.current.get(page);
796
+ if (previous) {
797
+ observerRef.current?.unobserve(previous);
798
+ onscreenRef.current?.unobserve(previous);
799
+ }
800
+ if (el) {
801
+ slotRefs.current.set(page, el);
802
+ observerRef.current?.observe(el);
803
+ onscreenRef.current?.observe(el);
804
+ } else {
805
+ slotRefs.current.delete(page);
806
+ }
807
+ }, []);
808
+
809
+ /** Keeps the current page's rectangle in view as the reader scrolls. */
810
+ const currentTickRef = useRef<HTMLButtonElement>(null);
811
+
812
+ const scrollToPage = useCallback((page: number) => {
813
+ scrollElementIntoView(slotRefs.current.get(page), { block: 'start' });
814
+ }, []);
815
+
816
+ // In the column the reader decides which page they are on by scrolling, so
817
+ // the indicator reports rather than controls: the topmost page actually ON
818
+ // SCREEN. NOT `visiblePages` — that set is deliberately widened by
819
+ // PRELOAD_MARGIN to mount pages before they are reached, so reading it here
820
+ // would name a page still a viewport away.
821
+ const currentPage = pageLayout === 'scroll'
822
+ ? (onscreenPages.size > 0 ? Math.min(...onscreenPages) : 1)
823
+ : pageNumber;
824
+
825
+ /**
826
+ * Left/Right step pages (S1a).
827
+ *
828
+ * Bound to the window rather than a focusable wrapper so it works without
829
+ * the reader hunting for the viewer's focus — but that reach is exactly why
830
+ * the guards matter more than the feature: a viewer that eats arrow keys
831
+ * while someone is editing an annotation body, or mid-drag, is worse than
832
+ * one with no shortcut at all.
833
+ */
834
+ useEffect(() => {
835
+ if (numPages === 0) return;
836
+ const BACK = ['ArrowLeft', 'PageUp'];
837
+ const FORWARD = ['ArrowRight', 'PageDown'];
838
+ const onKeyDown = (e: KeyboardEvent) => {
839
+ // PageUp/PageDown jump a page; Left/Right do too, because they have no
840
+ // native meaning in a vertical layout so taking them costs nothing.
841
+ //
842
+ // Up/Down are deliberately NOT here. They are the scroller's fine
843
+ // movement, and a page can be taller than the viewport — taking them
844
+ // would leave no keyboard way to reach the bottom of one. This is the
845
+ // split Preview, Chrome's PDF viewer and Acrobat all use.
846
+ if (!BACK.includes(e.key) && !FORWARD.includes(e.key)) return;
847
+ if (e.metaKey || e.ctrlKey || e.altKey) return; // browser/OS navigation
848
+ const el = e.target as HTMLElement | null;
849
+ const tag = el?.tagName;
850
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el?.isContentEditable) return;
851
+
852
+ const next = FORWARD.includes(e.key) ? currentPage + 1 : currentPage - 1;
853
+ if (next < 1 || next > numPages) return;
854
+ e.preventDefault();
855
+ if (pageLayout === 'scroll') scrollToPage(next);
856
+ else setPageNumber(next);
857
+ };
858
+ window.addEventListener('keydown', onKeyDown);
859
+ return () => window.removeEventListener('keydown', onKeyDown);
860
+ }, [numPages, currentPage, pageLayout, scrollToPage]);
861
+
862
+ const stripRef = useRef<HTMLDivElement>(null);
863
+
864
+ useEffect(() => {
865
+ // `nearest` so following the reader never yanks the strip around; it
866
+ // scrolls only when the current rectangle would otherwise leave view.
867
+ scrollElementIntoView(currentTickRef.current, { block: 'nearest', inline: 'nearest' });
868
+
869
+ // Move focus with the current page, but ONLY when the strip already owns
870
+ // focus. Otherwise a clicked rectangle keeps focus while the current-page
871
+ // marker moves on, and the focus ring — which appears as soon as the
872
+ // reader touches an arrow key and the browser switches to keyboard
873
+ // modality — sits on a page that is no longer current: two rectangles
874
+ // claiming to be "here". Guarding on ownership means scrolling with the
875
+ // mouse never snatches focus from whatever the reader was doing.
876
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
877
+ if (active && stripRef.current?.contains(active) && active !== currentTickRef.current) {
878
+ currentTickRef.current?.focus();
879
+ }
880
+ }, [currentPage]);
881
+
882
+ const pageProps = {
883
+ scale,
884
+ resourceUri,
885
+ existingAnnotations,
886
+ drawingMode,
887
+ selectedMotivation,
888
+ session,
889
+ hoveredAnnotationId,
890
+ selectedAnnotationId,
891
+ hoverDelayMs,
892
+ fetchResourceAnchored,
893
+ };
894
+
895
+ if (errorKey) {
896
+ return <div className="semiont-pdf-annotation-canvas__error">{t(errorKey)}</div>;
897
+ }
898
+
899
+ return (
900
+ <div className="semiont-pdf-annotation-canvas">
901
+ {isLoading && <div className="semiont-pdf-annotation-canvas__loading">{t('loading')}</div>}
902
+
903
+ {pdfDoc && pageLayout === 'scroll' ? (
904
+ <div className="semiont-pdf-annotation-canvas__viewport" data-axis={SCROLL_AXIS}>
905
+ {numPages > 0 && pageShape && (
906
+ <div
907
+ className="semiont-pdf-annotation-canvas__strip"
908
+ data-axis={SCROLL_AXIS}
909
+ data-scroller="true"
910
+ aria-orientation={SCROLL_AXIS}
911
+ ref={stripRef}
912
+ >
913
+ {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
914
+ <button
915
+ key={page}
916
+ type="button"
917
+ data-page={page}
918
+ className="semiont-pdf-annotation-canvas__strip-page"
919
+ style={{ height: `${Math.round(STRIP_PAGE_WIDTH * pageShape.aspect)}px` }}
920
+ aria-label={t('pageOf', { page, total: numPages })}
921
+ tabIndex={page === currentPage ? 0 : -1}
922
+ {...(page === currentPage ? { 'aria-current': 'page' as const } : {})}
923
+ ref={page === currentPage ? currentTickRef : undefined}
924
+ onClick={() => scrollToPage(page)}
510
925
  >
511
- {/* Render existing annotations for this page */}
512
- {pageRects.map(r => {
513
- const rect = pdfToCanvasCoordinates(r.coord, pageDimensions.height, 1.0);
514
-
515
- // Scale to display coordinates
516
- const scaleX = displayDimensions.width / pageDimensions.width;
517
- const scaleY = displayDimensions.height / pageDimensions.height;
518
-
519
- const isHovered = r.annId === hoveredAnnotationId;
520
- const isSelected = r.annId === selectedAnnotationId;
521
-
522
- // Colour by the annotation's own motivation (not the toolbar's).
523
- const annMotivation = r.annotation.motivation as SelectionMotivation | null;
524
- const { stroke: annStroke, fill: annFill } = getMotivationColor(annMotivation);
525
-
526
- return (
527
- <rect
528
- key={`${r.annId}:${r.selectorIndex}`}
529
- x={rect.x * scaleX}
530
- y={rect.y * scaleY}
531
- width={rect.width * scaleX}
532
- height={rect.height * scaleY}
533
- stroke={annStroke}
534
- strokeWidth={isSelected ? 4 : isHovered ? 3 : 2}
535
- fill={annFill}
536
- style={{
537
- pointerEvents: 'auto',
538
- cursor: 'pointer',
539
- opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
540
- }}
541
- onClick={(e) => session?.client.browse.click(r.annId, r.annotation.motivation, e.currentTarget.getBoundingClientRect())}
542
- onMouseEnter={() => handleMouseEnter(r.annId)}
543
- onMouseLeave={handleMouseLeave}
544
- />
545
- );
546
- })}
547
-
548
- {/* Render current selection while drawing or awaiting save */}
549
- {selection && (() => {
550
- const rectX = Math.min(selection.startX, selection.endX);
551
- const rectY = Math.min(selection.startY, selection.endY);
552
- const rectWidth = Math.abs(selection.endX - selection.startX);
553
- const rectHeight = Math.abs(selection.endY - selection.startY);
554
-
555
- // PDF only supports rectangle shapes (FragmentSelector with viewrect)
556
- // Circle/polygon are disabled in the UI for PDF media types
557
- return (
558
- <rect
559
- x={rectX}
560
- y={rectY}
561
- width={rectWidth}
562
- height={rectHeight}
563
- stroke={stroke}
564
- strokeWidth={2}
565
- strokeDasharray="5,5"
566
- fill={fill}
567
- pointerEvents="none"
568
- />
569
- );
570
- })()}
571
- </svg>
572
- </div>
926
+ {page}
927
+ </button>
928
+ ))}
573
929
  </div>
574
930
  )}
575
- </div>
931
+ <div className="semiont-pdf-annotation-canvas__column" data-axis={SCROLL_AXIS} ref={columnRef}>
932
+ {Array.from({ length: numPages }, (_, i) => i + 1).map((page) => (
933
+ <div
934
+ key={page}
935
+ ref={registerSlot(page)}
936
+ data-page={page}
937
+ className="semiont-pdf-annotation-canvas__slot"
938
+ // min-height, and applied whether or not the page is mounted:
939
+ // releasing it on mount is what made the scrollbar jump. `min`
940
+ // rather than a fixed height so a page that renders slightly
941
+ // taller expands instead of clipping.
942
+ style={slotHeight ? { minHeight: slotHeight } : undefined}
943
+ >
944
+ {visiblePages.has(page) && (
945
+ <PdfPageView doc={pdfDoc} pageNumber={page} {...pageProps} />
946
+ )}
947
+ </div>
948
+ ))}
949
+ </div>
950
+ </div>
951
+ ) : (
952
+ pdfDoc && !isLoading && (
953
+ // `key` is load-bearing: without it React reuses this instance
954
+ // across page changes, so the previous page's raster, text map and
955
+ // load error survive into the next page — a stale overlay, a quote
956
+ // taken from the wrong page, and an error that never clears. Each
957
+ // page is a different thing; mounting it as one resets all of it.
958
+ <PdfPageView key={pageNumber} doc={pdfDoc} pageNumber={pageNumber} {...pageProps} />
959
+ )
960
+ )}
576
961
 
577
962
  {/* Page navigation controls */}
578
963
  {numPages > 0 && (
579
- <div className="semiont-pdf-annotation-canvas__controls">
964
+ <nav className="semiont-pdf-annotation-canvas__controls" aria-label={t('pagination')}>
580
965
  <button
581
- disabled={pageNumber <= 1}
582
- onClick={() => setPageNumber(pageNumber - 1)}
966
+ disabled={currentPage <= 1}
967
+ onClick={() => (pageLayout === 'scroll' ? scrollToPage(currentPage - 1) : setPageNumber(pageNumber - 1))}
583
968
  className="semiont-pdf-annotation-canvas__button"
584
969
  >
585
- Previous
970
+ {t('previous')}
586
971
  </button>
587
- <span className="semiont-pdf-annotation-canvas__page-info">
588
- Page {pageNumber} of {numPages}
972
+ {/*
973
+ aria-live: the page number is the only feedback a page change gives,
974
+ and in the scrolling column it changes without any control being
975
+ activated at all — so a screen-reader user who scrolls would
976
+ otherwise get silence.
977
+ */}
978
+ <span className="semiont-pdf-annotation-canvas__page-info" aria-live="polite">
979
+ {t('pageOf', { page: currentPage, total: numPages })}
589
980
  </span>
590
981
  <button
591
- disabled={pageNumber >= numPages}
592
- onClick={() => setPageNumber(pageNumber + 1)}
982
+ disabled={currentPage >= numPages}
983
+ onClick={() => (pageLayout === 'scroll' ? scrollToPage(currentPage + 1) : setPageNumber(pageNumber + 1))}
593
984
  className="semiont-pdf-annotation-canvas__button"
594
985
  >
595
- Next
986
+ {t('next')}
596
987
  </button>
597
- </div>
988
+ </nav>
598
989
  )}
599
990
  </div>
600
991
  );