@semiont/react-ui 0.5.10 → 0.5.12

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 (81) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
  3. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
  4. package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
  5. package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  6. package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  7. package/dist/chunk-VXASXU4K.js.map +1 -0
  8. package/dist/index.css +150 -112
  9. package/dist/index.css.map +1 -1
  10. package/dist/index.d.ts +334 -48
  11. package/dist/index.js +2494 -2201
  12. package/dist/index.js.map +1 -1
  13. package/dist/test-utils.d.ts +5 -1
  14. package/dist/test-utils.js +10 -4
  15. package/dist/test-utils.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/components/ResourceTagsInline.tsx +94 -11
  18. package/src/components/StatusDisplay.css +36 -0
  19. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  20. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  21. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  23. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  24. package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
  25. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  26. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  27. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +17 -4
  28. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
  29. package/src/components/resource/AnnotateView.tsx +59 -38
  30. package/src/components/resource/BrowseView.tsx +172 -138
  31. package/src/components/resource/ResourceViewer.tsx +123 -130
  32. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  33. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  34. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  35. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  36. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  37. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  38. package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
  39. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
  40. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  41. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  42. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  43. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  44. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  45. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  46. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  47. package/src/components/resource/browse-renderers.tsx +85 -0
  48. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  49. package/src/components/resource/panels/AssessmentPanel.tsx +12 -5
  50. package/src/components/resource/panels/AssistSection.tsx +4 -3
  51. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  52. package/src/components/resource/panels/CommentsPanel.tsx +12 -5
  53. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  54. package/src/components/resource/panels/HighlightPanel.tsx +12 -5
  55. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  56. package/src/components/resource/panels/ReferencesPanel.tsx +15 -6
  57. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  58. package/src/components/resource/panels/TagEntry.tsx +5 -3
  59. package/src/components/resource/panels/TaggingPanel.tsx +11 -4
  60. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
  61. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  62. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -28
  63. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  64. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +54 -24
  66. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  67. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  68. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  70. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  71. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +78 -59
  72. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  73. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  74. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +16 -7
  75. package/src/components/toolbar/Toolbar.css +14 -0
  76. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  77. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
  78. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  79. package/src/styles/features/resource-viewer.css +19 -0
  80. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
  81. package/dist/chunk-O2MD7TGE.js.map +0 -1
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useRef, useState, useEffect, useCallback } from 'react';
4
- import type { Annotation } from '@semiont/core';
5
- import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, Point } from '@semiont/core';
4
+ import type { Annotation, AnchorRect } from '@semiont/core';
5
+ import { createRectangleSvg, createCircleSvg, createPolygonSvg, scaleSvgToNative, parseSvgSelector, Point, resourceId as toResourceId } from '@semiont/core';
6
+ import { toViewportAnchorRect } from '../../lib/anchor-rect';
6
7
  import { AnnotationOverlay } from './AnnotationOverlay';
7
8
  import type { SelectionMotivation } from '../annotation/AnnotateToolbar';
8
9
  import type { SemiontSession } from '@semiont/sdk';
@@ -35,6 +36,8 @@ function getMotivationColor(motivation: SelectionMotivation | null): { stroke: s
35
36
 
36
37
  interface SvgDrawingCanvasProps {
37
38
  imageUrl: string;
39
+ /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
40
+ resourceUri: string;
38
41
  existingAnnotations?: Annotation[];
39
42
  drawingMode: DrawingMode;
40
43
  selectedMotivation?: SelectionMotivation | null;
@@ -52,6 +55,7 @@ interface SvgDrawingCanvasProps {
52
55
  */
53
56
  export function SvgDrawingCanvas({
54
57
  imageUrl,
58
+ resourceUri,
55
59
  existingAnnotations = [],
56
60
  drawingMode,
57
61
  selectedMotivation,
@@ -172,7 +176,10 @@ export function SvgDrawingCanvas({
172
176
  // This was a click, not a drag - check if we clicked an existing annotation
173
177
  if (existingAnnotations.length > 0) {
174
178
  // Find annotation at click point
175
- // Note: We're checking in display coordinates
179
+ // Note: We're checking in display coordinates. The hit-test owns the
180
+ // coordinate transform — capture the hit annotation's viewport rect
181
+ // for the emission below (A1 anchor).
182
+ let hitRect: AnchorRect | undefined;
176
183
  const clickedAnnotation = existingAnnotations.find(ann => {
177
184
  if (typeof ann.target === 'string') return false;
178
185
 
@@ -198,19 +205,23 @@ export function SvgDrawingCanvas({
198
205
  const displayWidth = width * scaleX;
199
206
  const displayHeight = height * scaleY;
200
207
 
201
- return (
208
+ const hit = (
202
209
  endPoint.x >= displayX &&
203
210
  endPoint.x <= displayX + displayWidth &&
204
211
  endPoint.y >= displayY &&
205
212
  endPoint.y <= displayY + displayHeight
206
213
  );
214
+ if (hit && imageRef.current) {
215
+ hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
216
+ }
217
+ return hit;
207
218
  }
208
219
 
209
220
  return false;
210
221
  });
211
222
 
212
223
  if (clickedAnnotation) {
213
- session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation);
224
+ session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);
214
225
  setIsDrawing(false);
215
226
  setStartPoint(null);
216
227
  setCurrentPoint(null);
@@ -274,6 +285,7 @@ export function SvgDrawingCanvas({
274
285
  // Emit annotation:requested event with SvgSelector
275
286
  if (session && selectedMotivation) {
276
287
  session.client.mark.request(
288
+ toResourceId(resourceUri),
277
289
  { type: 'SvgSelector', value: nativeSvg },
278
290
  selectedMotivation,
279
291
  );
@@ -0,0 +1,137 @@
1
+ /**
2
+ * A1 anchor thread (HEADLESS-ANNOTATION-PANELS Phase 3) — image overlay pin.
3
+ *
4
+ * The overlay's shape elements own their on-screen geometry: a click passes
5
+ * the element's viewport rect as browse.click's third argument so hosts can
6
+ * anchor popovers. Runtime-only view geometry; no schema involvement.
7
+ */
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import { render, fireEvent } from '@testing-library/react';
10
+ import '@testing-library/jest-dom';
11
+ import type { SemiontSession } from '@semiont/sdk';
12
+ import type { Annotation, AnnotationId } from '@semiont/core';
13
+ import { AnnotationOverlay } from '../AnnotationOverlay';
14
+
15
+ function svgAnnotation(id: string, motivation: Annotation['motivation'], svgValue: string): Annotation {
16
+ return {
17
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
18
+ id: id as AnnotationId,
19
+ type: 'Annotation',
20
+ motivation,
21
+ created: '2024-01-01T10:00:00Z',
22
+ target: {
23
+ source: 'resource-1',
24
+ selector: {
25
+ type: 'SvgSelector',
26
+ value: svgValue,
27
+ },
28
+ },
29
+ };
30
+ }
31
+
32
+ const rectAnnotation = svgAnnotation('img-ann-1', 'highlighting', '<svg><rect x="10" y="10" width="20" height="20"/></svg>');
33
+
34
+ function sessionDouble() {
35
+ const click = vi.fn();
36
+ const session = {
37
+ client: {
38
+ browse: { click },
39
+ beckon: { hover: vi.fn() },
40
+ },
41
+ } as unknown as SemiontSession;
42
+ return { session, click };
43
+ }
44
+
45
+ describe('AnnotationOverlay — anchorRect on click', () => {
46
+ it('passes the shape element rect as browse.click third argument', () => {
47
+ const { session, click } = sessionDouble();
48
+
49
+ const { container } = render(
50
+ <AnnotationOverlay
51
+ annotations={[rectAnnotation]}
52
+ imageWidth={100}
53
+ imageHeight={100}
54
+ displayWidth={200}
55
+ displayHeight={200}
56
+ session={session}
57
+ hoverDelayMs={0}
58
+ />,
59
+ );
60
+
61
+ const shape = container.querySelector('.semiont-annotation-overlay__shape');
62
+ expect(shape).toBeInTheDocument();
63
+
64
+ fireEvent.click(shape!);
65
+
66
+ expect(click).toHaveBeenCalledTimes(1);
67
+ expect(click.mock.calls[0]?.[0]).toBe('img-ann-1');
68
+ expect(click.mock.calls[0]?.[1]).toBe('highlighting');
69
+ const anchorRect = click.mock.calls[0]?.[2];
70
+ expect(anchorRect).toBeDefined();
71
+ expect(typeof anchorRect.width).toBe('number');
72
+ expect(typeof anchorRect.left).toBe('number');
73
+ });
74
+
75
+ // The other shape kinds render distinct SVG elements with their own
76
+ // handlers — each instance of the pattern gets its own pin.
77
+ it.each([
78
+ ['circle', svgAnnotation('img-circle-1', 'commenting', '<svg><circle cx="30" cy="30" r="10"/></svg>')],
79
+ ['polygon', svgAnnotation('img-polygon-1', 'assessing', '<svg><polygon points="10,10 30,10 20,30"/></svg>')],
80
+ ] as const)('passes the %s element rect as browse.click third argument', (_kind, annotation) => {
81
+ const { session, click } = sessionDouble();
82
+
83
+ const { container } = render(
84
+ <AnnotationOverlay
85
+ annotations={[annotation]}
86
+ imageWidth={100}
87
+ imageHeight={100}
88
+ displayWidth={200}
89
+ displayHeight={200}
90
+ session={session}
91
+ hoverDelayMs={0}
92
+ />,
93
+ );
94
+
95
+ const shape = container.querySelector('.semiont-annotation-overlay__shape');
96
+ expect(shape).toBeInTheDocument();
97
+
98
+ fireEvent.click(shape!);
99
+
100
+ expect(click).toHaveBeenCalledTimes(1);
101
+ expect(click.mock.calls[0]?.[0]).toBe(annotation.id);
102
+ const anchorRect = click.mock.calls[0]?.[2];
103
+ expect(anchorRect).toBeDefined();
104
+ expect(typeof anchorRect.width).toBe('number');
105
+ });
106
+
107
+ it('passes the status-indicator element rect as browse.click third argument', () => {
108
+ // Only references render a status indicator (🔗/❓).
109
+ const reference = svgAnnotation('img-ref-1', 'linking', '<svg><rect x="10" y="10" width="20" height="20"/></svg>');
110
+ const { session, click } = sessionDouble();
111
+
112
+ const { container } = render(
113
+ <AnnotationOverlay
114
+ annotations={[reference]}
115
+ imageWidth={100}
116
+ imageHeight={100}
117
+ displayWidth={200}
118
+ displayHeight={200}
119
+ session={session}
120
+ hoverDelayMs={0}
121
+ />,
122
+ );
123
+
124
+ const indicator = container.querySelector('.semiont-annotation-overlay__status-indicator');
125
+ expect(indicator).toBeInTheDocument();
126
+
127
+ fireEvent.click(indicator!);
128
+
129
+ // stopPropagation: the indicator's own handler emits, exactly once.
130
+ expect(click).toHaveBeenCalledTimes(1);
131
+ expect(click.mock.calls[0]?.[0]).toBe('img-ref-1');
132
+ expect(click.mock.calls[0]?.[1]).toBe('linking');
133
+ const anchorRect = click.mock.calls[0]?.[2];
134
+ expect(anchorRect).toBeDefined();
135
+ expect(typeof anchorRect.width).toBe('number');
136
+ });
137
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * A1 anchor thread — image canvas drawing-path hit-test pin.
3
+ *
4
+ * In drawing mode, a sub-10px "click" on an existing annotation goes through
5
+ * the mouse-up hit-test (not the overlay's element handlers): the hit-test
6
+ * owns the display-coordinate transform and must emit browse.click with the
7
+ * annotation's viewport rect (display rect offset by the image's position —
8
+ * zeros in jsdom, so viewport == display coordinates here).
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { render, fireEvent, waitFor } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import type { SemiontSession } from '@semiont/sdk';
14
+ import type { Annotation, AnnotationId } from '@semiont/core';
15
+ import { SvgDrawingCanvas } from '../SvgDrawingCanvas';
16
+
17
+ // jsdom's Image never fires onload; the canvas loads natural dimensions via a
18
+ // detached `new Image()`. Stub it to report 100×100 synchronously-ish.
19
+ class FakeImage {
20
+ onload: (() => void) | null = null;
21
+ naturalWidth = 100;
22
+ naturalHeight = 100;
23
+ set src(_value: string) {
24
+ queueMicrotask(() => this.onload?.());
25
+ }
26
+ }
27
+
28
+ const rectAnnotation: Annotation = {
29
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
30
+ id: 'canvas-ann-1' as AnnotationId,
31
+ type: 'Annotation',
32
+ motivation: 'highlighting',
33
+ created: '2024-01-01T10:00:00Z',
34
+ target: {
35
+ source: 'resource-1',
36
+ selector: {
37
+ type: 'SvgSelector',
38
+ value: '<svg><rect x="10" y="10" width="20" height="20"/></svg>',
39
+ },
40
+ },
41
+ };
42
+
43
+ function sessionDouble() {
44
+ const click = vi.fn();
45
+ const session = {
46
+ client: {
47
+ browse: { click },
48
+ beckon: { hover: vi.fn() },
49
+ },
50
+ } as unknown as SemiontSession;
51
+ return { session, click };
52
+ }
53
+
54
+ describe('SvgDrawingCanvas — drawing-path hit-test anchorRect', () => {
55
+ beforeEach(() => {
56
+ vi.stubGlobal('Image', FakeImage);
57
+ });
58
+
59
+ afterEach(() => {
60
+ vi.unstubAllGlobals();
61
+ });
62
+
63
+ it('emits browse.click with the hit annotation viewport rect on a sub-10px click', async () => {
64
+ const { session, click } = sessionDouble();
65
+
66
+ const { container } = render(
67
+ <SvgDrawingCanvas
68
+ imageUrl="http://test/image.png"
69
+ resourceUri="resource-1"
70
+ existingAnnotations={[rectAnnotation]}
71
+ drawingMode="rectangle"
72
+ selectedMotivation="highlighting"
73
+ session={session}
74
+ />,
75
+ );
76
+
77
+ // Wait for the stubbed Image load to land imageDimensions (overlay mounts).
78
+ await waitFor(() => {
79
+ expect(container.querySelector('.semiont-svg-drawing-canvas__overlay-container')).toBeInTheDocument();
80
+ });
81
+
82
+ // jsdom has no layout: give the rendered <img> display dimensions and let
83
+ // the resize listener re-read them (display 200×200 over natural 100×100
84
+ // → scale 2, so the 10,10,20,20 annotation displays at 20,20,40,40).
85
+ const img = container.querySelector('.semiont-svg-drawing-canvas__image') as HTMLImageElement;
86
+ Object.defineProperty(img, 'clientWidth', { value: 200, configurable: true });
87
+ Object.defineProperty(img, 'clientHeight', { value: 200, configurable: true });
88
+ fireEvent(window, new Event('resize'));
89
+
90
+ await waitFor(() => {
91
+ const overlay = container.querySelector('.semiont-svg-drawing-canvas__overlay') as HTMLElement;
92
+ expect(overlay?.style.width).toBe('200px');
93
+ });
94
+
95
+ // Sub-10px gesture inside the displayed annotation (image rect is zeros
96
+ // in jsdom, so client coordinates are display coordinates).
97
+ const canvas = container.querySelector('.semiont-svg-drawing-canvas')!;
98
+ fireEvent.mouseDown(canvas, { clientX: 30, clientY: 30 });
99
+ fireEvent.mouseUp(canvas, { clientX: 30, clientY: 30 });
100
+
101
+ expect(click).toHaveBeenCalledTimes(1);
102
+ expect(click.mock.calls[0]?.[0]).toBe('canvas-ann-1');
103
+ expect(click.mock.calls[0]?.[1]).toBe('highlighting');
104
+ expect(click.mock.calls[0]?.[2]).toEqual({
105
+ x: 20, y: 20, left: 20, top: 20, width: 40, height: 40, right: 60, bottom: 60,
106
+ });
107
+ });
108
+ });
@@ -1,7 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
4
- import type { Annotation } from '@semiont/core';
4
+ import type { Annotation, AnchorRect } from '@semiont/core';
5
+ import { resourceId as toResourceId } from '@semiont/core';
6
+ import { toViewportAnchorRect } from '../../lib/anchor-rect';
5
7
  import {
6
8
  getTargetSelector,
7
9
  createFragmentSelector,
@@ -48,6 +50,8 @@ function getMotivationColor(motivation: SelectionMotivation | null): { stroke: s
48
50
 
49
51
  interface PdfAnnotationCanvasProps {
50
52
  pdfUrl: string;
53
+ /** The '@id' of the annotated resource — stamped as `source` on mark:requested (multi-viewer routing). */
54
+ resourceUri: string;
51
55
  existingAnnotations?: Annotation[];
52
56
  drawingMode: DrawingMode;
53
57
  selectedMotivation?: SelectionMotivation | null;
@@ -66,6 +70,7 @@ interface PdfAnnotationCanvasProps {
66
70
  */
67
71
  export function PdfAnnotationCanvas({
68
72
  pdfUrl,
73
+ resourceUri,
69
74
  existingAnnotations = [],
70
75
  drawingMode,
71
76
  selectedMotivation,
@@ -252,6 +257,9 @@ export function PdfAnnotationCanvas({
252
257
  if (dragDistance < MIN_DRAG_DISTANCE) {
253
258
  // This was a click, not a drag - check if we clicked an existing annotation
254
259
  if (existingAnnotations.length > 0) {
260
+ // The hit-test owns the coordinate transform — capture the hit
261
+ // annotation's viewport rect for the emission below (A1 anchor).
262
+ let hitRect: AnchorRect | undefined;
255
263
  const clickedAnnotation = pageAnnotations.find(ann => {
256
264
  const fragmentSel = getFragmentSelector(ann.target);
257
265
  if (!fragmentSel) return false;
@@ -270,16 +278,20 @@ export function PdfAnnotationCanvas({
270
278
  const displayWidth = rect.width * scaleX;
271
279
  const displayHeight = rect.height * scaleY;
272
280
 
273
- return (
281
+ const hit = (
274
282
  selection.endX >= displayX &&
275
283
  selection.endX <= displayX + displayWidth &&
276
284
  selection.endY >= displayY &&
277
285
  selection.endY <= displayY + displayHeight
278
286
  );
287
+ if (hit && imageRef.current) {
288
+ hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
289
+ }
290
+ return hit;
279
291
  });
280
292
 
281
293
  if (clickedAnnotation) {
282
- session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation);
294
+ session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);
283
295
  setIsDrawing(false);
284
296
  setSelection(null);
285
297
  return;
@@ -319,6 +331,7 @@ export function PdfAnnotationCanvas({
319
331
  // Emit annotation:requested event with FragmentSelector
320
332
  if (selectedMotivation) {
321
333
  session.client.mark.request(
334
+ toResourceId(resourceUri),
322
335
  {
323
336
  type: 'FragmentSelector',
324
337
  conformsTo: 'http://tools.ietf.org/rfc/rfc3778',
@@ -456,7 +469,7 @@ export function PdfAnnotationCanvas({
456
469
  cursor: 'pointer',
457
470
  opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
458
471
  }}
459
- onClick={() => session?.client.browse.click(ann.id, ann.motivation)}
472
+ onClick={(e) => session?.client.browse.click(ann.id, ann.motivation, e.currentTarget.getBoundingClientRect())}
460
473
  onMouseEnter={() => handleMouseEnter(ann.id)}
461
474
  onMouseLeave={handleMouseLeave}
462
475
  />
@@ -11,7 +11,8 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
11
11
  import { render, screen, waitFor, fireEvent } from '@testing-library/react';
12
12
  import userEvent from '@testing-library/user-event';
13
13
  import { PdfAnnotationCanvas } from '../PdfAnnotationCanvas';
14
- import { resourceId, annotationId } from '@semiont/core';
14
+ import { resourceId, annotationId, parseFragmentSelector } from '@semiont/core';
15
+ import { pdfToCanvasCoordinates } from '../../../lib/pdf-coordinates';
15
16
 
16
17
  import type { Annotation } from '@semiont/core';
17
18
 
@@ -56,7 +57,7 @@ describe('PdfAnnotationCanvas', () => {
56
57
 
57
58
  test('renders loading state initially', () => {
58
59
  render(
59
- <PdfAnnotationCanvas
60
+ <PdfAnnotationCanvas resourceUri="res-1"
60
61
  pdfUrl={mockPdfUrl}
61
62
  drawingMode={null}
62
63
  />
@@ -67,7 +68,7 @@ describe('PdfAnnotationCanvas', () => {
67
68
 
68
69
  test('renders page navigation controls after loading', async () => {
69
70
  render(
70
- <PdfAnnotationCanvas
71
+ <PdfAnnotationCanvas resourceUri="res-1"
71
72
  pdfUrl={mockPdfUrl}
72
73
  drawingMode={null}
73
74
  />
@@ -83,7 +84,7 @@ describe('PdfAnnotationCanvas', () => {
83
84
 
84
85
  test('previous button is disabled on first page', async () => {
85
86
  render(
86
- <PdfAnnotationCanvas
87
+ <PdfAnnotationCanvas resourceUri="res-1"
87
88
  pdfUrl={mockPdfUrl}
88
89
  drawingMode={null}
89
90
  />
@@ -99,7 +100,7 @@ describe('PdfAnnotationCanvas', () => {
99
100
 
100
101
  test('next button is disabled on last page', async () => {
101
102
  render(
102
- <PdfAnnotationCanvas
103
+ <PdfAnnotationCanvas resourceUri="res-1"
103
104
  pdfUrl={mockPdfUrl}
104
105
  drawingMode={null}
105
106
  />
@@ -144,7 +145,7 @@ describe('PdfAnnotationCanvas', () => {
144
145
  ];
145
146
 
146
147
  render(
147
- <PdfAnnotationCanvas
148
+ <PdfAnnotationCanvas resourceUri="res-1"
148
149
  pdfUrl={mockPdfUrl}
149
150
  existingAnnotations={mockAnnotations}
150
151
  drawingMode={null}
@@ -177,9 +178,144 @@ describe('PdfAnnotationCanvas', () => {
177
178
  });
178
179
  });
179
180
 
181
+ test('passes the annotation rect as browse.click third argument (A1 anchor)', async () => {
182
+ const click = vi.fn();
183
+ const session = {
184
+ client: { browse: { click }, beckon: { hover: vi.fn() } },
185
+ } as unknown as import('@semiont/sdk').SemiontSession;
186
+
187
+ const mockAnnotations: Annotation[] = [
188
+ {
189
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
190
+ type: 'Annotation',
191
+ id: annotationId('ann-1'),
192
+ target: {
193
+ source: mockResourceId,
194
+ selector: {
195
+ type: 'FragmentSelector',
196
+ value: 'page=1&viewrect=100,200,150,100',
197
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778'
198
+ }
199
+ },
200
+ motivation: 'highlighting',
201
+ created: new Date().toISOString()
202
+ }
203
+ ];
204
+
205
+ render(
206
+ <PdfAnnotationCanvas resourceUri="res-1"
207
+ pdfUrl={mockPdfUrl}
208
+ existingAnnotations={mockAnnotations}
209
+ drawingMode={null}
210
+ session={session}
211
+ />
212
+ );
213
+
214
+ await waitFor(() => {
215
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
216
+ expect(img).toBeInTheDocument();
217
+ });
218
+
219
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
220
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
221
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
222
+ fireEvent.load(img);
223
+
224
+ await waitFor(() => {
225
+ const rects = document.querySelector('.semiont-pdf-annotation-canvas__svg')?.querySelectorAll('rect');
226
+ expect(rects?.length).toBeGreaterThan(0);
227
+ });
228
+
229
+ const annotationRect = document.querySelector('.semiont-pdf-annotation-canvas__svg')!.querySelector('rect')!;
230
+ fireEvent.click(annotationRect);
231
+
232
+ expect(click).toHaveBeenCalledTimes(1);
233
+ expect(click.mock.calls[0]?.[0]).toBe('ann-1');
234
+ expect(click.mock.calls[0]?.[1]).toBe('highlighting');
235
+ const anchorRect = click.mock.calls[0]?.[2];
236
+ expect(anchorRect).toBeDefined();
237
+ expect(typeof anchorRect.width).toBe('number');
238
+ });
239
+
240
+ test('drawing-path hit-test emits browse.click with the annotation viewport rect', async () => {
241
+ // A1 anchor: in drawing mode, a sub-10px click on an existing annotation
242
+ // goes through the mouse-up hit-test, which owns the PDF→display
243
+ // coordinate transform. Expected rect computed with the same lib
244
+ // functions the component uses (scale is 1: display 612×792 == page).
245
+ const click = vi.fn();
246
+ const session = {
247
+ client: { browse: { click }, beckon: { hover: vi.fn() } },
248
+ } as unknown as import('@semiont/sdk').SemiontSession;
249
+
250
+ const fragmentValue = 'page=1&viewrect=100,200,150,100';
251
+ const mockAnnotations: Annotation[] = [
252
+ {
253
+ '@context': 'http://www.w3.org/ns/anno.jsonld',
254
+ type: 'Annotation',
255
+ id: annotationId('ann-hit-1'),
256
+ target: {
257
+ source: mockResourceId,
258
+ selector: {
259
+ type: 'FragmentSelector',
260
+ value: fragmentValue,
261
+ conformsTo: 'http://tools.ietf.org/rfc/rfc3778'
262
+ }
263
+ },
264
+ motivation: 'highlighting',
265
+ created: new Date().toISOString()
266
+ }
267
+ ];
268
+
269
+ render(
270
+ <PdfAnnotationCanvas resourceUri="res-1"
271
+ pdfUrl={mockPdfUrl}
272
+ existingAnnotations={mockAnnotations}
273
+ drawingMode="rectangle"
274
+ selectedMotivation="highlighting"
275
+ session={session}
276
+ />
277
+ );
278
+
279
+ await waitFor(() => {
280
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
281
+ expect(img).toBeInTheDocument();
282
+ });
283
+
284
+ const img = document.querySelector('.semiont-pdf-annotation-canvas__image') as HTMLImageElement;
285
+ Object.defineProperty(img, 'clientWidth', { value: 612, configurable: true });
286
+ Object.defineProperty(img, 'clientHeight', { value: 792, configurable: true });
287
+ fireEvent.load(img);
288
+
289
+ await waitFor(() => {
290
+ const rects = document.querySelector('.semiont-pdf-annotation-canvas__svg')?.querySelectorAll('rect');
291
+ expect(rects?.length).toBeGreaterThan(0);
292
+ });
293
+
294
+ const pdfCoord = parseFragmentSelector(fragmentValue)!;
295
+ const displayRect = pdfToCanvasCoordinates(pdfCoord, 792, 1.0);
296
+
297
+ // Sub-10px gesture at the annotation's display-rect center (image rect is
298
+ // zeros in jsdom, so client coordinates are display coordinates).
299
+ const canvasContainer = document.querySelector('.semiont-pdf-annotation-canvas__container')!;
300
+ const clickX = displayRect.x + displayRect.width / 2;
301
+ const clickY = displayRect.y + displayRect.height / 2;
302
+ fireEvent.mouseDown(canvasContainer, { clientX: clickX, clientY: clickY });
303
+ fireEvent.mouseUp(canvasContainer, { clientX: clickX, clientY: clickY });
304
+
305
+ expect(click).toHaveBeenCalledTimes(1);
306
+ expect(click.mock.calls[0]?.[0]).toBe('ann-hit-1');
307
+ expect(click.mock.calls[0]?.[1]).toBe('highlighting');
308
+ expect(click.mock.calls[0]?.[2]).toMatchObject({
309
+ left: displayRect.x,
310
+ top: displayRect.y,
311
+ width: displayRect.width,
312
+ height: displayRect.height,
313
+ });
314
+ });
315
+
180
316
  test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
181
317
  render(
182
- <PdfAnnotationCanvas
318
+ <PdfAnnotationCanvas resourceUri="res-1"
183
319
  pdfUrl={mockPdfUrl}
184
320
  drawingMode="rectangle"
185
321
  selectedMotivation="highlighting"