@semiont/react-ui 0.5.11 → 0.5.13
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
|
@@ -70,12 +70,8 @@ vi.mock('../../../lib/annotation-registry', () => ({
|
|
|
70
70
|
},
|
|
71
71
|
}));
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
ImageViewer: ({ resourceUri }: { resourceUri: string }) => (
|
|
76
|
-
<img data-testid="image-viewer" src={resourceUri} alt="Resource content" />
|
|
77
|
-
),
|
|
78
|
-
}));
|
|
73
|
+
// Image browse renders the read-only SvgDrawingCanvas (real component — jsdom
|
|
74
|
+
// renders its container fine; shape painting is covered by the dispatch spec).
|
|
79
75
|
|
|
80
76
|
// Mock AnnotateToolbar
|
|
81
77
|
vi.mock('../../annotation/AnnotateToolbar', () => ({
|
|
@@ -224,10 +220,12 @@ describe('BrowseView Component', () => {
|
|
|
224
220
|
expect(screen.getByTestId('annotate-toolbar')).toBeInTheDocument();
|
|
225
221
|
});
|
|
226
222
|
|
|
227
|
-
it('should render
|
|
228
|
-
|
|
223
|
+
it('should render the read-only annotation canvas for image mime types', () => {
|
|
224
|
+
// Was a bare ImageViewer, which silently dropped the annotations prop
|
|
225
|
+
// (bugs/image-browse-renderer-drops-annotations.md).
|
|
226
|
+
const { container } = renderWithProviders(<BrowseView {...defaultProps} mimeType="image/png" />);
|
|
229
227
|
|
|
230
|
-
expect(
|
|
228
|
+
expect(container.querySelector('.semiont-svg-drawing-canvas')).toBeInTheDocument();
|
|
231
229
|
});
|
|
232
230
|
|
|
233
231
|
it('should render unsupported message for unsupported mime types', () => {
|
|
@@ -404,12 +402,16 @@ describe('BrowseView Component', () => {
|
|
|
404
402
|
});
|
|
405
403
|
});
|
|
406
404
|
|
|
407
|
-
it('should emit browse:click
|
|
405
|
+
it('should emit browse:click for every motivation', async () => {
|
|
406
|
+
// browse:click is the platform's one "user clicked an annotation" signal;
|
|
407
|
+
// text browse mode must emit it for whatever the click resolves, matching
|
|
408
|
+
// the image/PDF/annotate emitters (BROWSE-CLICK-ALL-MOTIVATIONS.md).
|
|
408
409
|
const tracker = createEventTracker();
|
|
409
410
|
const annotations = {
|
|
410
411
|
...defaultProps.annotations,
|
|
411
412
|
references: [createMockAnnotation('linking', 'ref-1')],
|
|
412
413
|
highlights: [createMockAnnotation('highlighting', 'highlight-1')],
|
|
414
|
+
comments: [createMockAnnotation('commenting', 'comment-1')],
|
|
413
415
|
};
|
|
414
416
|
|
|
415
417
|
const { container } = renderWithEventTracking(
|
|
@@ -417,36 +419,101 @@ describe('BrowseView Component', () => {
|
|
|
417
419
|
tracker
|
|
418
420
|
);
|
|
419
421
|
|
|
420
|
-
const
|
|
421
|
-
mockReferenceElement.setAttribute('data-annotation-id', 'ref-1');
|
|
422
|
-
mockReferenceElement.setAttribute('data-annotation-type', 'reference');
|
|
422
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
423
423
|
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
424
|
+
const clickCases = [
|
|
425
|
+
{ id: 'ref-1', type: 'reference', motivation: 'linking' },
|
|
426
|
+
{ id: 'highlight-1', type: 'highlight', motivation: 'highlighting' },
|
|
427
|
+
{ id: 'comment-1', type: 'comment', motivation: 'commenting' },
|
|
428
|
+
] as const;
|
|
427
429
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
+
for (const { id, type, motivation } of clickCases) {
|
|
431
|
+
const el = document.createElement('span');
|
|
432
|
+
el.setAttribute('data-annotation-id', id);
|
|
433
|
+
el.setAttribute('data-annotation-type', type);
|
|
434
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
435
|
+
|
|
436
|
+
tracker.clear();
|
|
437
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
438
|
+
|
|
439
|
+
await waitFor(() => {
|
|
440
|
+
expect(tracker.events.some(e =>
|
|
441
|
+
e.event === 'browse:click' &&
|
|
442
|
+
e.payload?.annotationId === id &&
|
|
443
|
+
e.payload?.motivation === motivation
|
|
444
|
+
)).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
430
448
|
|
|
431
|
-
|
|
449
|
+
it('should include the clicked span rect as anchorRect', async () => {
|
|
450
|
+
// A1 anchor thread (HEADLESS-ANNOTATION-PANELS Phase 3): the emission
|
|
451
|
+
// site owns the geometry — the clicked span's viewport rect rides the
|
|
452
|
+
// event so hosts can anchor popovers. Runtime-only; no schema pin.
|
|
453
|
+
const tracker = createEventTracker();
|
|
454
|
+
const annotations = {
|
|
455
|
+
...defaultProps.annotations,
|
|
456
|
+
highlights: [createMockAnnotation('highlighting', 'highlight-1')],
|
|
457
|
+
};
|
|
432
458
|
|
|
433
|
-
|
|
459
|
+
const { container } = renderWithEventTracking(
|
|
460
|
+
<BrowseView {...defaultProps} annotations={annotations} />,
|
|
461
|
+
tracker
|
|
462
|
+
);
|
|
434
463
|
|
|
435
|
-
|
|
436
|
-
|
|
464
|
+
const el = document.createElement('span');
|
|
465
|
+
el.setAttribute('data-annotation-id', 'highlight-1');
|
|
466
|
+
el.setAttribute('data-annotation-type', 'highlight');
|
|
467
|
+
const RECT: DOMRect = {
|
|
468
|
+
x: 10, y: 20, width: 30, height: 40,
|
|
469
|
+
top: 20, right: 40, bottom: 60, left: 10,
|
|
470
|
+
toJSON: () => ({}),
|
|
471
|
+
};
|
|
472
|
+
el.getBoundingClientRect = () => RECT;
|
|
473
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
474
|
+
|
|
475
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
476
|
+
tracker.clear();
|
|
477
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
437
478
|
|
|
438
479
|
await waitFor(() => {
|
|
439
480
|
expect(tracker.events.some(e =>
|
|
440
481
|
e.event === 'browse:click' &&
|
|
441
|
-
e.payload?.annotationId === '
|
|
442
|
-
e.payload?.
|
|
482
|
+
e.payload?.annotationId === 'highlight-1' &&
|
|
483
|
+
e.payload?.anchorRect?.left === 10 &&
|
|
484
|
+
e.payload?.anchorRect?.width === 30
|
|
443
485
|
)).toBe(true);
|
|
444
486
|
});
|
|
487
|
+
});
|
|
445
488
|
|
|
446
|
-
|
|
489
|
+
it('should not emit browse:click when the click completes a text selection', async () => {
|
|
490
|
+
// Browse mode is the reading surface: a drag-select that starts and ends
|
|
491
|
+
// inside one annotated span fires click on it. The guard applies to
|
|
492
|
+
// references too — a copy-drag inside a reference span must not emit
|
|
493
|
+
// (deliberate behavior change; previously it navigated under `follow`).
|
|
494
|
+
const tracker = createEventTracker();
|
|
495
|
+
const annotations = {
|
|
496
|
+
...defaultProps.annotations,
|
|
497
|
+
references: [createMockAnnotation('linking', 'ref-1')],
|
|
498
|
+
};
|
|
447
499
|
|
|
448
|
-
|
|
449
|
-
|
|
500
|
+
const { container } = renderWithEventTracking(
|
|
501
|
+
<BrowseView {...defaultProps} annotations={annotations} />,
|
|
502
|
+
tracker
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const el = document.createElement('span');
|
|
506
|
+
el.setAttribute('data-annotation-id', 'ref-1');
|
|
507
|
+
el.setAttribute('data-annotation-type', 'reference');
|
|
508
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
509
|
+
|
|
510
|
+
vi.spyOn(window, 'getSelection').mockReturnValue({
|
|
511
|
+
isCollapsed: false,
|
|
512
|
+
} as Selection);
|
|
513
|
+
|
|
514
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
515
|
+
tracker.clear();
|
|
516
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
450
517
|
|
|
451
518
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
452
519
|
expect(tracker.events.filter(e => e.event === 'browse:click').length).toBe(0);
|
|
@@ -552,13 +619,13 @@ describe('BrowseView Component', () => {
|
|
|
552
619
|
expect(eventTracker.some(e => e.event === 'browse:click' && e.annotationId === 'ref-1')).toBe(true);
|
|
553
620
|
});
|
|
554
621
|
|
|
555
|
-
//
|
|
622
|
+
// Highlights emit through the same delegated handler (all motivations)
|
|
556
623
|
eventTracker.length = 0; // Clear tracker
|
|
557
624
|
fireEvent.click(browseContainer!, { target: mockHighlightTarget });
|
|
558
625
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
626
|
+
await waitFor(() => {
|
|
627
|
+
expect(eventTracker.some(e => e.event === 'browse:click' && e.annotationId === 'highlight-1')).toBe(true);
|
|
628
|
+
});
|
|
562
629
|
|
|
563
630
|
// The key insight: With event delegation, we can handle 100 annotations
|
|
564
631
|
// with only container-level listeners, not 100+ individual listeners
|
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* subtree is covered by AnnotateView.embeddable.test.tsx.
|
|
13
13
|
*/
|
|
14
14
|
import { describe, it, expect, vi } from 'vitest';
|
|
15
|
-
import { render, screen } from '@testing-library/react';
|
|
15
|
+
import { render, screen, act, waitFor } from '@testing-library/react';
|
|
16
16
|
import '@testing-library/jest-dom';
|
|
17
17
|
import type { SemiontSession } from '@semiont/sdk';
|
|
18
18
|
import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
|
|
19
|
+
import { createTestSemiontWrapper } from '../../../test-utils';
|
|
19
20
|
import { ResourceViewer } from '../ResourceViewer';
|
|
20
21
|
|
|
21
22
|
// Minimal bring-your-own-session double: just the surface ResourceViewer + its
|
|
@@ -60,4 +61,42 @@ describe('ResourceViewer — embeddable (bring-your-own-session, no providers)',
|
|
|
60
61
|
);
|
|
61
62
|
expect(screen.getByText('Embeddable content.')).toBeInTheDocument();
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
// A1 anchor thread: the viewer is a pass-through for view geometry — an
|
|
66
|
+
// anchorRect arriving on browse:click reaches the host's onOpenPanel
|
|
67
|
+
// untouched (detail routing, the default click action).
|
|
68
|
+
it('forwards the browse:click anchorRect into onOpenPanel', async () => {
|
|
69
|
+
const { session, eventBus } = createTestSemiontWrapper();
|
|
70
|
+
const onOpenPanel = vi.fn();
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<ResourceViewer
|
|
74
|
+
session={session}
|
|
75
|
+
resource={resource}
|
|
76
|
+
annotations={annotations}
|
|
77
|
+
onOpenResource={vi.fn()}
|
|
78
|
+
onOpenPanel={onOpenPanel}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const anchorRect = {
|
|
83
|
+
x: 5, y: 6, width: 7, height: 8,
|
|
84
|
+
top: 6, right: 12, bottom: 14, left: 5,
|
|
85
|
+
};
|
|
86
|
+
act(() => {
|
|
87
|
+
eventBus.get('browse:click').next({
|
|
88
|
+
annotationId: 'ann-1',
|
|
89
|
+
motivation: 'highlighting',
|
|
90
|
+
anchorRect,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(onOpenPanel).toHaveBeenCalledWith(expect.objectContaining({
|
|
96
|
+
panel: 'annotations',
|
|
97
|
+
scrollToAnnotationId: 'ann-1',
|
|
98
|
+
anchorRect: expect.objectContaining({ left: 5, width: 7 }),
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
63
102
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUG: image-browse-renderer-drops-annotations — the default renderers must
|
|
3
|
+
* forward what BrowseView hands them.
|
|
4
|
+
*
|
|
5
|
+
* ImageBrowseRenderer destructured only content/mimeType and mounted a bare
|
|
6
|
+
* ImageViewer: shape annotations invisible in browse mode (annotate mode
|
|
7
|
+
* proves the data). Fix: the read-only annotation canvas (drawingMode=null),
|
|
8
|
+
* WITH the session extension on MediaRendererProps — clicks/hover route in
|
|
9
|
+
* browse mode too, and the PDF renderer's pre-existing session-less click gap
|
|
10
|
+
* heals in the same motion (per the fork note in the bug doc).
|
|
11
|
+
*
|
|
12
|
+
* Prop-capturing canvas mocks pin the contract: mounted, given the
|
|
13
|
+
* annotations, read-only, session threaded. Started RED (image: no canvas at
|
|
14
|
+
* all; pdf: no session) → GREEN.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
17
|
+
import { render, within } from '@testing-library/react';
|
|
18
|
+
import '@testing-library/jest-dom';
|
|
19
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
20
|
+
import type { Annotation, AnnotationId } from '@semiont/core';
|
|
21
|
+
import { BrowseView } from '../BrowseView';
|
|
22
|
+
|
|
23
|
+
const captured = vi.hoisted(() => ({
|
|
24
|
+
svg: [] as Record<string, unknown>[],
|
|
25
|
+
pdf: [] as Record<string, unknown>[],
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({
|
|
29
|
+
SvgDrawingCanvas: (props: Record<string, unknown>) => {
|
|
30
|
+
captured.svg.push(props);
|
|
31
|
+
return <div className="semiont-svg-drawing-canvas">svg-canvas-mock</div>;
|
|
32
|
+
},
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../../pdf-annotation/PdfAnnotationCanvas.client', () => ({
|
|
35
|
+
PdfAnnotationCanvas: (props: Record<string, unknown>) => {
|
|
36
|
+
captured.pdf.push(props);
|
|
37
|
+
return <div>pdf-canvas-mock</div>;
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
41
|
+
vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
|
|
42
|
+
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
|
43
|
+
|
|
44
|
+
function fakeSession(): SemiontSession {
|
|
45
|
+
return {
|
|
46
|
+
client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
|
|
47
|
+
subscribe: () => () => {},
|
|
48
|
+
} as unknown as SemiontSession;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A shape annotation (SvgSelector region) — the image-annotation shape. */
|
|
52
|
+
function shapeAnnotation(): Annotation {
|
|
53
|
+
return {
|
|
54
|
+
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
|
55
|
+
id: 'shape-1' as AnnotationId,
|
|
56
|
+
type: 'Annotation',
|
|
57
|
+
motivation: 'highlighting',
|
|
58
|
+
creator: { '@type': 'Person', name: 'user@example.com' },
|
|
59
|
+
created: '2026-07-14T00:00:00Z',
|
|
60
|
+
target: {
|
|
61
|
+
source: 'res-1',
|
|
62
|
+
selector: { type: 'SvgSelector', value: '<svg><rect x="1" y="1" width="5" height="5"/></svg>' },
|
|
63
|
+
},
|
|
64
|
+
} as unknown as Annotation;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
68
|
+
|
|
69
|
+
const baseProps = {
|
|
70
|
+
resourceUri: 'res-1',
|
|
71
|
+
annotateMode: false,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe('browse-renderers — annotation + session forwarding (dispatch contract)', () => {
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
captured.svg.length = 0;
|
|
78
|
+
captured.pdf.length = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('image browse mounts the read-only annotation canvas with the annotations and session', () => {
|
|
82
|
+
const session = fakeSession();
|
|
83
|
+
const annotations = { ...emptyAnnotations, highlights: [shapeAnnotation()] };
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<BrowseView {...baseProps} content="blob:image-url" mimeType="image/png"
|
|
86
|
+
annotations={annotations} session={session} />,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(within(container).getByText('svg-canvas-mock')).toBeInTheDocument();
|
|
90
|
+
expect(captured.svg).toHaveLength(1);
|
|
91
|
+
const props = captured.svg[0]!;
|
|
92
|
+
expect(props.imageUrl).toBe('blob:image-url');
|
|
93
|
+
expect(props.existingAnnotations).toEqual([shapeAnnotation()]); // the dropped prop
|
|
94
|
+
expect(props.drawingMode).toBeNull(); // read-only in browse
|
|
95
|
+
expect(props.session).toBe(session); // interaction parity
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('pdf browse keeps its annotations (pinned) and gains the session (the healed click gap)', async () => {
|
|
99
|
+
const session = fakeSession();
|
|
100
|
+
const annotations = { ...emptyAnnotations, highlights: [shapeAnnotation()] };
|
|
101
|
+
const { container } = render(
|
|
102
|
+
<BrowseView {...baseProps} content="blob:pdf-url" mimeType="application/pdf"
|
|
103
|
+
annotations={annotations} session={session} />,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
await within(container).findByText('pdf-canvas-mock'); // flush the lazy canvas
|
|
107
|
+
expect(captured.pdf).toHaveLength(1);
|
|
108
|
+
const props = captured.pdf[0]!;
|
|
109
|
+
expect(props.existingAnnotations).toEqual([shapeAnnotation()]); // pinned — worked before
|
|
110
|
+
expect(props.drawingMode).toBeNull();
|
|
111
|
+
expect(props.session).toBe(session); // NEW — was a session-less no-op
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -4,7 +4,8 @@ import { lazy, Suspense, memo, type ComponentType } from 'react';
|
|
|
4
4
|
import ReactMarkdown from 'react-markdown';
|
|
5
5
|
import remarkGfm from 'remark-gfm';
|
|
6
6
|
import type { Annotation } from '@semiont/core';
|
|
7
|
-
import {
|
|
7
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
8
|
+
import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
|
|
8
9
|
|
|
9
10
|
// Lazy-load the PDF component to avoid SSR issues with browser PDF.js loading.
|
|
10
11
|
const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
|
|
@@ -19,6 +20,13 @@ export interface MediaRendererProps {
|
|
|
19
20
|
mimeType: string;
|
|
20
21
|
resourceUri: string;
|
|
21
22
|
annotations: Annotation[];
|
|
23
|
+
/**
|
|
24
|
+
* Session for interaction routing inside annotation-bearing renderers — the
|
|
25
|
+
* canvases emit clicks/hover via `session.client.browse/beckon`. Absent/null
|
|
26
|
+
* → the annotations still PAINT, but are inert (paint-only). Threaded by
|
|
27
|
+
* BrowseView's dispatch; custom renderers may ignore it.
|
|
28
|
+
*/
|
|
29
|
+
session?: SemiontSession | null;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/** Read-only media dispatch, keyed by the registry render mode. */
|
|
@@ -33,11 +41,24 @@ export function TextBrowseRenderer({ content }: MediaRendererProps) {
|
|
|
33
41
|
return <MemoizedMarkdown content={content} />;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
export function ImageBrowseRenderer({ content,
|
|
37
|
-
|
|
44
|
+
export function ImageBrowseRenderer({ content, resourceUri, annotations, session }: MediaRendererProps) {
|
|
45
|
+
// The annotate-mode canvas, read-only (drawingMode=null): paints SvgSelector
|
|
46
|
+
// shapes over the image and routes click/hover via the session — the same
|
|
47
|
+
// shapes browse mode silently dropped when this was a bare ImageViewer
|
|
48
|
+
// (bugs/image-browse-renderer-drops-annotations.md).
|
|
49
|
+
return (
|
|
50
|
+
<SvgDrawingCanvas
|
|
51
|
+
imageUrl={content}
|
|
52
|
+
resourceUri={resourceUri}
|
|
53
|
+
existingAnnotations={annotations}
|
|
54
|
+
drawingMode={null}
|
|
55
|
+
selectedMotivation={null}
|
|
56
|
+
session={session}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
|
-
export function PdfBrowseRenderer({ content, resourceUri, annotations }: MediaRendererProps) {
|
|
61
|
+
export function PdfBrowseRenderer({ content, resourceUri, annotations, session }: MediaRendererProps) {
|
|
41
62
|
return (
|
|
42
63
|
<Suspense fallback={<div className="semiont-browse-view__loading">Loading PDF viewer...</div>}>
|
|
43
64
|
<PdfAnnotationCanvas
|
|
@@ -46,6 +67,7 @@ export function PdfBrowseRenderer({ content, resourceUri, annotations }: MediaRe
|
|
|
46
67
|
existingAnnotations={annotations}
|
|
47
68
|
drawingMode={null}
|
|
48
69
|
selectedMotivation={null}
|
|
70
|
+
session={session}
|
|
49
71
|
/>
|
|
50
72
|
</Suspense>
|
|
51
73
|
);
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
import type { Ref } from 'react';
|
|
4
4
|
import type { Annotation } from '@semiont/core';
|
|
5
5
|
import { getAnnotationExactText } from '@semiont/core';
|
|
6
|
-
import {
|
|
7
|
-
import { useObservable } from '../../../hooks/useObservable';
|
|
6
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
8
7
|
import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
|
|
9
8
|
import { renderAgentLabel } from './agent-label';
|
|
10
9
|
|
|
@@ -17,6 +16,8 @@ interface TextualBody {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
interface AssessmentEntryProps {
|
|
19
|
+
/** Session for interaction routing (browse.click etc.); the panel threads it. */
|
|
20
|
+
session: SemiontSession | null;
|
|
20
21
|
assessment: Annotation;
|
|
21
22
|
isFocused: boolean;
|
|
22
23
|
isHovered?: boolean;
|
|
@@ -69,13 +70,13 @@ function getAssessmentText(annotation: Annotation): string | null {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
export function AssessmentEntry({
|
|
73
|
+
session,
|
|
72
74
|
assessment,
|
|
73
75
|
isFocused,
|
|
74
76
|
isHovered = false,
|
|
75
77
|
ref,
|
|
76
78
|
}: AssessmentEntryProps) {
|
|
77
|
-
const
|
|
78
|
-
const hoverProps = useHoverEmitter(assessment.id);
|
|
79
|
+
const hoverProps = useHoverEmitter(session, assessment.id);
|
|
79
80
|
|
|
80
81
|
const selectedText = getAnnotationExactText(assessment);
|
|
81
82
|
const assessmentText = getAssessmentText(assessment);
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
4
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
5
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
6
|
+
import { useSessionEventSubscriptions } from '../../../hooks/useSessionEventSubscriptions';
|
|
8
7
|
import type { components, Selector } from '@semiont/core';
|
|
9
8
|
import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
|
|
10
9
|
import { AssessmentEntry } from './AssessmentEntry';
|
|
@@ -40,6 +39,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
interface AssessmentPanelProps {
|
|
42
|
+
/** Session carrying the client and event bus; null renders inert. */
|
|
43
|
+
session: SemiontSession | null;
|
|
43
44
|
/** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
|
|
44
45
|
resourceId: string;
|
|
45
46
|
annotations: Annotation[];
|
|
@@ -63,6 +64,7 @@ interface AssessmentPanelProps {
|
|
|
63
64
|
* @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
|
|
64
65
|
*/
|
|
65
66
|
export function AssessmentPanel({
|
|
67
|
+
session,
|
|
66
68
|
resourceId,
|
|
67
69
|
annotations,
|
|
68
70
|
pendingAnnotation,
|
|
@@ -76,7 +78,6 @@ export function AssessmentPanel({
|
|
|
76
78
|
hoveredAnnotationId,
|
|
77
79
|
}: AssessmentPanelProps) {
|
|
78
80
|
const t = useTranslations('AssessmentPanel');
|
|
79
|
-
const session = useObservable(useSemiont().activeSession$);
|
|
80
81
|
const [newAssessmentText, setNewAssessmentText] = useState('');
|
|
81
82
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
|
|
82
83
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -185,7 +186,7 @@ export function AssessmentPanel({
|
|
|
185
186
|
}, []);
|
|
186
187
|
|
|
187
188
|
// Subscribe to click events - update focused state
|
|
188
|
-
|
|
189
|
+
useSessionEventSubscriptions(session, {
|
|
189
190
|
'browse:click': handleAnnotationClick,
|
|
190
191
|
});
|
|
191
192
|
|
|
@@ -247,6 +248,7 @@ export function AssessmentPanel({
|
|
|
247
248
|
{/* Assist Section - only in Annotate mode and for text resources */}
|
|
248
249
|
{annotateMode && (
|
|
249
250
|
<AssistSection
|
|
251
|
+
session={session}
|
|
250
252
|
annotationType="assessment"
|
|
251
253
|
isAssisting={isAssisting}
|
|
252
254
|
locale={locale}
|
|
@@ -264,6 +266,7 @@ export function AssessmentPanel({
|
|
|
264
266
|
) : (
|
|
265
267
|
sortedAnnotations.map((assessment) => (
|
|
266
268
|
<AssessmentEntry
|
|
269
|
+
session={session}
|
|
267
270
|
key={assessment.id}
|
|
268
271
|
assessment={assessment}
|
|
269
272
|
isFocused={assessment.id === focusedAnnotationId}
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
5
|
-
import {
|
|
6
|
-
import { useObservable } from '../../../hooks/useObservable';
|
|
5
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
7
6
|
import type { Motivation, components } from '@semiont/core';
|
|
8
7
|
import './AssistSection.css';
|
|
9
8
|
|
|
10
9
|
type JobProgress = components['schemas']['JobProgress'];
|
|
11
10
|
|
|
12
11
|
interface AssistSectionProps {
|
|
12
|
+
/** Session carrying the client and event bus; null renders inert. */
|
|
13
|
+
session: SemiontSession | null;
|
|
13
14
|
annotationType: 'highlight' | 'assessment' | 'comment';
|
|
14
15
|
isAssisting: boolean;
|
|
15
16
|
/** User UI locale — written into the annotation body's `language` field for comment/assessment. */
|
|
@@ -34,6 +35,7 @@ interface AssistSectionProps {
|
|
|
34
35
|
* @emits mark:progress-dismiss - Dismiss the annotation progress display
|
|
35
36
|
*/
|
|
36
37
|
export function AssistSection({
|
|
38
|
+
session,
|
|
37
39
|
annotationType,
|
|
38
40
|
isAssisting,
|
|
39
41
|
locale,
|
|
@@ -45,7 +47,6 @@ export function AssistSection({
|
|
|
45
47
|
annotationType === 'assessment' ? 'AssessmentPanel' :
|
|
46
48
|
'CommentsPanel';
|
|
47
49
|
const t = useTranslations(panelName);
|
|
48
|
-
const session = useObservable(useSemiont().activeSession$);
|
|
49
50
|
const [instructions, setInstructions] = useState('');
|
|
50
51
|
type ToneValue = 'scholarly' | 'explanatory' | 'conversational' | 'technical' | 'analytical' | 'critical' | 'balanced' | 'constructive' | '';
|
|
51
52
|
const [tone, setTone] = useState<ToneValue>('');
|
|
@@ -4,12 +4,13 @@ import { useState, useEffect, useRef, useImperativeHandle, type Ref } from 'reac
|
|
|
4
4
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
5
5
|
import type { Annotation } from '@semiont/core';
|
|
6
6
|
import { getAnnotationExactText, getCommentText } from '@semiont/core';
|
|
7
|
-
import {
|
|
8
|
-
import { useObservable } from '../../../hooks/useObservable';
|
|
7
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
9
8
|
import { useHoverEmitter } from '../../../hooks/useHoverEmitter';
|
|
10
9
|
import { renderAgentLabel } from './agent-label';
|
|
11
10
|
|
|
12
11
|
interface CommentEntryProps {
|
|
12
|
+
/** Session for interaction routing (browse.click etc.); the panel threads it. */
|
|
13
|
+
session: SemiontSession | null;
|
|
13
14
|
comment: Annotation;
|
|
14
15
|
isFocused: boolean;
|
|
15
16
|
isHovered?: boolean;
|
|
@@ -35,6 +36,7 @@ function formatRelativeTime(isoString: string): string {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export function CommentEntry({
|
|
39
|
+
session,
|
|
38
40
|
comment,
|
|
39
41
|
isFocused,
|
|
40
42
|
isHovered = false,
|
|
@@ -42,8 +44,7 @@ export function CommentEntry({
|
|
|
42
44
|
ref,
|
|
43
45
|
}: CommentEntryProps) {
|
|
44
46
|
const t = useTranslations('CommentsPanel');
|
|
45
|
-
const
|
|
46
|
-
const hoverProps = useHoverEmitter(comment.id);
|
|
47
|
+
const hoverProps = useHoverEmitter(session, comment.id);
|
|
47
48
|
const [isEditing, setIsEditing] = useState(false);
|
|
48
49
|
const [editText, setEditText] = useState('');
|
|
49
50
|
const internalRef = useRef<HTMLDivElement>(null);
|
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
4
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
5
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
6
|
+
import { useSessionEventSubscriptions } from '../../../hooks/useSessionEventSubscriptions';
|
|
8
7
|
import type { components, Selector } from '@semiont/core';
|
|
9
8
|
import { getTextPositionSelector, getTargetSelector } from '@semiont/core';
|
|
10
9
|
import { CommentEntry } from './CommentEntry';
|
|
@@ -40,6 +39,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
|
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
interface CommentsPanelProps {
|
|
42
|
+
/** Session carrying the client and event bus; null renders inert. */
|
|
43
|
+
session: SemiontSession | null;
|
|
43
44
|
/** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
|
|
44
45
|
resourceId: string;
|
|
45
46
|
annotations: Annotation[];
|
|
@@ -63,6 +64,7 @@ interface CommentsPanelProps {
|
|
|
63
64
|
* @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
|
|
64
65
|
*/
|
|
65
66
|
export function CommentsPanel({
|
|
67
|
+
session,
|
|
66
68
|
resourceId,
|
|
67
69
|
annotations,
|
|
68
70
|
pendingAnnotation,
|
|
@@ -76,7 +78,6 @@ export function CommentsPanel({
|
|
|
76
78
|
hoveredAnnotationId,
|
|
77
79
|
}: CommentsPanelProps) {
|
|
78
80
|
const t = useTranslations('CommentsPanel');
|
|
79
|
-
const session = useObservable(useSemiont().activeSession$);
|
|
80
81
|
const [newCommentText, setNewCommentText] = useState('');
|
|
81
82
|
const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
|
|
82
83
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -167,7 +168,7 @@ export function CommentsPanel({
|
|
|
167
168
|
setTimeout(() => setFocusedAnnotationId(null), 3000);
|
|
168
169
|
}, []);
|
|
169
170
|
|
|
170
|
-
|
|
171
|
+
useSessionEventSubscriptions(session, {
|
|
171
172
|
'browse:click': handleAnnotationClick,
|
|
172
173
|
});
|
|
173
174
|
|
|
@@ -257,6 +258,7 @@ export function CommentsPanel({
|
|
|
257
258
|
{/* Assist Section - only in Annotate mode and for text resources */}
|
|
258
259
|
{annotateMode && (
|
|
259
260
|
<AssistSection
|
|
261
|
+
session={session}
|
|
260
262
|
annotationType="comment"
|
|
261
263
|
isAssisting={isAssisting}
|
|
262
264
|
locale={locale}
|
|
@@ -274,6 +276,7 @@ export function CommentsPanel({
|
|
|
274
276
|
) : (
|
|
275
277
|
sortedAnnotations.map((comment) => (
|
|
276
278
|
<CommentEntry
|
|
279
|
+
session={session}
|
|
277
280
|
key={comment.id}
|
|
278
281
|
comment={comment}
|
|
279
282
|
isFocused={comment.id === focusedAnnotationId}
|