@semiont/react-ui 0.5.23 → 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 (190) 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-RO5CRVIA.js → chunk-TG2XQEC4.js} +92 -60
  10. package/dist/chunk-TG2XQEC4.js.map +1 -0
  11. package/dist/{cs-LCHHOR4O.js → cs-YBRWY7NH.js} +20 -6
  12. package/dist/cs-YBRWY7NH.js.map +1 -0
  13. package/dist/{da-TARGPMJR.js → da-PHDEOBV3.js} +20 -6
  14. package/dist/da-PHDEOBV3.js.map +1 -0
  15. package/dist/{de-SBH4QBPJ.js → de-O4UCCVLJ.js} +20 -6
  16. package/dist/de-O4UCCVLJ.js.map +1 -0
  17. package/dist/{el-2D2OLGWB.js → el-MVGO6WZV.js} +20 -6
  18. package/dist/el-MVGO6WZV.js.map +1 -0
  19. package/dist/{en-QFIEG3CA.js → en-2KQILFKQ.js} +4 -2
  20. package/dist/{es-U6YK2LX6.js → es-USSLXQY7.js} +20 -6
  21. package/dist/es-USSLXQY7.js.map +1 -0
  22. package/dist/{fa-S7SZE37M.js → fa-WMAREFWY.js} +20 -6
  23. package/dist/fa-WMAREFWY.js.map +1 -0
  24. package/dist/{fi-CBL3NIXN.js → fi-B3NBNOTS.js} +20 -6
  25. package/dist/fi-B3NBNOTS.js.map +1 -0
  26. package/dist/{fr-EABUHV6S.js → fr-TXYK5T5W.js} +20 -6
  27. package/dist/fr-TXYK5T5W.js.map +1 -0
  28. package/dist/{he-THWD244Y.js → he-JCJACW5D.js} +20 -6
  29. package/dist/he-JCJACW5D.js.map +1 -0
  30. package/dist/{hi-GHGFZJ44.js → hi-VOQLZNJU.js} +20 -6
  31. package/dist/hi-VOQLZNJU.js.map +1 -0
  32. package/dist/{id-POZOUMDW.js → id-BFN5RZNZ.js} +20 -6
  33. package/dist/id-BFN5RZNZ.js.map +1 -0
  34. package/dist/index.css +0 -36
  35. package/dist/index.css.map +1 -1
  36. package/dist/index.d.ts +102 -53
  37. package/dist/index.js +2132 -2097
  38. package/dist/index.js.map +1 -1
  39. package/dist/{it-KDYJADSH.js → it-TWWDHKKM.js} +20 -6
  40. package/dist/it-TWWDHKKM.js.map +1 -0
  41. package/dist/{ja-Z5Z6ZF3P.js → ja-YRQLEBFM.js} +20 -6
  42. package/dist/ja-YRQLEBFM.js.map +1 -0
  43. package/dist/{ko-7X3XUXRN.js → ko-PG6B3CRB.js} +20 -6
  44. package/dist/ko-PG6B3CRB.js.map +1 -0
  45. package/dist/{ms-BTWEIW43.js → ms-7EZECVKM.js} +20 -6
  46. package/dist/ms-7EZECVKM.js.map +1 -0
  47. package/dist/{nl-IS2CGF5I.js → nl-HP753YAP.js} +20 -6
  48. package/dist/nl-HP753YAP.js.map +1 -0
  49. package/dist/{no-AAMEVA26.js → no-JUD5WX3N.js} +20 -6
  50. package/dist/no-JUD5WX3N.js.map +1 -0
  51. package/dist/{pl-PVXUQYFT.js → pl-RVUL4CBM.js} +20 -6
  52. package/dist/pl-RVUL4CBM.js.map +1 -0
  53. package/dist/{pt-3ITQ76TL.js → pt-RPOVD4QM.js} +20 -6
  54. package/dist/pt-RPOVD4QM.js.map +1 -0
  55. package/dist/{ro-FRB4IIVS.js → ro-DMYWQTRD.js} +20 -6
  56. package/dist/ro-DMYWQTRD.js.map +1 -0
  57. package/dist/{sv-DS3RO24M.js → sv-DMZ4FGXD.js} +20 -6
  58. package/dist/sv-DMZ4FGXD.js.map +1 -0
  59. package/dist/test-utils.js +4 -3
  60. package/dist/test-utils.js.map +1 -1
  61. package/dist/{th-AX66MWWB.js → th-4HVBJBQ2.js} +20 -6
  62. package/dist/th-4HVBJBQ2.js.map +1 -0
  63. package/dist/{tr-7KTB67TT.js → tr-6BDPU5ML.js} +20 -6
  64. package/dist/tr-6BDPU5ML.js.map +1 -0
  65. package/dist/{uk-SMQS2MAQ.js → uk-TBZBT2TL.js} +20 -6
  66. package/dist/uk-TBZBT2TL.js.map +1 -0
  67. package/dist/{vi-D3JXL2EZ.js → vi-UMKZKZL3.js} +20 -6
  68. package/dist/vi-UMKZKZL3.js.map +1 -0
  69. package/dist/{zh-S754AWFS.js → zh-YBJWJJYB.js} +20 -6
  70. package/dist/zh-YBJWJJYB.js.map +1 -0
  71. package/package.json +8 -8
  72. package/src/components/AssistProgress.tsx +42 -30
  73. package/src/components/EntityFoundLog.tsx +38 -0
  74. package/src/components/__tests__/AssistProgress.test.tsx +86 -28
  75. package/src/components/annotation-popups/JsonLdView.tsx +1 -1
  76. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +8 -2
  77. package/src/components/modals/ReferenceWizardModal.tsx +2 -1
  78. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +112 -12
  79. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +247 -1
  80. package/src/components/resource/AnnotateView.tsx +14 -7
  81. package/src/components/resource/BrowseView.tsx +2 -7
  82. package/src/components/resource/DownloadFileLink.tsx +49 -0
  83. package/src/components/resource/__tests__/AnnotateView.focus.test.tsx +58 -0
  84. package/src/components/resource/__tests__/download-link.test.tsx +129 -0
  85. package/src/components/resource/panels/AssistSection.css +0 -43
  86. package/src/components/resource/panels/AssistSection.tsx +11 -2
  87. package/src/components/resource/panels/AssistShell.tsx +5 -1
  88. package/src/components/resource/panels/JsonLdPanel.tsx +1 -1
  89. package/src/components/resource/panels/ReferencesPanel.tsx +12 -13
  90. package/src/components/resource/panels/TaggingPanel.tsx +21 -1
  91. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +2 -2
  92. package/src/components/resource/panels/__tests__/AssistShell.test.tsx +9 -4
  93. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +1 -1
  94. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +8 -2
  95. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +26 -0
  96. package/src/components/settings/SettingsPanel.tsx +6 -2
  97. package/src/components/settings/__tests__/SettingsPanel.test.tsx +28 -3
  98. package/src/features/admin-devops/__tests__/AdminDevOpsPage.test.tsx +3 -11
  99. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +0 -3
  100. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -1
  101. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -3
  102. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +1 -1
  103. package/src/features/admin-security/__tests__/AdminSecurityPage.test.tsx +0 -8
  104. package/src/features/admin-security/components/AdminSecurityPage.tsx +0 -3
  105. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +1 -1
  106. package/src/features/admin-users/__tests__/AdminUsersPage.test.tsx +0 -1
  107. package/src/features/admin-users/components/AdminUsersPage.tsx +0 -3
  108. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +1 -1
  109. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +1 -1
  110. package/src/features/moderate-entity-tags/__tests__/EntityTagsPage.test.tsx +0 -2
  111. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +0 -3
  112. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +1 -1
  113. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +3 -5
  114. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +0 -3
  115. package/src/features/moderate-tag-schemas/__tests__/TagSchemasPage.test.tsx +3 -5
  116. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +0 -3
  117. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -1
  118. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -3
  119. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +17 -7
  120. package/src/features/resource-compose/components/ResourceComposePage.tsx +5 -3
  121. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +1 -1
  122. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +0 -1
  123. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +0 -3
  124. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +1 -1
  125. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +40 -8
  126. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +20 -16
  127. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +1 -1
  128. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +1 -1
  129. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -2
  130. package/translations/ar.json +17 -5
  131. package/translations/bn.json +17 -5
  132. package/translations/cs.json +17 -5
  133. package/translations/da.json +17 -5
  134. package/translations/de.json +17 -5
  135. package/translations/el.json +17 -5
  136. package/translations/en.json +17 -5
  137. package/translations/es.json +17 -5
  138. package/translations/fa.json +17 -5
  139. package/translations/fi.json +17 -5
  140. package/translations/fr.json +17 -5
  141. package/translations/he.json +17 -5
  142. package/translations/hi.json +17 -5
  143. package/translations/id.json +17 -5
  144. package/translations/it.json +17 -5
  145. package/translations/ja.json +17 -5
  146. package/translations/ko.json +17 -5
  147. package/translations/ms.json +17 -5
  148. package/translations/nl.json +17 -5
  149. package/translations/no.json +17 -5
  150. package/translations/pl.json +17 -5
  151. package/translations/pt.json +17 -5
  152. package/translations/ro.json +17 -5
  153. package/translations/sv.json +17 -5
  154. package/translations/th.json +17 -5
  155. package/translations/tr.json +17 -5
  156. package/translations/uk.json +17 -5
  157. package/translations/vi.json +17 -5
  158. package/translations/zh.json +17 -5
  159. package/dist/PdfAnnotationCanvas.client-V7Q3BEEQ.js.map +0 -1
  160. package/dist/ar-JRTKCMTF.js.map +0 -1
  161. package/dist/bn-LO6EDSCC.js.map +0 -1
  162. package/dist/chunk-5Y5JDCKC.js.map +0 -1
  163. package/dist/chunk-RO5CRVIA.js.map +0 -1
  164. package/dist/cs-LCHHOR4O.js.map +0 -1
  165. package/dist/da-TARGPMJR.js.map +0 -1
  166. package/dist/de-SBH4QBPJ.js.map +0 -1
  167. package/dist/el-2D2OLGWB.js.map +0 -1
  168. package/dist/es-U6YK2LX6.js.map +0 -1
  169. package/dist/fa-S7SZE37M.js.map +0 -1
  170. package/dist/fi-CBL3NIXN.js.map +0 -1
  171. package/dist/fr-EABUHV6S.js.map +0 -1
  172. package/dist/he-THWD244Y.js.map +0 -1
  173. package/dist/hi-GHGFZJ44.js.map +0 -1
  174. package/dist/id-POZOUMDW.js.map +0 -1
  175. package/dist/it-KDYJADSH.js.map +0 -1
  176. package/dist/ja-Z5Z6ZF3P.js.map +0 -1
  177. package/dist/ko-7X3XUXRN.js.map +0 -1
  178. package/dist/ms-BTWEIW43.js.map +0 -1
  179. package/dist/nl-IS2CGF5I.js.map +0 -1
  180. package/dist/no-AAMEVA26.js.map +0 -1
  181. package/dist/pl-PVXUQYFT.js.map +0 -1
  182. package/dist/pt-3ITQ76TL.js.map +0 -1
  183. package/dist/ro-FRB4IIVS.js.map +0 -1
  184. package/dist/sv-DS3RO24M.js.map +0 -1
  185. package/dist/th-AX66MWWB.js.map +0 -1
  186. package/dist/tr-7KTB67TT.js.map +0 -1
  187. package/dist/uk-SMQS2MAQ.js.map +0 -1
  188. package/dist/vi-D3JXL2EZ.js.map +0 -1
  189. package/dist/zh-S754AWFS.js.map +0 -1
  190. /package/dist/{en-QFIEG3CA.js.map → en-2KQILFKQ.js.map} +0 -0
@@ -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
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The "Download File" affordance on the unsupported-media fallback, in BOTH
3
+ * resource views.
4
+ *
5
+ * `GET /api/resources/:id` is the browser-facing alias of the pipe, and it
6
+ * exists only as an auth affordance for `<img>`, PDF.js and download links: a
7
+ * plain `<a download href>` sends neither an Authorization header nor a cookie,
8
+ * so the alias takes bearer + `?token=` only. Both views shipped a bare
9
+ * `/api/resources/${id}` — tokenless, so a 401, AND relative, so under
10
+ * bring-your-own-session embedding it resolves against the HOST app's origin
11
+ * rather than the backend's.
12
+ *
13
+ * Started RED (both hrefs were `/api/resources/res-1`).
14
+ */
15
+ import { describe, it, expect, vi } from 'vitest';
16
+ import { render, screen, waitFor } from '@testing-library/react';
17
+ import '@testing-library/jest-dom';
18
+ import type { SemiontSession } from '@semiont/sdk';
19
+ import type { AnnotationUIState } from '../../../types/annotation-props';
20
+ import { AnnotateView } from '../AnnotateView';
21
+ import { BrowseView } from '../BrowseView';
22
+
23
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
24
+ vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => null }));
25
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => null }));
26
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
27
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
28
+
29
+ const BACKEND = 'http://backend.test:4000';
30
+ const UNSUPPORTED = 'application/octet-stream';
31
+
32
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
33
+ const uiState: AnnotationUIState = {
34
+ selectedMotivation: null,
35
+ selectedClick: 'detail',
36
+ selectedShape: 'rectangle',
37
+ hoveredAnnotationId: null,
38
+ scrollToAnnotationId: null,
39
+ };
40
+
41
+ /** A session whose client mints `token` — the batteries-included case. */
42
+ function sessionMinting(token: string): SemiontSession {
43
+ return fakeSession({ auth: { mediaToken: vi.fn(async () => ({ token })) } });
44
+ }
45
+
46
+ /** A session whose token request never settles — the in-flight case. */
47
+ function sessionPending(): SemiontSession {
48
+ return fakeSession({ auth: { mediaToken: vi.fn(() => new Promise<never>(() => {})) } });
49
+ }
50
+
51
+ /**
52
+ * A transport-only client: `SemiontClient.auth` is `AuthNamespace | undefined`,
53
+ * and a host wiring a bare transport (no `IBackendOperations`) has no `auth`.
54
+ */
55
+ function sessionWithoutAuth(): SemiontSession {
56
+ return fakeSession({});
57
+ }
58
+
59
+ function fakeSession(clientExtras: Record<string, unknown>): SemiontSession {
60
+ return {
61
+ client: {
62
+ baseUrl: BACKEND,
63
+ mark: { request: vi.fn() },
64
+ browse: { click: vi.fn() },
65
+ beckon: { hover: vi.fn() },
66
+ ...clientExtras,
67
+ },
68
+ subscribe: () => () => {},
69
+ } as unknown as SemiontSession;
70
+ }
71
+
72
+ const annotateProps = (session: SemiontSession) => ({
73
+ content: '',
74
+ mimeType: UNSUPPORTED,
75
+ resourceUri: 'res-1',
76
+ annotations: emptyAnnotations,
77
+ uiState,
78
+ annotateMode: false,
79
+ session,
80
+ });
81
+
82
+ const browseProps = (session: SemiontSession) => ({
83
+ content: '',
84
+ mimeType: UNSUPPORTED,
85
+ resourceUri: 'res-1',
86
+ annotations: emptyAnnotations,
87
+ annotateMode: false,
88
+ session,
89
+ });
90
+
91
+ const VIEWS = [
92
+ { name: 'AnnotateView', render: (s: SemiontSession) => render(<AnnotateView {...annotateProps(s)} />) },
93
+ { name: 'BrowseView', render: (s: SemiontSession) => render(<BrowseView {...browseProps(s)} />) },
94
+ ] as const;
95
+
96
+ describe.each(VIEWS)('$name — unsupported-media download link', ({ render: renderView }) => {
97
+ it('points at the backend origin and carries the media token', async () => {
98
+ renderView(sessionMinting('tok-abc'));
99
+
100
+ const link = await screen.findByRole('link', { name: 'Download File' });
101
+ expect(link).toHaveAttribute('href', `${BACKEND}/api/resources/res-1?token=tok-abc`);
102
+ expect(link).toHaveAttribute('download');
103
+ });
104
+
105
+ it('renders no href while the token is still in flight', async () => {
106
+ renderView(sessionPending());
107
+
108
+ // The affordance stays on screen — it is just not yet actionable. What it
109
+ // must NOT do is offer a URL already known to fail.
110
+ expect(await screen.findByText('Download File')).toBeInTheDocument();
111
+ expect(screen.queryByRole('link', { name: 'Download File' })).not.toBeInTheDocument();
112
+ });
113
+
114
+ it('renders no href when the client has no auth namespace', async () => {
115
+ renderView(sessionWithoutAuth());
116
+
117
+ expect(await screen.findByText('Download File')).toBeInTheDocument();
118
+ await waitFor(() => {
119
+ expect(screen.queryByRole('link', { name: 'Download File' })).not.toBeInTheDocument();
120
+ });
121
+ });
122
+
123
+ it('never builds a host-relative URL', async () => {
124
+ renderView(sessionMinting('tok-abc'));
125
+
126
+ const link = await screen.findByRole('link', { name: 'Download File' });
127
+ expect(link.getAttribute('href')).not.toMatch(/^\//);
128
+ });
129
+ });
@@ -126,49 +126,6 @@
126
126
  Detection Log (Completed Results)
127
127
  ============================================ */
128
128
 
129
- .semiont-assist-widget__log {
130
- background-color: var(--semiont-bg-primary);
131
- border-radius: 0.5rem;
132
- box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
133
- padding: 1rem;
134
- display: flex;
135
- flex-direction: column;
136
- gap: 0.75rem;
137
- }
138
-
139
- [data-theme="dark"] .semiont-assist-widget__log {
140
- background-color: var(--semiont-color-gray-800);
141
- }
142
-
143
- .semiont-assist-widget__log-items {
144
- display: flex;
145
- flex-direction: column;
146
- gap: 0.25rem;
147
- }
148
-
149
- .semiont-assist-widget__log-item {
150
- font-size: 0.75rem;
151
- color: var(--semiont-color-gray-600);
152
- display: flex;
153
- align-items: center;
154
- gap: 0.5rem;
155
- }
156
-
157
- [data-theme="dark"] .semiont-assist-widget__log-item {
158
- color: var(--semiont-color-gray-400);
159
- }
160
-
161
- .semiont-assist-widget__log-check {
162
- color: var(--semiont-color-green-600);
163
- }
164
-
165
- [data-theme="dark"] .semiont-assist-widget__log-check {
166
- color: var(--semiont-color-green-400);
167
- }
168
-
169
- .semiont-assist-widget__log-type {
170
- font-weight: 500;
171
- }
172
129
 
173
130
  /* ============================================
174
131
  Detection Progress Display
@@ -43,6 +43,7 @@ export function AssistSection({
43
43
  annotationType === 'assessment' ? 'AssessmentPanel' :
44
44
  'CommentsPanel';
45
45
  const t = useTranslations(panelName);
46
+ const ta = useTranslations('AssistProgress');
46
47
  const [instructions, setInstructions] = useState('');
47
48
  type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
48
49
  const [tone, setTone] = useState<ToneValue>('');
@@ -89,7 +90,15 @@ export function AssistSection({
89
90
  progress={progress}
90
91
  progressProps={{
91
92
  onDismiss: handleDismissProgress,
92
- translations: { close: t('closeProgress') },
93
+ translations: {
94
+ cancel: t('cancel'),
95
+ inProgress: t('annotating'),
96
+ complete: t('complete'),
97
+ failed: t('failed'),
98
+ paramsTitle: ta('paramsTitle'),
99
+ processing: (label) => ta('processing', { label }),
100
+ close: ta('close'),
101
+ },
93
102
  }}
94
103
  form={
95
104
  <>
@@ -157,7 +166,7 @@ export function AssistSection({
157
166
  <span>{t('densityLabel')}</span>
158
167
  </label>
159
168
  {useDensity && (
160
- <span className="semiont-form-field__info">{density} per 2000 words</span>
169
+ <span className="semiont-form-field__info">{t('densityPerWords', { density })}</span>
161
170
  )}
162
171
  </div>
163
172