@semiont/react-ui 0.2.35-build.97 → 0.2.35-build.98

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.35-build.97",
3
+ "version": "0.2.35-build.98",
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
  )}
@@ -33,7 +33,6 @@ interface Props {
33
33
  resourceUri: string;
34
34
  annotations: AnnotationsCollection;
35
35
  hoveredAnnotationId?: string | null;
36
- hoveredCommentId?: string | null;
37
36
  selectedClick?: ClickAction;
38
37
  annotateMode: boolean;
39
38
  hoverDelayMs?: number;
@@ -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
 
@@ -413,7 +412,6 @@ export function ResourceViewer({
413
412
  mimeType={mimeType}
414
413
  resourceUri={resource['@id']}
415
414
  annotations={annotationsCollection}
416
- hoveredCommentId={hoveredCommentId}
417
415
  selectedClick={selectedClick}
418
416
  hoverDelayMs={hoverDelayMs}
419
417
  annotateMode={annotateMode}
@@ -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
  };