@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.
- package/README.md +1 -1
- package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
- package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
- package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
- package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
- package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
- package/dist/chunk-VXASXU4K.js.map +1 -0
- package/dist/index.css +150 -112
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +334 -48
- package/dist/index.js +2494 -2201
- package/dist/index.js.map +1 -1
- package/dist/test-utils.d.ts +5 -1
- package/dist/test-utils.js +10 -4
- package/dist/test-utils.js.map +1 -1
- package/package.json +7 -7
- package/src/components/ResourceTagsInline.tsx +94 -11
- package/src/components/StatusDisplay.css +36 -0
- package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
- package/src/components/annotation/AnnotateToolbar.tsx +53 -28
- package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
- package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
- package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
- 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 +17 -4
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
- package/src/components/resource/AnnotateView.tsx +59 -38
- package/src/components/resource/BrowseView.tsx +172 -138
- package/src/components/resource/ResourceViewer.tsx +123 -130
- package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
- package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
- package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
- package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
- package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
- package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
- package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
- package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
- package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
- package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
- package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
- package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
- package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
- package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
- package/src/components/resource/browse-renderers.tsx +85 -0
- package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +12 -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 +12 -5
- package/src/components/resource/panels/HighlightEntry.tsx +5 -4
- package/src/components/resource/panels/HighlightPanel.tsx +12 -5
- package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
- package/src/components/resource/panels/ReferencesPanel.tsx +15 -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 +11 -4
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -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 +54 -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 +78 -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 +16 -7
- package/src/components/toolbar/Toolbar.css +14 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
- package/src/styles/features/resource-viewer.css +19 -0
- package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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"
|