@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.
Files changed (53) hide show
  1. package/dist/{PdfAnnotationCanvas.client-KWQ7XDWT.js → PdfAnnotationCanvas.client-75GY2EDR.js} +12 -6
  2. package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
  3. package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  4. package/dist/chunk-VXASXU4K.js.map +1 -0
  5. package/dist/index.d.ts +54 -16
  6. package/dist/index.js +395 -343
  7. package/dist/index.js.map +1 -1
  8. package/dist/test-utils.d.ts +5 -1
  9. package/dist/test-utils.js +3 -2
  10. package/dist/test-utils.js.map +1 -1
  11. package/package.json +5 -5
  12. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  13. package/src/components/image-annotation/SvgDrawingCanvas.tsx +12 -4
  14. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  15. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  16. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +12 -4
  17. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +137 -1
  18. package/src/components/resource/BrowseView.tsx +14 -6
  19. package/src/components/resource/ResourceViewer.tsx +6 -4
  20. package/src/components/resource/__tests__/BrowseView.test.tsx +98 -31
  21. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +40 -1
  22. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  23. package/src/components/resource/browse-renderers.tsx +26 -4
  24. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  25. package/src/components/resource/panels/AssessmentPanel.tsx +8 -5
  26. package/src/components/resource/panels/AssistSection.tsx +4 -3
  27. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  28. package/src/components/resource/panels/CommentsPanel.tsx +8 -5
  29. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  30. package/src/components/resource/panels/HighlightPanel.tsx +8 -5
  31. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  32. package/src/components/resource/panels/ReferencesPanel.tsx +11 -6
  33. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  34. package/src/components/resource/panels/TagEntry.tsx +5 -3
  35. package/src/components/resource/panels/TaggingPanel.tsx +7 -4
  36. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +10 -0
  37. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  38. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +38 -28
  39. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  40. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  41. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +53 -24
  42. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  43. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  44. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  45. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  46. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  47. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +77 -59
  48. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  49. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  50. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +15 -7
  51. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +3 -0
  52. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +0 -1
  53. package/dist/chunk-O2MD7TGE.js.map +0 -1
@@ -70,12 +70,8 @@ vi.mock('../../../lib/annotation-registry', () => ({
70
70
  },
71
71
  }));
72
72
 
73
- // Mock ImageViewer
74
- vi.mock('../../viewers', () => ({
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 image viewer for image mime types', () => {
228
- renderWithProviders(<BrowseView {...defaultProps} mimeType="image/png" />);
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(screen.getByTestId('image-viewer')).toBeInTheDocument();
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 only for reference annotations', async () => {
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 mockReferenceElement = document.createElement('span');
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 mockHighlightElement = document.createElement('span');
425
- mockHighlightElement.setAttribute('data-annotation-id', 'highlight-1');
426
- mockHighlightElement.setAttribute('data-annotation-type', 'highlight');
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 mockRefTarget = { closest: vi.fn(() => mockReferenceElement) } as any;
429
- const mockHighlightTarget = { closest: vi.fn(() => mockHighlightElement) } as any;
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
- const browseContainer = container.querySelector('.semiont-browse-view__content');
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
- tracker.clear();
459
+ const { container } = renderWithEventTracking(
460
+ <BrowseView {...defaultProps} annotations={annotations} />,
461
+ tracker
462
+ );
434
463
 
435
- // Click reference - should emit
436
- fireEvent.click(browseContainer!, { target: mockRefTarget });
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 === 'ref-1' &&
442
- e.payload?.motivation === 'linking'
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
- tracker.clear();
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
- // Click highlight - should not emit
449
- fireEvent.click(browseContainer!, { target: mockHighlightTarget });
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
- // Verify highlight doesn't trigger click events
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
- // Should not have any click events for highlights
560
- await new Promise(resolve => setTimeout(resolve, 50));
561
- expect(eventTracker.some(e => e.event === 'browse:click')).toBe(false);
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 { ImageViewer } from '../viewers';
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, mimeType }: MediaRendererProps) {
37
- return <ImageViewer imageUrl={content} mimeType={mimeType} alt="Resource content" />;
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 { useSemiont } from '../../../session/SemiontProvider';
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 session = useObservable(useSemiont().activeSession$);
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 { useSemiont } from '../../../session/SemiontProvider';
6
- import { useObservable } from '../../../hooks/useObservable';
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
- useEventSubscriptions({
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 { useSemiont } from '../../../session/SemiontProvider';
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 { useSemiont } from '../../../session/SemiontProvider';
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 session = useObservable(useSemiont().activeSession$);
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 { useSemiont } from '../../../session/SemiontProvider';
6
- import { useObservable } from '../../../hooks/useObservable';
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
- useEventSubscriptions({
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}