@semiont/react-ui 0.5.10 → 0.5.11

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 (58) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +4 -1
  3. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
  4. package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
  5. package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  6. package/dist/index.css +150 -112
  7. package/dist/index.css.map +1 -1
  8. package/dist/index.d.ts +287 -39
  9. package/dist/index.js +2525 -2284
  10. package/dist/index.js.map +1 -1
  11. package/dist/test-utils.js +7 -2
  12. package/dist/test-utils.js.map +1 -1
  13. package/package.json +6 -6
  14. package/src/components/ResourceTagsInline.tsx +94 -11
  15. package/src/components/StatusDisplay.css +36 -0
  16. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  17. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  18. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  19. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  20. package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
  21. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
  22. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
  23. package/src/components/resource/AnnotateView.tsx +59 -38
  24. package/src/components/resource/BrowseView.tsx +159 -133
  25. package/src/components/resource/ResourceViewer.tsx +119 -128
  26. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  27. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  28. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  29. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  30. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  31. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  32. package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
  33. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
  34. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  35. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  36. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  37. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  38. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  39. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  40. package/src/components/resource/browse-renderers.tsx +63 -0
  41. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  42. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  43. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  44. package/src/components/resource/panels/ReferencesPanel.tsx +4 -0
  45. package/src/components/resource/panels/TaggingPanel.tsx +4 -0
  46. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
  47. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
  48. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
  49. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
  50. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  51. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
  52. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
  53. package/src/components/toolbar/Toolbar.css +14 -0
  54. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  55. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +40 -17
  56. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  57. package/src/styles/features/resource-viewer.css +19 -0
  58. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
@@ -56,7 +56,7 @@ describe('PdfAnnotationCanvas', () => {
56
56
 
57
57
  test('renders loading state initially', () => {
58
58
  render(
59
- <PdfAnnotationCanvas
59
+ <PdfAnnotationCanvas resourceUri="res-1"
60
60
  pdfUrl={mockPdfUrl}
61
61
  drawingMode={null}
62
62
  />
@@ -67,7 +67,7 @@ describe('PdfAnnotationCanvas', () => {
67
67
 
68
68
  test('renders page navigation controls after loading', async () => {
69
69
  render(
70
- <PdfAnnotationCanvas
70
+ <PdfAnnotationCanvas resourceUri="res-1"
71
71
  pdfUrl={mockPdfUrl}
72
72
  drawingMode={null}
73
73
  />
@@ -83,7 +83,7 @@ describe('PdfAnnotationCanvas', () => {
83
83
 
84
84
  test('previous button is disabled on first page', async () => {
85
85
  render(
86
- <PdfAnnotationCanvas
86
+ <PdfAnnotationCanvas resourceUri="res-1"
87
87
  pdfUrl={mockPdfUrl}
88
88
  drawingMode={null}
89
89
  />
@@ -99,7 +99,7 @@ describe('PdfAnnotationCanvas', () => {
99
99
 
100
100
  test('next button is disabled on last page', async () => {
101
101
  render(
102
- <PdfAnnotationCanvas
102
+ <PdfAnnotationCanvas resourceUri="res-1"
103
103
  pdfUrl={mockPdfUrl}
104
104
  drawingMode={null}
105
105
  />
@@ -144,7 +144,7 @@ describe('PdfAnnotationCanvas', () => {
144
144
  ];
145
145
 
146
146
  render(
147
- <PdfAnnotationCanvas
147
+ <PdfAnnotationCanvas resourceUri="res-1"
148
148
  pdfUrl={mockPdfUrl}
149
149
  existingAnnotations={mockAnnotations}
150
150
  drawingMode={null}
@@ -179,7 +179,7 @@ describe('PdfAnnotationCanvas', () => {
179
179
 
180
180
  test('accepts a drawing gesture without throwing when drawing mode is active', async () => {
181
181
  render(
182
- <PdfAnnotationCanvas
182
+ <PdfAnnotationCanvas resourceUri="res-1"
183
183
  pdfUrl={mockPdfUrl}
184
184
  drawingMode="rectangle"
185
185
  selectedMotivation="highlighting"
@@ -1,22 +1,19 @@
1
1
  'use client';
2
2
 
3
3
  import { useRef, useEffect, useCallback, lazy, Suspense } from 'react';
4
- import { capabilitiesOf } from '@semiont/core';
4
+ import { capabilitiesOf, resourceId as toResourceId } from '@semiont/core';
5
5
  import { ANNOTATORS } from '../../lib/annotation-registry';
6
6
  import { segmentTextWithAnnotations } from '../../lib/text-segmentation';
7
7
  import { buildTextSelectors, fallbackTextPosition } from '../../lib/text-selection-handler';
8
8
  import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
9
9
 
10
- import { useResourceAnnotations } from '../../contexts/ResourceAnnotationsContext';
11
-
12
10
  // Lazy load PDF component to avoid SSR issues with browser PDF.js loading
13
11
  const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
14
12
 
15
13
  import { CodeMirrorRenderer } from '../CodeMirrorRenderer';
16
14
  import type { EditorView } from '@codemirror/view';
17
- import { useSemiont } from '../../session/SemiontProvider';
18
- import { useObservable } from '../../hooks/useObservable';
19
- import { useEventSubscriptions } from '../../contexts/useEventSubscription';
15
+ import type { SemiontSession } from '@semiont/sdk';
16
+ import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
20
17
 
21
18
  // Type augmentation for custom DOM properties
22
19
  interface EnrichedHTMLElement extends HTMLElement {
@@ -31,7 +28,8 @@ export type { SelectionMotivation, ClickAction, ShapeType };
31
28
  interface Props {
32
29
  content: string;
33
30
  mimeType?: string;
34
- resourceUri?: string;
31
+ /** The '@id' of the shown resource — stamped as `source` on mark:requested (multi-viewer routing). */
32
+ resourceUri: string;
35
33
  annotations: AnnotationsCollection;
36
34
  uiState: AnnotationUIState;
37
35
  onUIStateChange?: (state: Partial<AnnotationUIState>) => void;
@@ -42,15 +40,20 @@ interface Props {
42
40
  showLineNumbers?: boolean;
43
41
  hoverDelayMs?: number;
44
42
  annotateMode: boolean;
43
+ /** Session for the shown resource — its client emits mark:* / mark.request; its bus feeds toolbar + beckon events. */
44
+ session: SemiontSession | null;
45
+ /** Recently-created annotation ids to sparkle (host-provided; was ResourceAnnotationsContext). */
46
+ newAnnotationIds?: Set<string>;
47
+ /** The bar's Mode control reports the chosen mode here (the owner applies it). */
48
+ onModeChange?: (mode: boolean) => void;
49
+ /** Render the built-in bar (default). false → no bar; selection capture and drawing stay live. */
50
+ showToolbar?: boolean;
45
51
  }
46
52
 
47
53
  /**
48
54
  * View component for annotating resources with text selection and drawing
49
55
  *
50
56
  * @emits mark:requested - User requested to create annotation. Payload: { selector: Selector | Selector[], motivation: SelectionMotivation }
51
- * @subscribes mark:selection-changed - Toolbar selection changed. Payload: { motivation: string | null }
52
- * @subscribes mark:click-changed - Toolbar click action changed. Payload: { action: string }
53
- * @subscribes mark:shape-changed - Toolbar shape changed. Payload: { shape: string }
54
57
  * @subscribes beckon:hover - Annotation hovered. Payload: { annotationId: string | null }
55
58
  */
56
59
  export function AnnotateView({
@@ -65,11 +68,13 @@ export function AnnotateView({
65
68
  generatingReferenceId,
66
69
  showLineNumbers = false,
67
70
  hoverDelayMs = 150,
68
- annotateMode
71
+ annotateMode,
72
+ session,
73
+ newAnnotationIds,
74
+ onModeChange,
75
+ showToolbar = true,
69
76
  }: Props) {
70
- const { newAnnotationIds } = useResourceAnnotations();
71
77
  const containerRef = useRef<HTMLDivElement>(null);
72
- const session = useObservable(useSemiont().activeSession$);
73
78
 
74
79
  const render = capabilitiesOf(mimeType)?.render ?? 'none';
75
80
 
@@ -85,28 +90,27 @@ export function AnnotateView({
85
90
  const onUIStateChangeRef = useRef(onUIStateChange);
86
91
  onUIStateChangeRef.current = onUIStateChange;
87
92
 
88
- // Toolbar event handlers (extracted to avoid inline arrow functions)
89
- const handleToolbarSelectionChanged = useCallback(({ motivation }: { motivation: string | null }) => {
90
- onUIStateChangeRef.current?.({ selectedMotivation: motivation as SelectionMotivation | null });
93
+ // Toolbar callbacks: the presentational bar reports chosen values; route them up
94
+ // through the existing onUIStateChange path (the owner applies them).
95
+ const handleToolbarSelectionChange = useCallback((motivation: SelectionMotivation | null) => {
96
+ onUIStateChangeRef.current?.({ selectedMotivation: motivation });
91
97
  }, []);
92
98
 
93
- const handleToolbarClickChanged = useCallback(({ action }: { action: string }) => {
94
- onUIStateChangeRef.current?.({ selectedClick: action as ClickAction });
99
+ const handleToolbarClickActionChange = useCallback((action: ClickAction) => {
100
+ onUIStateChangeRef.current?.({ selectedClick: action });
95
101
  }, []);
96
102
 
97
- const handleToolbarShapeChanged = useCallback(({ shape }: { shape: string }) => {
98
- onUIStateChangeRef.current?.({ selectedShape: shape as ShapeType });
103
+ const handleToolbarShapeChange = useCallback((shape: ShapeType) => {
104
+ onUIStateChangeRef.current?.({ selectedShape: shape });
99
105
  }, []);
100
106
 
101
107
  const handleAnnotationHover = useCallback(({ annotationId }: { annotationId: string | null }) => {
102
108
  onUIStateChangeRef.current?.({ hoveredAnnotationId: annotationId });
103
109
  }, []);
104
110
 
105
- // Subscribe to toolbar events and annotation hover
106
- useEventSubscriptions({
107
- 'mark:selection-changed': handleToolbarSelectionChanged,
108
- 'mark:click-changed': handleToolbarClickChanged,
109
- 'mark:shape-changed': handleToolbarShapeChanged,
111
+ // Annotation hover (session-scoped). Toolbar preference changes flow through
112
+ // props/callbacks, not the bus (TOOLBAR-PREFS-AS-PROPS).
113
+ useSessionEventSubscriptions(session, {
110
114
  'beckon:hover': handleAnnotationHover,
111
115
  });
112
116
 
@@ -176,7 +180,7 @@ export function AnnotateView({
176
180
  const selectors = buildTextSelectors(content, text, start, end);
177
181
  if (!selectors) return;
178
182
 
179
- session?.client.mark.request(selectors, selectedMotivation);
183
+ session?.client.mark.request(toResourceId(resourceUri), selectors, selectedMotivation);
180
184
 
181
185
  // Clear selection after creating annotation
182
186
  selection.removeAllRanges();
@@ -190,20 +194,27 @@ export function AnnotateView({
190
194
  container.removeEventListener('mouseup', handleMouseUp);
191
195
  container.removeEventListener('mousedown', handleMouseDown);
192
196
  };
193
- }, [selectedMotivation, content]);
197
+ }, [selectedMotivation, content, resourceUri]);
194
198
 
195
199
  // Route to the annotation viewer for this media type's render mode.
196
200
  switch (render) {
197
201
  case 'text':
198
202
  return (
199
203
  <div className="semiont-annotate-view" data-mime-type="text" ref={containerRef}>
204
+ {showToolbar && (
200
205
  <AnnotateToolbar
201
206
  selectedMotivation={selectedMotivation}
202
207
  selectedClick={selectedClick}
208
+ selectedShape={selectedShape}
203
209
  mediaType={mimeType}
204
210
  annotateMode={annotateMode}
205
211
  annotators={ANNOTATORS}
212
+ onModeChange={onModeChange}
213
+ onClickActionChange={handleToolbarClickActionChange}
214
+ onSelectionChange={handleToolbarSelectionChange}
215
+ onShapeChange={handleToolbarShapeChange}
206
216
  />
217
+ )}
207
218
  <div className="semiont-annotate-view__content">
208
219
  <CodeMirrorRenderer
209
220
  content={content}
@@ -229,20 +240,26 @@ export function AnnotateView({
229
240
  // PDF annotation support (spatial, FragmentSelector)
230
241
  return (
231
242
  <div className="semiont-annotate-view" data-mime-type="pdf" ref={containerRef}>
243
+ {showToolbar && (
232
244
  <AnnotateToolbar
233
245
  selectedMotivation={selectedMotivation}
234
246
  selectedClick={selectedClick}
235
- showShapeGroup={true}
236
247
  selectedShape={selectedShape}
237
248
  mediaType={mimeType}
238
249
  annotateMode={annotateMode}
239
250
  annotators={ANNOTATORS}
251
+ onModeChange={onModeChange}
252
+ onClickActionChange={handleToolbarClickActionChange}
253
+ onSelectionChange={handleToolbarSelectionChange}
254
+ onShapeChange={handleToolbarShapeChange}
240
255
  />
256
+ )}
241
257
  <div className="semiont-annotate-view__content">
242
258
  {content && (
243
259
  <Suspense fallback={<div className="semiont-annotate-view__loading">Loading PDF viewer...</div>}>
244
260
  <PdfAnnotationCanvas
245
261
  pdfUrl={content}
262
+ resourceUri={resourceUri}
246
263
  existingAnnotations={allAnnotations}
247
264
  drawingMode={selectedMotivation ? selectedShape : null}
248
265
  selectedMotivation={selectedMotivation}
@@ -260,19 +277,25 @@ export function AnnotateView({
260
277
  // PNG, JPEG, etc. - full annotation support
261
278
  return (
262
279
  <div className="semiont-annotate-view" data-mime-type="image" ref={containerRef}>
280
+ {showToolbar && (
263
281
  <AnnotateToolbar
264
282
  selectedMotivation={selectedMotivation}
265
283
  selectedClick={selectedClick}
266
- showShapeGroup={true}
267
284
  selectedShape={selectedShape}
268
285
  mediaType={mimeType}
269
286
  annotateMode={annotateMode}
270
287
  annotators={ANNOTATORS}
288
+ onModeChange={onModeChange}
289
+ onClickActionChange={handleToolbarClickActionChange}
290
+ onSelectionChange={handleToolbarSelectionChange}
291
+ onShapeChange={handleToolbarShapeChange}
271
292
  />
293
+ )}
272
294
  <div className="semiont-annotate-view__content">
273
295
  {content && (
274
296
  <SvgDrawingCanvas
275
297
  imageUrl={content}
298
+ resourceUri={resourceUri}
276
299
  existingAnnotations={allAnnotations}
277
300
  drawingMode={selectedMotivation ? selectedShape : null}
278
301
  selectedMotivation={selectedMotivation}
@@ -293,15 +316,13 @@ export function AnnotateView({
293
316
  <p className="semiont-annotate-view__empty-message">
294
317
  Annotation not supported for {mimeType}
295
318
  </p>
296
- {resourceUri && (
297
- <a
298
- href={`/api/resources/${resourceUri}`}
299
- download
300
- className="semiont-button semiont-button--primary"
301
- >
302
- Download File
303
- </a>
304
- )}
319
+ <a
320
+ href={`/api/resources/${resourceUri}`}
321
+ download
322
+ className="semiont-button semiont-button--primary"
323
+ >
324
+ Download File
325
+ </a>
305
326
  </div>
306
327
  </div>
307
328
  );
@@ -1,14 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useCallback, useMemo, memo, lazy, Suspense } from 'react';
4
- import ReactMarkdown from 'react-markdown';
5
- import remarkGfm from 'remark-gfm';
6
- import { annotationId as toAnnotationId } from '@semiont/core';
7
- import { capabilitiesOf } from '@semiont/core';
3
+ import { useEffect, useRef, useCallback, useMemo, memo, type MouseEvent as ReactMouseEvent } from 'react';
4
+ import { annotationId as toAnnotationId, resourceId as toResourceId } from '@semiont/core';
5
+ import { capabilitiesOf, getBodySource, isResolvedReference } from '@semiont/core';
6
+ import type { Annotation, ResourceDescriptor } from '@semiont/core';
8
7
  import { createHoverHandlers } from '@semiont/sdk';
9
8
  import { ANNOTATORS } from '../../lib/annotation-registry';
10
9
  import { scrollAnnotationIntoView } from '../../lib/scroll-utils';
11
- import { ImageViewer } from '../viewers';
12
10
  import { AnnotateToolbar, type ClickAction } from '../annotation/AnnotateToolbar';
13
11
  import type { AnnotationsCollection } from '../../types/annotation-props';
14
12
  import {
@@ -20,13 +18,9 @@ import {
20
18
  toOverlayAnnotations,
21
19
  } from '../../lib/annotation-overlay';
22
20
 
23
- // Lazy load PDF component to avoid SSR issues with browser PDF.js loading
24
- const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
25
-
26
- import { useResourceAnnotations } from '../../contexts/ResourceAnnotationsContext';
27
- import { useSemiont } from '../../session/SemiontProvider';
28
- import { useObservable } from '../../hooks/useObservable';
29
- import { useEventSubscriptions } from '../../contexts/useEventSubscription';
21
+ import type { SemiontSession } from '@semiont/sdk';
22
+ import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
23
+ import { defaultBrowseRenderers, type BrowseMediaRenderers } from './browse-renderers';
30
24
 
31
25
  interface Props {
32
26
  content: string;
@@ -37,25 +31,35 @@ interface Props {
37
31
  selectedClick?: ClickAction;
38
32
  annotateMode: boolean;
39
33
  hoverDelayMs?: number;
34
+ /** Session for the shown resource — emits browse:click / beckon:hover; its bus feeds beckon events. */
35
+ session: SemiontSession | null;
36
+ /** Recently-created annotation ids to sparkle (host-provided; was ResourceAnnotationsContext). */
37
+ newAnnotationIds?: Set<string>;
38
+ /** Override the read-only media renderers (render mode → renderer); merged over the defaults. */
39
+ renderers?: BrowseMediaRenderers;
40
+ /** A content link (`<a href>` in the rendered content) was clicked. The viewer preventDefaults and
41
+ * delegates; it never navigates on its own. Omit → the click is still blocked (nothing happens). */
42
+ onLinkClick?: (link: { href: string; event: ReactMouseEvent }) => void;
43
+ /** A RESOLVED reference span is hovered: fires after the dwell AND the referent's cached descriptor
44
+ * resolves; `null` on leave (only if a hover fired — stubs stay silent). Host renders its own preview. */
45
+ onReferenceHover?: (hover: ReferenceHover | null) => void;
46
+ /** Inline display variant: auto-height to content, no inner scroll container, no pane chrome —
47
+ * drops into a chat bubble / card / list item. Default: fill-the-pane (unchanged). */
48
+ inline?: boolean;
49
+ /** The bar's Mode control reports the chosen mode here (the owner applies it). */
50
+ onModeChange?: (mode: boolean) => void;
51
+ /** The bar's Click control reports the chosen action here (the owner applies it). */
52
+ onClickActionChange?: (action: ClickAction) => void;
53
+ /** Render the built-in bar (default). false → no bar; all other seams stay live. */
54
+ showToolbar?: boolean;
40
55
  }
41
56
 
42
- /**
43
- * Memoized markdown renderer — only re-renders when content changes.
44
- * No annotation plugins: annotations are applied as a DOM overlay after paint.
45
- */
46
- const MemoizedMarkdown = memo(function MemoizedMarkdown({
47
- content,
48
- }: {
49
- content: string;
50
- }) {
51
- return (
52
- <ReactMarkdown
53
- remarkPlugins={[remarkGfm]}
54
- >
55
- {content}
56
- </ReactMarkdown>
57
- );
58
- });
57
+ /** Payload for `onReferenceHover` — the hovered linking annotation, its resolved referent, and where the span is. */
58
+ export interface ReferenceHover {
59
+ annotation: Annotation;
60
+ referent: ResourceDescriptor;
61
+ anchorRect: DOMRect;
62
+ }
59
63
 
60
64
  /**
61
65
  * View component for browsing annotated resources in read-only mode.
@@ -76,12 +80,20 @@ export const BrowseView = memo(function BrowseView({
76
80
  resourceUri,
77
81
  annotations,
78
82
  selectedClick = 'detail',
83
+ onModeChange,
84
+ onClickActionChange,
85
+ showToolbar = true,
79
86
  annotateMode,
80
- hoverDelayMs = 150
87
+ hoverDelayMs = 150,
88
+ session,
89
+ newAnnotationIds,
90
+ renderers,
91
+ onLinkClick,
92
+ onReferenceHover,
93
+ inline = false,
81
94
  }: Props) {
82
- const { newAnnotationIds } = useResourceAnnotations();
83
- const session = useObservable(useSemiont().activeSession$);
84
95
  const containerRef = useRef<HTMLDivElement>(null);
96
+ const inlineMod = inline ? ' semiont-browse-view--inline' : '';
85
97
 
86
98
  const render = capabilitiesOf(mimeType)?.render ?? 'none';
87
99
 
@@ -97,26 +109,23 @@ export const BrowseView = memo(function BrowseView({
97
109
  [allAnnotations]
98
110
  );
99
111
 
100
- // Cache offset map (recomputed only when content changes)
101
- const offsetMapRef = useRef<Map<number, number> | null>(null);
102
-
103
- // Build offset map after markdown DOM paints (once per content change)
112
+ // The two-layer overlay in ONE effect, keyed on everything it reads: the
113
+ // rendered content DOM (`content` re-renders it) AND the annotations.
114
+ // Splitting these across two effects with a ref-passed offset map silently
115
+ // dropped the `content` dependency content arriving after annotations
116
+ // (any async-content host) painted ZERO spans until a remount. The
117
+ // length===0 early-return is safe: the prior run's cleanup already cleared.
104
118
  useEffect(() => {
105
- if (!containerRef.current) return;
106
- offsetMapRef.current = buildSourceToRenderedMap(content, containerRef.current);
107
- }, [content]);
108
-
109
- // Layer 2: overlay annotations after DOM paint
110
- useEffect(() => {
111
- if (!containerRef.current || !offsetMapRef.current || overlayAnnotations.length === 0) return;
119
+ if (!containerRef.current || overlayAnnotations.length === 0) return;
112
120
 
113
121
  const container = containerRef.current;
122
+ const offsetMap = buildSourceToRenderedMap(content, container);
114
123
  const textNodeIndex = buildTextNodeIndex(container);
115
- const ranges = resolveAnnotationRanges(overlayAnnotations, offsetMapRef.current, textNodeIndex);
124
+ const ranges = resolveAnnotationRanges(overlayAnnotations, offsetMap, textNodeIndex);
116
125
  applyHighlights(ranges);
117
126
 
118
127
  return () => clearHighlights(container);
119
- }, [overlayAnnotations]);
128
+ }, [content, overlayAnnotations]);
120
129
 
121
130
  // Attach click handler, hover handler, and animations after render
122
131
  useEffect(() => {
@@ -142,8 +151,50 @@ export const BrowseView = memo(function BrowseView({
142
151
  }
143
152
  };
144
153
 
154
+ // Reference-hover delegation (host-facing event surface): after the dwell,
155
+ // resolve the referent's cached descriptor and hand the host
156
+ // { annotation, referent, anchorRect }; `null` on leave — but only if a hover
157
+ // fired, so stubs stay silent. Rides the SAME dwell emitter as beckon:hover —
158
+ // one state machine, two consumers.
159
+ let hoveredElement: HTMLElement | null = null;
160
+ let referentSub: { unsubscribe(): void } | null = null;
161
+ let referenceHoverFired = false;
162
+
163
+ const startReferenceHover = (id: string) => {
164
+ if (!onReferenceHover) return; // no handler → no referent load
165
+ const annotation = allAnnotations.find(a => a.id === id);
166
+ if (!annotation || !isResolvedReference(annotation)) return; // stub / non-reference: fires nothing
167
+ const referentId = getBodySource(annotation.body);
168
+ const element = hoveredElement;
169
+ if (!referentId || !element) return;
170
+ referentSub?.unsubscribe();
171
+ referentSub = session.client.browse.resource(toResourceId(referentId)).subscribe({
172
+ next: (referent) => {
173
+ if (referent === undefined) return; // cache still loading
174
+ referentSub?.unsubscribe();
175
+ referentSub = null;
176
+ referenceHoverFired = true;
177
+ // anchorRect taken at fire time so it reflects current layout.
178
+ onReferenceHover({ annotation, referent, anchorRect: element.getBoundingClientRect() });
179
+ },
180
+ error: () => { referentSub = null; }, // failed load: never fires
181
+ });
182
+ };
183
+
184
+ const endReferenceHover = () => {
185
+ referentSub?.unsubscribe(); // leave-before-resolve: cancel, no fire
186
+ referentSub = null;
187
+ if (referenceHoverFired) {
188
+ referenceHoverFired = false;
189
+ onReferenceHover?.(null);
190
+ }
191
+ };
192
+
145
193
  const { handleMouseEnter, handleMouseLeave, cleanup: cleanupHover } = createHoverHandlers(
146
- (id) => session.client.beckon.hover(id),
194
+ (id) => {
195
+ session.client.beckon.hover(id);
196
+ if (id) startReferenceHover(id); else endReferenceHover();
197
+ },
147
198
  hoverDelayMs
148
199
  );
149
200
 
@@ -152,7 +203,10 @@ export const BrowseView = memo(function BrowseView({
152
203
  const target = e.target as HTMLElement;
153
204
  const annotationElement = target.closest('[data-annotation-id]');
154
205
  const annotationId = annotationElement?.getAttribute('data-annotation-id');
155
- if (annotationId) handleMouseEnter(toAnnotationId(annotationId));
206
+ if (annotationId) {
207
+ hoveredElement = annotationElement as HTMLElement; // anchor for onReferenceHover
208
+ handleMouseEnter(toAnnotationId(annotationId));
209
+ }
156
210
  };
157
211
 
158
212
  // Single mouseout handler for the container - fires once on exit
@@ -182,8 +236,9 @@ export const BrowseView = memo(function BrowseView({
182
236
  container.removeEventListener('mouseover', handleMouseOver);
183
237
  container.removeEventListener('mouseout', handleMouseOut);
184
238
  cleanupHover();
239
+ referentSub?.unsubscribe(); // in-flight referent load dies with the effect
185
240
  };
186
- }, [content, allAnnotations, newAnnotationIds, hoverDelayMs, session]);
241
+ }, [content, allAnnotations, newAnnotationIds, hoverDelayMs, session, onReferenceHover]);
187
242
 
188
243
  // Helper to scroll annotation into view with pulse effect
189
244
  const scrollToAnnotation = useCallback((annotationId: string | null, removePulse = false) => {
@@ -202,94 +257,65 @@ export const BrowseView = memo(function BrowseView({
202
257
  scrollToAnnotation(annotationId ?? null, true);
203
258
  }, [scrollToAnnotation]);
204
259
 
205
- useEventSubscriptions({
260
+ useSessionEventSubscriptions(session, {
206
261
  'beckon:hover': handleAnnotationHover,
207
262
  'beckon:focus': handleAnnotationFocus,
208
263
  });
209
264
 
210
- // Route to the viewer for this media type's render mode. The switch is
211
- // exhaustive over RenderMode, so every path returns.
212
- switch (render) {
213
- case 'text':
214
- return (
215
- <div className="semiont-browse-view" data-mime-type="text">
216
- <AnnotateToolbar
217
- selectedMotivation={null}
218
- selectedClick={selectedClick}
219
- showSelectionGroup={false}
220
- showDeleteButton={false}
221
- annotateMode={annotateMode}
222
- annotators={ANNOTATORS}
223
- />
224
- <div ref={containerRef} className="semiont-browse-view__content">
225
- <MemoizedMarkdown content={content} />
226
- </div>
227
- </div>
228
- );
229
-
230
- case 'pdf':
231
- return (
232
- <div className="semiont-browse-view" data-mime-type="pdf">
233
- <AnnotateToolbar
234
- selectedMotivation={null}
235
- selectedClick={selectedClick}
236
- showSelectionGroup={false}
237
- showDeleteButton={false}
238
- annotateMode={annotateMode}
239
- annotators={ANNOTATORS}
240
- />
241
- <div ref={containerRef} className="semiont-browse-view__content">
242
- <Suspense fallback={<div className="semiont-browse-view__loading">Loading PDF viewer...</div>}>
243
- <PdfAnnotationCanvas
244
- pdfUrl={content}
245
- existingAnnotations={allAnnotations}
246
- drawingMode={null}
247
- selectedMotivation={null}
248
- />
249
- </Suspense>
250
- </div>
265
+ // A content link inside the rendered output (react-markdown / HTML `<a href>`) must never navigate
266
+ // on its own (embedded/Electron security): always preventDefault, then delegate to the host if it cares.
267
+ const handleContentClick = useCallback((e: ReactMouseEvent) => {
268
+ const anchor = (e.target as HTMLElement).closest('a[href]');
269
+ if (!anchor) return;
270
+ e.preventDefault();
271
+ const href = anchor.getAttribute('href');
272
+ if (href) onLinkClick?.({ href, event: e });
273
+ }, [onLinkClick]);
274
+
275
+ // Route to the media renderer for this render mode. `text`/`image`/`pdf` share
276
+ // the shell (toolbar + annotation-overlay container); `none` (no preview, or an
277
+ // unknown type) has its own metadata+download structure. Callers can override
278
+ // any renderer via `renderers`.
279
+ const mediaRenderers: BrowseMediaRenderers = { ...defaultBrowseRenderers, ...renderers };
280
+ const Renderer = render === 'none' ? undefined : mediaRenderers[render];
281
+
282
+ if (!Renderer) {
283
+ return (
284
+ <div ref={containerRef} className={`semiont-browse-view semiont-browse-view--unsupported${inlineMod}`} data-mime-type="unsupported">
285
+ <div className="semiont-browse-view__empty">
286
+ <p className="semiont-browse-view__empty-message">
287
+ Preview not available for {mimeType}
288
+ </p>
289
+ <a
290
+ href={`/api/resources/${resourceUri}`}
291
+ download
292
+ className="semiont-button semiont-button--primary"
293
+ >
294
+ Download File
295
+ </a>
251
296
  </div>
252
- );
253
-
254
- case 'image':
255
- return (
256
- <div className="semiont-browse-view" data-mime-type="image">
257
- <AnnotateToolbar
258
- selectedMotivation={null}
259
- selectedClick={selectedClick}
260
- showSelectionGroup={false}
261
- showDeleteButton={false}
262
- annotateMode={annotateMode}
263
- annotators={ANNOTATORS}
264
- />
265
- <div ref={containerRef} className="semiont-browse-view__content">
266
- <ImageViewer
267
- imageUrl={content}
268
- mimeType={mimeType}
269
- alt="Resource content"
270
- />
271
- </div>
272
- </div>
273
- );
274
-
275
- case 'none':
276
- // Catalogued type with no preview (render: 'none') or an imported
277
- // foreign type the registry doesn't know — same UI: metadata + download.
278
- return (
279
- <div ref={containerRef} className="semiont-browse-view semiont-browse-view--unsupported" data-mime-type="unsupported">
280
- <div className="semiont-browse-view__empty">
281
- <p className="semiont-browse-view__empty-message">
282
- Preview not available for {mimeType}
283
- </p>
284
- <a
285
- href={`/api/resources/${resourceUri}`}
286
- download
287
- className="semiont-button semiont-button--primary"
288
- >
289
- Download File
290
- </a>
291
- </div>
292
- </div>
293
- );
297
+ </div>
298
+ );
294
299
  }
300
+
301
+ return (
302
+ <div className={`semiont-browse-view${inlineMod}`} data-mime-type={render}>
303
+ {showToolbar && (
304
+ <AnnotateToolbar
305
+ selectedMotivation={null}
306
+ selectedClick={selectedClick}
307
+ parts={['clickAction', 'mode']}
308
+ showDeleteButton={false}
309
+ annotateMode={annotateMode}
310
+ annotators={ANNOTATORS}
311
+ onModeChange={onModeChange}
312
+ onClickActionChange={onClickActionChange}
313
+ compact={inline}
314
+ />
315
+ )}
316
+ <div ref={containerRef} className="semiont-browse-view__content" onClick={handleContentClick}>
317
+ <Renderer content={content} mimeType={mimeType} resourceUri={resourceUri} annotations={allAnnotations} />
318
+ </div>
319
+ </div>
320
+ );
295
321
  });