@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.35-build.95",
3
+ "version": "0.2.35-build.97",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -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';
@@ -36,39 +40,29 @@ interface Props {
36
40
  }
37
41
 
38
42
  /**
39
- * Convert W3C Annotations to simplified format for remark plugin.
40
- * Extracts position info and converts start/end to offset/length.
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
- 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
- }
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 = [...highlights, ...references, ...assessments, ...comments, ...tags];
90
+ const allAnnotations = useMemo(
91
+ () => [...highlights, ...references, ...assessments, ...comments, ...tags],
92
+ [highlights, references, assessments, comments, tags]
93
+ );
97
94
 
98
- const preparedAnnotations = prepareAnnotations(allAnnotations);
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
- <ReactMarkdown
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
- // Note: These objects are created inline - React's reconciliation handles re-renders efficiently
369
- const annotationsCollection = { highlights, references, assessments, comments, tags };
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 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
@@ -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 result = {
419
- highlights: [] as Annotation[],
420
- references: [] as Annotation[],
421
- assessments: [] as Annotation[],
422
- comments: [] as Annotation[],
423
- tags: [] as Annotation[]
424
- };
425
-
426
- for (const ann of annotations) {
427
- const annotator = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(ann));
428
- if (annotator) {
429
- const key = annotator.internalType + 's'; // highlight -> highlights
430
- if (result[key as keyof typeof result]) {
431
- result[key as keyof typeof result].push(ann);
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
- const groups = result;
437
+ return result;
438
+ }, [annotations]);
437
439
 
438
440
  // Combine resource with content
439
441
  const resourceWithContent = { ...resource, content };