@semiont/react-ui 0.5.24 → 0.5.25

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 (151) hide show
  1. package/dist/{PdfAnnotationCanvas.client-V7Q3BEEQ.js → PdfAnnotationCanvas.client-EHQWF6BR.js} +45 -10
  2. package/dist/PdfAnnotationCanvas.client-EHQWF6BR.js.map +1 -0
  3. package/dist/{ar-JRTKCMTF.js → ar-CULRHLNG.js} +20 -6
  4. package/dist/ar-CULRHLNG.js.map +1 -0
  5. package/dist/{bn-LO6EDSCC.js → bn-HYR7CEL2.js} +20 -6
  6. package/dist/bn-HYR7CEL2.js.map +1 -0
  7. package/dist/{chunk-5Y5JDCKC.js → chunk-NH6L2GIL.js} +20 -6
  8. package/dist/chunk-NH6L2GIL.js.map +1 -0
  9. package/dist/{chunk-4QMTEPQ3.js → chunk-TG2XQEC4.js} +31 -31
  10. package/dist/{cs-LCHHOR4O.js → cs-YBRWY7NH.js} +20 -6
  11. package/dist/cs-YBRWY7NH.js.map +1 -0
  12. package/dist/{da-TARGPMJR.js → da-PHDEOBV3.js} +20 -6
  13. package/dist/da-PHDEOBV3.js.map +1 -0
  14. package/dist/{de-SBH4QBPJ.js → de-O4UCCVLJ.js} +20 -6
  15. package/dist/de-O4UCCVLJ.js.map +1 -0
  16. package/dist/{el-2D2OLGWB.js → el-MVGO6WZV.js} +20 -6
  17. package/dist/el-MVGO6WZV.js.map +1 -0
  18. package/dist/{en-QFIEG3CA.js → en-2KQILFKQ.js} +4 -2
  19. package/dist/{es-U6YK2LX6.js → es-USSLXQY7.js} +20 -6
  20. package/dist/es-USSLXQY7.js.map +1 -0
  21. package/dist/{fa-S7SZE37M.js → fa-WMAREFWY.js} +20 -6
  22. package/dist/fa-WMAREFWY.js.map +1 -0
  23. package/dist/{fi-CBL3NIXN.js → fi-B3NBNOTS.js} +20 -6
  24. package/dist/fi-B3NBNOTS.js.map +1 -0
  25. package/dist/{fr-EABUHV6S.js → fr-TXYK5T5W.js} +20 -6
  26. package/dist/fr-TXYK5T5W.js.map +1 -0
  27. package/dist/{he-THWD244Y.js → he-JCJACW5D.js} +20 -6
  28. package/dist/he-JCJACW5D.js.map +1 -0
  29. package/dist/{hi-GHGFZJ44.js → hi-VOQLZNJU.js} +20 -6
  30. package/dist/hi-VOQLZNJU.js.map +1 -0
  31. package/dist/{id-POZOUMDW.js → id-BFN5RZNZ.js} +20 -6
  32. package/dist/id-BFN5RZNZ.js.map +1 -0
  33. package/dist/index.css +0 -36
  34. package/dist/index.css.map +1 -1
  35. package/dist/index.d.ts +85 -24
  36. package/dist/index.js +1763 -1690
  37. package/dist/index.js.map +1 -1
  38. package/dist/{it-KDYJADSH.js → it-TWWDHKKM.js} +20 -6
  39. package/dist/it-TWWDHKKM.js.map +1 -0
  40. package/dist/{ja-Z5Z6ZF3P.js → ja-YRQLEBFM.js} +20 -6
  41. package/dist/ja-YRQLEBFM.js.map +1 -0
  42. package/dist/{ko-7X3XUXRN.js → ko-PG6B3CRB.js} +20 -6
  43. package/dist/ko-PG6B3CRB.js.map +1 -0
  44. package/dist/{ms-BTWEIW43.js → ms-7EZECVKM.js} +20 -6
  45. package/dist/ms-7EZECVKM.js.map +1 -0
  46. package/dist/{nl-IS2CGF5I.js → nl-HP753YAP.js} +20 -6
  47. package/dist/nl-HP753YAP.js.map +1 -0
  48. package/dist/{no-AAMEVA26.js → no-JUD5WX3N.js} +20 -6
  49. package/dist/no-JUD5WX3N.js.map +1 -0
  50. package/dist/{pl-PVXUQYFT.js → pl-RVUL4CBM.js} +20 -6
  51. package/dist/pl-RVUL4CBM.js.map +1 -0
  52. package/dist/{pt-3ITQ76TL.js → pt-RPOVD4QM.js} +20 -6
  53. package/dist/pt-RPOVD4QM.js.map +1 -0
  54. package/dist/{ro-FRB4IIVS.js → ro-DMYWQTRD.js} +20 -6
  55. package/dist/ro-DMYWQTRD.js.map +1 -0
  56. package/dist/{sv-DS3RO24M.js → sv-DMZ4FGXD.js} +20 -6
  57. package/dist/sv-DMZ4FGXD.js.map +1 -0
  58. package/dist/test-utils.js +2 -2
  59. package/dist/{th-AX66MWWB.js → th-4HVBJBQ2.js} +20 -6
  60. package/dist/th-4HVBJBQ2.js.map +1 -0
  61. package/dist/{tr-7KTB67TT.js → tr-6BDPU5ML.js} +20 -6
  62. package/dist/tr-6BDPU5ML.js.map +1 -0
  63. package/dist/{uk-SMQS2MAQ.js → uk-TBZBT2TL.js} +20 -6
  64. package/dist/uk-TBZBT2TL.js.map +1 -0
  65. package/dist/{vi-D3JXL2EZ.js → vi-UMKZKZL3.js} +20 -6
  66. package/dist/vi-UMKZKZL3.js.map +1 -0
  67. package/dist/{zh-S754AWFS.js → zh-YBJWJJYB.js} +20 -6
  68. package/dist/zh-YBJWJJYB.js.map +1 -0
  69. package/package.json +8 -8
  70. package/src/components/AssistProgress.tsx +42 -30
  71. package/src/components/EntityFoundLog.tsx +38 -0
  72. package/src/components/__tests__/AssistProgress.test.tsx +86 -28
  73. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +112 -12
  74. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +247 -1
  75. package/src/components/resource/AnnotateView.tsx +14 -7
  76. package/src/components/resource/BrowseView.tsx +2 -7
  77. package/src/components/resource/DownloadFileLink.tsx +49 -0
  78. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
  79. package/src/components/resource/__tests__/download-link.test.tsx +129 -0
  80. package/src/components/resource/panels/AssistSection.css +0 -43
  81. package/src/components/resource/panels/AssistSection.tsx +11 -2
  82. package/src/components/resource/panels/AssistShell.tsx +5 -1
  83. package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
  84. package/src/components/resource/panels/TaggingPanel.tsx +21 -1
  85. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
  86. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
  87. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
  88. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
  89. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +32 -3
  90. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +17 -6
  91. package/translations/ar.json +17 -5
  92. package/translations/bn.json +17 -5
  93. package/translations/cs.json +17 -5
  94. package/translations/da.json +17 -5
  95. package/translations/de.json +17 -5
  96. package/translations/el.json +17 -5
  97. package/translations/en.json +17 -5
  98. package/translations/es.json +17 -5
  99. package/translations/fa.json +17 -5
  100. package/translations/fi.json +17 -5
  101. package/translations/fr.json +17 -5
  102. package/translations/he.json +17 -5
  103. package/translations/hi.json +17 -5
  104. package/translations/id.json +17 -5
  105. package/translations/it.json +17 -5
  106. package/translations/ja.json +17 -5
  107. package/translations/ko.json +17 -5
  108. package/translations/ms.json +17 -5
  109. package/translations/nl.json +17 -5
  110. package/translations/no.json +17 -5
  111. package/translations/pl.json +17 -5
  112. package/translations/pt.json +17 -5
  113. package/translations/ro.json +17 -5
  114. package/translations/sv.json +17 -5
  115. package/translations/th.json +17 -5
  116. package/translations/tr.json +17 -5
  117. package/translations/uk.json +17 -5
  118. package/translations/vi.json +17 -5
  119. package/translations/zh.json +17 -5
  120. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
  121. package/dist/ar-JRTKCMTF.js.map +0 -1
  122. package/dist/bn-LO6EDSCC.js.map +0 -1
  123. package/dist/chunk-5Y5JDCKC.js.map +0 -1
  124. package/dist/cs-LCHHOR4O.js.map +0 -1
  125. package/dist/da-TARGPMJR.js.map +0 -1
  126. package/dist/de-SBH4QBPJ.js.map +0 -1
  127. package/dist/el-2D2OLGWB.js.map +0 -1
  128. package/dist/es-U6YK2LX6.js.map +0 -1
  129. package/dist/fa-S7SZE37M.js.map +0 -1
  130. package/dist/fi-CBL3NIXN.js.map +0 -1
  131. package/dist/fr-EABUHV6S.js.map +0 -1
  132. package/dist/he-THWD244Y.js.map +0 -1
  133. package/dist/hi-GHGFZJ44.js.map +0 -1
  134. package/dist/id-POZOUMDW.js.map +0 -1
  135. package/dist/it-KDYJADSH.js.map +0 -1
  136. package/dist/ja-Z5Z6ZF3P.js.map +0 -1
  137. package/dist/ko-7X3XUXRN.js.map +0 -1
  138. package/dist/ms-BTWEIW43.js.map +0 -1
  139. package/dist/nl-IS2CGF5I.js.map +0 -1
  140. package/dist/no-AAMEVA26.js.map +0 -1
  141. package/dist/pl-PVXUQYFT.js.map +0 -1
  142. package/dist/pt-3ITQ76TL.js.map +0 -1
  143. package/dist/ro-FRB4IIVS.js.map +0 -1
  144. package/dist/sv-DS3RO24M.js.map +0 -1
  145. package/dist/th-AX66MWWB.js.map +0 -1
  146. package/dist/tr-7KTB67TT.js.map +0 -1
  147. package/dist/uk-SMQS2MAQ.js.map +0 -1
  148. package/dist/vi-D3JXL2EZ.js.map +0 -1
  149. package/dist/zh-S754AWFS.js.map +0 -1
  150. /package/dist/{chunk-4QMTEPQ3.js.map → chunk-TG2XQEC4.js.map} +0 -0
  151. /package/dist/{en-QFIEG3CA.js.map → en-2KQILFKQ.js.map} +0 -0
@@ -1,10 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
4
- import type { Annotation, AnchorRect } from '@semiont/core';
4
+ import type { Annotation, AnchorRect, AnchoredText } from '@semiont/core';
5
5
  import { resourceId as toResourceId } from '@semiont/core';
6
6
  import { toViewportAnchorRect } from '../../lib/anchor-rect';
7
- import { createFragmentSelector } from '@semiont/core';
7
+ import { createFragmentSelector, anchorRuns, isTextRun, textUnder } from '@semiont/core';
8
8
  import { rectsForPage } from './rects-for-page';
9
9
  import { createHoverHandlers, type SemiontSession } from '@semiont/sdk';
10
10
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
@@ -61,7 +61,7 @@ interface PdfAnnotationCanvasProps {
61
61
  * PDF annotation canvas with page navigation and rectangle drawing
62
62
  *
63
63
  * @emits browse:click - Annotation clicked on PDF. Payload: { annotationId: string, motivation: Motivation }
64
- * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: FragmentSelector, motivation: SelectionMotivation }
64
+ * @emits mark:requested - New annotation drawn on PDF. Payload: { selector: [FragmentSelector, TextQuoteSelector?], motivation: SelectionMotivation } — the quote is the text under the rectangle, omitted when the page has no text layer
65
65
  * @emits beckon:hover - Annotation hovered or unhovered. Payload: { annotationId: string | null }
66
66
  */
67
67
  export function PdfAnnotationCanvas({
@@ -83,6 +83,13 @@ export function PdfAnnotationCanvas({
83
83
  const [isLoading, setIsLoading] = useState(true);
84
84
  const [error, setError] = useState<string | null>(null);
85
85
  const [pageDimensions, setPageDimensions] = useState<{ width: number; height: number } | null>(null);
86
+ /**
87
+ * The current page's text and per-run geometry, read once when the page
88
+ * loads so a drag can be quoted without a round trip. Null while the page
89
+ * is loading; `items` is empty on a scanned page, which has no text layer
90
+ * for the browser to read.
91
+ */
92
+ const [pageAnchored, setPageAnchored] = useState<AnchoredText | null>(null);
86
93
  const [displayDimensions, setDisplayDimensions] = useState<{ width: number; height: number } | null>(null);
87
94
  const [scale] = useState(1.5); // Fixed scale for better quality
88
95
 
@@ -125,6 +132,36 @@ export function PdfAnnotationCanvas({
125
132
  };
126
133
  }, [pdfUrl]);
127
134
 
135
+ /**
136
+ * The server-derived map, fetched once per document rather than once per
137
+ * page-load effect (PERSIST-ANCHORS P4). The map is WHOLE-RESOURCE — one
138
+ * artifact covering every page — so re-reading it from the per-page effect
139
+ * meant a full refetch and re-decode on every page turn; on a 400-page scan
140
+ * that is one decode per interaction instead of one per document.
141
+ *
142
+ * The cache holds the in-flight promise so concurrent page loads share one
143
+ * fetch. Answers cache — including "no map" and a stored decline, which are
144
+ * definitive — but a transport failure clears the entry, so the next page
145
+ * load retries instead of pinning the whole document to geometry-only.
146
+ */
147
+ const resourceAnchoredRef = useRef<{ uri: string; outcome: Promise<AnchoredText | null> } | null>(null);
148
+ const fetchResourceAnchored = useCallback((): Promise<AnchoredText | null> => {
149
+ if (!session) return Promise.resolve(null); // no session yet — don't cache its absence
150
+ const cached = resourceAnchoredRef.current;
151
+ if (cached && cached.uri === resourceUri) return cached.outcome;
152
+
153
+ const uri = resourceUri;
154
+ const outcome = session.client.browse.resourceAnchoredText(toResourceId(uri)).then(
155
+ (served) => (served && !('declined' in served) ? served : null),
156
+ () => {
157
+ if (resourceAnchoredRef.current?.uri === uri) resourceAnchoredRef.current = null;
158
+ return null;
159
+ },
160
+ );
161
+ resourceAnchoredRef.current = { uri, outcome };
162
+ return outcome;
163
+ }, [session, resourceUri]);
164
+
128
165
  // Load current page when page number changes
129
166
  useEffect(() => {
130
167
  if (!pdfDoc) return;
@@ -132,6 +169,49 @@ export function PdfAnnotationCanvas({
132
169
  let cancelled = false;
133
170
  const doc = pdfDoc;
134
171
 
172
+ // Never quote the previous page's text under this page's rectangles.
173
+ setPageAnchored(null);
174
+
175
+ /**
176
+ * The map a rectangle on this page quotes from, or null when there is
177
+ * none to be had.
178
+ *
179
+ * Never rejects, which is what lets the render run alongside it: quoting
180
+ * is the optional half of loading a page, so a failure here degrades to
181
+ * geometry-only rather than reaching the caller's error path. It is also
182
+ * the only reason `Promise.all` below is safe — a rejection from either
183
+ * side would leave the other promise dangling, and only the render can
184
+ * reject.
185
+ */
186
+ async function resolveAnchored(page: Awaited<ReturnType<typeof doc.getPage>>): Promise<AnchoredText | null> {
187
+ try {
188
+ // The page's text layer, read once here rather than at drag time —
189
+ // `handleMouseUp` stays synchronous, and a native page costs nothing
190
+ // extra since pdf.js already parsed it to draw the page.
191
+ const runs = (await page.getTextContent()).items.filter(isTextRun);
192
+ if (runs.length > 0) return anchorRuns(runs, pageNumber);
193
+
194
+ // No runs means a scanned page: the characters exist only as pixels
195
+ // and pdf.js has nothing to give. The server derived a map at ingest,
196
+ // so ask for it rather than leaving the annotation anonymous.
197
+ // Whole-resource — served once per document via the cache above —
198
+ // and `textUnder` filters by page: the same shape the native branch
199
+ // produces, so nothing downstream branches.
200
+ //
201
+ // `null` is the ordinary answer for a document that has no map and
202
+ // never will; a failure is equally non-fatal. Either way the
203
+ // annotation ships with geometry only, which is what shipped before
204
+ // this existed. The served record is the full extraction outcome
205
+ // (PERSIST-ANCHORS D1); a stored decline means extraction ran and
206
+ // found nothing to anchor — for this canvas the same degradation as
207
+ // no map at all. A success outcome IS the anchoring shape, plus
208
+ // provenance this canvas does not read.
209
+ return await fetchResourceAnchored();
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
135
215
  async function loadPage() {
136
216
  try {
137
217
  const page = await doc.getPage(pageNumber);
@@ -145,11 +225,21 @@ export function PdfAnnotationCanvas({
145
225
  height: viewport.height
146
226
  });
147
227
 
148
- // Render page to image
149
- const { dataUrl } = await renderPdfPageToDataUrl(page, scale);
228
+ // Anchoring and rendering are independent, and only one of them the
229
+ // reader is waiting on. Sequencing them put a network round-trip in
230
+ // front of the pixels on exactly the documents that need it most: a
231
+ // scanned page fetches its map from the server, and rendering behind
232
+ // that await is how "failing to quote it must not fail to show it"
233
+ // became true of errors but not of latency. Started together, the
234
+ // page appears on its own schedule.
235
+ const [anchored, { dataUrl }] = await Promise.all([
236
+ resolveAnchored(page),
237
+ renderPdfPageToDataUrl(page, scale),
238
+ ]);
150
239
 
151
240
  if (cancelled) return;
152
241
 
242
+ setPageAnchored(anchored);
153
243
  setPageImageUrl(dataUrl);
154
244
  } catch (err) {
155
245
  if (cancelled) return;
@@ -164,7 +254,7 @@ export function PdfAnnotationCanvas({
164
254
  return () => {
165
255
  cancelled = true;
166
256
  };
167
- }, [pdfDoc, pageNumber, scale]);
257
+ }, [pdfDoc, pageNumber, scale, fetchResourceAnchored]);
168
258
 
169
259
  // Update display dimensions on resize
170
260
  useEffect(() => {
@@ -318,15 +408,25 @@ export function PdfAnnotationCanvas({
318
408
  // Create FragmentSelector
319
409
  const fragmentSelector = createFragmentSelector(pdfCoord);
320
410
 
411
+ // What the box was drawn around. Without it the annotation is a rectangle
412
+ // with no memory of its own content: the panel entry is blank, search over
413
+ // annotation text misses it, and an export has nothing to print. Empty on a
414
+ // scanned page or over an image — emit no quote at all rather than an empty
415
+ // one, which would assert the box was drawn around nothing.
416
+ const quoted = pageAnchored ? textUnder(pageAnchored, pdfCoord) : '';
417
+
321
418
  // Emit annotation:requested event with FragmentSelector
322
419
  if (selectedMotivation) {
323
420
  session.client.mark.request(
324
421
  toResourceId(resourceUri),
325
- {
326
- type: 'FragmentSelector',
327
- conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
328
- value: fragmentSelector,
329
- },
422
+ [
423
+ {
424
+ type: 'FragmentSelector',
425
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
426
+ value: fragmentSelector,
427
+ },
428
+ ...(quoted ? [{ type: 'TextQuoteSelector' as const, exact: quoted }] : []),
429
+ ],
330
430
  selectedMotivation,
331
431
  );
332
432
  }
@@ -336,7 +436,7 @@ export function PdfAnnotationCanvas({
336
436
  setIsDrawing(false);
337
437
  // Note: We keep selection so the preview remains visible
338
438
  // It will be cleared when drawingMode changes or user starts new selection
339
- }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri]);
439
+ }, [isDrawing, selection, pageNumber, pageDimensions, displayDimensions, selectedMotivation, existingAnnotations, session, resourceUri, pageAnchored]);
340
440
 
341
441
  // Every FragmentSelector rect on the current page — one per line for a
342
442
  // multi-line (multi-selector) annotation, exactly one for a manual annotation.
@@ -13,9 +13,35 @@ import userEvent from '@testing-library/user-event';
13
13
  import { PdfAnnotationCanvas } from '../PdfAnnotationCanvas';
14
14
  import { resourceId, annotationId, parseFragmentSelector } from '@semiont/core';
15
15
  import { pdfToCanvasCoordinates } from '../../../lib/pdf-coordinates';
16
+ import { loadPdfDocument, renderPdfPageToDataUrl } from '../../../lib/browser-pdfjs';
16
17
 
17
18
  import type { Annotation } from '@semiont/core';
18
19
 
20
+ // The page's text layer, as pdf.js reports it. Geometry is PDF points,
21
+ // bottom-left origin: "Hello world" sits on one line at y=700, "elsewhere" a
22
+ // hundred points below it.
23
+ // vi.hoisted: vi.mock's factory is lifted above these declarations.
24
+ const { MOCK_TEXT_ITEMS } = vi.hoisted(() => ({
25
+ MOCK_TEXT_ITEMS: [
26
+ { str: 'Hello', transform: [1, 0, 0, 1, 72, 700], width: 30, height: 12, hasEOL: false },
27
+ { str: 'world', transform: [1, 0, 0, 1, 106, 700], width: 32, height: 12, hasEOL: true },
28
+ { str: 'elsewhere', transform: [1, 0, 0, 1, 72, 600], width: 54, height: 12, hasEOL: true },
29
+ ],
30
+ }));
31
+
32
+ const mockPage = (textItems: unknown[]) => ({
33
+ getViewport: vi.fn().mockReturnValue({
34
+ width: 612,
35
+ height: 792,
36
+ scale: 1.0,
37
+ rotation: 0
38
+ }),
39
+ render: vi.fn().mockReturnValue({
40
+ promise: Promise.resolve()
41
+ }),
42
+ getTextContent: vi.fn().mockResolvedValue({ items: textItems })
43
+ });
44
+
19
45
  // Mock browser-pdfjs module
20
46
  vi.mock('../../../lib/browser-pdfjs', () => ({
21
47
  loadPdfDocument: vi.fn().mockResolvedValue({
@@ -29,7 +55,8 @@ vi.mock('../../../lib/browser-pdfjs', () => ({
29
55
  }),
30
56
  render: vi.fn().mockReturnValue({
31
57
  promise: Promise.resolve()
32
- })
58
+ }),
59
+ getTextContent: vi.fn().mockResolvedValue({ items: MOCK_TEXT_ITEMS })
33
60
  })
34
61
  }),
35
62
  renderPdfPageToDataUrl: vi.fn().mockResolvedValue({
@@ -313,6 +340,225 @@ describe('PdfAnnotationCanvas', () => {
313
340
  });
314
341
  });
315
342
 
343
+ /**
344
+ * Drives the drag → mark.request path far enough to inspect the selector.
345
+ * `drag` is in canvas pixels; at scale 1 with a 612×792 page these are also
346
+ * display coordinates, since jsdom's getBoundingClientRect returns zeros.
347
+ */
348
+ async function drawRectangle(
349
+ drag: { fromX: number; fromY: number; toX: number; toY: number },
350
+ ) {
351
+ const request = vi.fn();
352
+ const session = {
353
+ client: { mark: { request }, beckon: { hover: vi.fn() } },
354
+ } as unknown as import('@semiont/sdk').SemiontSession;
355
+
356
+ render(
357
+ <PdfAnnotationCanvas resourceUri="res-1"
358
+ pdfUrl={mockPdfUrl}
359
+ drawingMode="rectangle"
360
+ selectedMotivation="highlighting"
361
+ session={session}
362
+ />
363
+ );
364
+
365
+ await waitFor(() => {
366
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image');
367
+ expect(img).toBeInTheDocument();
368
+ });
369
+
370
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
371
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
372
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
373
+ fireEvent.load(img);
374
+
375
+ const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
376
+ fireEvent.mouseDown(container, { clientX: drag.fromX, clientY: drag.fromY });
377
+ fireEvent.mouseMove(container, { clientX: drag.toX, clientY: drag.toY });
378
+ fireEvent.mouseUp(container, { clientX: drag.toX, clientY: drag.toY });
379
+
380
+ return request;
381
+ }
382
+
383
+ // The manual-annotation capture gap (.plans/PDF-MANUAL-ANNOTATION-TEXT.md):
384
+ // a hand-drawn rectangle stored geometry and nothing else, so every panel
385
+ // that quotes an annotation showed it blank.
386
+ test('a drawn rectangle carries the text it was drawn around', async () => {
387
+ // The "Hello world" line in canvas space: PDF y=700..712 flips to
388
+ // canvas y=80..92 on a 792pt page. Drag a box a little larger than it.
389
+ const line = pdfToCanvasCoordinates(
390
+ { page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
391
+ );
392
+ const request = await drawRectangle({
393
+ fromX: line.x - 2,
394
+ fromY: line.y - 2,
395
+ toX: line.x + line.width + 8,
396
+ toY: line.y + line.height + 4,
397
+ });
398
+
399
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
400
+
401
+ const [source, selector, motivation] = request.mock.calls[0];
402
+ expect(source).toBe(resourceId('res-1'));
403
+ expect(motivation).toBe('highlighting');
404
+ expect(selector).toEqual([
405
+ {
406
+ type: 'FragmentSelector',
407
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
408
+ value: expect.stringContaining('page=1&viewrect='),
409
+ },
410
+ { type: 'TextQuoteSelector', exact: 'Hello world' },
411
+ ]);
412
+ });
413
+
414
+ test('a rectangle over blank space carries geometry only', async () => {
415
+ // Between the two lines of text — nothing under the box. An empty-string
416
+ // quote would assert the box was drawn around nothing, so emit no quote.
417
+ const request = await drawRectangle({ fromX: 40, fromY: 120, toX: 200, toY: 150 });
418
+
419
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
420
+
421
+ const selector = request.mock.calls[0][1];
422
+ expect(selector).toHaveLength(1);
423
+ expect(selector[0].type).toBe('FragmentSelector');
424
+ });
425
+
426
+ // Phase 2 of .plans/PDF-MANUAL-ANNOTATION-TEXT.md. A scanned page has no text
427
+ // in the browser, but the server derived one at ingest and serves it through
428
+ // `browse.resourceAnchoredText`. Same `AnchoredText` shape either way, so
429
+ // `textUnder` and the drag handler do not branch — only the source does.
430
+ test('a scanned page quotes from the map the server derived', async () => {
431
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
432
+ numPages: 3,
433
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
434
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
435
+
436
+ const request = vi.fn();
437
+ const session = {
438
+ client: {
439
+ mark: { request },
440
+ beckon: { hover: vi.fn() },
441
+ browse: {
442
+ resourceAnchoredText: vi.fn().mockResolvedValue({
443
+ text: 'Hello world again',
444
+ items: [
445
+ { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
446
+ { start: 6, end: 11, page: 1, x: 106, y: 700, width: 32, height: 12 },
447
+ ],
448
+ }),
449
+ },
450
+ },
451
+ } as unknown as import('@semiont/sdk').SemiontSession;
452
+
453
+ render(
454
+ <PdfAnnotationCanvas resourceUri="res-1"
455
+ pdfUrl={mockPdfUrl}
456
+ drawingMode="rectangle"
457
+ selectedMotivation="highlighting"
458
+ session={session}
459
+ />
460
+ );
461
+
462
+ await waitFor(() => {
463
+ expect(document.querySelector('.semiont-pdf-annotation-canvas__image')).toBeInTheDocument();
464
+ });
465
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
466
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
467
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
468
+ fireEvent.load(img);
469
+
470
+ const line = pdfToCanvasCoordinates({ page: 1, x: 72, y: 700, width: 66, height: 12 }, 792, 1.0);
471
+ const container = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
472
+ fireEvent.mouseDown(container, { clientX: line.x - 2, clientY: line.y - 2 });
473
+ fireEvent.mouseMove(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
474
+ fireEvent.mouseUp(container, { clientX: line.x + line.width + 8, clientY: line.y + line.height + 4 });
475
+
476
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
477
+ expect(request.mock.calls[0][1]).toEqual([
478
+ expect.objectContaining({ type: 'FragmentSelector' }),
479
+ { type: 'TextQuoteSelector', exact: 'Hello world' },
480
+ ]);
481
+ });
482
+
483
+ // PERSIST-ANCHORS P4. The server map is WHOLE-RESOURCE — one artifact
484
+ // covering every page — so fetching it from inside the per-page load
485
+ // effect refetched and re-decoded the entire document's geometry on every
486
+ // page turn. On a 400-page scan that is the difference between one decode
487
+ // and one per interaction.
488
+ test('the whole-resource map is fetched once per document, not once per page-turn', async () => {
489
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
490
+ numPages: 3,
491
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
492
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
493
+
494
+ const resourceAnchoredText = vi.fn().mockResolvedValue({
495
+ text: 'Hello world again',
496
+ items: [
497
+ { start: 0, end: 5, page: 1, x: 72, y: 700, width: 30, height: 12 },
498
+ { start: 6, end: 11, page: 2, x: 106, y: 700, width: 32, height: 12 },
499
+ ],
500
+ });
501
+ const session = {
502
+ client: { beckon: { hover: vi.fn() }, browse: { resourceAnchoredText } },
503
+ } as unknown as import('@semiont/sdk').SemiontSession;
504
+
505
+ render(
506
+ <PdfAnnotationCanvas resourceUri="res-1"
507
+ pdfUrl={mockPdfUrl}
508
+ drawingMode={null}
509
+ session={session}
510
+ />
511
+ );
512
+
513
+ const user = userEvent.setup();
514
+ await waitFor(() => {
515
+ expect(screen.getByText(/page 1 of 3/i)).toBeInTheDocument();
516
+ });
517
+
518
+ await user.click(screen.getByRole('button', { name: /next/i }));
519
+ await waitFor(() => {
520
+ expect(screen.getByText(/page 2 of 3/i)).toBeInTheDocument();
521
+ });
522
+ await user.click(screen.getByRole('button', { name: /next/i }));
523
+ await waitFor(() => {
524
+ expect(screen.getByText(/page 3 of 3/i)).toBeInTheDocument();
525
+ });
526
+
527
+ // The render half runs once per page load and starts alongside the
528
+ // anchoring half (Promise.all), so three render calls prove all three
529
+ // page-load effects ran to the point of resolving their map.
530
+ await waitFor(() => {
531
+ expect(vi.mocked(renderPdfPageToDataUrl)).toHaveBeenCalledTimes(3);
532
+ });
533
+
534
+ expect(resourceAnchoredText).toHaveBeenCalledTimes(1);
535
+ });
536
+
537
+ test('a scanned page with no server map carries geometry only', async () => {
538
+ // Class B: pdf.js returns no runs, so the browser cannot do the job and
539
+ // the annotation stays geometry-only pending async enrichment (Phase 2).
540
+ vi.mocked(loadPdfDocument).mockResolvedValueOnce({
541
+ numPages: 3,
542
+ getPage: vi.fn().mockResolvedValue(mockPage([])),
543
+ } as unknown as Awaited<ReturnType<typeof loadPdfDocument>>);
544
+
545
+ const line = pdfToCanvasCoordinates(
546
+ { page: 1, x: 72, y: 700, width: 60, height: 12 }, 792, 1.0,
547
+ );
548
+ const request = await drawRectangle({
549
+ fromX: line.x - 2,
550
+ fromY: line.y - 2,
551
+ toX: line.x + line.width + 8,
552
+ toY: line.y + line.height + 4,
553
+ });
554
+
555
+ await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
556
+
557
+ const selector = request.mock.calls[0][1];
558
+ expect(selector).toHaveLength(1);
559
+ expect(selector[0].type).toBe('FragmentSelector');
560
+ });
561
+
316
562
  test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
317
563
  render(
318
564
  <PdfAnnotationCanvas resourceUri="res-1"
@@ -5,9 +5,11 @@ import { capabilitiesOf, resourceId as toResourceId } from '@semiont/core';
5
5
  import { ANNOTATORS } from '../../lib/annotation-registry';
6
6
  import { buildTextSelectors, fallbackTextPosition } from '../../lib/text-selection-handler';
7
7
  import { defaultAnnotateRenderers, type AnnotateMediaRenderers } from './annotate-renderers';
8
+ import { DownloadFileLink } from './DownloadFileLink';
8
9
  import type { EditorView } from '@codemirror/view';
9
10
  import type { SemiontSession } from '@semiont/sdk';
10
11
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
12
+ import { scrollAnnotationIntoView } from '../../lib/scroll-utils';
11
13
 
12
14
  // Type augmentation for custom DOM properties
13
15
  interface EnrichedHTMLElement extends HTMLElement {
@@ -51,6 +53,7 @@ interface Props {
51
53
  *
52
54
  * @emits mark:requested - User requested to create annotation. Payload: { selector: Selector | Selector[], motivation: SelectionMotivation }
53
55
  * @subscribes beckon:hover - Annotation hovered. Payload: { annotationId: string | null }
56
+ * @subscribes beckon:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
54
57
  */
55
58
  export function AnnotateView({
56
59
  content,
@@ -104,10 +107,20 @@ export function AnnotateView({
104
107
  onUIStateChangeRef.current?.({ hoveredAnnotationId: annotationId });
105
108
  }, []);
106
109
 
110
+ // "Scroll to and highlight this annotation" — the same contract BrowseView
111
+ // serves, so the behaviour no longer depends on which view mode is active.
112
+ // The `scrollToAnnotationId` prop path (uiState → renderer) stays as the
113
+ // host-facing capability it is; this is the in-app producer's route.
114
+ const handleAnnotationFocus = useCallback(({ annotationId }: { annotationId?: string | null }) => {
115
+ if (!containerRef.current) return;
116
+ scrollAnnotationIntoView(annotationId ?? null, containerRef.current, { pulse: true });
117
+ }, []);
118
+
107
119
  // Annotation hover (session-scoped). Toolbar preference changes flow through
108
120
  // props/callbacks, not the bus (TOOLBAR-PREFS-AS-PROPS).
109
121
  useSessionEventSubscriptions(session, {
110
122
  'beckon:hover': handleAnnotationHover,
123
+ 'beckon:focus': handleAnnotationFocus,
111
124
  });
112
125
 
113
126
  // Handle text annotation with sparkle or immediate creation
@@ -210,13 +223,7 @@ export function AnnotateView({
210
223
  <p className="semiont-annotate-view__empty-message">
211
224
  Annotation not supported for {mimeType}
212
225
  </p>
213
- <a
214
- href={`/api/resources/${resourceUri}`}
215
- download
216
- className="semiont-button semiont-button--primary"
217
- >
218
- Download File
219
- </a>
226
+ <DownloadFileLink resourceUri={resourceUri} session={session} />
220
227
  </div>
221
228
  </div>
222
229
  );
@@ -21,6 +21,7 @@ import {
21
21
  import type { SemiontSession } from '@semiont/sdk';
22
22
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
23
23
  import { defaultBrowseRenderers, type BrowseMediaRenderers } from './browse-renderers';
24
+ import { DownloadFileLink } from './DownloadFileLink';
24
25
 
25
26
  interface Props {
26
27
  content: string;
@@ -295,13 +296,7 @@ export const BrowseView = memo(function BrowseView({
295
296
  <p className="semiont-browse-view__empty-message">
296
297
  Preview not available for {mimeType}
297
298
  </p>
298
- <a
299
- href={`/api/resources/${resourceUri}`}
300
- download
301
- className="semiont-button semiont-button--primary"
302
- >
303
- Download File
304
- </a>
299
+ <DownloadFileLink resourceUri={resourceUri} session={session} />
305
300
  </div>
306
301
  </div>
307
302
  );
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import { resourceId as toResourceId } from '@semiont/core';
4
+ import type { SemiontSession } from '@semiont/sdk';
5
+ import { useMediaToken } from '../../hooks/useMediaToken';
6
+ import { mediaUrl } from '../../lib/media-url';
7
+
8
+ interface Props {
9
+ /** The '@id' of the resource whose bytes to offer. */
10
+ resourceUri: string;
11
+ /** Session for that resource — its client mints the media token and supplies the backend origin. */
12
+ session: SemiontSession | null;
13
+ }
14
+
15
+ /**
16
+ * The "Download File" affordance on the unsupported-media fallback, shared by
17
+ * the browse and annotate views.
18
+ *
19
+ * Its own component rather than inline markup for two reasons. The token is a
20
+ * hook, and this fallback is a branch — mounting the component only in that
21
+ * branch keeps every text/image/PDF view from minting a media token it will
22
+ * never use. And the URL rule (absolute + `?token=`) lives in `mediaUrl`,
23
+ * where both views and the viewer page read the same one.
24
+ *
25
+ * Before the token lands the anchor renders as a disabled button: a tokenless
26
+ * URL is a guaranteed 401, and a link that silently fails is worse than one
27
+ * visibly not ready yet. A client with no `auth` namespace (a host on a bare
28
+ * transport) stays in that state permanently, which is the honest answer —
29
+ * it cannot mint tokens at all.
30
+ */
31
+ export function DownloadFileLink({ resourceUri, session }: Props) {
32
+ const client = session?.client ?? null;
33
+ const { token } = useMediaToken(client, toResourceId(resourceUri));
34
+ const href = mediaUrl(client, toResourceId(resourceUri), token);
35
+
36
+ if (!href) {
37
+ return (
38
+ <button type="button" disabled className="semiont-button semiont-button--primary semiont-button--disabled">
39
+ Download File
40
+ </button>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <a href={href} download className="semiont-button semiont-button--primary">
46
+ Download File
47
+ </a>
48
+ );
49
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * ASSIST-SURFACE-WARTS Lane D — in-content scroll on `beckon:focus`.
3
+ *
4
+ * `beckon:focus` is the established "scroll to and highlight this annotation"
5
+ * contract; BrowseView has subscribed to it all along. AnnotateView did not,
6
+ * so the same event scrolled the content in browse mode and did nothing in
7
+ * annotate mode. With the history panel now producing the event, that asymmetry
8
+ * becomes user-visible: the same click works or doesn't depending on the mode.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
11
+ import { render } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import type { AnnotationUIState } from '../../../types/annotation-props';
14
+ import { createTestSemiontWrapper } from '../../../test-utils';
15
+
16
+ const scrollSpy = vi.hoisted(() => vi.fn());
17
+ vi.mock('../../../lib/scroll-utils', () => ({
18
+ scrollAnnotationIntoView: scrollSpy,
19
+ }));
20
+ vi.mock('../../CodeMirrorRenderer', () => ({
21
+ CodeMirrorRenderer: () => <div data-annotation-id="ann-7">cm-mock</div>,
22
+ }));
23
+
24
+ import { AnnotateView } from '../AnnotateView';
25
+
26
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
27
+ const uiState: AnnotationUIState = {
28
+ selectedMotivation: 'linking',
29
+ selectedClick: 'detail',
30
+ selectedShape: 'rectangle',
31
+ hoveredAnnotationId: null,
32
+ scrollToAnnotationId: null,
33
+ };
34
+
35
+ describe('AnnotateView — beckon:focus scrolls the content', () => {
36
+ beforeEach(() => { scrollSpy.mockClear(); });
37
+
38
+ it('scrolls to the annotation when the session emits beckon:focus', () => {
39
+ const { session, client } = createTestSemiontWrapper();
40
+
41
+ render(
42
+ <AnnotateView
43
+ content="hello world"
44
+ mimeType="text/plain"
45
+ resourceUri="res-1"
46
+ annotations={emptyAnnotations}
47
+ uiState={uiState}
48
+ session={session}
49
+ annotateMode
50
+ />,
51
+ );
52
+
53
+ client.bus.get('beckon:focus').next({ annotationId: 'ann-7' });
54
+
55
+ expect(scrollSpy).toHaveBeenCalledTimes(1);
56
+ expect(scrollSpy.mock.calls[0]?.[0]).toBe('ann-7');
57
+ });
58
+ });