@semiont/react-ui 0.2.35-build.95 → 0.2.35-build.97
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/dist/index.d.mts +83 -31
- package/dist/index.mjs +475 -512
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/resource/BrowseView.tsx +62 -51
- package/src/components/resource/ResourceViewer.tsx +6 -4
- package/src/components/resource/__tests__/BrowseView.test.tsx +8 -8
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +19 -17
package/package.json
CHANGED
|
@@ -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';
|
|
@@ -36,39 +40,29 @@ interface Props {
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
43
|
+
* Memoized markdown renderer — only re-renders when content changes.
|
|
44
|
+
* No annotation plugins: annotations are applied as a DOM overlay after paint.
|
|
41
45
|
*/
|
|
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
|
-
}
|
|
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
|
+
});
|
|
69
59
|
|
|
70
60
|
/**
|
|
71
|
-
* View component for browsing annotated resources in read-only mode
|
|
61
|
+
* View component for browsing annotated resources in read-only mode.
|
|
62
|
+
*
|
|
63
|
+
* Two-layer rendering:
|
|
64
|
+
* - Layer 1: Markdown renders once (MemoizedMarkdown, cached by content)
|
|
65
|
+
* - Layer 2: Annotation overlay applied via DOM Range API after paint
|
|
72
66
|
*
|
|
73
67
|
* @emits attend:click - User clicked on annotation. Payload: { annotationId: string, motivation: Motivation }
|
|
74
68
|
* @emits attend:hover - User hovered over annotation. Payload: { annotationId: string | null }
|
|
@@ -76,7 +70,7 @@ function prepareAnnotations(annotations: Annotation[]): PreparedAnnotation[] {
|
|
|
76
70
|
* @subscribes attend:hover - Highlight annotation on hover. Payload: { annotationId: string | null }
|
|
77
71
|
* @subscribes attend:focus - Scroll to and highlight annotation. Payload: { annotationId: string }
|
|
78
72
|
*/
|
|
79
|
-
export function BrowseView({
|
|
73
|
+
export const BrowseView = memo(function BrowseView({
|
|
80
74
|
content,
|
|
81
75
|
mimeType,
|
|
82
76
|
resourceUri,
|
|
@@ -93,9 +87,36 @@ export function BrowseView({
|
|
|
93
87
|
|
|
94
88
|
const { highlights, references, assessments, comments, tags } = annotations;
|
|
95
89
|
|
|
96
|
-
const allAnnotations =
|
|
90
|
+
const allAnnotations = useMemo(
|
|
91
|
+
() => [...highlights, ...references, ...assessments, ...comments, ...tags],
|
|
92
|
+
[highlights, references, assessments, comments, tags]
|
|
93
|
+
);
|
|
97
94
|
|
|
98
|
-
const
|
|
95
|
+
const overlayAnnotations = useMemo(
|
|
96
|
+
() => toOverlayAnnotations(allAnnotations),
|
|
97
|
+
[allAnnotations]
|
|
98
|
+
);
|
|
99
|
+
|
|
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)
|
|
104
|
+
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;
|
|
112
|
+
|
|
113
|
+
const container = containerRef.current;
|
|
114
|
+
const textNodeIndex = buildTextNodeIndex(container);
|
|
115
|
+
const ranges = resolveAnnotationRanges(overlayAnnotations, offsetMapRef.current, textNodeIndex);
|
|
116
|
+
applyHighlights(ranges);
|
|
117
|
+
|
|
118
|
+
return () => clearHighlights(container);
|
|
119
|
+
}, [overlayAnnotations]);
|
|
99
120
|
|
|
100
121
|
// Attach click handler, hover handler, and animations after render
|
|
101
122
|
useEffect(() => {
|
|
@@ -199,17 +220,7 @@ export function BrowseView({
|
|
|
199
220
|
annotators={ANNOTATORS}
|
|
200
221
|
/>
|
|
201
222
|
<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>
|
|
223
|
+
<MemoizedMarkdown content={content} />
|
|
213
224
|
</div>
|
|
214
225
|
</div>
|
|
215
226
|
);
|
|
@@ -280,4 +291,4 @@ export function BrowseView({
|
|
|
280
291
|
</div>
|
|
281
292
|
);
|
|
282
293
|
}
|
|
283
|
-
}
|
|
294
|
+
});
|
|
@@ -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';
|
|
@@ -364,9 +364,11 @@ export function ResourceViewer({
|
|
|
364
364
|
'attend:click': handleAnnotationClickEvent,
|
|
365
365
|
});
|
|
366
366
|
|
|
367
|
-
// Prepare props for child components
|
|
368
|
-
|
|
369
|
-
|
|
367
|
+
// Prepare props for child components (memoized to prevent unnecessary re-renders of BrowseView/AnnotateView)
|
|
368
|
+
const annotationsCollection = useMemo(
|
|
369
|
+
() => ({ highlights, references, assessments, comments, tags }),
|
|
370
|
+
[highlights, references, assessments, comments, tags]
|
|
371
|
+
);
|
|
370
372
|
|
|
371
373
|
const uiState = {
|
|
372
374
|
selectedMotivation,
|
|
@@ -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
|
|
@@ -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 };
|