@semiont/react-ui 0.5.10 → 0.5.12
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 -1
- package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
- package/dist/PdfAnnotationCanvas.client-75GY2EDR.js.map +1 -0
- package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
- package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
- package/dist/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
- package/dist/chunk-VXASXU4K.js.map +1 -0
- package/dist/index.css +150 -112
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +334 -48
- package/dist/index.js +2494 -2201
- package/dist/index.js.map +1 -1
- package/dist/test-utils.d.ts +5 -1
- package/dist/test-utils.js +10 -4
- package/dist/test-utils.js.map +1 -1
- package/package.json +7 -7
- package/src/components/ResourceTagsInline.tsx +94 -11
- package/src/components/StatusDisplay.css +36 -0
- package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
- package/src/components/annotation/AnnotateToolbar.tsx +53 -28
- package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
- package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
- package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
- package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
- package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +17 -4
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
- package/src/components/resource/AnnotateView.tsx +59 -38
- package/src/components/resource/BrowseView.tsx +172 -138
- package/src/components/resource/ResourceViewer.tsx +123 -130
- package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
- package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
- package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
- package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
- package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
- package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
- package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
- package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
- package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
- package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
- package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
- package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
- package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
- package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
- package/src/components/resource/browse-renderers.tsx +85 -0
- package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +12 -5
- package/src/components/resource/panels/AssistSection.tsx +4 -3
- package/src/components/resource/panels/CommentEntry.tsx +5 -4
- package/src/components/resource/panels/CommentsPanel.tsx +12 -5
- package/src/components/resource/panels/HighlightEntry.tsx +5 -4
- package/src/components/resource/panels/HighlightPanel.tsx +12 -5
- package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
- package/src/components/resource/panels/ReferencesPanel.tsx +15 -6
- package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
- package/src/components/resource/panels/TagEntry.tsx +5 -3
- package/src/components/resource/panels/TaggingPanel.tsx +11 -4
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -28
- package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
- package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
- package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +54 -24
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
- package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +78 -59
- package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +16 -7
- package/src/components/toolbar/Toolbar.css +14 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
- package/src/styles/features/resource-viewer.css +19 -0
- package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
- package/dist/chunk-O2MD7TGE.js.map +0 -1
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState,
|
|
3
|
+
import React, { useState, 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
|
-
import { BrowseView } from './BrowseView';
|
|
6
|
+
import { BrowseView, type ReferenceHover } from './BrowseView';
|
|
7
7
|
import { PopupContainer } from '../annotation-popups/SharedPopupElements';
|
|
8
8
|
import { JsonLdView } from '../annotation-popups/JsonLdView';
|
|
9
|
-
import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components } from '@semiont/core';
|
|
9
|
+
import type { Annotation, AnnotationId, ResourceDescriptor as SemiontResource, components, EventMap, AnchorRect } from '@semiont/core';
|
|
10
10
|
import { getExactText, getTargetSelector, isHighlight, isAssessment, isReference, isComment, isTag, getBodySource } from '@semiont/core';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { useObservable } from '../../hooks/useObservable';
|
|
14
|
-
import { useObservableExternalNavigation } from '../../hooks/useObservableBrowse';
|
|
11
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
12
|
+
import { useSessionEventSubscriptions } from '../../hooks/useSessionEventSubscriptions';
|
|
15
13
|
import { ANNOTATORS } from '../../lib/annotation-registry';
|
|
16
14
|
import type { AnnotationsCollection } from '../../types/annotation-props';
|
|
17
|
-
import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForSelectorType } from '../../lib/media-shapes';
|
|
18
15
|
|
|
19
16
|
/**
|
|
20
17
|
* ResourceViewer - Display and interact with resource content and annotations
|
|
@@ -24,9 +21,11 @@ import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForS
|
|
|
24
21
|
* - Automatically invalidates cache when annotations change
|
|
25
22
|
* - No manual refetch needed - events handle cache invalidation
|
|
26
23
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* Bring-your-own-session: the `session` (SemiontSession) and the host's
|
|
25
|
+
* navigation / panel callbacks come in as props — no SemiontProvider required.
|
|
26
|
+
* Every event it subscribes to is session-scoped (mark:*, browse:click) and
|
|
27
|
+
* reaches it via `session.subscribe`. Translations fall back to built-in English
|
|
28
|
+
* when no TranslationProvider is mounted; caching flows through `session.client.browse.*`.
|
|
30
29
|
*
|
|
31
30
|
* Event flow:
|
|
32
31
|
* make-meaning → EventLog → SSE → EventBus → ResourceViewer → Cache invalidation
|
|
@@ -37,42 +36,86 @@ import { getSelectorType, getSelectedShapeForSelectorType, saveSelectedShapeForS
|
|
|
37
36
|
interface Props {
|
|
38
37
|
resource: SemiontResource & { content: string };
|
|
39
38
|
annotations: AnnotationsCollection;
|
|
39
|
+
/** Session for the shown resource — its client mutates/invalidates, its bus feeds annotation events. */
|
|
40
|
+
session: SemiontSession | null;
|
|
41
|
+
/** Host-owned navigation: a resolved reference was followed. Omit for a view with no follow behavior. */
|
|
42
|
+
onOpenResource?: (resourceId: string) => void;
|
|
43
|
+
/** Host-owned panel control (annotation clicks open a panel). Omit for hosts without side panels. */
|
|
44
|
+
onOpenPanel?: (event: EventMap['panel:open']) => void;
|
|
45
|
+
/** A content link in the rendered content was clicked — the viewer preventDefaults and delegates; it never navigates on its own. */
|
|
46
|
+
onLinkClick?: (link: { href: string; event: React.MouseEvent }) => void;
|
|
47
|
+
/** A resolved reference span is hovered (after dwell + referent descriptor resolve); `null` on leave. Host renders its own preview. */
|
|
48
|
+
onReferenceHover?: (hover: ReferenceHover | null) => void;
|
|
49
|
+
/** Inline display variant: auto-height, no inner scroll, no pane chrome (browse path). Default: fill-the-pane. */
|
|
50
|
+
inline?: boolean;
|
|
51
|
+
/** Recently-created annotation ids to sparkle (threaded to the browse/annotate subtree). */
|
|
52
|
+
newAnnotationIds?: Set<string>;
|
|
40
53
|
generatingReferenceId?: string | null;
|
|
41
54
|
showLineNumbers?: boolean;
|
|
42
55
|
hoverDelayMs?: number;
|
|
43
56
|
hoveredAnnotationId?: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Toolbar preferences as controlled props (TOOLBAR-PREFS-AS-PROPS). Supply a value
|
|
59
|
+
* to own that pref: the instance renders it and reports intents via the callback —
|
|
60
|
+
* it never self-mutates, never persists, never listens to other instances. Omit for
|
|
61
|
+
* a plain uncontrolled default (false / 'detail' / 'linking' / 'rectangle').
|
|
62
|
+
* Hosts wanting the shared+persisted Browser UX compose `useToolbarPrefs()`.
|
|
63
|
+
*/
|
|
64
|
+
annotateMode?: boolean;
|
|
65
|
+
onAnnotateModeChange?: (mode: boolean) => void;
|
|
66
|
+
clickAction?: ClickAction;
|
|
67
|
+
onClickActionChange?: (action: ClickAction) => void;
|
|
68
|
+
selectionMotivation?: SelectionMotivation | null;
|
|
69
|
+
onSelectionMotivationChange?: (motivation: SelectionMotivation | null) => void;
|
|
70
|
+
shape?: ShapeType;
|
|
71
|
+
onShapeChange?: (shape: ShapeType) => void;
|
|
72
|
+
/**
|
|
73
|
+
* Render the built-in AnnotateToolbar (default true — today's behavior,
|
|
74
|
+
* byte-identical). `false` → no bar at any internal site (browse + every
|
|
75
|
+
* annotate render mode), while every seam stays live: annotate-mode selection
|
|
76
|
+
* capture, image/pdf drawing, `mark.request` emission, and the controlled
|
|
77
|
+
* props. Tier-2 hosts compose their own controls from the controlled props;
|
|
78
|
+
* this makes that opt-out a contract instead of a CSS override.
|
|
79
|
+
*/
|
|
80
|
+
showToolbar?: boolean;
|
|
44
81
|
}
|
|
45
82
|
|
|
46
83
|
/**
|
|
47
84
|
* @emits mark:delete - User requested to delete annotation. Payload: { annotationId: string }
|
|
48
85
|
* @emits panel:open - Request to open panel with annotation. Payload: { panel: string, scrollToAnnotationId?: string, motivation?: Motivation }
|
|
49
86
|
*
|
|
50
|
-
* @subscribes mark:mode-toggled - Toggles between browse and annotate mode. Payload: { mode: 'browse' | 'annotate' }
|
|
51
87
|
* @subscribes mark:added - New annotation was added. Payload: { annotation: Annotation }
|
|
52
88
|
* @subscribes mark:removed - Annotation was removed. Payload: { annotationId: string }
|
|
53
89
|
* @subscribes mark:body-updated - Annotation was updated. Payload: { annotation: Annotation }
|
|
54
|
-
* @subscribes mark:selection-changed - Text selection tool changed. Payload: { selection: boolean }
|
|
55
|
-
* @subscribes mark:click-changed - Click annotation tool changed. Payload: { click: 'detail' | 'scroll' | null }
|
|
56
|
-
* @subscribes mark:shape-changed - Drawing shape changed. Payload: { shape: string }
|
|
57
90
|
* @subscribes browse:click - User clicked on annotation. Payload: { annotationId: string }
|
|
58
91
|
*/
|
|
59
92
|
export function ResourceViewer({
|
|
60
93
|
resource,
|
|
61
94
|
annotations,
|
|
95
|
+
session,
|
|
96
|
+
onOpenResource,
|
|
97
|
+
onOpenPanel,
|
|
98
|
+
onLinkClick,
|
|
99
|
+
onReferenceHover,
|
|
100
|
+
inline = false,
|
|
101
|
+
newAnnotationIds,
|
|
62
102
|
generatingReferenceId,
|
|
63
103
|
showLineNumbers = false,
|
|
64
104
|
hoverDelayMs,
|
|
65
|
-
hoveredAnnotationId: hoveredAnnotationIdProp
|
|
105
|
+
hoveredAnnotationId: hoveredAnnotationIdProp,
|
|
106
|
+
annotateMode: annotateModeProp,
|
|
107
|
+
onAnnotateModeChange,
|
|
108
|
+
clickAction: clickActionProp,
|
|
109
|
+
onClickActionChange,
|
|
110
|
+
selectionMotivation: selectionMotivationProp,
|
|
111
|
+
onSelectionMotivationChange,
|
|
112
|
+
shape: shapeProp,
|
|
113
|
+
onShapeChange,
|
|
114
|
+
showToolbar = true,
|
|
66
115
|
}: Props) {
|
|
67
116
|
const t = useTranslations('ResourceViewer');
|
|
68
117
|
const documentViewerRef = useRef<HTMLDivElement>(null);
|
|
69
118
|
|
|
70
|
-
const browser = useSemiont();
|
|
71
|
-
const session = useObservable(browser.activeSession$);
|
|
72
|
-
|
|
73
|
-
// Get observable navigation for event-driven routing
|
|
74
|
-
const navigate = useObservableExternalNavigation();
|
|
75
|
-
|
|
76
119
|
const { highlights, references, assessments, comments, tags } = annotations;
|
|
77
120
|
|
|
78
121
|
// Extract resource URI once at the top - required for all annotation operations
|
|
@@ -93,25 +136,16 @@ export function ResourceViewer({
|
|
|
93
136
|
|
|
94
137
|
const mimeType = getMimeType();
|
|
95
138
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (typeof window !== 'undefined') {
|
|
107
|
-
localStorage.setItem('annotateMode', annotateMode.toString());
|
|
108
|
-
}
|
|
109
|
-
}, [annotateMode]);
|
|
110
|
-
|
|
111
|
-
// Event handlers (extracted to avoid inline arrow functions)
|
|
112
|
-
const handleViewModeToggle = useCallback(() => {
|
|
113
|
-
setAnnotateMode(prev => !prev);
|
|
114
|
-
}, []);
|
|
139
|
+
// Toolbar preferences (TOOLBAR-PREFS-AS-PROPS): controlled (prop supplied) or a
|
|
140
|
+
// plain uncontrolled default. Preferences are state, not events — no localStorage
|
|
141
|
+
// and no preference bus channels here; hosts wanting the shared+persisted Browser
|
|
142
|
+
// UX compose useToolbarPrefs() (the policy layer) and pass the values down.
|
|
143
|
+
const [internalAnnotateMode, setInternalAnnotateMode] = useState(false);
|
|
144
|
+
const annotateMode = annotateModeProp ?? internalAnnotateMode;
|
|
145
|
+
const changeAnnotateMode = useCallback((mode: boolean) => {
|
|
146
|
+
if (annotateModeProp === undefined) setInternalAnnotateMode(mode);
|
|
147
|
+
onAnnotateModeChange?.(mode);
|
|
148
|
+
}, [annotateModeProp, onAnnotateModeChange]);
|
|
115
149
|
|
|
116
150
|
// Determine active view based on annotate mode
|
|
117
151
|
const activeView = annotateMode ? 'annotate' : 'browse';
|
|
@@ -130,74 +164,27 @@ export function ResourceViewer({
|
|
|
130
164
|
semiont?.browse.invalidateAnnotationList(rUri);
|
|
131
165
|
}, [semiont, rUri]);
|
|
132
166
|
|
|
133
|
-
//
|
|
134
|
-
const [
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// Get selector type for current media type
|
|
156
|
-
const selectorType = getSelectorType(mimeType);
|
|
157
|
-
|
|
158
|
-
// Get selected shape for this selector type
|
|
159
|
-
const [selectedShape, setSelectedShape] = useState<ShapeType>(() => {
|
|
160
|
-
return getSelectedShapeForSelectorType(selectorType);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Toolbar event handlers (extracted to avoid inline arrow functions)
|
|
164
|
-
const handleToolbarSelectionChanged = useCallback(({ motivation }: { motivation: string | null }) => {
|
|
165
|
-
setSelectedMotivation(motivation as SelectionMotivation | null);
|
|
166
|
-
}, []);
|
|
167
|
-
|
|
168
|
-
const handleToolbarClickChanged = useCallback(({ action }: { action: string }) => {
|
|
169
|
-
setSelectedClick(action as ClickAction);
|
|
170
|
-
}, []);
|
|
171
|
-
|
|
172
|
-
const handleToolbarShapeChanged = useCallback(({ shape }: { shape: string }) => {
|
|
173
|
-
setSelectedShape(shape as ShapeType);
|
|
174
|
-
}, []);
|
|
175
|
-
|
|
176
|
-
// Persist toolbar state to localStorage
|
|
177
|
-
useEffect(() => {
|
|
178
|
-
if (selectedMotivation === null) {
|
|
179
|
-
localStorage.setItem('semiont-toolbar-selection', 'null');
|
|
180
|
-
} else {
|
|
181
|
-
localStorage.setItem('semiont-toolbar-selection', selectedMotivation);
|
|
182
|
-
}
|
|
183
|
-
}, [selectedMotivation]);
|
|
184
|
-
|
|
185
|
-
useEffect(() => {
|
|
186
|
-
localStorage.setItem('semiont-toolbar-click', selectedClick);
|
|
187
|
-
}, [selectedClick]);
|
|
188
|
-
|
|
189
|
-
// Persist shape selection per selector type
|
|
190
|
-
useEffect(() => {
|
|
191
|
-
saveSelectedShapeForSelectorType(selectorType, selectedShape);
|
|
192
|
-
}, [selectorType, selectedShape]);
|
|
193
|
-
|
|
194
|
-
// Update selected shape when selector type changes (e.g., switching between PDF and image)
|
|
195
|
-
useEffect(() => {
|
|
196
|
-
const shapeForType = getSelectedShapeForSelectorType(selectorType);
|
|
197
|
-
if (shapeForType !== selectedShape) {
|
|
198
|
-
setSelectedShape(shapeForType);
|
|
199
|
-
}
|
|
200
|
-
}, [selectorType]);
|
|
167
|
+
// Remaining toolbar preferences — same controlled/uncontrolled split as mode.
|
|
168
|
+
const [internalSelectionMotivation, setInternalSelectionMotivation] = useState<SelectionMotivation | null>('linking');
|
|
169
|
+
const selectedMotivation = selectionMotivationProp !== undefined ? selectionMotivationProp : internalSelectionMotivation;
|
|
170
|
+
const changeSelectionMotivation = useCallback((motivation: SelectionMotivation | null) => {
|
|
171
|
+
if (selectionMotivationProp === undefined) setInternalSelectionMotivation(motivation);
|
|
172
|
+
onSelectionMotivationChange?.(motivation);
|
|
173
|
+
}, [selectionMotivationProp, onSelectionMotivationChange]);
|
|
174
|
+
|
|
175
|
+
const [internalClickAction, setInternalClickAction] = useState<ClickAction>('detail');
|
|
176
|
+
const selectedClick = clickActionProp ?? internalClickAction;
|
|
177
|
+
const changeClickAction = useCallback((action: ClickAction) => {
|
|
178
|
+
if (clickActionProp === undefined) setInternalClickAction(action);
|
|
179
|
+
onClickActionChange?.(action);
|
|
180
|
+
}, [clickActionProp, onClickActionChange]);
|
|
181
|
+
|
|
182
|
+
const [internalShape, setInternalShape] = useState<ShapeType>('rectangle');
|
|
183
|
+
const selectedShape = shapeProp ?? internalShape;
|
|
184
|
+
const changeShape = useCallback((shape: ShapeType) => {
|
|
185
|
+
if (shapeProp === undefined) setInternalShape(shape);
|
|
186
|
+
onShapeChange?.(shape);
|
|
187
|
+
}, [shapeProp, onShapeChange]);
|
|
201
188
|
|
|
202
189
|
// JSON-LD view state
|
|
203
190
|
const [showJsonLdView, setShowJsonLdView] = useState(false);
|
|
@@ -267,8 +254,8 @@ export function ResourceViewer({
|
|
|
267
254
|
if (selectedClick === 'follow' && isReference(annotation)) {
|
|
268
255
|
const bodySource = getBodySource(annotation.body);
|
|
269
256
|
if (bodySource) {
|
|
270
|
-
// bodySource is already a bare resource ID
|
|
271
|
-
|
|
257
|
+
// bodySource is already a bare resource ID — the host owns navigation
|
|
258
|
+
onOpenResource?.(bodySource);
|
|
272
259
|
}
|
|
273
260
|
return;
|
|
274
261
|
}
|
|
@@ -293,12 +280,13 @@ export function ResourceViewer({
|
|
|
293
280
|
setDeleteConfirmation({ annotation, position });
|
|
294
281
|
return;
|
|
295
282
|
}
|
|
296
|
-
}, [annotateMode, selectedClick, focusAnnotation]);
|
|
283
|
+
}, [annotateMode, selectedClick, focusAnnotation, onOpenResource]);
|
|
297
284
|
|
|
298
285
|
// Annotation click coordinator - handles panel opening and scrolling
|
|
299
|
-
const handleAnnotationClickEvent = useCallback(({ annotationId, motivation }: {
|
|
286
|
+
const handleAnnotationClickEvent = useCallback(({ annotationId, motivation, anchorRect }: {
|
|
300
287
|
annotationId: string;
|
|
301
288
|
motivation: components['schemas']['Motivation'];
|
|
289
|
+
anchorRect?: AnchorRect;
|
|
302
290
|
}) => {
|
|
303
291
|
// Find the annotation metadata
|
|
304
292
|
const metadata = Object.values(ANNOTATORS).find(a => a.matchesAnnotation({ motivation } as Annotation));
|
|
@@ -323,27 +311,20 @@ export function ResourceViewer({
|
|
|
323
311
|
return;
|
|
324
312
|
}
|
|
325
313
|
|
|
326
|
-
// All annotations open the unified annotations panel
|
|
327
|
-
// The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel
|
|
328
|
-
|
|
329
|
-
|
|
314
|
+
// All annotations open the unified annotations panel — the host owns the panel.
|
|
315
|
+
// The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel.
|
|
316
|
+
// View geometry passes through untouched: the emitter owned it, the host anchors with it.
|
|
317
|
+
onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation, ...(anchorRect ? { anchorRect } : {}) });
|
|
318
|
+
}, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
|
|
330
319
|
|
|
331
320
|
// Event subscriptions - Combined into single useEventSubscriptions call to prevent hook ordering issues
|
|
332
321
|
// IMPORTANT: All event subscriptions MUST be in a single call to maintain consistent hook order between renders
|
|
333
|
-
|
|
334
|
-
// View mode
|
|
335
|
-
'mark:mode-toggled': handleViewModeToggle,
|
|
336
|
-
|
|
322
|
+
useSessionEventSubscriptions(session, {
|
|
337
323
|
// Annotation cache invalidation
|
|
338
324
|
'mark:added': handleAnnotateAdded,
|
|
339
325
|
'mark:removed': handleAnnotateRemoved,
|
|
340
326
|
'mark:body-updated': handleAnnotateBodyUpdated,
|
|
341
327
|
|
|
342
|
-
// Toolbar state
|
|
343
|
-
'mark:selection-changed': handleToolbarSelectionChanged,
|
|
344
|
-
'mark:click-changed': handleToolbarClickChanged,
|
|
345
|
-
'mark:shape-changed': handleToolbarShapeChanged,
|
|
346
|
-
|
|
347
328
|
// Annotation clicks
|
|
348
329
|
'browse:click': handleAnnotationClickEvent,
|
|
349
330
|
});
|
|
@@ -370,7 +351,7 @@ export function ResourceViewer({
|
|
|
370
351
|
}, [references]);
|
|
371
352
|
|
|
372
353
|
return (
|
|
373
|
-
<div ref={documentViewerRef} className=
|
|
354
|
+
<div ref={documentViewerRef} className={`semiont-resource-viewer${inline ? ' semiont-resource-viewer--inline' : ''}`}>
|
|
374
355
|
{/* Content */}
|
|
375
356
|
{activeView === 'annotate' ? (
|
|
376
357
|
<AnnotateView
|
|
@@ -380,9 +361,9 @@ export function ResourceViewer({
|
|
|
380
361
|
annotations={annotationsCollection}
|
|
381
362
|
uiState={uiState}
|
|
382
363
|
onUIStateChange={(updates) => {
|
|
383
|
-
if ('selectedMotivation' in updates)
|
|
384
|
-
if ('selectedClick' in updates)
|
|
385
|
-
if ('selectedShape' in updates)
|
|
364
|
+
if ('selectedMotivation' in updates) changeSelectionMotivation(updates.selectedMotivation ?? null);
|
|
365
|
+
if ('selectedClick' in updates) changeClickAction(updates.selectedClick!);
|
|
366
|
+
if ('selectedShape' in updates) changeShape(updates.selectedShape!);
|
|
386
367
|
}}
|
|
387
368
|
enableWidgets={true}
|
|
388
369
|
getTargetResourceName={getTargetResourceName}
|
|
@@ -390,6 +371,10 @@ export function ResourceViewer({
|
|
|
390
371
|
showLineNumbers={showLineNumbers}
|
|
391
372
|
hoverDelayMs={hoverDelayMs}
|
|
392
373
|
annotateMode={annotateMode}
|
|
374
|
+
onModeChange={changeAnnotateMode}
|
|
375
|
+
showToolbar={showToolbar}
|
|
376
|
+
session={session}
|
|
377
|
+
newAnnotationIds={newAnnotationIds}
|
|
393
378
|
/>
|
|
394
379
|
) : (
|
|
395
380
|
<BrowseView
|
|
@@ -400,6 +385,14 @@ export function ResourceViewer({
|
|
|
400
385
|
selectedClick={selectedClick}
|
|
401
386
|
hoverDelayMs={hoverDelayMs}
|
|
402
387
|
annotateMode={annotateMode}
|
|
388
|
+
onModeChange={changeAnnotateMode}
|
|
389
|
+
onClickActionChange={changeClickAction}
|
|
390
|
+
showToolbar={showToolbar}
|
|
391
|
+
session={session}
|
|
392
|
+
newAnnotationIds={newAnnotationIds}
|
|
393
|
+
onLinkClick={onLinkClick}
|
|
394
|
+
onReferenceHover={onReferenceHover}
|
|
395
|
+
inline={inline}
|
|
403
396
|
/>
|
|
404
397
|
)}
|
|
405
398
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-RESOURCE-VIEWER step 1b — AnnotateView + AnnotateToolbar provider-free.
|
|
3
|
+
*
|
|
4
|
+
* AnnotateView takes `session` + `newAnnotationIds` as props; the REAL
|
|
5
|
+
* AnnotateToolbar (not mocked — its decoupling is the crux of 1b) takes `session`
|
|
6
|
+
* as a prop. CodeMirrorRenderer is mocked (heavy; already prop-based).
|
|
7
|
+
*
|
|
8
|
+
* Started RED (tsc: no `session` prop) and GREEN once step 1b lands.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
11
|
+
import { render, screen } from '@testing-library/react';
|
|
12
|
+
import '@testing-library/jest-dom';
|
|
13
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
14
|
+
import type { AnnotationUIState } from '../../../types/annotation-props';
|
|
15
|
+
import { AnnotateView } from '../AnnotateView';
|
|
16
|
+
|
|
17
|
+
vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => <div>cm-mock</div> }));
|
|
18
|
+
|
|
19
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
20
|
+
const uiState: AnnotationUIState = {
|
|
21
|
+
selectedMotivation: 'linking',
|
|
22
|
+
selectedClick: 'detail',
|
|
23
|
+
selectedShape: 'rectangle',
|
|
24
|
+
hoveredAnnotationId: null,
|
|
25
|
+
scrollToAnnotationId: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function fakeSession(): SemiontSession {
|
|
29
|
+
return {
|
|
30
|
+
client: {
|
|
31
|
+
mark: {
|
|
32
|
+
changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
|
|
33
|
+
toggleMode: vi.fn(), request: vi.fn(),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
subscribe: () => () => {},
|
|
37
|
+
} as unknown as SemiontSession;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('AnnotateView — embeddable (session prop, no providers)', () => {
|
|
41
|
+
it('renders (incl. the real AnnotateToolbar) fed only a session, no providers', () => {
|
|
42
|
+
render(
|
|
43
|
+
<AnnotateView resourceUri="res-1"
|
|
44
|
+
content="hello"
|
|
45
|
+
mimeType="text/plain"
|
|
46
|
+
annotations={emptyAnnotations}
|
|
47
|
+
uiState={uiState}
|
|
48
|
+
annotateMode={true}
|
|
49
|
+
session={fakeSession()}
|
|
50
|
+
/>,
|
|
51
|
+
);
|
|
52
|
+
// The tree (real AnnotateToolbar + mocked CodeMirror) rendered without a provider.
|
|
53
|
+
expect(screen.getByText('cm-mock')).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-RESOURCE-VIEWER step 1a — BrowseView renders provider-free.
|
|
3
|
+
*
|
|
4
|
+
* BrowseView takes its `session` + `newAnnotationIds` as props (not
|
|
5
|
+
* `useSemiont()` / `useResourceAnnotations()`), and subscribes to session-scoped
|
|
6
|
+
* beckon events via `session.subscribe`. `AnnotateToolbar` is mocked here — its
|
|
7
|
+
* own provider decoupling is step 1b — so this spec isolates BrowseView's body.
|
|
8
|
+
*
|
|
9
|
+
* Started RED (tsc: no `session` prop) and GREEN once step 1a lands.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
12
|
+
import { render, screen } from '@testing-library/react';
|
|
13
|
+
import '@testing-library/jest-dom';
|
|
14
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
15
|
+
import { BrowseView } from '../BrowseView';
|
|
16
|
+
|
|
17
|
+
// AnnotateToolbar still calls useSemiont() (step 1b) — stub it out so this spec
|
|
18
|
+
// exercises only BrowseView's own provider-freedom.
|
|
19
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
20
|
+
vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
|
|
21
|
+
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
|
22
|
+
|
|
23
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
24
|
+
|
|
25
|
+
function fakeSession(): SemiontSession {
|
|
26
|
+
return {
|
|
27
|
+
client: {
|
|
28
|
+
browse: { click: vi.fn() },
|
|
29
|
+
beckon: { hover: vi.fn() },
|
|
30
|
+
},
|
|
31
|
+
subscribe: () => () => {},
|
|
32
|
+
} as unknown as SemiontSession;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('BrowseView — embeddable (session prop, no providers)', () => {
|
|
36
|
+
it('renders content fed only a session, with NO providers mounted', () => {
|
|
37
|
+
render(
|
|
38
|
+
<BrowseView
|
|
39
|
+
content="Embeddable browse content."
|
|
40
|
+
mimeType="text/plain"
|
|
41
|
+
resourceUri="res-1"
|
|
42
|
+
annotations={emptyAnnotations}
|
|
43
|
+
annotateMode={false}
|
|
44
|
+
session={fakeSession()}
|
|
45
|
+
/>,
|
|
46
|
+
);
|
|
47
|
+
expect(screen.getByText('Embeddable browse content.')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-VIEWER-COMPLETION Phase 2 — content-link delegation.
|
|
3
|
+
*
|
|
4
|
+
* A content link (`<a href>` rendered inside the resource content) must be
|
|
5
|
+
* intercepted: the viewer `preventDefault`s (never navigates on its own — an
|
|
6
|
+
* embedded/Electron security requirement) and delegates to `onLinkClick({href,
|
|
7
|
+
* event})`. With no handler, the click is still cancelled (nothing happens).
|
|
8
|
+
*
|
|
9
|
+
* Started RED (no `onLinkClick` prop) and GREEN once Phase 2 lands. Real
|
|
10
|
+
* react-markdown (not the mock) so the `<a>` is a genuine rendered content link.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
13
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
14
|
+
import '@testing-library/jest-dom';
|
|
15
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
16
|
+
import { BrowseView } from '../BrowseView';
|
|
17
|
+
|
|
18
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
19
|
+
|
|
20
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
21
|
+
|
|
22
|
+
function fakeSession(): SemiontSession {
|
|
23
|
+
return {
|
|
24
|
+
client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
|
|
25
|
+
subscribe: () => () => {},
|
|
26
|
+
} as unknown as SemiontSession;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MD = 'see [the link](https://example.test/x) here';
|
|
30
|
+
|
|
31
|
+
describe('BrowseView — content link delegation (Phase 2)', () => {
|
|
32
|
+
it('intercepts a content <a> click: preventDefault + onLinkClick(href)', () => {
|
|
33
|
+
const onLinkClick = vi.fn();
|
|
34
|
+
render(
|
|
35
|
+
<BrowseView
|
|
36
|
+
content={MD} mimeType="text/markdown" resourceUri="res-1"
|
|
37
|
+
annotations={emptyAnnotations} annotateMode={false}
|
|
38
|
+
session={fakeSession()} onLinkClick={onLinkClick}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
const anchor = screen.getByText('the link').closest('a')!;
|
|
42
|
+
// fireEvent.click returns false when the event was cancelled (preventDefault).
|
|
43
|
+
const notCancelled = fireEvent.click(anchor);
|
|
44
|
+
expect(notCancelled).toBe(false); // navigation blocked
|
|
45
|
+
expect(onLinkClick).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(onLinkClick.mock.calls[0]![0]).toMatchObject({ href: 'https://example.test/x' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('blocks navigation even with no handler (never navigates on its own)', () => {
|
|
50
|
+
render(
|
|
51
|
+
<BrowseView
|
|
52
|
+
content={MD} mimeType="text/markdown" resourceUri="res-1"
|
|
53
|
+
annotations={emptyAnnotations} annotateMode={false}
|
|
54
|
+
session={fakeSession()}
|
|
55
|
+
/>,
|
|
56
|
+
);
|
|
57
|
+
const anchor = screen.getByText('the link').closest('a')!;
|
|
58
|
+
expect(fireEvent.click(anchor)).toBe(false); // still cancelled — nothing happens, but no navigation
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BUG: browse-view-overlay-misses-async-content — the overlay effect must be
|
|
3
|
+
* keyed on everything it reads: the rendered content DOM AND the annotations.
|
|
4
|
+
*
|
|
5
|
+
* When annotations arrive BEFORE content (any host loading content async —
|
|
6
|
+
* useResourceContent), the overlay resolved ranges against the empty document
|
|
7
|
+
* and never re-ran when content landed: rendered text, ZERO spans, healed only
|
|
8
|
+
* by a remount. When content arrives first (sync-content hosts), it worked by
|
|
9
|
+
* accident of ordering. Both orders are pinned here, plus shrink-to-zero.
|
|
10
|
+
*
|
|
11
|
+
* REAL overlay pipeline (no annotation-overlay mocks) — react-markdown is
|
|
12
|
+
* mocked to identity, so the source→rendered offset map is 1:1 and spans
|
|
13
|
+
* paint in jsdom via the actual Range/surroundContents path.
|
|
14
|
+
*
|
|
15
|
+
* Started RED (async order → 0 spans) → GREEN with the single keyed effect.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
18
|
+
import { render } from '@testing-library/react';
|
|
19
|
+
import '@testing-library/jest-dom';
|
|
20
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
21
|
+
import type { Annotation, AnnotationId } from '@semiont/core';
|
|
22
|
+
import { BrowseView } from '../BrowseView';
|
|
23
|
+
|
|
24
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
25
|
+
vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
|
|
26
|
+
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
|
27
|
+
|
|
28
|
+
const CONTENT = 'hello world selectme end';
|
|
29
|
+
|
|
30
|
+
function fakeSession(): SemiontSession {
|
|
31
|
+
return {
|
|
32
|
+
client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
|
|
33
|
+
subscribe: () => () => {},
|
|
34
|
+
} as unknown as SemiontSession;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A resolved reference over "world" (offsets 6..11) — real W3C shape. */
|
|
38
|
+
function referenceOnWorld(): Annotation {
|
|
39
|
+
return {
|
|
40
|
+
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
|
41
|
+
id: 'ref-world' as AnnotationId,
|
|
42
|
+
type: 'Annotation',
|
|
43
|
+
motivation: 'linking',
|
|
44
|
+
creator: { '@type': 'Person', name: 'user@example.com' },
|
|
45
|
+
created: '2026-07-10T00:00:00Z',
|
|
46
|
+
target: {
|
|
47
|
+
source: 'res-1',
|
|
48
|
+
selector: { type: 'TextPositionSelector', start: 6, end: 11 },
|
|
49
|
+
},
|
|
50
|
+
} as unknown as Annotation;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
54
|
+
const withReference = { ...emptyAnnotations, references: [referenceOnWorld()] };
|
|
55
|
+
|
|
56
|
+
const baseProps = {
|
|
57
|
+
mimeType: 'text/plain',
|
|
58
|
+
resourceUri: 'res-1',
|
|
59
|
+
annotateMode: false,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const spans = (c: HTMLElement) => c.querySelectorAll('[data-annotation-id]');
|
|
63
|
+
|
|
64
|
+
describe('BrowseView — overlay vs async content arrival', () => {
|
|
65
|
+
beforeEach(() => vi.clearAllMocks());
|
|
66
|
+
|
|
67
|
+
it('paints spans when content arrives AFTER annotations (the async-content bug)', () => {
|
|
68
|
+
const session = fakeSession();
|
|
69
|
+
const { container, rerender } = render(
|
|
70
|
+
<BrowseView {...baseProps} content="" annotations={withReference} session={session} />,
|
|
71
|
+
);
|
|
72
|
+
expect(spans(container)).toHaveLength(0); // nothing to anchor on yet
|
|
73
|
+
|
|
74
|
+
// Content lands (useResourceContent resolved) — annotations unchanged.
|
|
75
|
+
rerender(
|
|
76
|
+
<BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(spans(container)).toHaveLength(1);
|
|
80
|
+
expect(spans(container)[0]!.textContent).toBe('world');
|
|
81
|
+
expect(spans(container)[0]!.getAttribute('data-annotation-type')).toBe('reference');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('paints spans when annotations arrive AFTER content (the always-working order — pinned)', () => {
|
|
85
|
+
const session = fakeSession();
|
|
86
|
+
const { container, rerender } = render(
|
|
87
|
+
<BrowseView {...baseProps} content={CONTENT} annotations={emptyAnnotations} session={session} />,
|
|
88
|
+
);
|
|
89
|
+
expect(spans(container)).toHaveLength(0);
|
|
90
|
+
|
|
91
|
+
rerender(
|
|
92
|
+
<BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(spans(container)).toHaveLength(1);
|
|
96
|
+
expect(spans(container)[0]!.textContent).toBe('world');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('clears spans when annotations shrink to zero', () => {
|
|
100
|
+
const session = fakeSession();
|
|
101
|
+
const { container, rerender } = render(
|
|
102
|
+
<BrowseView {...baseProps} content={CONTENT} annotations={withReference} session={session} />,
|
|
103
|
+
);
|
|
104
|
+
expect(spans(container)).toHaveLength(1);
|
|
105
|
+
|
|
106
|
+
rerender(
|
|
107
|
+
<BrowseView {...baseProps} content={CONTENT} annotations={emptyAnnotations} session={session} />,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
expect(spans(container)).toHaveLength(0);
|
|
111
|
+
expect(container.textContent).toContain(CONTENT); // text restored intact
|
|
112
|
+
});
|
|
113
|
+
});
|