@semiont/react-ui 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
  3. package/dist/PdfAnnotationCanvas.client-75GY2EDR.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/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  7. package/dist/chunk-VXASXU4K.js.map +1 -0
  8. package/dist/index.css +150 -112
  9. package/dist/index.css.map +1 -1
  10. package/dist/index.d.ts +334 -48
  11. package/dist/index.js +2494 -2201
  12. package/dist/index.js.map +1 -1
  13. package/dist/test-utils.d.ts +5 -1
  14. package/dist/test-utils.js +10 -4
  15. package/dist/test-utils.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/components/ResourceTagsInline.tsx +94 -11
  18. package/src/components/StatusDisplay.css +36 -0
  19. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  20. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  21. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  23. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  24. package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
  25. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  26. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  27. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +17 -4
  28. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
  29. package/src/components/resource/AnnotateView.tsx +59 -38
  30. package/src/components/resource/BrowseView.tsx +172 -138
  31. package/src/components/resource/ResourceViewer.tsx +123 -130
  32. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  33. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  34. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  35. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  36. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  37. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  38. package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
  39. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
  40. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  41. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  42. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  43. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  44. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  45. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  46. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  47. package/src/components/resource/browse-renderers.tsx +85 -0
  48. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  49. package/src/components/resource/panels/AssessmentPanel.tsx +12 -5
  50. package/src/components/resource/panels/AssistSection.tsx +4 -3
  51. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  52. package/src/components/resource/panels/CommentsPanel.tsx +12 -5
  53. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  54. package/src/components/resource/panels/HighlightPanel.tsx +12 -5
  55. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  56. package/src/components/resource/panels/ReferencesPanel.tsx +15 -6
  57. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  58. package/src/components/resource/panels/TagEntry.tsx +5 -3
  59. package/src/components/resource/panels/TaggingPanel.tsx +11 -4
  60. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
  61. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  62. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -28
  63. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  64. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +54 -24
  66. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  67. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  68. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  70. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  71. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +78 -59
  72. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  73. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  74. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +16 -7
  75. package/src/components/toolbar/Toolbar.css +14 -0
  76. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  77. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
  78. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  79. package/src/styles/features/resource-viewer.css +19 -0
  80. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
  81. package/dist/chunk-O2MD7TGE.js.map +0 -1
@@ -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(() => {
@@ -125,25 +134,75 @@ export const BrowseView = memo(function BrowseView({
125
134
 
126
135
  const container = containerRef.current;
127
136
 
128
- // Single click handler for the container
137
+ // Single click handler for the container — emits browse:click for whatever
138
+ // annotation the click resolves, any motivation (parity with the image/PDF
139
+ // and annotate-mode emitters; ResourceViewer routes by click action).
129
140
  const handleClick = (e: MouseEvent) => {
130
141
  const target = e.target as HTMLElement;
131
142
  const annotationElement = target.closest('[data-annotation-id]');
132
143
  if (!annotationElement) return;
133
144
 
134
- const annotationId = annotationElement.getAttribute('data-annotation-id');
135
- const annotationType = annotationElement.getAttribute('data-annotation-type');
145
+ // Browse mode is the reading surface: a drag-select that starts and ends
146
+ // inside one annotated span fires click on it — suppress emission so
147
+ // copying text never opens or navigates.
148
+ const selection = window.getSelection();
149
+ if (selection && !selection.isCollapsed) return;
136
150
 
137
- if (annotationId && annotationType === 'reference') {
151
+ const annotationId = annotationElement.getAttribute('data-annotation-id');
152
+ if (annotationId) {
138
153
  const annotation = allAnnotations.find(a => a.id === annotationId);
139
154
  if (annotation) {
140
- session.client.browse.click(annotation.id, annotation.motivation);
155
+ // The emission site owns the geometry: the clicked span's viewport
156
+ // rect rides the event so hosts can anchor popovers (A1).
157
+ session.client.browse.click(annotation.id, annotation.motivation, annotationElement.getBoundingClientRect());
141
158
  }
142
159
  }
143
160
  };
144
161
 
162
+ // Reference-hover delegation (host-facing event surface): after the dwell,
163
+ // resolve the referent's cached descriptor and hand the host
164
+ // { annotation, referent, anchorRect }; `null` on leave — but only if a hover
165
+ // fired, so stubs stay silent. Rides the SAME dwell emitter as beckon:hover —
166
+ // one state machine, two consumers.
167
+ let hoveredElement: HTMLElement | null = null;
168
+ let referentSub: { unsubscribe(): void } | null = null;
169
+ let referenceHoverFired = false;
170
+
171
+ const startReferenceHover = (id: string) => {
172
+ if (!onReferenceHover) return; // no handler → no referent load
173
+ const annotation = allAnnotations.find(a => a.id === id);
174
+ if (!annotation || !isResolvedReference(annotation)) return; // stub / non-reference: fires nothing
175
+ const referentId = getBodySource(annotation.body);
176
+ const element = hoveredElement;
177
+ if (!referentId || !element) return;
178
+ referentSub?.unsubscribe();
179
+ referentSub = session.client.browse.resource(toResourceId(referentId)).subscribe({
180
+ next: (referent) => {
181
+ if (referent === undefined) return; // cache still loading
182
+ referentSub?.unsubscribe();
183
+ referentSub = null;
184
+ referenceHoverFired = true;
185
+ // anchorRect taken at fire time so it reflects current layout.
186
+ onReferenceHover({ annotation, referent, anchorRect: element.getBoundingClientRect() });
187
+ },
188
+ error: () => { referentSub = null; }, // failed load: never fires
189
+ });
190
+ };
191
+
192
+ const endReferenceHover = () => {
193
+ referentSub?.unsubscribe(); // leave-before-resolve: cancel, no fire
194
+ referentSub = null;
195
+ if (referenceHoverFired) {
196
+ referenceHoverFired = false;
197
+ onReferenceHover?.(null);
198
+ }
199
+ };
200
+
145
201
  const { handleMouseEnter, handleMouseLeave, cleanup: cleanupHover } = createHoverHandlers(
146
- (id) => session.client.beckon.hover(id),
202
+ (id) => {
203
+ session.client.beckon.hover(id);
204
+ if (id) startReferenceHover(id); else endReferenceHover();
205
+ },
147
206
  hoverDelayMs
148
207
  );
149
208
 
@@ -152,7 +211,10 @@ export const BrowseView = memo(function BrowseView({
152
211
  const target = e.target as HTMLElement;
153
212
  const annotationElement = target.closest('[data-annotation-id]');
154
213
  const annotationId = annotationElement?.getAttribute('data-annotation-id');
155
- if (annotationId) handleMouseEnter(toAnnotationId(annotationId));
214
+ if (annotationId) {
215
+ hoveredElement = annotationElement as HTMLElement; // anchor for onReferenceHover
216
+ handleMouseEnter(toAnnotationId(annotationId));
217
+ }
156
218
  };
157
219
 
158
220
  // Single mouseout handler for the container - fires once on exit
@@ -182,8 +244,9 @@ export const BrowseView = memo(function BrowseView({
182
244
  container.removeEventListener('mouseover', handleMouseOver);
183
245
  container.removeEventListener('mouseout', handleMouseOut);
184
246
  cleanupHover();
247
+ referentSub?.unsubscribe(); // in-flight referent load dies with the effect
185
248
  };
186
- }, [content, allAnnotations, newAnnotationIds, hoverDelayMs, session]);
249
+ }, [content, allAnnotations, newAnnotationIds, hoverDelayMs, session, onReferenceHover]);
187
250
 
188
251
  // Helper to scroll annotation into view with pulse effect
189
252
  const scrollToAnnotation = useCallback((annotationId: string | null, removePulse = false) => {
@@ -202,94 +265,65 @@ export const BrowseView = memo(function BrowseView({
202
265
  scrollToAnnotation(annotationId ?? null, true);
203
266
  }, [scrollToAnnotation]);
204
267
 
205
- useEventSubscriptions({
268
+ useSessionEventSubscriptions(session, {
206
269
  'beckon:hover': handleAnnotationHover,
207
270
  'beckon:focus': handleAnnotationFocus,
208
271
  });
209
272
 
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>
273
+ // A content link inside the rendered output (react-markdown / HTML `<a href>`) must never navigate
274
+ // on its own (embedded/Electron security): always preventDefault, then delegate to the host if it cares.
275
+ const handleContentClick = useCallback((e: ReactMouseEvent) => {
276
+ const anchor = (e.target as HTMLElement).closest('a[href]');
277
+ if (!anchor) return;
278
+ e.preventDefault();
279
+ const href = anchor.getAttribute('href');
280
+ if (href) onLinkClick?.({ href, event: e });
281
+ }, [onLinkClick]);
282
+
283
+ // Route to the media renderer for this render mode. `text`/`image`/`pdf` share
284
+ // the shell (toolbar + annotation-overlay container); `none` (no preview, or an
285
+ // unknown type) has its own metadata+download structure. Callers can override
286
+ // any renderer via `renderers`.
287
+ const mediaRenderers: BrowseMediaRenderers = { ...defaultBrowseRenderers, ...renderers };
288
+ const Renderer = render === 'none' ? undefined : mediaRenderers[render];
289
+
290
+ if (!Renderer) {
291
+ return (
292
+ <div ref={containerRef} className={`semiont-browse-view semiont-browse-view--unsupported${inlineMod}`} data-mime-type="unsupported">
293
+ <div className="semiont-browse-view__empty">
294
+ <p className="semiont-browse-view__empty-message">
295
+ Preview not available for {mimeType}
296
+ </p>
297
+ <a
298
+ href={`/api/resources/${resourceUri}`}
299
+ download
300
+ className="semiont-button semiont-button--primary"
301
+ >
302
+ Download File
303
+ </a>
227
304
  </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>
251
- </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
- );
305
+ </div>
306
+ );
294
307
  }
308
+
309
+ return (
310
+ <div className={`semiont-browse-view${inlineMod}`} data-mime-type={render}>
311
+ {showToolbar && (
312
+ <AnnotateToolbar
313
+ selectedMotivation={null}
314
+ selectedClick={selectedClick}
315
+ parts={['clickAction', 'mode']}
316
+ showDeleteButton={false}
317
+ annotateMode={annotateMode}
318
+ annotators={ANNOTATORS}
319
+ onModeChange={onModeChange}
320
+ onClickActionChange={onClickActionChange}
321
+ compact={inline}
322
+ />
323
+ )}
324
+ <div ref={containerRef} className="semiont-browse-view__content" onClick={handleContentClick}>
325
+ <Renderer content={content} mimeType={mimeType} resourceUri={resourceUri} annotations={allAnnotations} session={session} />
326
+ </div>
327
+ </div>
328
+ );
295
329
  });