@semiont/react-ui 0.2.34 → 0.2.35-build.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.34",
3
+ "version": "0.2.35-build.101",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -5,7 +5,7 @@ import { EditorView, Decoration, DecorationSet, lineNumbers } from '@codemirror/
5
5
  import { EditorState, RangeSetBuilder, StateField, StateEffect, Compartment } from '@codemirror/state';
6
6
  import { markdown } from '@codemirror/lang-markdown';
7
7
  import { ANNOTATORS } from '../lib/annotation-registry';
8
- import { ReferenceResolutionWidget } from '../lib/codemirror-widgets';
8
+ import { ReferenceResolutionWidget, showWidgetPreview, hideWidgetPreview } from '../lib/codemirror-widgets';
9
9
  import { scrollAnnotationIntoView } from '../lib/scroll-utils';
10
10
  import { isHighlight, isReference, isResolvedReference, isComment, isAssessment, isTag, getBodySource } from '@semiont/api-client';
11
11
  import type { components } from '@semiont/core';
@@ -35,7 +35,6 @@ interface Props {
35
35
  editable?: boolean;
36
36
  newAnnotationIds?: Set<string>;
37
37
  hoveredAnnotationId?: string | null;
38
- hoveredCommentId?: string | null;
39
38
  scrollToAnnotationId?: string | null;
40
39
  sourceView?: boolean; // If true, show raw source (no markdown rendering)
41
40
  showLineNumbers?: boolean; // If true, show line numbers
@@ -59,7 +58,6 @@ interface WidgetUpdate {
59
58
  content: string;
60
59
  segments: TextSegment[];
61
60
  generatingReferenceId?: string | null | undefined;
62
- eventBus?: EventBus;
63
61
  getTargetDocumentName?: (documentId: string) => string | undefined;
64
62
  }
65
63
 
@@ -88,11 +86,16 @@ function convertSegmentPositions(segments: TextSegment[], content: string): Text
88
86
  }
89
87
  }
90
88
 
91
- // Convert a single position from CRLF space to LF space
89
+ // Binary search: count CRLFs before a position in O(log n)
92
90
  const convertPosition = (pos: number): number => {
93
- // Count how many CRLFs appear before this position
94
- const crlfsBefore = crlfPositions.filter(crlfPos => crlfPos < pos).length;
95
- return pos - crlfsBefore;
91
+ let lo = 0;
92
+ let hi = crlfPositions.length;
93
+ while (lo < hi) {
94
+ const mid = (lo + hi) >>> 1;
95
+ if (crlfPositions[mid]! < pos) lo = mid + 1;
96
+ else hi = mid;
97
+ }
98
+ return pos - lo;
96
99
  };
97
100
 
98
101
  return segments.map(seg => ({
@@ -203,13 +206,10 @@ function buildWidgetDecorations(
203
206
  _content: string,
204
207
  segments: TextSegment[],
205
208
  generatingReferenceId: string | null | undefined,
206
- eventBus: any,
207
209
  getTargetDocumentName?: (documentId: string) => string | undefined
208
210
  ): DecorationSet {
209
211
  const builder = new RangeSetBuilder<Decoration>();
210
212
 
211
- // Wiki link widgets removed (WikiLinkWidget was deleted)
212
-
213
213
  // Process all annotations (references and highlights) in sorted order
214
214
  // This ensures decorations are added in the correct order for CodeMirror
215
215
  const allAnnotatedSegments = segments
@@ -222,20 +222,17 @@ function buildWidgetDecorations(
222
222
  const annotation = segment.annotation;
223
223
 
224
224
  // For references: add resolution widget (🔗, ✨ pulsing, or ❓)
225
- // Use W3C helper to determine if this is a reference
226
225
  if (isReference(annotation)) {
227
226
  const bodySource = getBodySource(annotation.body);
228
227
  const targetName = bodySource
229
228
  ? getTargetDocumentName?.(bodySource)
230
229
  : undefined;
231
- // Compare by ID portion (handle both URI and internal ID formats)
232
230
  const isGenerating = generatingReferenceId
233
231
  ? annotation.id === generatingReferenceId
234
232
  : false;
235
233
  const widget = new ReferenceResolutionWidget(
236
234
  annotation,
237
235
  targetName,
238
- eventBus,
239
236
  isGenerating
240
237
  );
241
238
  builder.add(
@@ -264,7 +261,6 @@ const widgetDecorationsField = StateField.define<DecorationSet>({
264
261
  effect.value.content,
265
262
  effect.value.segments,
266
263
  effect.value.generatingReferenceId,
267
- effect.value.eventBus,
268
264
  effect.value.getTargetDocumentName
269
265
  );
270
266
  }
@@ -282,7 +278,6 @@ export function CodeMirrorRenderer({
282
278
  editable = false,
283
279
  newAnnotationIds,
284
280
  hoveredAnnotationId,
285
- hoveredCommentId,
286
281
  scrollToAnnotationId,
287
282
  sourceView = false,
288
283
  showLineNumbers = false,
@@ -301,12 +296,19 @@ export function CodeMirrorRenderer({
301
296
  const convertedSegments = convertSegmentPositions(segments, content);
302
297
 
303
298
  const segmentsRef = useRef(convertedSegments);
299
+ // Index segments by annotation ID for O(1) click lookups
300
+ const segmentsByIdRef = useRef(new Map<string, TextSegment>());
304
301
  const lineNumbersCompartment = useRef(new Compartment());
305
302
  const eventBusRef = useRef(eventBus);
306
303
  const getTargetDocumentNameRef = useRef(getTargetDocumentName);
307
304
 
308
305
  // Update refs when they change
309
306
  segmentsRef.current = segments;
307
+ const segmentsById = new Map<string, TextSegment>();
308
+ for (const s of segments) {
309
+ if (s.annotation) segmentsById.set(s.annotation.id, s);
310
+ }
311
+ segmentsByIdRef.current = segmentsById;
310
312
  eventBusRef.current = eventBus;
311
313
  getTargetDocumentNameRef.current = getTargetDocumentName;
312
314
 
@@ -343,7 +345,7 @@ export function CodeMirrorRenderer({
343
345
  const annotationId = annotationElement?.getAttribute('data-annotation-id');
344
346
 
345
347
  if (annotationId && eventBusRef.current) {
346
- const segment = segmentsRef.current.find(s => s.annotation?.id === annotationId);
348
+ const segment = segmentsByIdRef.current.get(annotationId);
347
349
  if (segment?.annotation) {
348
350
  event.preventDefault();
349
351
  eventBusRef.current.get('attend:click').next({
@@ -431,12 +433,67 @@ export function CodeMirrorRenderer({
431
433
  if (annotationElement) handleMouseLeave();
432
434
  };
433
435
 
436
+ // Delegated widget event handlers (replaces per-widget listeners)
437
+ const handleWidgetClick = (e: MouseEvent) => {
438
+ const target = e.target as HTMLElement;
439
+ const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
440
+ if (!widget || widget.dataset.widgetGenerating === 'true') return;
441
+
442
+ e.preventDefault();
443
+ e.stopPropagation();
444
+
445
+ const annotationId = widget.dataset.widgetAnnotationId;
446
+ const bodySource = widget.dataset.widgetBodySource;
447
+ const isResolved = widget.dataset.widgetResolved === 'true';
448
+
449
+ if (!annotationId || !eventBusRef.current) return;
450
+
451
+ if (isResolved && bodySource) {
452
+ eventBusRef.current.get('navigation:reference-navigate').next({ documentId: bodySource });
453
+ } else {
454
+ const motivation = (widget.dataset.widgetMotivation || 'linking') as Annotation['motivation'];
455
+ eventBusRef.current.get('attend:click').next({ annotationId, motivation });
456
+ }
457
+ };
458
+
459
+ const handleWidgetMouseEnter = (e: MouseEvent) => {
460
+ const target = e.target as HTMLElement;
461
+ const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
462
+ if (!widget || widget.dataset.widgetGenerating === 'true') return;
463
+
464
+ const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
465
+ if (indicator) indicator.style.opacity = '1';
466
+
467
+ if (widget.dataset.widgetResolved === 'true' && widget.dataset.widgetTargetName) {
468
+ showWidgetPreview(widget, widget.dataset.widgetTargetName);
469
+ }
470
+ };
471
+
472
+ const handleWidgetMouseLeave = (e: MouseEvent) => {
473
+ const target = e.target as HTMLElement;
474
+ const widget = target.closest('.reference-preview-widget') as HTMLElement | null;
475
+ if (!widget) return;
476
+
477
+ const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
478
+ if (indicator) indicator.style.opacity = '0.6';
479
+
480
+ if (widget.dataset.widgetResolved === 'true') {
481
+ hideWidgetPreview(widget);
482
+ }
483
+ };
484
+
434
485
  container.addEventListener('mouseover', handleMouseOver);
435
486
  container.addEventListener('mouseout', handleMouseOut);
487
+ container.addEventListener('click', handleWidgetClick);
488
+ container.addEventListener('mouseenter', handleWidgetMouseEnter, true);
489
+ container.addEventListener('mouseleave', handleWidgetMouseLeave, true);
436
490
 
437
491
  return () => {
438
492
  container.removeEventListener('mouseover', handleMouseOver);
439
493
  container.removeEventListener('mouseout', handleMouseOut);
494
+ container.removeEventListener('click', handleWidgetClick);
495
+ container.removeEventListener('mouseenter', handleWidgetMouseEnter, true);
496
+ container.removeEventListener('mouseleave', handleWidgetMouseLeave, true);
440
497
  cleanupHover();
441
498
  view.destroy();
442
499
  viewRef.current = null;
@@ -496,7 +553,6 @@ export function CodeMirrorRenderer({
496
553
  content,
497
554
  segments: convertedSegments,
498
555
  generatingReferenceId,
499
- eventBus: eventBusRef.current,
500
556
  getTargetDocumentName: getTargetDocumentNameRef.current
501
557
  })
502
558
  });
@@ -550,54 +606,6 @@ export function CodeMirrorRenderer({
550
606
  };
551
607
  }, [hoveredAnnotationId]);
552
608
 
553
- // Handle hovered comment - add pulse effect and scroll if not visible
554
- useEffect(() => {
555
- if (!viewRef.current || !hoveredCommentId) return undefined;
556
-
557
- const view = viewRef.current;
558
-
559
- // Find the comment element in the DOM
560
- const element = view.contentDOM.querySelector(
561
- `[data-annotation-id="${CSS.escape(hoveredCommentId)}"]`
562
- ) as HTMLElement;
563
-
564
- if (!element) return undefined;
565
-
566
- // Find the actual scroll container - could be annotate view or document viewer
567
- const scrollContainer = (element.closest('.semiont-annotate-view__content') ||
568
- element.closest('.semiont-document-viewer__scrollable-body')) as HTMLElement;
569
-
570
- if (scrollContainer) {
571
- // Check visibility within the scroll container, not window
572
- const elementRect = element.getBoundingClientRect();
573
- const containerRect = scrollContainer.getBoundingClientRect();
574
-
575
- const isVisible =
576
- elementRect.top >= containerRect.top &&
577
- elementRect.bottom <= containerRect.bottom;
578
-
579
- if (!isVisible) {
580
- // Manually scroll the container instead of using scrollIntoView
581
- const elementTop = element.offsetTop;
582
- const containerHeight = scrollContainer.clientHeight;
583
- const elementHeight = element.offsetHeight;
584
- const scrollTo = elementTop - (containerHeight / 2) + (elementHeight / 2);
585
-
586
- scrollContainer.scrollTo({ top: scrollTo, behavior: 'smooth' });
587
- }
588
- }
589
-
590
- // Add pulse effect after a brief delay to ensure element is visible
591
- const timeoutId = setTimeout(() => {
592
- element.classList.add('annotation-pulse');
593
- }, 100);
594
-
595
- return () => {
596
- clearTimeout(timeoutId);
597
- element.classList.remove('annotation-pulse');
598
- };
599
- }, [hoveredCommentId]);
600
-
601
609
  // Handle scroll to annotation
602
610
  useEffect(() => {
603
611
  if (!viewRef.current || !scrollToAnnotationId) return;
@@ -3,7 +3,7 @@
3
3
  import { useRef, useEffect, useCallback, lazy, Suspense } from 'react';
4
4
  import type { components } from '@semiont/core';
5
5
  import { resourceUri as toResourceUri } from '@semiont/core';
6
- import { getTextPositionSelector, getTextQuoteSelector, getTargetSelector, getMimeCategory, isPdfMimeType, extractContext, findTextWithContext } from '@semiont/api-client';
6
+ import { getTextPositionSelector, getTextQuoteSelector, getTargetSelector, getMimeCategory, isPdfMimeType, extractContext, findTextWithContext, buildContentCache } from '@semiont/api-client';
7
7
  import { ANNOTATORS } from '../../lib/annotation-registry';
8
8
  import { SvgDrawingCanvas } from '../image-annotation/SvgDrawingCanvas';
9
9
  import { useResourceAnnotations } from '../../contexts/ResourceAnnotationsContext';
@@ -51,6 +51,9 @@ function segmentTextWithAnnotations(content: string, annotations: Annotation[]):
51
51
  return [{ exact: '', start: 0, end: 0 }];
52
52
  }
53
53
 
54
+ // Pre-compute normalized/lowered content once for all annotations
55
+ const cache = buildContentCache(content);
56
+
54
57
  const normalizedAnnotations = annotations
55
58
  .map(ann => {
56
59
  const targetSelector = getTargetSelector(ann.target);
@@ -66,7 +69,8 @@ function segmentTextWithAnnotations(content: string, annotations: Annotation[]):
66
69
  quoteSelector.exact,
67
70
  quoteSelector.prefix,
68
71
  quoteSelector.suffix,
69
- posSelector?.start // Position hint for fuzzy matching
72
+ posSelector?.start,
73
+ cache
70
74
  );
71
75
  }
72
76
 
@@ -160,7 +164,7 @@ export function AnnotateView({
160
164
  const segments = segmentTextWithAnnotations(content, allAnnotations);
161
165
 
162
166
  // Extract UI state
163
- const { selectedMotivation, selectedClick, selectedShape, hoveredAnnotationId, hoveredCommentId, scrollToAnnotationId } = uiState;
167
+ const { selectedMotivation, selectedClick, selectedShape, hoveredAnnotationId, scrollToAnnotationId } = uiState;
164
168
 
165
169
  // Store onUIStateChange in ref to avoid dependency issues
166
170
  const onUIStateChangeRef = useRef(onUIStateChange);
@@ -336,7 +340,6 @@ export function AnnotateView({
336
340
  editable={false}
337
341
  newAnnotationIds={newAnnotationIds}
338
342
  {...(hoveredAnnotationId !== undefined && { hoveredAnnotationId })}
339
- {...(hoveredCommentId !== undefined && { hoveredCommentId })}
340
343
  {...(scrollToAnnotationId !== undefined && { scrollToAnnotationId })}
341
344
  sourceView={true}
342
345
  showLineNumbers={showLineNumbers}
@@ -375,7 +378,7 @@ export function AnnotateView({
375
378
  drawingMode={selectedMotivation ? selectedShape : null}
376
379
  selectedMotivation={selectedMotivation}
377
380
  eventBus={eventBus}
378
- hoveredAnnotationId={hoveredCommentId || hoveredAnnotationId || null}
381
+ hoveredAnnotationId={hoveredAnnotationId || null}
379
382
  hoverDelayMs={hoverDelayMs}
380
383
  />
381
384
  </Suspense>
@@ -405,7 +408,7 @@ export function AnnotateView({
405
408
  drawingMode={selectedMotivation ? selectedShape : null}
406
409
  selectedMotivation={selectedMotivation}
407
410
  eventBus={eventBus}
408
- hoveredAnnotationId={hoveredCommentId || hoveredAnnotationId || null}
411
+ hoveredAnnotationId={hoveredAnnotationId || null}
409
412
  hoverDelayMs={hoverDelayMs}
410
413
  />
411
414
  )}
@@ -1,24 +1,28 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useCallback, lazy, Suspense } from 'react';
3
+ import { useEffect, useRef, useCallback, useMemo, memo, lazy, Suspense } from 'react';
4
4
  import ReactMarkdown from 'react-markdown';
5
5
  import remarkGfm from 'remark-gfm';
6
- import { remarkAnnotations, type PreparedAnnotation } from '../../lib/remark-annotations';
7
- import { rehypeRenderAnnotations } from '../../lib/rehype-render-annotations';
8
- import type { components } from '@semiont/core';
9
6
  import { resourceUri as toResourceUri } from '@semiont/core';
10
- import { getExactText, getTextPositionSelector, getTargetSelector, getBodySource, getMimeCategory, isPdfMimeType } from '@semiont/api-client';
7
+ import { getMimeCategory, isPdfMimeType } from '@semiont/api-client';
11
8
  import { ANNOTATORS } from '../../lib/annotation-registry';
12
9
  import { createHoverHandlers } from '../../hooks/useAttentionFlow';
13
10
  import { scrollAnnotationIntoView } from '../../lib/scroll-utils';
14
11
  import { ImageViewer } from '../viewers';
15
12
  import { AnnotateToolbar, type ClickAction } from '../annotation/AnnotateToolbar';
16
13
  import type { AnnotationsCollection } from '../../types/annotation-props';
14
+ import {
15
+ buildSourceToRenderedMap,
16
+ buildTextNodeIndex,
17
+ resolveAnnotationRanges,
18
+ applyHighlights,
19
+ clearHighlights,
20
+ toOverlayAnnotations,
21
+ } from '../../lib/annotation-overlay';
17
22
 
18
23
  // Lazy load PDF component to avoid SSR issues with browser PDF.js loading
19
24
  const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
20
25
 
21
- type Annotation = components['schemas']['Annotation'];
22
26
  import { useResourceAnnotations } from '../../contexts/ResourceAnnotationsContext';
23
27
  import { useEventBus } from '../../contexts/EventBusContext';
24
28
  import { useEventSubscriptions } from '../../contexts/useEventSubscription';
@@ -29,46 +33,35 @@ interface Props {
29
33
  resourceUri: string;
30
34
  annotations: AnnotationsCollection;
31
35
  hoveredAnnotationId?: string | null;
32
- hoveredCommentId?: string | null;
33
36
  selectedClick?: ClickAction;
34
37
  annotateMode: boolean;
35
38
  hoverDelayMs?: number;
36
39
  }
37
40
 
38
41
  /**
39
- * Convert W3C Annotations to simplified format for remark plugin.
40
- * Extracts position info and converts start/end to offset/length.
42
+ * Memoized markdown renderer only re-renders when content changes.
43
+ * No annotation plugins: annotations are applied as a DOM overlay after paint.
41
44
  */
42
- function prepareAnnotations(annotations: Annotation[]): PreparedAnnotation[] {
43
- /**
44
- * View component for browsing resources with rendered annotations
45
- *
46
- * @emits attend:click - Annotation clicked in browse view. Payload: { annotationId: string, motivation: Motivation }
47
- * @emits attend:hover - Annotation hovered in browse view. Payload: { annotationId: string | null }
48
- */
49
- return annotations
50
- .map(ann => {
51
- const targetSelector = getTargetSelector(ann.target);
52
- const posSelector = getTextPositionSelector(targetSelector);
53
- const start = posSelector?.start ?? 0;
54
- const end = posSelector?.end ?? 0;
55
-
56
- // Use ANNOTATORS registry to determine type
57
- const type = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(ann))?.internalType || 'highlight';
58
-
59
- return {
60
- id: ann.id,
61
- exact: getExactText(targetSelector),
62
- offset: start, // remark plugin expects 'offset'
63
- length: end - start, // remark plugin expects 'length', not 'end'
64
- type,
65
- source: getBodySource(ann.body)
66
- };
67
- });
68
- }
45
+ const MemoizedMarkdown = memo(function MemoizedMarkdown({
46
+ content,
47
+ }: {
48
+ content: string;
49
+ }) {
50
+ return (
51
+ <ReactMarkdown
52
+ remarkPlugins={[remarkGfm]}
53
+ >
54
+ {content}
55
+ </ReactMarkdown>
56
+ );
57
+ });
69
58
 
70
59
  /**
71
- * View component for browsing annotated resources in read-only mode
60
+ * View component for browsing annotated resources in read-only mode.
61
+ *
62
+ * Two-layer rendering:
63
+ * - Layer 1: Markdown renders once (MemoizedMarkdown, cached by content)
64
+ * - Layer 2: Annotation overlay applied via DOM Range API after paint
72
65
  *
73
66
  * @emits attend:click - User clicked on annotation. Payload: { annotationId: string, motivation: Motivation }
74
67
  * @emits attend:hover - User hovered over annotation. Payload: { annotationId: string | null }
@@ -76,7 +69,7 @@ function prepareAnnotations(annotations: Annotation[]): PreparedAnnotation[] {
76
69
  * @subscribes attend:hover - Highlight annotation on hover. Payload: { annotationId: string | null }
77
70
  * @subscribes attend:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
78
71
  */
79
- export function BrowseView({
72
+ export const BrowseView = memo(function BrowseView({
80
73
  content,
81
74
  mimeType,
82
75
  resourceUri,
@@ -93,9 +86,36 @@ export function BrowseView({
93
86
 
94
87
  const { highlights, references, assessments, comments, tags } = annotations;
95
88
 
96
- const allAnnotations = [...highlights, ...references, ...assessments, ...comments, ...tags];
89
+ const allAnnotations = useMemo(
90
+ () => [...highlights, ...references, ...assessments, ...comments, ...tags],
91
+ [highlights, references, assessments, comments, tags]
92
+ );
97
93
 
98
- const preparedAnnotations = prepareAnnotations(allAnnotations);
94
+ const overlayAnnotations = useMemo(
95
+ () => toOverlayAnnotations(allAnnotations),
96
+ [allAnnotations]
97
+ );
98
+
99
+ // Cache offset map (recomputed only when content changes)
100
+ const offsetMapRef = useRef<Map<number, number> | null>(null);
101
+
102
+ // Build offset map after markdown DOM paints (once per content change)
103
+ useEffect(() => {
104
+ if (!containerRef.current) return;
105
+ offsetMapRef.current = buildSourceToRenderedMap(content, containerRef.current);
106
+ }, [content]);
107
+
108
+ // Layer 2: overlay annotations after DOM paint
109
+ useEffect(() => {
110
+ if (!containerRef.current || !offsetMapRef.current || overlayAnnotations.length === 0) return;
111
+
112
+ const container = containerRef.current;
113
+ const textNodeIndex = buildTextNodeIndex(container);
114
+ const ranges = resolveAnnotationRanges(overlayAnnotations, offsetMapRef.current, textNodeIndex);
115
+ applyHighlights(ranges);
116
+
117
+ return () => clearHighlights(container);
118
+ }, [overlayAnnotations]);
99
119
 
100
120
  // Attach click handler, hover handler, and animations after render
101
121
  useEffect(() => {
@@ -199,17 +219,7 @@ export function BrowseView({
199
219
  annotators={ANNOTATORS}
200
220
  />
201
221
  <div ref={containerRef} className="semiont-browse-view__content">
202
- <ReactMarkdown
203
- remarkPlugins={[
204
- remarkGfm,
205
- [remarkAnnotations, { annotations: preparedAnnotations }]
206
- ]}
207
- rehypePlugins={[
208
- rehypeRenderAnnotations
209
- ]}
210
- >
211
- {content}
212
- </ReactMarkdown>
222
+ <MemoizedMarkdown content={content} />
213
223
  </div>
214
224
  </div>
215
225
  );
@@ -280,4 +290,4 @@ export function BrowseView({
280
290
  </div>
281
291
  );
282
292
  }
283
- }
293
+ });
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect, useCallback, useRef } from 'react';
3
+ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
4
4
  import { useTranslations } from '../../contexts/TranslationContext';
5
5
  import { AnnotateView, type SelectionMotivation, type ClickAction, type ShapeType } from './AnnotateView';
6
6
  import { BrowseView } from './BrowseView';
@@ -224,7 +224,6 @@ export function ResourceViewer({
224
224
  // Internal UI state for hover, focus, and scroll
225
225
  // Use prop value when provided (controlled by parent), otherwise null
226
226
  const hoveredAnnotationId = hoveredAnnotationIdProp ?? null;
227
- const [hoveredCommentId, _setHoveredCommentId] = useState<string | null>(null);
228
227
  const [scrollToAnnotationId, setScrollToAnnotationId] = useState<string | null>(null);
229
228
  const [_focusedAnnotationId, setFocusedAnnotationId] = useState<string | null>(null);
230
229
 
@@ -364,9 +363,11 @@ export function ResourceViewer({
364
363
  'attend:click': handleAnnotationClickEvent,
365
364
  });
366
365
 
367
- // Prepare props for child components
368
- // Note: These objects are created inline - React's reconciliation handles re-renders efficiently
369
- const annotationsCollection = { highlights, references, assessments, comments, tags };
366
+ // Prepare props for child components (memoized to prevent unnecessary re-renders of BrowseView/AnnotateView)
367
+ const annotationsCollection = useMemo(
368
+ () => ({ highlights, references, assessments, comments, tags }),
369
+ [highlights, references, assessments, comments, tags]
370
+ );
370
371
 
371
372
  const uiState = {
372
373
  selectedMotivation,
@@ -411,7 +412,6 @@ export function ResourceViewer({
411
412
  mimeType={mimeType}
412
413
  resourceUri={resource['@id']}
413
414
  annotations={annotationsCollection}
414
- hoveredCommentId={hoveredCommentId}
415
415
  selectedClick={selectedClick}
416
416
  hoverDelayMs={hoverDelayMs}
417
417
  annotateMode={annotateMode}
@@ -45,14 +45,14 @@ vi.mock('remark-gfm', () => ({
45
45
  default: vi.fn(),
46
46
  }));
47
47
 
48
- // Mock remark-annotations
49
- vi.mock('../../../lib/remark-annotations', () => ({
50
- remarkAnnotations: vi.fn(),
51
- }));
52
-
53
- // Mock rehype-render-annotations
54
- vi.mock('../../../lib/rehype-render-annotations', () => ({
55
- rehypeRenderAnnotations: vi.fn(),
48
+ // Mock annotation-overlay — DOM Range API is not available in jsdom
49
+ vi.mock('../../../lib/annotation-overlay', () => ({
50
+ buildSourceToRenderedMap: vi.fn(() => new Map()),
51
+ buildTextNodeIndex: vi.fn(() => []),
52
+ resolveAnnotationRanges: vi.fn(() => new Map()),
53
+ applyHighlights: vi.fn(),
54
+ clearHighlights: vi.fn(),
55
+ toOverlayAnnotations: vi.fn(() => []),
56
56
  }));
57
57
 
58
58
  // Mock ANNOTATORS
@@ -209,7 +209,6 @@ describe('BrowseView Component', () => {
209
209
  tags: [],
210
210
  },
211
211
  hoveredAnnotationId: null,
212
- hoveredCommentId: null,
213
212
  selectedClick: 'detail' as const,
214
213
  annotateMode: false,
215
214
  };
@@ -9,6 +9,7 @@ import React from 'react';
9
9
  import {
10
10
  CommandLineIcon
11
11
  } from '@heroicons/react/24/outline';
12
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../hooks/usePanelNavigation';
12
13
 
13
14
  export interface DevOpsFeature {
14
15
  title: string;
@@ -54,7 +55,7 @@ export function AdminDevOpsPage({
54
55
  Toolbar,
55
56
  }: AdminDevOpsPageProps) {
56
57
  return (
57
- <div className={`semiont-page${activePanel ? ' semiont-page--panel-open' : ''}`}>
58
+ <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
58
59
  {/* Main Content Area */}
59
60
  <div className="semiont-page__content">
60
61
  {/* Page Title */}
@@ -12,6 +12,7 @@ import {
12
12
  CheckCircleIcon,
13
13
  InformationCircleIcon
14
14
  } from '@heroicons/react/24/outline';
15
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../hooks/usePanelNavigation';
15
16
 
16
17
  export interface OAuthProvider {
17
18
  name: string;
@@ -67,7 +68,7 @@ export function AdminSecurityPage({
67
68
  Toolbar,
68
69
  }: AdminSecurityPageProps) {
69
70
  return (
70
- <div className={`semiont-page${activePanel ? ' semiont-page--panel-open' : ''}`}>
71
+ <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
71
72
  {/* Main Content Area */}
72
73
  <div className="semiont-page__content">
73
74
  <div className="semiont-page__sections">
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import React, { useState } from 'react';
9
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../hooks/usePanelNavigation';
9
10
  import {
10
11
  PlusIcon,
11
12
  MagnifyingGlassIcon,
@@ -235,7 +236,7 @@ export function AdminUsersPage({
235
236
  });
236
237
 
237
238
  return (
238
- <div className={`semiont-page${activePanel ? ' semiont-page--panel-open' : ''}`}>
239
+ <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
239
240
  {/* Main Content Area */}
240
241
  <div className="semiont-page__content">
241
242
  <div className="semiont-page__sections">
@@ -11,6 +11,7 @@ import {
11
11
  PlusIcon,
12
12
  ExclamationCircleIcon
13
13
  } from '@heroicons/react/24/outline';
14
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../hooks/usePanelNavigation';
14
15
 
15
16
  export interface EntityTagsPageProps {
16
17
  // Data props
@@ -69,7 +70,7 @@ export function EntityTagsPage({
69
70
  };
70
71
 
71
72
  return (
72
- <div className={`semiont-page${activePanel ? ' semiont-page--panel-open' : ''}`}>
73
+ <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
73
74
  {/* Main Content Area */}
74
75
  <div className="semiont-page__content">
75
76
  {/* Page Title */}
@@ -7,6 +7,7 @@
7
7
 
8
8
  import React from 'react';
9
9
  import { ClockIcon } from '@heroicons/react/24/outline';
10
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../hooks/usePanelNavigation';
10
11
 
11
12
  export interface RecentDocumentsPageProps {
12
13
  // Data props
@@ -53,7 +54,7 @@ export function RecentDocumentsPage({
53
54
  }
54
55
 
55
56
  return (
56
- <div className={`semiont-page${activePanel ? ' semiont-page--panel-open' : ''}`}>
57
+ <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
57
58
  {/* Main Content Area */}
58
59
  <div className="semiont-page__content">
59
60
  {/* Page Title */}