@semiont/react-ui 0.2.34 → 0.2.35-build.100
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/README.md +1 -0
- package/dist/index.d.mts +104 -44
- package/dist/index.mjs +577 -634
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/CodeMirrorRenderer.tsx +73 -65
- package/src/components/resource/AnnotateView.tsx +9 -6
- package/src/components/resource/BrowseView.tsx +62 -52
- package/src/components/resource/ResourceViewer.tsx +6 -6
- package/src/components/resource/__tests__/BrowseView.test.tsx +8 -9
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +19 -17
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
89
|
+
// Binary search: count CRLFs before a position in O(log n)
|
|
92
90
|
const convertPosition = (pos: number): number => {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
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={
|
|
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={
|
|
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 {
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
89
|
+
const allAnnotations = useMemo(
|
|
90
|
+
() => [...highlights, ...references, ...assessments, ...comments, ...tags],
|
|
91
|
+
[highlights, references, assessments, comments, tags]
|
|
92
|
+
);
|
|
97
93
|
|
|
98
|
-
const
|
|
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
|
-
<
|
|
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
|
-
|
|
369
|
-
|
|
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
|
|
49
|
-
vi.mock('../../../lib/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
vi.
|
|
55
|
-
|
|
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
|
};
|
|
@@ -414,26 +414,28 @@ export function ResourceViewerPage({
|
|
|
414
414
|
return false;
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
-
// Group annotations by type using static ANNOTATORS
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
result[key as keyof typeof result]
|
|
417
|
+
// Group annotations by type using static ANNOTATORS (memoized to avoid re-grouping on unrelated re-renders)
|
|
418
|
+
const groups = useMemo(() => {
|
|
419
|
+
const result = {
|
|
420
|
+
highlights: [] as Annotation[],
|
|
421
|
+
references: [] as Annotation[],
|
|
422
|
+
assessments: [] as Annotation[],
|
|
423
|
+
comments: [] as Annotation[],
|
|
424
|
+
tags: [] as Annotation[]
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
for (const ann of annotations) {
|
|
428
|
+
const annotator = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(ann));
|
|
429
|
+
if (annotator) {
|
|
430
|
+
const key = annotator.internalType + 's'; // highlight -> highlights
|
|
431
|
+
if (result[key as keyof typeof result]) {
|
|
432
|
+
result[key as keyof typeof result].push(ann);
|
|
433
|
+
}
|
|
432
434
|
}
|
|
433
435
|
}
|
|
434
|
-
}
|
|
435
436
|
|
|
436
|
-
|
|
437
|
+
return result;
|
|
438
|
+
}, [annotations]);
|
|
437
439
|
|
|
438
440
|
// Combine resource with content
|
|
439
441
|
const resourceWithContent = { ...resource, content };
|