@semiont/react-ui 0.5.11 → 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 (53) hide show
  1. package/dist/{PdfAnnotationCanvas.client-KWQ7XDWT.js → PdfAnnotationCanvas.client-75GY2EDR.js} +12 -6
  2. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
  3. package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  4. package/dist/chunk-VXASXU4K.js.map +1 -0
  5. package/dist/index.d.ts +54 -16
  6. package/dist/index.js +395 -343
  7. package/dist/index.js.map +1 -1
  8. package/dist/test-utils.d.ts +5 -1
  9. package/dist/test-utils.js +3 -2
  10. package/dist/test-utils.js.map +1 -1
  11. package/package.json +5 -5
  12. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  13. package/src/components/image-annotation/SvgDrawingCanvas.tsx +12 -4
  14. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  15. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  16. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +12 -4
  17. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +137 -1
  18. package/src/components/resource/BrowseView.tsx +14 -6
  19. package/src/components/resource/ResourceViewer.tsx +6 -4
  20. package/src/components/resource/__tests__/BrowseView.test.tsx +98 -31
  21. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +40 -1
  22. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  23. package/src/components/resource/browse-renderers.tsx +26 -4
  24. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  25. package/src/components/resource/panels/AssessmentPanel.tsx +8 -5
  26. package/src/components/resource/panels/AssistSection.tsx +4 -3
  27. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  28. package/src/components/resource/panels/CommentsPanel.tsx +8 -5
  29. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  30. package/src/components/resource/panels/HighlightPanel.tsx +8 -5
  31. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  32. package/src/components/resource/panels/ReferencesPanel.tsx +11 -6
  33. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  34. package/src/components/resource/panels/TagEntry.tsx +5 -3
  35. package/src/components/resource/panels/TaggingPanel.tsx +7 -4
  36. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +10 -0
  37. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  38. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +38 -28
  39. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  40. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  41. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +53 -24
  42. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  43. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  44. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  45. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  46. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  47. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +77 -59
  48. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  49. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  50. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -7
  51. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +3 -0
  52. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +0 -1
  53. package/dist/chunk-O2MD7TGE.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "engines": {
5
5
  "node": ">=24.0.0"
6
6
  },
@@ -109,7 +109,7 @@
109
109
  "styled-components": "^6.4.3",
110
110
  "tsup": "^8.0.1",
111
111
  "typescript": "^6.0.2",
112
- "vitest": "^4.1.8"
112
+ "vitest": "^4.1.10"
113
113
  },
114
114
  "publishConfig": {
115
115
  "access": "public"
@@ -122,9 +122,9 @@
122
122
  "directory": "packages/react-ui"
123
123
  },
124
124
  "dependencies": {
125
- "@semiont/core": "0.5.11",
126
- "@semiont/http-transport": "0.5.11",
127
- "@semiont/sdk": "0.5.11",
125
+ "@semiont/core": "0.5.12",
126
+ "@semiont/http-transport": "0.5.12",
127
+ "@semiont/sdk": "0.5.12",
128
128
  "pdfjs-dist": "^6.1.200",
129
129
  "react-error-boundary": "^6.1.2",
130
130
  "react-markdown": "^10.1.0",
@@ -128,7 +128,7 @@ export function AnnotationOverlay({
128
128
  className="semiont-annotation-overlay__shape"
129
129
  data-hovered={isHovered ? 'true' : 'false'}
130
130
  data-selected={isSelected ? 'true' : 'false'}
131
- onClick={() => session?.client.browse.click(annotation.id, annotation.motivation)}
131
+ onClick={(e) => session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect())}
132
132
  onMouseEnter={() => handleMouseEnter(annotation.id)}
133
133
  onMouseLeave={handleMouseLeave}
134
134
  />
@@ -141,7 +141,7 @@ export function AnnotationOverlay({
141
141
  style={{ userSelect: 'none' }}
142
142
  onClick={(e) => {
143
143
  e.stopPropagation();
144
- session?.client.browse.click(annotation.id, annotation.motivation);
144
+ session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect());
145
145
  }}
146
146
  onMouseEnter={() => handleMouseEnter(annotation.id)}
147
147
  onMouseLeave={handleMouseLeave}
@@ -175,7 +175,7 @@ export function AnnotationOverlay({
175
175
  className="semiont-annotation-overlay__shape"
176
176
  data-hovered={isHovered ? 'true' : 'false'}
177
177
  data-selected={isSelected ? 'true' : 'false'}
178
- onClick={() => session?.client.browse.click(annotation.id, annotation.motivation)}
178
+ onClick={(e) => session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect())}
179
179
  onMouseEnter={() => handleMouseEnter(annotation.id)}
180
180
  onMouseLeave={handleMouseLeave}
181
181
  />
@@ -188,7 +188,7 @@ export function AnnotationOverlay({
188
188
  style={{ userSelect: 'none' }}
189
189
  onClick={(e) => {
190
190
  e.stopPropagation();
191
- session?.client.browse.click(annotation.id, annotation.motivation);
191
+ session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect());
192
192
  }}
193
193
  onMouseEnter={() => handleMouseEnter(annotation.id)}
194
194
  onMouseLeave={handleMouseLeave}
@@ -235,7 +235,7 @@ export function AnnotationOverlay({
235
235
  className="semiont-annotation-overlay__shape"
236
236
  data-hovered={isHovered ? 'true' : 'false'}
237
237
  data-selected={isSelected ? 'true' : 'false'}
238
- onClick={() => session?.client.browse.click(annotation.id, annotation.motivation)}
238
+ onClick={(e) => session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect())}
239
239
  onMouseEnter={() => handleMouseEnter(annotation.id)}
240
240
  onMouseLeave={handleMouseLeave}
241
241
  />
@@ -248,7 +248,7 @@ export function AnnotationOverlay({
248
248
  style={{ userSelect: 'none' }}
249
249
  onClick={(e) => {
250
250
  e.stopPropagation();
251
- session?.client.browse.click(annotation.id, annotation.motivation);
251
+ session?.client.browse.click(annotation.id, annotation.motivation, e.currentTarget.getBoundingClientRect());
252
252
  }}
253
253
  onMouseEnter={() => handleMouseEnter(annotation.id)}
254
254
  onMouseLeave={handleMouseLeave}
@@ -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';
4
+ import type { Annotation, AnchorRect } from '@semiont/core';
5
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';
@@ -175,7 +176,10 @@ export function SvgDrawingCanvas({
175
176
  // This was a click, not a drag - check if we clicked an existing annotation
176
177
  if (existingAnnotations.length > 0) {
177
178
  // Find annotation at click point
178
- // 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;
179
183
  const clickedAnnotation = existingAnnotations.find(ann => {
180
184
  if (typeof ann.target === 'string') return false;
181
185
 
@@ -201,19 +205,23 @@ export function SvgDrawingCanvas({
201
205
  const displayWidth = width * scaleX;
202
206
  const displayHeight = height * scaleY;
203
207
 
204
- return (
208
+ const hit = (
205
209
  endPoint.x >= displayX &&
206
210
  endPoint.x <= displayX + displayWidth &&
207
211
  endPoint.y >= displayY &&
208
212
  endPoint.y <= displayY + displayHeight
209
213
  );
214
+ if (hit && imageRef.current) {
215
+ hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
216
+ }
217
+ return hit;
210
218
  }
211
219
 
212
220
  return false;
213
221
  });
214
222
 
215
223
  if (clickedAnnotation) {
216
- session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation);
224
+ session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);
217
225
  setIsDrawing(false);
218
226
  setStartPoint(null);
219
227
  setCurrentPoint(null);
@@ -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,8 +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
5
  import { resourceId as toResourceId } from '@semiont/core';
6
+ import { toViewportAnchorRect } from '../../lib/anchor-rect';
6
7
  import {
7
8
  getTargetSelector,
8
9
  createFragmentSelector,
@@ -256,6 +257,9 @@ export function PdfAnnotationCanvas({
256
257
  if (dragDistance < MIN_DRAG_DISTANCE) {
257
258
  // This was a click, not a drag - check if we clicked an existing annotation
258
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;
259
263
  const clickedAnnotation = pageAnnotations.find(ann => {
260
264
  const fragmentSel = getFragmentSelector(ann.target);
261
265
  if (!fragmentSel) return false;
@@ -274,16 +278,20 @@ export function PdfAnnotationCanvas({
274
278
  const displayWidth = rect.width * scaleX;
275
279
  const displayHeight = rect.height * scaleY;
276
280
 
277
- return (
281
+ const hit = (
278
282
  selection.endX >= displayX &&
279
283
  selection.endX <= displayX + displayWidth &&
280
284
  selection.endY >= displayY &&
281
285
  selection.endY <= displayY + displayHeight
282
286
  );
287
+ if (hit && imageRef.current) {
288
+ hitRect = toViewportAnchorRect(imageRef.current.getBoundingClientRect(), displayX, displayY, displayWidth, displayHeight);
289
+ }
290
+ return hit;
283
291
  });
284
292
 
285
293
  if (clickedAnnotation) {
286
- session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation);
294
+ session?.client.browse.click(clickedAnnotation.id, clickedAnnotation.motivation, hitRect);
287
295
  setIsDrawing(false);
288
296
  setSelection(null);
289
297
  return;
@@ -461,7 +469,7 @@ export function PdfAnnotationCanvas({
461
469
  cursor: 'pointer',
462
470
  opacity: isSelected ? 1 : isHovered ? 0.9 : 0.7
463
471
  }}
464
- onClick={() => session?.client.browse.click(ann.id, ann.motivation)}
472
+ onClick={(e) => session?.client.browse.click(ann.id, ann.motivation, e.currentTarget.getBoundingClientRect())}
465
473
  onMouseEnter={() => handleMouseEnter(ann.id)}
466
474
  onMouseLeave={handleMouseLeave}
467
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
 
@@ -177,6 +178,141 @@ 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
318
  <PdfAnnotationCanvas resourceUri="res-1"
@@ -134,19 +134,27 @@ export const BrowseView = memo(function BrowseView({
134
134
 
135
135
  const container = containerRef.current;
136
136
 
137
- // Single click handler for the container
137
+ // Single click handler for the container — emits browse:click for whatever
138
+ // annotation the click resolves, any motivation (parity with the image/PDF
139
+ // and annotate-mode emitters; ResourceViewer routes by click action).
138
140
  const handleClick = (e: MouseEvent) => {
139
141
  const target = e.target as HTMLElement;
140
142
  const annotationElement = target.closest('[data-annotation-id]');
141
143
  if (!annotationElement) return;
142
144
 
143
- const annotationId = annotationElement.getAttribute('data-annotation-id');
144
- const annotationType = annotationElement.getAttribute('data-annotation-type');
145
+ // Browse mode is the reading surface: a drag-select that starts and ends
146
+ // inside one annotated span fires click on it — suppress emission so
147
+ // copying text never opens or navigates.
148
+ const selection = window.getSelection();
149
+ if (selection && !selection.isCollapsed) return;
145
150
 
146
- if (annotationId && annotationType === 'reference') {
151
+ const annotationId = annotationElement.getAttribute('data-annotation-id');
152
+ if (annotationId) {
147
153
  const annotation = allAnnotations.find(a => a.id === annotationId);
148
154
  if (annotation) {
149
- session.client.browse.click(annotation.id, annotation.motivation);
155
+ // The emission site owns the geometry: the clicked span's viewport
156
+ // rect rides the event so hosts can anchor popovers (A1).
157
+ session.client.browse.click(annotation.id, annotation.motivation, annotationElement.getBoundingClientRect());
150
158
  }
151
159
  }
152
160
  };
@@ -314,7 +322,7 @@ export const BrowseView = memo(function BrowseView({
314
322
  />
315
323
  )}
316
324
  <div ref={containerRef} className="semiont-browse-view__content" onClick={handleContentClick}>
317
- <Renderer content={content} mimeType={mimeType} resourceUri={resourceUri} annotations={allAnnotations} />
325
+ <Renderer content={content} mimeType={mimeType} resourceUri={resourceUri} annotations={allAnnotations} session={session} />
318
326
  </div>
319
327
  </div>
320
328
  );
@@ -6,7 +6,7 @@ import { AnnotateView, type SelectionMotivation, type ClickAction, type ShapeTyp
6
6
  import { BrowseView, type ReferenceHover } from './BrowseView';
7
7
  import { PopupContainer } from '../annotation-popups/SharedPopupElements';
8
8
  import { JsonLdView } from '../annotation-popups/JsonLdView';
9
- import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap } from '@semiont/core';
9
+ import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap, AnchorRect } from '@semiont/core';
10
10
  import { getExactText, getTargetSelector, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
11
11
  import type { SemiontSession } from '@semiont/sdk';
12
12
  import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
@@ -283,9 +283,10 @@ export function ResourceViewer({
283
283
  }, [annotateMode, selectedClick, focusAnnotation, onOpenResource]);
284
284
 
285
285
  // Annotation click coordinator - handles panel opening and scrolling
286
- const handleAnnotationClickEvent = useCallback(({ annotationId, motivation }: {
286
+ const handleAnnotationClickEvent = useCallback(({ annotationId, motivation, anchorRect }: {
287
287
  annotationId: string;
288
288
  motivation: components['schemas']['Motivation'];
289
+ anchorRect?: AnchorRect;
289
290
  }) => {
290
291
  // Find the annotation metadata
291
292
  const metadata = Object.values(ANNOTATORS).find(a => a.matchesAnnotation({ motivation } as Annotation));
@@ -311,8 +312,9 @@ export function ResourceViewer({
311
312
  }
312
313
 
313
314
  // All annotations open the unified annotations panel — the host owns the panel.
314
- // The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel
315
- onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation });
315
+ // The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel.
316
+ // View geometry passes through untouched: the emitter owned it, the host anchors with it.
317
+ onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation, ...(anchorRect ? { anchorRect } : {}) });
316
318
  }, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
317
319
 
318
320
  // Event subscriptions - Combined into single useEventSubscriptions call to prevent hook ordering issues