@semiont/react-ui 0.5.24 → 0.5.26

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 (179) hide show
  1. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js +677 -0
  2. package/dist/PdfAnnotationCanvas.client-6ZSF6ZTJ.js.map +1 -0
  3. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css +180 -0
  4. package/dist/PdfAnnotationCanvas.client-JCJMXWWJ.css.map +1 -0
  5. package/dist/{ar-JRTKCMTF.js → ar-YTUUGK2O.js} +32 -6
  6. package/dist/ar-YTUUGK2O.js.map +1 -0
  7. package/dist/{bn-LO6EDSCC.js → bn-ASGNGPMD.js} +32 -6
  8. package/dist/bn-ASGNGPMD.js.map +1 -0
  9. package/dist/{chunk-4QMTEPQ3.js → chunk-BTUG3VJJ.js} +18 -298
  10. package/dist/chunk-BTUG3VJJ.js.map +1 -0
  11. package/dist/{chunk-5Y5JDCKC.js → chunk-L3WKRWP3.js} +32 -6
  12. package/dist/chunk-L3WKRWP3.js.map +1 -0
  13. package/dist/chunk-OBQOZCJE.js +287 -0
  14. package/dist/chunk-OBQOZCJE.js.map +1 -0
  15. package/dist/{cs-LCHHOR4O.js → cs-WMYVAOZY.js} +32 -6
  16. package/dist/cs-WMYVAOZY.js.map +1 -0
  17. package/dist/{da-TARGPMJR.js → da-FSHHN4D6.js} +32 -6
  18. package/dist/da-FSHHN4D6.js.map +1 -0
  19. package/dist/{de-SBH4QBPJ.js → de-FJFO3TPT.js} +32 -6
  20. package/dist/de-FJFO3TPT.js.map +1 -0
  21. package/dist/{el-2D2OLGWB.js → el-XOH4QIAG.js} +32 -6
  22. package/dist/el-XOH4QIAG.js.map +1 -0
  23. package/dist/{en-QFIEG3CA.js → en-3FCBW3YD.js} +6 -2
  24. package/dist/{es-U6YK2LX6.js → es-HDX4QI7E.js} +32 -6
  25. package/dist/es-HDX4QI7E.js.map +1 -0
  26. package/dist/{fa-S7SZE37M.js → fa-DJ6WTFTJ.js} +32 -6
  27. package/dist/fa-DJ6WTFTJ.js.map +1 -0
  28. package/dist/{fi-CBL3NIXN.js → fi-4S7H7W7U.js} +32 -6
  29. package/dist/fi-4S7H7W7U.js.map +1 -0
  30. package/dist/{fr-EABUHV6S.js → fr-6UJSYHXC.js} +32 -6
  31. package/dist/fr-6UJSYHXC.js.map +1 -0
  32. package/dist/{he-THWD244Y.js → he-ZZJOHJX2.js} +32 -6
  33. package/dist/he-ZZJOHJX2.js.map +1 -0
  34. package/dist/{hi-GHGFZJ44.js → hi-HZJTAW7U.js} +32 -6
  35. package/dist/hi-HZJTAW7U.js.map +1 -0
  36. package/dist/{id-POZOUMDW.js → id-E3I6ILEM.js} +32 -6
  37. package/dist/id-E3I6ILEM.js.map +1 -0
  38. package/dist/index.css +140 -94
  39. package/dist/index.css.map +1 -1
  40. package/dist/index.d.ts +85 -24
  41. package/dist/index.js +1782 -1697
  42. package/dist/index.js.map +1 -1
  43. package/dist/{it-KDYJADSH.js → it-ULAPAD7U.js} +32 -6
  44. package/dist/it-ULAPAD7U.js.map +1 -0
  45. package/dist/{ja-Z5Z6ZF3P.js → ja-SAHZQ4CZ.js} +32 -6
  46. package/dist/ja-SAHZQ4CZ.js.map +1 -0
  47. package/dist/{ko-7X3XUXRN.js → ko-DFU2ADQ4.js} +32 -6
  48. package/dist/ko-DFU2ADQ4.js.map +1 -0
  49. package/dist/{ms-BTWEIW43.js → ms-NWL76XNT.js} +32 -6
  50. package/dist/ms-NWL76XNT.js.map +1 -0
  51. package/dist/{nl-IS2CGF5I.js → nl-ATCM76WN.js} +32 -6
  52. package/dist/nl-ATCM76WN.js.map +1 -0
  53. package/dist/{no-AAMEVA26.js → no-UIAQ5RJF.js} +32 -6
  54. package/dist/no-UIAQ5RJF.js.map +1 -0
  55. package/dist/{pl-PVXUQYFT.js → pl-NOECQDXD.js} +32 -6
  56. package/dist/pl-NOECQDXD.js.map +1 -0
  57. package/dist/{pt-3ITQ76TL.js → pt-SVWIIUPC.js} +32 -6
  58. package/dist/pt-SVWIIUPC.js.map +1 -0
  59. package/dist/{ro-FRB4IIVS.js → ro-NR2DRTZG.js} +32 -6
  60. package/dist/ro-NR2DRTZG.js.map +1 -0
  61. package/dist/{sv-DS3RO24M.js → sv-STVJ37JP.js} +32 -6
  62. package/dist/sv-STVJ37JP.js.map +1 -0
  63. package/dist/test-utils.js +5 -3
  64. package/dist/test-utils.js.map +1 -1
  65. package/dist/{th-AX66MWWB.js → th-UWQR4K54.js} +32 -6
  66. package/dist/th-UWQR4K54.js.map +1 -0
  67. package/dist/{tr-7KTB67TT.js → tr-WNXZSD27.js} +32 -6
  68. package/dist/tr-WNXZSD27.js.map +1 -0
  69. package/dist/{uk-SMQS2MAQ.js → uk-SJRMB47Y.js} +32 -6
  70. package/dist/uk-SJRMB47Y.js.map +1 -0
  71. package/dist/{vi-D3JXL2EZ.js → vi-TMRQFES2.js} +32 -6
  72. package/dist/vi-TMRQFES2.js.map +1 -0
  73. package/dist/{zh-S754AWFS.js → zh-QOL3LYEM.js} +32 -6
  74. package/dist/zh-QOL3LYEM.js.map +1 -0
  75. package/package.json +8 -8
  76. package/src/components/AssistProgress.tsx +42 -30
  77. package/src/components/EntityFoundLog.tsx +38 -0
  78. package/src/components/StatusDisplay.css +35 -35
  79. package/src/components/__tests__/AssistProgress.test.tsx +86 -28
  80. package/src/components/annotation/annotations.css +1 -1
  81. package/src/components/modals/SearchModal.css +5 -5
  82. package/src/components/pdf-annotation/PdfAnnotationCanvas.css +143 -6
  83. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +689 -198
  84. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +1008 -3
  85. package/src/components/pdf-annotation/__tests__/estimate-slot-height.test.ts +53 -0
  86. package/src/components/pdf-annotation/estimate-slot-height.ts +31 -0
  87. package/src/components/resource/AnnotateView.tsx +14 -7
  88. package/src/components/resource/BrowseView.tsx +2 -7
  89. package/src/components/resource/DownloadFileLink.tsx +49 -0
  90. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
  91. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +33 -0
  92. package/src/components/resource/__tests__/download-link.test.tsx +129 -0
  93. package/src/components/resource/annotate-renderers.tsx +10 -1
  94. package/src/components/resource/browse-renderers.tsx +10 -1
  95. package/src/components/resource/panels/AssistSection.css +0 -43
  96. package/src/components/resource/panels/AssistSection.tsx +11 -2
  97. package/src/components/resource/panels/AssistShell.tsx +5 -1
  98. package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
  99. package/src/components/resource/panels/TaggingPanel.tsx +21 -1
  100. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
  101. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
  102. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
  103. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
  104. package/src/features/auth/auth.css +10 -10
  105. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +58 -3
  106. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +18 -7
  107. package/src/styles/core/inputs.css +1 -1
  108. package/src/styles/features/compose.css +1 -1
  109. package/src/styles/features/resource-viewer.css +1 -1
  110. package/src/styles/features/static-pages.css +11 -11
  111. package/src/styles/motivations/motivation-comment.css +2 -2
  112. package/src/styles/motivations/motivation-reference.css +2 -2
  113. package/src/styles/patterns/panels-base.css +4 -4
  114. package/src/styles/utilities/motion.css +15 -15
  115. package/src/styles/variables.css +29 -1
  116. package/translations/ar.json +27 -5
  117. package/translations/bn.json +27 -5
  118. package/translations/cs.json +27 -5
  119. package/translations/da.json +27 -5
  120. package/translations/de.json +27 -5
  121. package/translations/el.json +27 -5
  122. package/translations/en.json +27 -5
  123. package/translations/es.json +27 -5
  124. package/translations/fa.json +27 -5
  125. package/translations/fi.json +27 -5
  126. package/translations/fr.json +27 -5
  127. package/translations/he.json +27 -5
  128. package/translations/hi.json +27 -5
  129. package/translations/id.json +27 -5
  130. package/translations/it.json +27 -5
  131. package/translations/ja.json +27 -5
  132. package/translations/ko.json +27 -5
  133. package/translations/ms.json +27 -5
  134. package/translations/nl.json +27 -5
  135. package/translations/no.json +27 -5
  136. package/translations/pl.json +27 -5
  137. package/translations/pt.json +27 -5
  138. package/translations/ro.json +27 -5
  139. package/translations/sv.json +27 -5
  140. package/translations/th.json +27 -5
  141. package/translations/tr.json +27 -5
  142. package/translations/uk.json +27 -5
  143. package/translations/vi.json +27 -5
  144. package/translations/zh.json +27 -5
  145. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css +0 -98
  146. package/dist/PdfAnnotationCanvas.client-SU5KNT7G.css.map +0 -1
  147. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js +0 -427
  148. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
  149. package/dist/ar-JRTKCMTF.js.map +0 -1
  150. package/dist/bn-LO6EDSCC.js.map +0 -1
  151. package/dist/chunk-4QMTEPQ3.js.map +0 -1
  152. package/dist/chunk-5Y5JDCKC.js.map +0 -1
  153. package/dist/cs-LCHHOR4O.js.map +0 -1
  154. package/dist/da-TARGPMJR.js.map +0 -1
  155. package/dist/de-SBH4QBPJ.js.map +0 -1
  156. package/dist/el-2D2OLGWB.js.map +0 -1
  157. package/dist/es-U6YK2LX6.js.map +0 -1
  158. package/dist/fa-S7SZE37M.js.map +0 -1
  159. package/dist/fi-CBL3NIXN.js.map +0 -1
  160. package/dist/fr-EABUHV6S.js.map +0 -1
  161. package/dist/he-THWD244Y.js.map +0 -1
  162. package/dist/hi-GHGFZJ44.js.map +0 -1
  163. package/dist/id-POZOUMDW.js.map +0 -1
  164. package/dist/it-KDYJADSH.js.map +0 -1
  165. package/dist/ja-Z5Z6ZF3P.js.map +0 -1
  166. package/dist/ko-7X3XUXRN.js.map +0 -1
  167. package/dist/ms-BTWEIW43.js.map +0 -1
  168. package/dist/nl-IS2CGF5I.js.map +0 -1
  169. package/dist/no-AAMEVA26.js.map +0 -1
  170. package/dist/pl-PVXUQYFT.js.map +0 -1
  171. package/dist/pt-3ITQ76TL.js.map +0 -1
  172. package/dist/ro-FRB4IIVS.js.map +0 -1
  173. package/dist/sv-DS3RO24M.js.map +0 -1
  174. package/dist/th-AX66MWWB.js.map +0 -1
  175. package/dist/tr-7KTB67TT.js.map +0 -1
  176. package/dist/uk-SMQS2MAQ.js.map +0 -1
  177. package/dist/vi-D3JXL2EZ.js.map +0 -1
  178. package/dist/zh-S754AWFS.js.map +0 -1
  179. /package/dist/{en-QFIEG3CA.js.map → en-3FCBW3YD.js.map} +0 -0
@@ -7,15 +7,43 @@
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';
18
+ import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
16
19
 
17
20
  import type { Annotation } from '@semiont/core';
18
21
 
22
+ // The page's text layer, as pdf.js reports it. Geometry is PDF points,
23
+ // bottom-left origin: "Hello world" sits on one line at y=700, "elsewhere" a
24
+ // hundred points below it.
25
+ // vi.hoisted: vi.mock's factory is lifted above these declarations.
26
+ const { MOCK_TEXT_ITEMS } = vi.hoisted(() => ({
27
+ MOCK_TEXT_ITEMS: [
28
+ { str: 'Hello', transform: [1, 0, 0, 1, 72, 700], width: 30, height: 12, hasEOL: false },
29
+ { str: 'world', transform: [1, 0, 0, 1, 106, 700], width: 32, height: 12, hasEOL: true },
30
+ { str: 'elsewhere', transform: [1, 0, 0, 1, 72, 600], width: 54, height: 12, hasEOL: true },
31
+ ],
32
+ }));
33
+
34
+ const mockPage = (textItems: unknown[]) => ({
35
+ getViewport: vi.fn().mockReturnValue({
36
+ width: 612,
37
+ height: 792,
38
+ scale: 1.0,
39
+ rotation: 0
40
+ }),
41
+ render: vi.fn().mockReturnValue({
42
+ promise: Promise.resolve()
43
+ }),
44
+ getTextContent: vi.fn().mockResolvedValue({ items: textItems })
45
+ });
46
+
19
47
  // Mock browser-pdfjs module
20
48
  vi.mock('../../../lib/browser-pdfjs', () => ({
21
49
  loadPdfDocument: vi.fn().mockResolvedValue({
@@ -29,7 +57,8 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
29
57
  }),
30
58
  render: vi.fn().mockReturnValue({
31
59
  promise: Promise.resolve()
32
- })
60
+ }),
61
+ getTextContent: vi.fn().mockResolvedValue({ items: MOCK_TEXT_ITEMS })
33
62
  })
34
63
  }),
35
64
  renderPdfPageToDataUrl: vi.fn().mockResolvedValue({
@@ -39,10 +68,78 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
39
68
  })
40
69
  }));
41
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
+
42
134
  describe('PdfAnnotationCanvas', () => {
43
135
  const mockResourceId = resourceId('123');
44
136
  const mockPdfUrl = 'https://example.com/resources/123.pdf';
45
137
 
138
+ afterEach(() => {
139
+ // Stubs must not leak into other files sharing this Vitest environment.
140
+ vi.unstubAllGlobals();
141
+ });
142
+
46
143
  beforeEach(() => {
47
144
  vi.clearAllMocks();
48
145
 
@@ -313,6 +410,225 @@ describe('PdfAnnotationCanvas', () => {
313
410
  });
314
411
  });
315
412
 
413
+ /**
414
+ * Drives the drag → mark.request path far enough to inspect the selector.
415
+ * `drag` is in canvas pixels; at scale 1 with a 612×792 page these are also
416
+ * display coordinates, since jsdom's getBoundingClientRect returns zeros.
417
+ */
418
+ async function drawRectangle(
419
+ drag: { fromX: number; fromY: number; toX: number; toY: number },
420
+ ) {
421
+ const request = vi.fn();
422
+ const session = {
423
+ client: { mark: { request }, beckon: { hover: vi.fn() } },
424
+ } as unknown as import('@semiont/sdk').SemiontSession;
425
+
426
+ render(
427
+ <PdfAnnotationCanvas resourceUri="res-1"
428
+ pdfUrl={mockPdfUrl}
429
+ drawingMode="rectangle"
430
+ selectedMotivation="highlighting"
431
+ session={session}
432
+ />
433
+ );
434
+
435
+ await waitFor(() => {
436
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image');
437
+ expect(img).toBeInTheDocument();
438
+ });
439
+
440
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
441
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
442
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
443
+ fireEvent.load(img);
444
+
445
+ const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
446
+ fireEvent.mouseDown(container, { clientX: drag.fromX, clientY: drag.fromY });
447
+ fireEvent.mouseMove(container, { clientX: drag.toX, clientY: drag.toY });
448
+ fireEvent.mouseUp(container, { clientX: drag.toX, clientY: drag.toY });
449
+
450
+ return request;
451
+ }
452
+
453
+ // The manual-annotation capture gap (.plans/PDF-MANUAL-ANNOTATION-TEXT.md):
454
+ // a hand-drawn rectangle stored geometry and nothing else, so every panel
455
+ // that quotes an annotation showed it blank.
456
+ test('a drawn rectangle carries the text it was drawn around', async () => {
457
+ // The "Hello world" line in canvas space: PDF y=700..712 flips to
458
+ // canvas y=80..92 on a 792pt page. Drag a box a little larger than it.
459
+ const line = pdfToCanvasCoordinates(
460
+ { page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
461
+ );
462
+ const request = await drawRectangle({
463
+ fromX: line.x - 2,
464
+ fromY: line.y - 2,
465
+ toX: line.x + line.width + 8,
466
+ toY: line.y + line.height + 4,
467
+ });
468
+
469
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
470
+
471
+ const [source, selector, motivation] = request.mock.calls[0];
472
+ expect(source).toBe(resourceId('res-1'));
473
+ expect(motivation).toBe('highlighting');
474
+ expect(selector).toEqual([
475
+ {
476
+ type: 'FragmentSelector',
477
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
478
+ value: expect.stringContaining('page=1&viewrect='),
479
+ },
480
+ { type: 'TextQuoteSelector', exact: 'Hello world' },
481
+ ]);
482
+ });
483
+
484
+ test('a rectangle over blank space carries geometry only', async () => {
485
+ // Between the two lines of text — nothing under the box. An empty-string
486
+ // quote would assert the box was drawn around nothing, so emit no quote.
487
+ const request = await drawRectangle({ fromX: 40, fromY: 120, toX: 200, toY: 150 });
488
+
489
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
490
+
491
+ const selector = request.mock.calls[0][1];
492
+ expect(selector).toHaveLength(1);
493
+ expect(selector[0].type).toBe('FragmentSelector');
494
+ });
495
+
496
+ // Phase 2 of .plans/PDF-MANUAL-ANNOTATION-TEXT.md. A scanned page has no text
497
+ // in the browser, but the server derived one at ingest and serves it through
498
+ // `browse.resourceAnchoredText`. Same `AnchoredText` shape either way, so
499
+ // `textUnder` and the drag handler do not branch — only the source does.
500
+ test('a scanned page quotes from the map the server derived', async () => {
501
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
502
+ numPages: 3,
503
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
504
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
505
+
506
+ const request = vi.fn();
507
+ const session = {
508
+ client: {
509
+ mark: { request },
510
+ beckon: { hover: vi.fn() },
511
+ browse: {
512
+ resourceAnchoredText: vi.fn().mockResolvedValue({
513
+ text: 'Hello world again',
514
+ items: [
515
+ { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
516
+ { start: 6, end: 11, page: 1, x: 106, y: 700, width: 32, height: 12 },
517
+ ],
518
+ }),
519
+ },
520
+ },
521
+ } as unknown as import('@semiont/sdk').SemiontSession;
522
+
523
+ render(
524
+ <PdfAnnotationCanvas resourceUri="res-1"
525
+ pdfUrl={mockPdfUrl}
526
+ drawingMode="rectangle"
527
+ selectedMotivation="highlighting"
528
+ session={session}
529
+ />
530
+ );
531
+
532
+ await waitFor(() => {
533
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__image')).toBeInTheDocument();
534
+ });
535
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
536
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
537
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
538
+ fireEvent.load(img);
539
+
540
+ const line = pdfToCanvasCoordinates({ page: 1, x: 72, y: 700, width: 66, height: 12 }, 792, 1.0);
541
+ const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
542
+ fireEvent.mouseDown(container, { clientX: line.x - 2, clientY: line.y - 2 });
543
+ fireEvent.mouseMove(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
544
+ fireEvent.mouseUp(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
545
+
546
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
547
+ expect(request.mock.calls[0][1]).toEqual([
548
+ expect.objectContaining({ type: 'FragmentSelector' }),
549
+ { type: 'TextQuoteSelector', exact: 'Hello world' },
550
+ ]);
551
+ });
552
+
553
+ // PERSIST-ANCHORS P4. The server map is WHOLE-RESOURCE — one artifact
554
+ // covering every page — so fetching it from inside the per-page load
555
+ // effect refetched and re-decoded the entire document's geometry on every
556
+ // page turn. On a 400-page scan that is the difference between one decode
557
+ // and one per interaction.
558
+ test('the whole-resource map is fetched once per document, not once per page-turn', async () => {
559
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
560
+ numPages: 3,
561
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
562
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
563
+
564
+ const resourceAnchoredText = vi.fn().mockResolvedValue({
565
+ text: 'Hello world again',
566
+ items: [
567
+ { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
568
+ { start: 6, end: 11, page: 2, x: 106, y: 700, width: 32, height: 12 },
569
+ ],
570
+ });
571
+ const session = {
572
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
573
+ } as unknown as import('@semiont/sdk').SemiontSession;
574
+
575
+ render(
576
+ <PdfAnnotationCanvas resourceUri="res-1"
577
+ pdfUrl={mockPdfUrl}
578
+ drawingMode={null}
579
+ session={session}
580
+ />
581
+ );
582
+
583
+ const user = userEvent.setup();
584
+ await waitFor(() => {
585
+ expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
586
+ });
587
+
588
+ await user.click(screen.getByRole('button', { name: /next/i }));
589
+ await waitFor(() => {
590
+ expect(screen.getByText(/page 2 of 3/i)).toBeInTheDocument();
591
+ });
592
+ await user.click(screen.getByRole('button', { name: /next/i }));
593
+ await waitFor(() => {
594
+ expect(screen.getByText(/page 3 of 3/i)).toBeInTheDocument();
595
+ });
596
+
597
+ // The render half runs once per page load and starts alongside the
598
+ // anchoring half (Promise.all), so three render calls prove all three
599
+ // page-load effects ran to the point of resolving their map.
600
+ await waitFor(() => {
601
+ expect(vi.mocked(renderPdfPageToDataUrl)).toHaveBeenCalledTimes(3);
602
+ });
603
+
604
+ expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
605
+ });
606
+
607
+ test('a scanned page with no server map carries geometry only', async () => {
608
+ // Class B: pdf.js returns no runs, so the browser cannot do the job and
609
+ // the annotation stays geometry-only pending async enrichment (Phase 2).
610
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
611
+ numPages: 3,
612
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
613
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
614
+
615
+ const line = pdfToCanvasCoordinates(
616
+ { page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
617
+ );
618
+ const request = await drawRectangle({
619
+ fromX: line.x - 2,
620
+ fromY: line.y - 2,
621
+ toX: line.x + line.width + 8,
622
+ toY: line.y + line.height + 4,
623
+ });
624
+
625
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
626
+
627
+ const selector = request.mock.calls[0][1];
628
+ expect(selector).toHaveLength(1);
629
+ expect(selector[0].type).toBe('FragmentSelector');
630
+ });
631
+
316
632
  test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
317
633
  render(
318
634
  <PdfAnnotationCanvas resourceUri="res-1"
@@ -344,4 +660,693 @@ describe('PdfAnnotationCanvas', () => {
344
660
  expect(container).toBeInTheDocument();
345
661
  }
346
662
  });
663
+
664
+ // PDF-CONTINUOUS-SCROLL S1. Browse mode scrolls: every page has a slot so
665
+ // the scrollbar is honest about the document's length, but only a window of
666
+ // pages is MOUNTED — unmounting is what releases a page's raster, so the
667
+ // window IS the memory budget (D2).
668
+ describe('scroll layout (browse mode)', () => {
669
+ const scannedDoc = () =>
670
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
671
+ numPages: 5,
672
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
673
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
674
+
675
+ const mountedPages = () =>
676
+ [...document.querySelectorAll('.semiont-pdf-annotation-canvas__image')]
677
+ .map((img) => Number((img as HTMLImageElement).alt.replace(/\D+/g, '')))
678
+ .sort((a, b) => a - b);
679
+
680
+ test('gives every page a slot but mounts only the visible window', async () => {
681
+ const io = stubIntersectionObserver();
682
+ scannedDoc();
683
+
684
+ render(
685
+ <PdfAnnotationCanvas resourceUri="res-1"
686
+ pdfUrl={mockPdfUrl}
687
+ drawingMode={null}
688
+ pageLayout="scroll"
689
+ />
690
+ );
691
+
692
+ await waitFor(() => {
693
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
694
+ });
695
+ // Nothing rasterized before anything is visible.
696
+ expect(mountedPages()).toEqual([]);
697
+
698
+ io.fire([1, 2]);
699
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
700
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
701
+ });
702
+
703
+ test('releases a page that scrolls out of the window', async () => {
704
+ const io = stubIntersectionObserver();
705
+ scannedDoc();
706
+
707
+ render(
708
+ <PdfAnnotationCanvas resourceUri="res-1"
709
+ pdfUrl={mockPdfUrl}
710
+ drawingMode={null}
711
+ pageLayout="scroll"
712
+ />
713
+ );
714
+ await waitFor(() => {
715
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
716
+ });
717
+
718
+ io.fire([1, 2]);
719
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2]));
720
+
721
+ io.fire([4, 5]);
722
+ await waitFor(() => expect(mountedPages()).toEqual([4, 5]));
723
+ });
724
+
725
+ test('still fetches the whole-resource map once, across many mounted pages (P4)', async () => {
726
+ // P4's invariant has to survive the move from one shared page-load
727
+ // effect into N independent page views.
728
+ const io = stubIntersectionObserver();
729
+ scannedDoc();
730
+
731
+ const resourceAnchoredText = vi.fn().mockResolvedValue({
732
+ text: 'Hello world again',
733
+ items: [{ start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 }],
734
+ });
735
+ const session = {
736
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
737
+ } as unknown as import('@semiont/sdk').SemiontSession;
738
+
739
+ render(
740
+ <PdfAnnotationCanvas resourceUri="res-1"
741
+ pdfUrl={mockPdfUrl}
742
+ drawingMode={null}
743
+ pageLayout="scroll"
744
+ session={session}
745
+ />
746
+ );
747
+ await waitFor(() => {
748
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
749
+ });
750
+
751
+ io.fire([1, 2, 3]);
752
+ await waitFor(() => expect(mountedPages()).toEqual([1, 2, 3]));
753
+ io.fire([3, 4, 5]);
754
+ await waitFor(() => expect(mountedPages()).toEqual([3, 4, 5]));
755
+
756
+ expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
757
+ });
758
+
759
+ test('a slot keeps its reserved height when its page mounts', async () => {
760
+ // S1b. Releasing the reservation on mount is what made the column's
761
+ // total height change on every scroll — the scrollbar jumped and
762
+ // resized under the cursor. The reservation must survive mounting so
763
+ // the column's geometry never depends on which pages are mounted.
764
+ const io = stubIntersectionObserver();
765
+ scannedDoc();
766
+
767
+ render(
768
+ <PdfAnnotationCanvas resourceUri="res-1"
769
+ pdfUrl={mockPdfUrl}
770
+ drawingMode={null}
771
+ pageLayout="scroll"
772
+ />
773
+ );
774
+ await waitFor(() => {
775
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
776
+ });
777
+
778
+ const slotOf = (page: number) =>
779
+ document.querySelector(`.semiont-pdf-annotation-canvas__slot[data-page="${page}"]`) as HTMLElement;
780
+
781
+ // Read whichever property expresses the reservation, so the pin is
782
+ // about the CONTRACT (space is reserved, and mounting does not release
783
+ // it) rather than about which CSS property implements it.
784
+ const reserved = (el: HTMLElement) => el.style.minHeight || el.style.height;
785
+
786
+ io.fire([1]);
787
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
788
+
789
+ // Page 5 is unmounted and must hold space open.
790
+ expect(reserved(slotOf(5))).not.toBe('');
791
+ // Page 1 is mounted and must hold exactly the same space, or the
792
+ // column's height changes as pages come and go.
793
+ expect(reserved(slotOf(1))).toBe(reserved(slotOf(5)));
794
+ });
795
+
796
+ test('a rectangle drawn on a scrolled page carries THAT page number', async () => {
797
+ // S2. The drag lives in the page view, so page identity comes from the
798
+ // component that owns the pixels rather than from a shared `pageNumber`
799
+ // — the invariant that makes a column safe to draw on at all.
800
+ const io = stubIntersectionObserver();
801
+ scannedDoc();
802
+
803
+ const request = vi.fn();
804
+ const session = {
805
+ client: { mark: { request }, beckon: { hover: vi.fn() } },
806
+ } as unknown as import('@semiont/sdk').SemiontSession;
807
+
808
+ render(
809
+ <PdfAnnotationCanvas resourceUri="res-1"
810
+ pdfUrl={mockPdfUrl}
811
+ drawingMode="rectangle"
812
+ selectedMotivation="highlighting"
813
+ session={session}
814
+ pageLayout="scroll"
815
+ />
816
+ );
817
+ await waitFor(() => {
818
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
819
+ });
820
+
821
+ io.fire([2, 3]);
822
+ await waitFor(() => expect(mountedPages()).toEqual([2, 3]));
823
+
824
+ // Draw on page 3's container, not page 2's.
825
+ const slot3 = document.querySelector('.semiont-pdf-annotation-canvas__slot[data-page="3"]')!;
826
+ const img = slot3.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
827
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
828
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
829
+ fireEvent.load(img);
830
+
831
+ const container = slot3.querySelector('.semiont-pdf-annotation-canvas__container')!;
832
+ fireEvent.mouseDown(container, { clientX: 40, clientY: 40 });
833
+ fireEvent.mouseMove(container, { clientX: 200, clientY: 160 });
834
+ fireEvent.mouseUp(container, { clientX: 200, clientY: 160 });
835
+
836
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
837
+ expect(request.mock.calls[0][1][0].value).toContain('page=3');
838
+ });
839
+
840
+ test('annotate mode scrolls too — the primary mode is not left on the pager', async () => {
841
+ // The registries are the seam: both now ask for the column.
842
+ const io = stubIntersectionObserver();
843
+ scannedDoc();
844
+
845
+ render(
846
+ <PdfAnnotationCanvas resourceUri="res-1"
847
+ pdfUrl={mockPdfUrl}
848
+ drawingMode="rectangle"
849
+ selectedMotivation="highlighting"
850
+ pageLayout="scroll"
851
+ />
852
+ );
853
+
854
+ await waitFor(() => {
855
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
856
+ });
857
+ io.fire([1]);
858
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
859
+ // Drawing still works in the column: the container is the page's own.
860
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__container'))
861
+ .toHaveAttribute('data-drawing-mode', 'rectangle');
862
+ });
863
+
864
+ // S4. A strip of proportional rectangles — one per page, current
865
+ // highlighted, click to jump. Deliberately NOT thumbnails: it needs no
866
+ // rasterization, only the page count and the aspect ratio S1b already
867
+ // measures, so it costs nothing next to the document itself. It answers
868
+ // "where am I in this document", which neither the pager nor the
869
+ // scrollbar does.
870
+ test('renders one strip rectangle per page, marking the current one', async () => {
871
+ const io = stubIntersectionObserver();
872
+ scannedDoc();
873
+
874
+ render(
875
+ <PdfAnnotationCanvas resourceUri="res-1"
876
+ pdfUrl={mockPdfUrl}
877
+ drawingMode={null}
878
+ pageLayout="scroll"
879
+ />
880
+ );
881
+ await waitFor(() => {
882
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
883
+ });
884
+
885
+ const ticks = () => document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page');
886
+ await waitFor(() => expect(ticks()).toHaveLength(5));
887
+
888
+ io.fire([3, 4]);
889
+ await waitFor(() => {
890
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
891
+ });
892
+ });
893
+
894
+ test('clicking a strip rectangle jumps to that page', async () => {
895
+ const io = stubIntersectionObserver();
896
+ scannedDoc();
897
+ const scrollIntoView = vi.fn();
898
+ Element.prototype.scrollIntoView = scrollIntoView;
899
+
900
+ render(
901
+ <PdfAnnotationCanvas resourceUri="res-1"
902
+ pdfUrl={mockPdfUrl}
903
+ drawingMode={null}
904
+ pageLayout="scroll"
905
+ />
906
+ );
907
+ await waitFor(() => {
908
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
909
+ });
910
+ io.fire([1]);
911
+
912
+ const tick4 = document.querySelector('.semiont-pdf-annotation-canvas__strip-page[data-page="4"]')!;
913
+ fireEvent.click(tick4);
914
+
915
+ expect(scrollIntoView).toHaveBeenCalled();
916
+ });
917
+
918
+ // S1a. Arrow keys step pages. The guards are the substance: a viewer that
919
+ // steals arrow keys from a text field is worse than one with no shortcut.
920
+ test('PageUp/PageDown step pages; Up/Down are left to scroll', async () => {
921
+ // The convention every mainstream viewer follows (Preview, Chrome's PDF
922
+ // viewer, Acrobat): PageUp/PageDown jump a page, Up/Down scroll finely.
923
+ // Taking Up/Down would leave no keyboard way to reach the bottom of a
924
+ // page taller than the viewport.
925
+ const io = stubIntersectionObserver();
926
+ scannedDoc();
927
+ const scrollIntoView = vi.fn();
928
+ Element.prototype.scrollIntoView = scrollIntoView;
929
+
930
+ render(
931
+ <PdfAnnotationCanvas resourceUri="res-1"
932
+ pdfUrl={mockPdfUrl}
933
+ drawingMode={null}
934
+ pageLayout="scroll"
935
+ />
936
+ );
937
+ await waitFor(() => {
938
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
939
+ });
940
+ io.fire([2]);
941
+ await waitFor(() => expect(mountedPages()).toEqual([2]));
942
+
943
+ scrollIntoView.mockClear();
944
+ fireEvent.keyDown(window, { key: 'PageDown' });
945
+ expect(scrollIntoView).toHaveBeenCalled();
946
+
947
+ scrollIntoView.mockClear();
948
+ fireEvent.keyDown(window, { key: 'PageUp' });
949
+ expect(scrollIntoView).toHaveBeenCalled();
950
+
951
+ // Up/Down belong to the scroller, not to us.
952
+ scrollIntoView.mockClear();
953
+ fireEvent.keyDown(window, { key: 'ArrowDown' });
954
+ fireEvent.keyDown(window, { key: 'ArrowUp' });
955
+ expect(scrollIntoView).not.toHaveBeenCalled();
956
+
957
+ // The same guard as the horizontal pair: never steal from a text field.
958
+ const input = document.createElement('input');
959
+ document.body.appendChild(input);
960
+ input.focus();
961
+ scrollIntoView.mockClear();
962
+ fireEvent.keyDown(input, { key: 'PageDown' });
963
+ expect(scrollIntoView).not.toHaveBeenCalled();
964
+ input.remove();
965
+ });
966
+
967
+ test('Left/Right step pages, and never steal keys from a text field', async () => {
968
+ const io = stubIntersectionObserver();
969
+ scannedDoc();
970
+ const scrollIntoView = vi.fn();
971
+ Element.prototype.scrollIntoView = scrollIntoView;
972
+
973
+ render(
974
+ <PdfAnnotationCanvas resourceUri="res-1"
975
+ pdfUrl={mockPdfUrl}
976
+ drawingMode={null}
977
+ pageLayout="scroll"
978
+ />
979
+ );
980
+ await waitFor(() => {
981
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
982
+ });
983
+ io.fire([1]);
984
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
985
+
986
+ scrollIntoView.mockClear();
987
+ fireEvent.keyDown(window, { key: 'ArrowRight' });
988
+ expect(scrollIntoView).toHaveBeenCalled();
989
+
990
+ // Typing in an input must not page the document underneath it.
991
+ const input = document.createElement('input');
992
+ document.body.appendChild(input);
993
+ input.focus();
994
+ scrollIntoView.mockClear();
995
+ fireEvent.keyDown(input, { key: 'ArrowRight' });
996
+ expect(scrollIntoView).not.toHaveBeenCalled();
997
+ input.remove();
998
+ });
999
+
1000
+ test('focus follows the current page when the strip owns focus', async () => {
1001
+ // Reported: click a page in the strip, then arrow away — the focus ring
1002
+ // stays behind on the clicked rectangle while the current-page marker
1003
+ // moves, so two rectangles claim to be "here". (The ring only appears
1004
+ // after the arrow press because that is when the browser switches to
1005
+ // keyboard modality and the clicked button starts matching
1006
+ // :focus-visible.) Focus must travel with the current page — but ONLY
1007
+ // when the strip already had it, so scrolling with the mouse never
1008
+ // snatches focus away from whatever the reader was doing.
1009
+ const io = stubIntersectionObserver();
1010
+ scannedDoc();
1011
+ Element.prototype.scrollIntoView = vi.fn();
1012
+
1013
+ render(
1014
+ <PdfAnnotationCanvas resourceUri="res-1"
1015
+ pdfUrl={mockPdfUrl}
1016
+ drawingMode={null}
1017
+ pageLayout="scroll"
1018
+ />
1019
+ );
1020
+ await waitFor(() => {
1021
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1022
+ });
1023
+ io.fire([1]);
1024
+ await waitFor(() => expect(mountedPages()).toEqual([1]));
1025
+
1026
+ const tick = (page: number) =>
1027
+ document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`) as HTMLButtonElement;
1028
+
1029
+ tick(2).focus();
1030
+ fireEvent.click(tick(2));
1031
+ io.fire([3]);
1032
+
1033
+ await waitFor(() => expect(tick(3)).toHaveAttribute('aria-current', 'page'));
1034
+ expect(document.activeElement).toBe(tick(3));
1035
+ });
1036
+
1037
+ test('the strip is one tab stop, not one per page', async () => {
1038
+ // A 400-page document must not put 400 stops in the tab order. The
1039
+ // ARIA pattern: the current item is tabbable, the rest are reachable
1040
+ // with arrows (which already page the document).
1041
+ const io = stubIntersectionObserver();
1042
+ scannedDoc();
1043
+ Element.prototype.scrollIntoView = vi.fn();
1044
+
1045
+ render(
1046
+ <PdfAnnotationCanvas resourceUri="res-1"
1047
+ pdfUrl={mockPdfUrl}
1048
+ drawingMode={null}
1049
+ pageLayout="scroll"
1050
+ />
1051
+ );
1052
+ await waitFor(() => {
1053
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1054
+ });
1055
+ io.fire([1]);
1056
+
1057
+ const tabbable = [...document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')]
1058
+ .filter((el) => (el as HTMLElement).tabIndex === 0);
1059
+ expect(tabbable).toHaveLength(1);
1060
+ expect(tabbable[0]).toHaveAttribute('data-page', '1');
1061
+ });
1062
+
1063
+ test('the strip runs along the same axis as the scrolling, beside the column', async () => {
1064
+ // Pages scroll vertically, so a horizontal strip reads across a
1065
+ // direction the document does not move in. The strip's axis follows the
1066
+ // scroll axis — which is also what makes horizontal scrolling (a later
1067
+ // phase) a change of one value rather than a second layout.
1068
+ const io = stubIntersectionObserver();
1069
+ scannedDoc();
1070
+ Element.prototype.scrollIntoView = vi.fn();
1071
+
1072
+ render(
1073
+ <PdfAnnotationCanvas resourceUri="res-1"
1074
+ pdfUrl={mockPdfUrl}
1075
+ drawingMode={null}
1076
+ pageLayout="scroll"
1077
+ />
1078
+ );
1079
+ await waitFor(() => {
1080
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1081
+ });
1082
+ io.fire([1]);
1083
+
1084
+ const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
1085
+ const strip = document.querySelector('.semiont-pdf-annotation-canvas__strip')!;
1086
+
1087
+ // One axis, declared in one place, read by both.
1088
+ expect(column).toHaveAttribute('data-axis', 'vertical');
1089
+ expect(strip).toHaveAttribute('data-axis', 'vertical');
1090
+ // Tells assistive tech which arrow keys apply to the strip.
1091
+ expect(strip).toHaveAttribute('aria-orientation', 'vertical');
1092
+ // The strip leads: it hugs the left edge of the content, the
1093
+ // conventional place for a page rail (Preview, Acrobat), and the
1094
+ // reader's eye finds it before the page rather than after it.
1095
+ expect(strip.nextElementSibling).toBe(column);
1096
+ expect(strip.parentElement).toHaveClass('semiont-pdf-annotation-canvas__viewport');
1097
+ });
1098
+
1099
+ test('each strip rectangle shows its page number', async () => {
1100
+ // At 70 pages the rectangles are indistinguishable from one another —
1101
+ // the strip shows position but not WHICH page, which is most of what a
1102
+ // reader wants from it on a long document.
1103
+ const io = stubIntersectionObserver();
1104
+ scannedDoc();
1105
+ Element.prototype.scrollIntoView = vi.fn();
1106
+
1107
+ render(
1108
+ <PdfAnnotationCanvas resourceUri="res-1"
1109
+ pdfUrl={mockPdfUrl}
1110
+ drawingMode={null}
1111
+ pageLayout="scroll"
1112
+ />
1113
+ );
1114
+ await waitFor(() => {
1115
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__strip-page')).toHaveLength(5);
1116
+ });
1117
+ io.fire([1]);
1118
+
1119
+ const tick = (page: number) =>
1120
+ document.querySelector(`.semiont-pdf-annotation-canvas__strip-page[data-page="${page}"]`)!;
1121
+ expect(tick(1)).toHaveTextContent('1');
1122
+ expect(tick(5)).toHaveTextContent('5');
1123
+
1124
+ // The accessible name keeps the context the bare digit loses, and
1125
+ // still contains the visible text (WCAG 2.5.3 Label in Name).
1126
+ expect(tick(5)).toHaveAttribute('aria-label', expect.stringContaining('5'));
1127
+ });
1128
+
1129
+
1130
+ test('the column does not add a second scrollbar inside the scrolling pane', async () => {
1131
+ // The strip and a nested column scrollbar were two controls for one
1132
+ // movement. The pane the viewer sits in already scrolls (flex: 1;
1133
+ // min-height: 0), so the column must not declare a viewport-relative
1134
+ // height of its own — that is what produced scroll-within-scroll.
1135
+ const io = stubIntersectionObserver();
1136
+ scannedDoc();
1137
+ Element.prototype.scrollIntoView = vi.fn();
1138
+
1139
+ render(
1140
+ <PdfAnnotationCanvas resourceUri="res-1"
1141
+ pdfUrl={mockPdfUrl}
1142
+ drawingMode={null}
1143
+ pageLayout="scroll"
1144
+ />
1145
+ );
1146
+ await waitFor(() => {
1147
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1148
+ });
1149
+ io.fire([1]);
1150
+
1151
+ // jsdom applies no stylesheet, so assert the CONTRACT the CSS keys off:
1152
+ // the column declares the axis but claims no scroll role of its own.
1153
+ const column = document.querySelector('.semiont-pdf-annotation-canvas__column')!;
1154
+ expect(column).toHaveAttribute('data-axis', 'vertical');
1155
+ expect(column).not.toHaveAttribute('data-scroller');
1156
+ // The strip is the one that scrolls independently, and silently.
1157
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__strip'))
1158
+ .toHaveAttribute('data-scroller', 'true');
1159
+ });
1160
+
1161
+ test('the indicator reports a page the reader can SEE, not a preloaded one', async () => {
1162
+ // The mount window deliberately reaches a viewport beyond the view
1163
+ // (PRELOAD_MARGIN) so pages arrive early. Deriving the current page
1164
+ // from that same set makes the indicator name a page that is still
1165
+ // off-screen — it must come from actual visibility.
1166
+ const io = stubIntersectionObserver();
1167
+ scannedDoc();
1168
+ Element.prototype.scrollIntoView = vi.fn();
1169
+
1170
+ render(
1171
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1172
+ drawingMode={null} pageLayout="scroll" />
1173
+ );
1174
+ await waitFor(() => {
1175
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1176
+ });
1177
+
1178
+ // Pages 1-3 are within the preload window; only 3 is actually on screen.
1179
+ io.fire([1, 2, 3]);
1180
+ io.fireOnscreen([3]);
1181
+
1182
+ await waitFor(() => {
1183
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
1184
+ });
1185
+ });
1186
+
1187
+ test('paged navigation does not carry one page\'s state onto the next', async () => {
1188
+ // In the paged layout a single PdfPageView is reused as `pageNumber`
1189
+ // changes, so its state survives the switch: the previous page's raster
1190
+ // shows briefly under the new page's overlay, a drag could quote the
1191
+ // previous page's text, and a load failure sticks forever because
1192
+ // nothing clears it. Each page is a different thing and must mount as one.
1193
+ // Reject by PAGE, not by call order: the parent also calls getPage(1)
1194
+ // to measure the document's shape, so a `...Once` rejection would be
1195
+ // consumed there and never reach the page view.
1196
+ const failingDoc = {
1197
+ numPages: 3,
1198
+ getPage: vi.fn((n: number) => (n === 1
1199
+ ? Promise.reject(new Error('page 1 is broken'))
1200
+ : Promise.resolve(mockPage(MOCK_TEXT_ITEMS)))),
1201
+ };
1202
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce(
1203
+ failingDoc as unknown as Awaited<ReturnType<typeof loadPdfDocument>>,
1204
+ );
1205
+
1206
+ render(
1207
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1208
+ );
1209
+
1210
+ await waitFor(() => {
1211
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).toBeInTheDocument();
1212
+ });
1213
+
1214
+ fireEvent.click(screen.getByRole('button', { name: /next/i }));
1215
+
1216
+ // Page 2 loads fine; the failure belonged to page 1 and must not persist.
1217
+ await waitFor(() => {
1218
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__error')).not.toBeInTheDocument();
1219
+ });
1220
+ });
1221
+
1222
+ test('without IntersectionObserver every page mounts — degraded, not broken', async () => {
1223
+ // The virtualization is an optimization, not a correctness requirement.
1224
+ // An environment without the observer must show the whole document
1225
+ // rather than nothing.
1226
+ scannedDoc();
1227
+ vi.stubGlobal('IntersectionObserver', undefined);
1228
+ Element.prototype.scrollIntoView = vi.fn();
1229
+
1230
+ render(
1231
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1232
+ drawingMode={null} pageLayout="scroll" />
1233
+ );
1234
+
1235
+ await waitFor(() => {
1236
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__image')).toHaveLength(5);
1237
+ });
1238
+ });
1239
+
1240
+ test('a failed map fetch is not cached — the next page retries', async () => {
1241
+ // Answers cache, including "no map". A transport failure must not, or
1242
+ // one bad moment pins the whole document to geometry-only.
1243
+ const io = stubIntersectionObserver();
1244
+ scannedDoc();
1245
+ Element.prototype.scrollIntoView = vi.fn();
1246
+
1247
+ const resourceAnchoredText = vi.fn()
1248
+ .mockRejectedValueOnce(new Error('transport down'))
1249
+ .mockResolvedValue({ text: 'later', items: [] });
1250
+ const session = {
1251
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
1252
+ } as unknown as import('@semiont/sdk').SemiontSession;
1253
+
1254
+ render(
1255
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1256
+ drawingMode={null} pageLayout="scroll" session={session} />
1257
+ );
1258
+ await waitFor(() => {
1259
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1260
+ });
1261
+
1262
+ io.fire([1]);
1263
+ await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(1));
1264
+
1265
+ io.fire([2]);
1266
+ await waitFor(() => expect(resourceAnchoredText).toHaveBeenCalledTimes(2));
1267
+ });
1268
+
1269
+ test('the pager still works in the column, and modifiers are left alone', async () => {
1270
+ const io = stubIntersectionObserver();
1271
+ scannedDoc();
1272
+ const scrollIntoView = vi.fn();
1273
+ Element.prototype.scrollIntoView = scrollIntoView;
1274
+
1275
+ render(
1276
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl}
1277
+ drawingMode={null} pageLayout="scroll" />
1278
+ );
1279
+ await waitFor(() => {
1280
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(5);
1281
+ });
1282
+ io.fire([3]);
1283
+ await waitFor(() => {
1284
+ expect(document.querySelector('[aria-current="page"]')).toHaveAttribute('data-page', '3');
1285
+ });
1286
+
1287
+ scrollIntoView.mockClear();
1288
+ fireEvent.click(screen.getByRole('button', { name: /previous/i }));
1289
+ expect(scrollIntoView).toHaveBeenCalled();
1290
+
1291
+ // Cmd/Ctrl/Alt + arrow belongs to the browser and the OS.
1292
+ scrollIntoView.mockClear();
1293
+ fireEvent.keyDown(window, { key: 'ArrowRight', metaKey: true });
1294
+ fireEvent.keyDown(window, { key: 'PageDown', ctrlKey: true });
1295
+ expect(scrollIntoView).not.toHaveBeenCalled();
1296
+ });
1297
+
1298
+ test('keeps the paged layout when no layout is requested', async () => {
1299
+ render(
1300
+ <PdfAnnotationCanvas resourceUri="res-1"
1301
+ pdfUrl={mockPdfUrl}
1302
+ drawingMode="rectangle"
1303
+ selectedMotivation="highlighting"
1304
+ />
1305
+ );
1306
+
1307
+ await waitFor(() => {
1308
+ expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
1309
+ });
1310
+ expect(document.querySelectorAll('.semiont-pdf-annotation-canvas__slot')).toHaveLength(0);
1311
+ expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
1312
+ });
1313
+ });
1314
+
1315
+ // PDF-CONTINUOUS-SCROLL S3. The viewer chrome was the last hardcoded-English
1316
+ // surface in this component: Previous/Next, the page indicator, and both
1317
+ // failure lines. The mock translation manager echoes "<namespace>.<key>",
1318
+ // so asserting the echo proves the string came from translations rather
1319
+ // than from a literal that happens to read the same in English.
1320
+ describe('viewer chrome', () => {
1321
+ const renderTranslated = (ui: React.ReactElement) =>
1322
+ render(<TranslationProvider translationManager={defaultMocks.translationManager}>{ui}</TranslationProvider>);
1323
+
1324
+ test('takes its controls and page indicator from translations', async () => {
1325
+ renderTranslated(
1326
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1327
+ );
1328
+
1329
+ await waitFor(() => {
1330
+ expect(screen.getByRole('button', { name: 'PdfViewer.previous' })).toBeInTheDocument();
1331
+ });
1332
+ expect(screen.getByRole('button', { name: 'PdfViewer.next' })).toBeInTheDocument();
1333
+ expect(screen.getByText(/PdfViewer\.pageOf/)).toBeInTheDocument();
1334
+ expect(screen.queryByText('Previous')).not.toBeInTheDocument();
1335
+ expect(screen.queryByText(/^Page 1 of 3$/)).not.toBeInTheDocument();
1336
+ });
1337
+
1338
+ test('announces page changes — a silent indicator is invisible to a screen reader', async () => {
1339
+ renderTranslated(
1340
+ <PdfAnnotationCanvas resourceUri="res-1" pdfUrl={mockPdfUrl} drawingMode={null} />
1341
+ );
1342
+
1343
+ await waitFor(() => {
1344
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__page-info')).toBeInTheDocument();
1345
+ });
1346
+ const indicator = document.querySelector('.semiont-pdf-annotation-canvas__page-info')!;
1347
+ expect(indicator).toHaveAttribute('aria-live', 'polite');
1348
+ // The pager is a navigation landmark, not loose buttons in the page.
1349
+ expect(screen.getByRole('navigation')).toBeInTheDocument();
1350
+ });
1351
+ });
347
1352
  });