@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.
- package/dist/{PdfAnnotationCanvas.client-KWQ7XDWT.js → PdfAnnotationCanvas.client-75GY2EDR.js} +12 -6
- package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
- package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
- package/dist/chunk-VXASXU4K.js.map +1 -0
- package/dist/index.d.ts +54 -16
- package/dist/index.js +395 -343
- package/dist/index.js.map +1 -1
- package/dist/test-utils.d.ts +5 -1
- package/dist/test-utils.js +3 -2
- package/dist/test-utils.js.map +1 -1
- package/package.json +5 -5
- package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +12 -4
- package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
- package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +12 -4
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +137 -1
- package/src/components/resource/BrowseView.tsx +14 -6
- package/src/components/resource/ResourceViewer.tsx +6 -4
- package/src/components/resource/__tests__/BrowseView.test.tsx +98 -31
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +40 -1
- package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
- package/src/components/resource/browse-renderers.tsx +26 -4
- package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +8 -5
- package/src/components/resource/panels/AssistSection.tsx +4 -3
- package/src/components/resource/panels/CommentEntry.tsx +5 -4
- package/src/components/resource/panels/CommentsPanel.tsx +8 -5
- package/src/components/resource/panels/HighlightEntry.tsx +5 -4
- package/src/components/resource/panels/HighlightPanel.tsx +8 -5
- package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
- package/src/components/resource/panels/ReferencesPanel.tsx +11 -6
- package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
- package/src/components/resource/panels/TagEntry.tsx +5 -3
- package/src/components/resource/panels/TaggingPanel.tsx +7 -4
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +10 -0
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +38 -28
- package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
- package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
- package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +53 -24
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
- package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +77 -59
- package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -7
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +3 -0
- package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +0 -1
- 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.
|
|
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.
|
|
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.
|
|
126
|
-
"@semiont/http-transport": "0.5.
|
|
127
|
-
"@semiont/sdk": "0.5.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|