@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
@@ -7,10 +7,12 @@
7
7
  * - Annotation display
8
8
  */
9
9
 
10
- import { describe, test, expect, vi, beforeEach } from 'vitest';
11
- import { render, screen, waitFor, fireEvent } from '@testing-library/react';
10
+ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
13
  import { PdfAnnotationCanvas } from '../PdfAnnotationCanvas';
14
+ import { TranslationProvider } from '../../../contexts/TranslationContext';
15
+ import { defaultMocks } from '../../../test-utils';
14
16
  import { resourceId, annotationId, parseFragmentSelector } from '@semiont/core';
15
17
  import { pdfToCanvasCoordinates } from '../../../lib/pdf-coordinates';
16
18
  import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
@@ -66,10 +68,78 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
66
68
  })
67
69
  }));
68
70
 
71
+
72
+ /**
73
+ * jsdom has no IntersectionObserver. This stub records what the component
74
+ * observes and hands the test the callback, so a "scroll" is an explicit
75
+ * fire() rather than a simulated layout — jsdom has no layout either.
76
+ */
77
+ function stubIntersectionObserver() {
78
+ // (element, callback) PAIRS, not a map keyed by element: the component runs
79
+ // two observers over the same slots — one widened for preloading, one bare
80
+ // for "what is on screen" — and a map would keep only the last registered.
81
+ const observed: Array<{ el: Element; cb: IntersectionObserverCallback }> = [];
82
+ const margins = new Map<IntersectionObserverCallback, string>();
83
+ class FakeIO {
84
+ constructor(private cb: IntersectionObserverCallback, options?: IntersectionObserverInit) {
85
+ margins.set(cb, options?.rootMargin ?? '0px');
86
+ }
87
+ observe(el: Element) { observed.push({ el, cb: this.cb }); }
88
+ unobserve(el: Element) {
89
+ for (let i = observed.length - 1; i >= 0; i--) {
90
+ if (observed[i]!.el === el && observed[i]!.cb === this.cb) observed.splice(i, 1);
91
+ }
92
+ }
93
+ disconnect() {
94
+ for (let i = observed.length - 1; i >= 0; i--) {
95
+ if (observed[i]!.cb === this.cb) observed.splice(i, 1);
96
+ }
97
+ }
98
+ takeRecords() { return []; }
99
+ }
100
+ vi.stubGlobal('IntersectionObserver', FakeIO);
101
+
102
+ /** Fire only the observer that has no preload margin — the one that decides
103
+ * which page the reader is actually looking at. */
104
+ function fireFor(visible: number[], match: (rootMargin: string) => boolean) {
105
+ const byCb = new Map<IntersectionObserverCallback, IntersectionObserverEntry[]>();
106
+ for (const { el, cb } of observed) {
107
+ if (!match(margins.get(cb) ?? '0px')) continue;
108
+ const page = Number((el as HTMLElement).dataset.page);
109
+ byCb.set(cb, [...(byCb.get(cb) ?? []), {
110
+ target: el, isIntersecting: visible.includes(page),
111
+ } as unknown as IntersectionObserverEntry]);
112
+ }
113
+ act(() => { for (const [cb, entries] of byCb) cb(entries, {} as IntersectionObserver); });
114
+ }
115
+
116
+ /** Only the bare observer — what the reader can actually see. */
117
+ function fireOnscreen(visible: number[]) {
118
+ fireFor(visible, (m) => m === '0px');
119
+ }
120
+
121
+ return {
122
+ fireOnscreen,
123
+ /**
124
+ * Fire both observers: these pages are mounted AND on screen — the
125
+ * ordinary case. A test that needs the two to differ (a page preloaded
126
+ * but not yet visible) calls `fireOnscreen` afterwards to narrow it.
127
+ */
128
+ fire(visible: number[]) {
129
+ fireFor(visible, () => true);
130
+ },
131
+ };
132
+ }
133
+
69
134
  describe('PdfAnnotationCanvas', () => {
70
135
  const mockResourceId = resourceId('123');
71
136
  const mockPdfUrl = 'https://example.com/resources/123.pdf';
72
137
 
138
+ afterEach(() => {
139
+ // Stubs must not leak into other files sharing this Vitest environment.
140
+ vi.unstubAllGlobals();
141
+ });
142
+
73
143
  beforeEach(() => {
74
144
  vi.clearAllMocks();
75
145
 
@@ -205,7 +275,7 @@ describe('PdfAnnotationCanvas', () => {
205
275
  });
206
276
  });
207
277
 
208
- test('passes the annotation rect as browse.click third argument (A1 anchor)', async () => {
278
+ test('passes the annotation rect as browse.click second argument (A1 anchor)', async () => {
209
279
  const click = vi.fn();
210
280
  const session = {
211
281
  client: { browse: { click }, beckon: { hover: vi.fn() } },
@@ -258,8 +328,7 @@ describe('PdfAnnotationCanvas', () => {
258
328
 
259
329
  expect(click).toHaveBeenCalledTimes(1);
260
330
  expect(click.mock.calls[0]?.[0]).toBe('ann-1');
261
- expect(click.mock.calls[0]?.[1]).toBe('highlighting');
262
- const anchorRect = click.mock.calls[0]?.[2];
331
+ const anchorRect = click.mock.calls[0]?.[1];
263
332
  expect(anchorRect).toBeDefined();
264
333
  expect(typeof anchorRect.width).toBe('number');
265
334
  });
@@ -331,8 +400,7 @@ describe('PdfAnnotationCanvas', () => {
331
400
 
332
401
  expect(click).toHaveBeenCalledTimes(1);
333
402
  expect(click.mock.calls[0]?.[0]).toBe('ann-hit-1');
334
- expect(click.mock.calls[0]?.[1]).toBe('highlighting');
335
- expect(click.mock.calls[0]?.[2]).toMatchObject({
403
+ expect(click.mock.calls[0]?.[1]).toMatchObject({
336
404
  left: displayRect.x,
337
405
  top: displayRect.y,
338
406
  width: displayRect.width,
@@ -590,4 +658,693 @@ describe('PdfAnnotationCanvas', () => {
590
658
  expect(container).toBeInTheDocument();
591
659
  }
592
660
  });
661
+
662
+ // PDF-CONTINUOUS-SCROLL S1. Browse mode scrolls: every page has a slot so
663
+ // the scrollbar is honest about the document's length, but only a window of
664
+ // pages is MOUNTED — unmounting is what releases a page's raster, so the
665
+ // window IS the memory budget (D2).
666
+ describe('scroll layout (browse mode)', () => {
667
+ const scannedDoc = () =>
668
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
669
+ numPages: 5,
670
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
671
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
672
+
673
+ const mountedPages = () =>
674
+ [...document.querySelectorAll('.semiont-pdf-annotation-canvas__image')]
675
+ .map((img) => Number((img as HTMLImageElement).alt.replace(/\D+/g, '')))
676
+ .sort((a, b) => a - b);
677
+
678
+ test('gives every page a slot but mounts only the visible window', async () => {
679
+ const io = stubIntersectionObserver();
680
+ scannedDoc();
681
+
682
+ render(
683
+ <PdfAnnotationCanvas resourceUri="res-1"
684
+ pdfUrl={mockPdfUrl}
685
+ drawingMode={null}
686
+ pageLayout="scroll"
687
+ />
688
+ );
689
+
690
+ await waitFor(() => {
691
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
692
+ });
693
+ // Nothing rasterized before anything is visible.
694
+ expect(mountedPages()).toEqual([]);
695
+
696
+ io.fire([1, 2]);
697
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
698
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
699
+ });
700
+
701
+ test('releases a page that scrolls out of the window', async () => {
702
+ const io = stubIntersectionObserver();
703
+ scannedDoc();
704
+
705
+ render(
706
+ <PdfAnnotationCanvas resourceUri="res-1"
707
+ pdfUrl={mockPdfUrl}
708
+ drawingMode={null}
709
+ pageLayout="scroll"
710
+ />
711
+ );
712
+ await waitFor(() => {
713
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
714
+ });
715
+
716
+ io.fire([1, 2]);
717
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
718
+
719
+ io.fire([4, 5]);
720
+ await waitFor(() => expect(mountedPages()).toEqual([4, 5]));
721
+ });
722
+
723
+ test('still fetches the whole-resource map once, across many mounted pages (P4)', async () => {
724
+ // P4's invariant has to survive the move from one shared page-load
725
+ // effect into N independent page views.
726
+ const io = stubIntersectionObserver();
727
+ scannedDoc();
728
+
729
+ const resourceAnchoredText = vi.fn().mockResolvedValue({
730
+ text: 'Hello world again',
731
+ items: [{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 }],
732
+ });
733
+ const session = {
734
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
735
+ } as unknown as import('@semiont/sdk').SemiontSession;
736
+
737
+ render(
738
+ <PdfAnnotationCanvas resourceUri="res-1"
739
+ pdfUrl={mockPdfUrl}
740
+ drawingMode={null}
741
+ pageLayout="scroll"
742
+ session={session}
743
+ />
744
+ );
745
+ await waitFor(() => {
746
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
747
+ });
748
+
749
+ io.fire([1, 2, 3]);
750
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2, 3]));
751
+ io.fire([3, 4, 5]);
752
+ await waitFor(() => expect(mountedPages()).toEqual([3, 4, 5]));
753
+
754
+ expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
755
+ });
756
+
757
+ test('a slot keeps its reserved height when its page mounts', async () => {
758
+ // S1b. Releasing the reservation on mount is what made the column's
759
+ // total height change on every scroll — the scrollbar jumped and
760
+ // resized under the cursor. The reservation must survive mounting so
761
+ // the column's geometry never depends on which pages are mounted.
762
+ const io = stubIntersectionObserver();
763
+ scannedDoc();
764
+
765
+ render(
766
+ <PdfAnnotationCanvas resourceUri="res-1"
767
+ pdfUrl={mockPdfUrl}
768
+ drawingMode={null}
769
+ pageLayout="scroll"
770
+ />
771
+ );
772
+ await waitFor(() => {
773
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
774
+ });
775
+
776
+ const slotOf = (page: number) =>
777
+ document.querySelector(`.semiont-pdf-annotation-canvas__slot[data-page="${page}"]`) as HTMLElement;
778
+
779
+ // Read whichever property expresses the reservation, so the pin is
780
+ // about the CONTRACT (space is reserved, and mounting does not release
781
+ // it) rather than about which CSS property implements it.
782
+ const reserved = (el: HTMLElement) => el.style.minHeight || el.style.height;
783
+
784
+ io.fire([1]);
785
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
786
+
787
+ // Page 5 is unmounted and must hold space open.
788
+ expect(reserved(slotOf(5))).not.toBe('');
789
+ // Page 1 is mounted and must hold exactly the same space, or the
790
+ // column's height changes as pages come and go.
791
+ expect(reserved(slotOf(1))).toBe(reserved(slotOf(5)));
792
+ });
793
+
794
+ test('a rectangle drawn on a scrolled page carries THAT page number', async () => {
795
+ // S2. The drag lives in the page view, so page identity comes from the
796
+ // component that owns the pixels rather than from a shared `pageNumber`
797
+ // — the invariant that makes a column safe to draw on at all.
798
+ const io = stubIntersectionObserver();
799
+ scannedDoc();
800
+
801
+ const request = vi.fn();
802
+ const session = {
803
+ client: { mark: { request }, beckon: { hover: vi.fn() } },
804
+ } as unknown as import('@semiont/sdk').SemiontSession;
805
+
806
+ render(
807
+ <PdfAnnotationCanvas resourceUri="res-1"
808
+ pdfUrl={mockPdfUrl}
809
+ drawingMode="rectangle"
810
+ selectedMotivation="highlighting"
811
+ session={session}
812
+ pageLayout="scroll"
813
+ />
814
+ );
815
+ await waitFor(() => {
816
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
817
+ });
818
+
819
+ io.fire([2, 3]);
820
+ await waitFor(() => expect(mountedPages()).toEqual([2, 3]));
821
+
822
+ // Draw on page 3's container, not page 2's.
823
+ const slot3 = document.querySelector('.semiont-pdf-annotation-canvas__slot[data-page="3"]')!;
824
+ const img = slot3.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
825
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
826
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
827
+ fireEvent.load(img);
828
+
829
+ const container = slot3.querySelector('.semiont-pdf-annotation-canvas__container')!;
830
+ fireEvent.mouseDown(container, { clientX: 40, clientY: 40 });
831
+ fireEvent.mouseMove(container, { clientX: 200, clientY: 160 });
832
+ fireEvent.mouseUp(container, { clientX: 200, clientY: 160 });
833
+
834
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
835
+ expect(request.mock.calls[0][1][0].value).toContain('page=3');
836
+ });
837
+
838
+ test('annotate mode scrolls too — the primary mode is not left on the pager', async () => {
839
+ // The registries are the seam: both now ask for the column.
840
+ const io = stubIntersectionObserver();
841
+ scannedDoc();
842
+
843
+ render(
844
+ <PdfAnnotationCanvas resourceUri="res-1"
845
+ pdfUrl={mockPdfUrl}
846
+ drawingMode="rectangle"
847
+ selectedMotivation="highlighting"
848
+ pageLayout="scroll"
849
+ />
850
+ );
851
+
852
+ await waitFor(() => {
853
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
854
+ });
855
+ io.fire([1]);
856
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
857
+ // Drawing still works in the column: the container is the page's own.
858
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__container'))
859
+ .toHaveAttribute('data-drawing-mode', 'rectangle');
860
+ });
861
+
862
+ // S4. A strip of proportional rectangles — one per page, current
863
+ // highlighted, click to jump. Deliberately NOT thumbnails: it needs no
864
+ // rasterization, only the page count and the aspect ratio S1b already
865
+ // measures, so it costs nothing next to the document itself. It answers
866
+ // "where am I in this document", which neither the pager nor the
867
+ // scrollbar does.
868
+ test('renders one strip rectangle per page, marking the current one', async () => {
869
+ const io = stubIntersectionObserver();
870
+ scannedDoc();
871
+
872
+ render(
873
+ <PdfAnnotationCanvas resourceUri="res-1"
874
+ pdfUrl={mockPdfUrl}
875
+ drawingMode={null}
876
+ pageLayout="scroll"
877
+ />
878
+ );
879
+ await waitFor(() => {
880
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
881
+ });
882
+
883
+ const ticks = () => document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page');
884
+ await waitFor(() => expect(ticks()).toHaveLength(5));
885
+
886
+ io.fire([3, 4]);
887
+ await waitFor(() => {
888
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
889
+ });
890
+ });
891
+
892
+ test('clicking a strip rectangle jumps to that page', async () => {
893
+ const io = stubIntersectionObserver();
894
+ scannedDoc();
895
+ const scrollIntoView = vi.fn();
896
+ Element.prototype.scrollIntoView = scrollIntoView;
897
+
898
+ render(
899
+ <PdfAnnotationCanvas resourceUri="res-1"
900
+ pdfUrl={mockPdfUrl}
901
+ drawingMode={null}
902
+ pageLayout="scroll"
903
+ />
904
+ );
905
+ await waitFor(() => {
906
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
907
+ });
908
+ io.fire([1]);
909
+
910
+ const tick4 = document.querySelector('.semiont-pdf-annotation-canvas__strip-page[data-page="4"]')!;
911
+ fireEvent.click(tick4);
912
+
913
+ expect(scrollIntoView).toHaveBeenCalled();
914
+ });
915
+
916
+ // S1a. Arrow keys step pages. The guards are the substance: a viewer that
917
+ // steals arrow keys from a text field is worse than one with no shortcut.
918
+ test('PageUp/PageDown step pages; Up/Down are left to scroll', async () => {
919
+ // The convention every mainstream viewer follows (Preview, Chrome's PDF
920
+ // viewer, Acrobat): PageUp/PageDown jump a page, Up/Down scroll finely.
921
+ // Taking Up/Down would leave no keyboard way to reach the bottom of a
922
+ // page taller than the viewport.
923
+ const io = stubIntersectionObserver();
924
+ scannedDoc();
925
+ const scrollIntoView = vi.fn();
926
+ Element.prototype.scrollIntoView = scrollIntoView;
927
+
928
+ render(
929
+ <PdfAnnotationCanvas resourceUri="res-1"
930
+ pdfUrl={mockPdfUrl}
931
+ drawingMode={null}
932
+ pageLayout="scroll"
933
+ />
934
+ );
935
+ await waitFor(() => {
936
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
937
+ });
938
+ io.fire([2]);
939
+ await waitFor(() => expect(mountedPages()).toEqual([2]));
940
+
941
+ scrollIntoView.mockClear();
942
+ fireEvent.keyDown(window, { key: 'PageDown' });
943
+ expect(scrollIntoView).toHaveBeenCalled();
944
+
945
+ scrollIntoView.mockClear();
946
+ fireEvent.keyDown(window, { key: 'PageUp' });
947
+ expect(scrollIntoView).toHaveBeenCalled();
948
+
949
+ // Up/Down belong to the scroller, not to us.
950
+ scrollIntoView.mockClear();
951
+ fireEvent.keyDown(window, { key: 'ArrowDown' });
952
+ fireEvent.keyDown(window, { key: 'ArrowUp' });
953
+ expect(scrollIntoView).not.toHaveBeenCalled();
954
+
955
+ // The same guard as the horizontal pair: never steal from a text field.
956
+ const input = document.createElement('input');
957
+ document.body.appendChild(input);
958
+ input.focus();
959
+ scrollIntoView.mockClear();
960
+ fireEvent.keyDown(input, { key: 'PageDown' });
961
+ expect(scrollIntoView).not.toHaveBeenCalled();
962
+ input.remove();
963
+ });
964
+
965
+ test('Left/Right step pages, and never steal keys from a text field', async () => {
966
+ const io = stubIntersectionObserver();
967
+ scannedDoc();
968
+ const scrollIntoView = vi.fn();
969
+ Element.prototype.scrollIntoView = scrollIntoView;
970
+
971
+ render(
972
+ <PdfAnnotationCanvas resourceUri="res-1"
973
+ pdfUrl={mockPdfUrl}
974
+ drawingMode={null}
975
+ pageLayout="scroll"
976
+ />
977
+ );
978
+ await waitFor(() => {
979
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
980
+ });
981
+ io.fire([1]);
982
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
983
+
984
+ scrollIntoView.mockClear();
985
+ fireEvent.keyDown(window, { key: 'ArrowRight' });
986
+ expect(scrollIntoView).toHaveBeenCalled();
987
+
988
+ // Typing in an input must not page the document underneath it.
989
+ const input = document.createElement('input');
990
+ document.body.appendChild(input);
991
+ input.focus();
992
+ scrollIntoView.mockClear();
993
+ fireEvent.keyDown(input, { key: 'ArrowRight' });
994
+ expect(scrollIntoView).not.toHaveBeenCalled();
995
+ input.remove();
996
+ });
997
+
998
+ test('focus follows the current page when the strip owns focus', async () => {
999
+ // Reported: click a page in the strip, then arrow away — the focus ring
1000
+ // stays behind on the clicked rectangle while the current-page marker
1001
+ // moves, so two rectangles claim to be "here". (The ring only appears
1002
+ // after the arrow press because that is when the browser switches to
1003
+ // keyboard modality and the clicked button starts matching
1004
+ // :focus-visible.) Focus must travel with the current page — but ONLY
1005
+ // when the strip already had it, so scrolling with the mouse never
1006
+ // snatches focus away from whatever the reader was doing.
1007
+ const io = stubIntersectionObserver();
1008
+ scannedDoc();
1009
+ Element.prototype.scrollIntoView = vi.fn();
1010
+
1011
+ render(
1012
+ <PdfAnnotationCanvas resourceUri="res-1"
1013
+ pdfUrl={mockPdfUrl}
1014
+ drawingMode={null}
1015
+ pageLayout="scroll"
1016
+ />
1017
+ );
1018
+ await waitFor(() => {
1019
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1020
+ });
1021
+ io.fire([1]);
1022
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
1023
+
1024
+ const tick = (page: number) =>
1025
+ document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`) as HTMLButtonElement;
1026
+
1027
+ tick(2).focus();
1028
+ fireEvent.click(tick(2));
1029
+ io.fire([3]);
1030
+
1031
+ await waitFor(() => expect(tick(3)).toHaveAttribute('aria-current', 'page'));
1032
+ expect(document.activeElement).toBe(tick(3));
1033
+ });
1034
+
1035
+ test('the strip is one tab stop, not one per page', async () => {
1036
+ // A 400-page document must not put 400 stops in the tab order. The
1037
+ // ARIA pattern: the current item is tabbable, the rest are reachable
1038
+ // with arrows (which already page the document).
1039
+ const io = stubIntersectionObserver();
1040
+ scannedDoc();
1041
+ Element.prototype.scrollIntoView = vi.fn();
1042
+
1043
+ render(
1044
+ <PdfAnnotationCanvas resourceUri="res-1"
1045
+ pdfUrl={mockPdfUrl}
1046
+ drawingMode={null}
1047
+ pageLayout="scroll"
1048
+ />
1049
+ );
1050
+ await waitFor(() => {
1051
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1052
+ });
1053
+ io.fire([1]);
1054
+
1055
+ const tabbable = [...document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')]
1056
+ .filter((el) => (el as HTMLElement).tabIndex === 0);
1057
+ expect(tabbable).toHaveLength(1);
1058
+ expect(tabbable[0]).toHaveAttribute('data-page', '1');
1059
+ });
1060
+
1061
+ test('the strip runs along the same axis as the scrolling, beside the column', async () => {
1062
+ // Pages scroll vertically, so a horizontal strip reads across a
1063
+ // direction the document does not move in. The strip's axis follows the
1064
+ // scroll axis — which is also what makes horizontal scrolling (a later
1065
+ // phase) a change of one value rather than a second layout.
1066
+ const io = stubIntersectionObserver();
1067
+ scannedDoc();
1068
+ Element.prototype.scrollIntoView = vi.fn();
1069
+
1070
+ render(
1071
+ <PdfAnnotationCanvas resourceUri="res-1"
1072
+ pdfUrl={mockPdfUrl}
1073
+ drawingMode={null}
1074
+ pageLayout="scroll"
1075
+ />
1076
+ );
1077
+ await waitFor(() => {
1078
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1079
+ });
1080
+ io.fire([1]);
1081
+
1082
+ const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
1083
+ const strip = document.querySelector('.semiont-pdf-annotation-canvas__strip')!;
1084
+
1085
+ // One axis, declared in one place, read by both.
1086
+ expect(column).toHaveAttribute('data-axis', 'vertical');
1087
+ expect(strip).toHaveAttribute('data-axis', 'vertical');
1088
+ // Tells assistive tech which arrow keys apply to the strip.
1089
+ expect(strip).toHaveAttribute('aria-orientation', 'vertical');
1090
+ // The strip leads: it hugs the left edge of the content, the
1091
+ // conventional place for a page rail (Preview, Acrobat), and the
1092
+ // reader's eye finds it before the page rather than after it.
1093
+ expect(strip.nextElementSibling).toBe(column);
1094
+ expect(strip.parentElement).toHaveClass('semiont-pdf-annotation-canvas__viewport');
1095
+ });
1096
+
1097
+ test('each strip rectangle shows its page number', async () => {
1098
+ // At 70 pages the rectangles are indistinguishable from one another —
1099
+ // the strip shows position but not WHICH page, which is most of what a
1100
+ // reader wants from it on a long document.
1101
+ const io = stubIntersectionObserver();
1102
+ scannedDoc();
1103
+ Element.prototype.scrollIntoView = vi.fn();
1104
+
1105
+ render(
1106
+ <PdfAnnotationCanvas resourceUri="res-1"
1107
+ pdfUrl={mockPdfUrl}
1108
+ drawingMode={null}
1109
+ pageLayout="scroll"
1110
+ />
1111
+ );
1112
+ await waitFor(() => {
1113
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1114
+ });
1115
+ io.fire([1]);
1116
+
1117
+ const tick = (page: number) =>
1118
+ document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`)!;
1119
+ expect(tick(1)).toHaveTextContent('1');
1120
+ expect(tick(5)).toHaveTextContent('5');
1121
+
1122
+ // The accessible name keeps the context the bare digit loses, and
1123
+ // still contains the visible text (WCAG 2.5.3 Label in Name).
1124
+ expect(tick(5)).toHaveAttribute('aria-label', expect.stringContaining('5'));
1125
+ });
1126
+
1127
+
1128
+ test('the column does not add a second scrollbar inside the scrolling pane', async () => {
1129
+ // The strip and a nested column scrollbar were two controls for one
1130
+ // movement. The pane the viewer sits in already scrolls (flex: 1;
1131
+ // min-height: 0), so the column must not declare a viewport-relative
1132
+ // height of its own — that is what produced scroll-within-scroll.
1133
+ const io = stubIntersectionObserver();
1134
+ scannedDoc();
1135
+ Element.prototype.scrollIntoView = vi.fn();
1136
+
1137
+ render(
1138
+ <PdfAnnotationCanvas resourceUri="res-1"
1139
+ pdfUrl={mockPdfUrl}
1140
+ drawingMode={null}
1141
+ pageLayout="scroll"
1142
+ />
1143
+ );
1144
+ await waitFor(() => {
1145
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1146
+ });
1147
+ io.fire([1]);
1148
+
1149
+ // jsdom applies no stylesheet, so assert the CONTRACT the CSS keys off:
1150
+ // the column declares the axis but claims no scroll role of its own.
1151
+ const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
1152
+ expect(column).toHaveAttribute('data-axis', 'vertical');
1153
+ expect(column).not.toHaveAttribute('data-scroller');
1154
+ // The strip is the one that scrolls independently, and silently.
1155
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__strip'))
1156
+ .toHaveAttribute('data-scroller', 'true');
1157
+ });
1158
+
1159
+ test('the indicator reports a page the reader can SEE, not a preloaded one', async () => {
1160
+ // The mount window deliberately reaches a viewport beyond the view
1161
+ // (PRELOAD_MARGIN) so pages arrive early. Deriving the current page
1162
+ // from that same set makes the indicator name a page that is still
1163
+ // off-screen — it must come from actual visibility.
1164
+ const io = stubIntersectionObserver();
1165
+ scannedDoc();
1166
+ Element.prototype.scrollIntoView = vi.fn();
1167
+
1168
+ render(
1169
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1170
+ drawingMode={null} pageLayout="scroll" />
1171
+ );
1172
+ await waitFor(() => {
1173
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1174
+ });
1175
+
1176
+ // Pages 1-3 are within the preload window; only 3 is actually on screen.
1177
+ io.fire([1, 2, 3]);
1178
+ io.fireOnscreen([3]);
1179
+
1180
+ await waitFor(() => {
1181
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
1182
+ });
1183
+ });
1184
+
1185
+ test('paged navigation does not carry one page\'s state onto the next', async () => {
1186
+ // In the paged layout a single PdfPageView is reused as `pageNumber`
1187
+ // changes, so its state survives the switch: the previous page's raster
1188
+ // shows briefly under the new page's overlay, a drag could quote the
1189
+ // previous page's text, and a load failure sticks forever because
1190
+ // nothing clears it. Each page is a different thing and must mount as one.
1191
+ // Reject by PAGE, not by call order: the parent also calls getPage(1)
1192
+ // to measure the document's shape, so a `...Once` rejection would be
1193
+ // consumed there and never reach the page view.
1194
+ const failingDoc = {
1195
+ numPages: 3,
1196
+ getPage: vi.fn((n: number) => (n === 1
1197
+ ? Promise.reject(new Error('page 1 is broken'))
1198
+ : Promise.resolve(mockPage(MOCK_TEXT_ITEMS)))),
1199
+ };
1200
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce(
1201
+ failingDoc as unknown as Awaited<ReturnType<typeof loadPdfDocument>>,
1202
+ );
1203
+
1204
+ render(
1205
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1206
+ );
1207
+
1208
+ await waitFor(() => {
1209
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).toBeInTheDocument();
1210
+ });
1211
+
1212
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
1213
+
1214
+ // Page 2 loads fine; the failure belonged to page 1 and must not persist.
1215
+ await waitFor(() => {
1216
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).not.toBeInTheDocument();
1217
+ });
1218
+ });
1219
+
1220
+ test('without IntersectionObserver every page mounts — degraded, not broken', async () => {
1221
+ // The virtualization is an optimization, not a correctness requirement.
1222
+ // An environment without the observer must show the whole document
1223
+ // rather than nothing.
1224
+ scannedDoc();
1225
+ vi.stubGlobal('IntersectionObserver', undefined);
1226
+ Element.prototype.scrollIntoView = vi.fn();
1227
+
1228
+ render(
1229
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1230
+ drawingMode={null} pageLayout="scroll" />
1231
+ );
1232
+
1233
+ await waitFor(() => {
1234
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__image')).toHaveLength(5);
1235
+ });
1236
+ });
1237
+
1238
+ test('a failed map fetch is not cached — the next page retries', async () => {
1239
+ // Answers cache, including "no map". A transport failure must not, or
1240
+ // one bad moment pins the whole document to geometry-only.
1241
+ const io = stubIntersectionObserver();
1242
+ scannedDoc();
1243
+ Element.prototype.scrollIntoView = vi.fn();
1244
+
1245
+ const resourceAnchoredText = vi.fn()
1246
+ .mockRejectedValueOnce(new Error('transport down'))
1247
+ .mockResolvedValue({ text: 'later', items: [] });
1248
+ const session = {
1249
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
1250
+ } as unknown as import('@semiont/sdk').SemiontSession;
1251
+
1252
+ render(
1253
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1254
+ drawingMode={null} pageLayout="scroll" session={session} />
1255
+ );
1256
+ await waitFor(() => {
1257
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1258
+ });
1259
+
1260
+ io.fire([1]);
1261
+ await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(1));
1262
+
1263
+ io.fire([2]);
1264
+ await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(2));
1265
+ });
1266
+
1267
+ test('the pager still works in the column, and modifiers are left alone', async () => {
1268
+ const io = stubIntersectionObserver();
1269
+ scannedDoc();
1270
+ const scrollIntoView = vi.fn();
1271
+ Element.prototype.scrollIntoView = scrollIntoView;
1272
+
1273
+ render(
1274
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1275
+ drawingMode={null} pageLayout="scroll" />
1276
+ );
1277
+ await waitFor(() => {
1278
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1279
+ });
1280
+ io.fire([3]);
1281
+ await waitFor(() => {
1282
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
1283
+ });
1284
+
1285
+ scrollIntoView.mockClear();
1286
+ fireEvent.click(screen.getByRole('button', { name: /previous/i }));
1287
+ expect(scrollIntoView).toHaveBeenCalled();
1288
+
1289
+ // Cmd/Ctrl/Alt + arrow belongs to the browser and the OS.
1290
+ scrollIntoView.mockClear();
1291
+ fireEvent.keyDown(window, { key: 'ArrowRight', metaKey: true });
1292
+ fireEvent.keyDown(window, { key: 'PageDown', ctrlKey: true });
1293
+ expect(scrollIntoView).not.toHaveBeenCalled();
1294
+ });
1295
+
1296
+ test('keeps the paged layout when no layout is requested', async () => {
1297
+ render(
1298
+ <PdfAnnotationCanvas resourceUri="res-1"
1299
+ pdfUrl={mockPdfUrl}
1300
+ drawingMode="rectangle"
1301
+ selectedMotivation="highlighting"
1302
+ />
1303
+ );
1304
+
1305
+ await waitFor(() => {
1306
+ expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
1307
+ });
1308
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(0);
1309
+ expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
1310
+ });
1311
+ });
1312
+
1313
+ // PDF-CONTINUOUS-SCROLL S3. The viewer chrome was the last hardcoded-English
1314
+ // surface in this component: Previous/Next, the page indicator, and both
1315
+ // failure lines. The mock translation manager echoes "<namespace>.<key>",
1316
+ // so asserting the echo proves the string came from translations rather
1317
+ // than from a literal that happens to read the same in English.
1318
+ describe('viewer chrome', () => {
1319
+ const renderTranslated = (ui: React.ReactElement) =>
1320
+ render(<TranslationProvider translationManager={defaultMocks.translationManager}>{ui}</TranslationProvider>);
1321
+
1322
+ test('takes its controls and page indicator from translations', async () => {
1323
+ renderTranslated(
1324
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1325
+ );
1326
+
1327
+ await waitFor(() => {
1328
+ expect(screen.getByRole('button', { name: 'PdfViewer.previous' })).toBeInTheDocument();
1329
+ });
1330
+ expect(screen.getByRole('button', { name: 'PdfViewer.next' })).toBeInTheDocument();
1331
+ expect(screen.getByText(/PdfViewer\.pageOf/)).toBeInTheDocument();
1332
+ expect(screen.queryByText('Previous')).not.toBeInTheDocument();
1333
+ expect(screen.queryByText(/^Page 1 of 3$/)).not.toBeInTheDocument();
1334
+ });
1335
+
1336
+ test('announces page changes — a silent indicator is invisible to a screen reader', async () => {
1337
+ renderTranslated(
1338
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1339
+ );
1340
+
1341
+ await waitFor(() => {
1342
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__page-info')).toBeInTheDocument();
1343
+ });
1344
+ const indicator = document.querySelector('.semiont-pdf-annotation-canvas__page-info')!;
1345
+ expect(indicator).toHaveAttribute('aria-live', 'polite');
1346
+ // The pager is a navigation landmark, not loose buttons in the page.
1347
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
1348
+ });
1349
+ });
593
1350
  });