@semiont/react-ui 0.5.9 → 0.5.11
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-U4EVBZEV.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +5 -2
- package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
- package/dist/{ar-WA47UUWA.js → ar-ZHKO4XDR.js} +45 -4
- package/dist/ar-ZHKO4XDR.js.map +1 -0
- package/dist/{bn-5ANDRIU6.js → bn-3BUDKAK5.js} +45 -4
- package/dist/bn-3BUDKAK5.js.map +1 -0
- package/dist/{chunk-YBGN4ACY.js → chunk-O23TLLXW.js} +1 -6
- package/dist/{chunk-WHUGODTB.js → chunk-PFUXQPWJ.js} +127 -127
- package/dist/{chunk-WHUGODTB.js.map → chunk-PFUXQPWJ.js.map} +1 -1
- package/dist/{chunk-GERSK2DY.js → chunk-R52YAGHK.js} +42 -1
- package/dist/chunk-R52YAGHK.js.map +1 -0
- package/dist/{cs-3RU7F4JX.js → cs-2EFEUKTW.js} +45 -4
- package/dist/cs-2EFEUKTW.js.map +1 -0
- package/dist/{da-GSW5P5HG.js → da-SUD3Q44Q.js} +45 -4
- package/dist/da-SUD3Q44Q.js.map +1 -0
- package/dist/{de-JUAUYF4Z.js → de-6IC5QRX7.js} +45 -4
- package/dist/de-6IC5QRX7.js.map +1 -0
- package/dist/{el-JNLWCKEC.js → el-Q4NAXFRC.js} +45 -4
- package/dist/el-Q4NAXFRC.js.map +1 -0
- package/dist/{en-RBN2GUHF.js → en-P2L4X27N.js} +5 -3
- package/dist/{es-WPOX225H.js → es-YUKZBD4W.js} +45 -4
- package/dist/es-YUKZBD4W.js.map +1 -0
- package/dist/{fa-TAEP6N77.js → fa-OJ47LILE.js} +45 -4
- package/dist/fa-OJ47LILE.js.map +1 -0
- package/dist/{fi-ZVZHANSP.js → fi-QFFL5CUB.js} +45 -4
- package/dist/fi-QFFL5CUB.js.map +1 -0
- package/dist/{fr-VLZW7M4N.js → fr-BOQZZWP4.js} +45 -4
- package/dist/fr-BOQZZWP4.js.map +1 -0
- package/dist/{he-QFAFYA77.js → he-LXDY3TFA.js} +45 -4
- package/dist/he-LXDY3TFA.js.map +1 -0
- package/dist/{hi-AO3DQPCV.js → hi-HOJER77V.js} +45 -4
- package/dist/hi-HOJER77V.js.map +1 -0
- package/dist/{id-GTXF42WM.js → id-I5WQ6EWD.js} +45 -4
- package/dist/id-I5WQ6EWD.js.map +1 -0
- package/dist/index.css +150 -112
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +403 -45
- package/dist/index.js +4660 -18860
- package/dist/index.js.map +1 -1
- package/dist/integrations/css-modules-helper.js +1 -1
- package/dist/integrations/styled-components-theme.js +1 -1
- package/dist/{it-AS5GM232.js → it-UYH642IJ.js} +45 -4
- package/dist/it-UYH642IJ.js.map +1 -0
- package/dist/{ja-GZ4HLUOF.js → ja-JTVWAC4L.js} +45 -4
- package/dist/ja-JTVWAC4L.js.map +1 -0
- package/dist/{ko-A4XUXFGJ.js → ko-IHXFMOIZ.js} +45 -4
- package/dist/ko-IHXFMOIZ.js.map +1 -0
- package/dist/{ms-YBAO3S6K.js → ms-JH25VFLO.js} +45 -4
- package/dist/ms-JH25VFLO.js.map +1 -0
- package/dist/{nl-3TJGIIIU.js → nl-SURXW7IQ.js} +45 -4
- package/dist/nl-SURXW7IQ.js.map +1 -0
- package/dist/{no-4AXIQPRQ.js → no-HXIKQFDZ.js} +45 -4
- package/dist/no-HXIKQFDZ.js.map +1 -0
- package/dist/{pl-5GP6GKXO.js → pl-7A4KU6XK.js} +45 -4
- package/dist/pl-7A4KU6XK.js.map +1 -0
- package/dist/{pt-26Y6JFG5.js → pt-6MGYP7Q5.js} +45 -4
- package/dist/pt-6MGYP7Q5.js.map +1 -0
- package/dist/{ro-C7UXFRWJ.js → ro-IROCHNIA.js} +45 -4
- package/dist/ro-IROCHNIA.js.map +1 -0
- package/dist/{sv-44DVD76T.js → sv-WDJD75CV.js} +45 -4
- package/dist/sv-WDJD75CV.js.map +1 -0
- package/dist/test-utils.js +9 -4
- package/dist/test-utils.js.map +1 -1
- package/dist/{th-GIQRTBOY.js → th-RIO2E6B3.js} +45 -4
- package/dist/th-RIO2E6B3.js.map +1 -0
- package/dist/{tr-WMQWO4D6.js → tr-TZ7ERCIN.js} +45 -4
- package/dist/tr-TZ7ERCIN.js.map +1 -0
- package/dist/{uk-I7ML6RGG.js → uk-ZFWDKLUE.js} +45 -4
- package/dist/uk-ZFWDKLUE.js.map +1 -0
- package/dist/{vi-FGWECASQ.js → vi-UIOSP2JG.js} +45 -4
- package/dist/vi-UIOSP2JG.js.map +1 -0
- package/dist/{zh-L5FN73XC.js → zh-6R6YUY46.js} +45 -4
- package/dist/zh-6R6YUY46.js.map +1 -0
- package/package.json +18 -9
- package/src/components/AnnotateReferencesProgressWidget.tsx +35 -16
- package/src/components/ResourceTagsInline.tsx +94 -11
- package/src/components/StatusDisplay.css +36 -0
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +41 -91
- 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/SvgDrawingCanvas.tsx +5 -1
- package/src/components/modals/ConfigureGatherStep.tsx +102 -0
- package/src/components/modals/ContextSummary.tsx +15 -6
- package/src/components/modals/GatherContextStep.tsx +104 -59
- package/src/components/modals/ResourceGenerateModal.tsx +258 -0
- package/src/components/modals/__tests__/ConfigureGatherStep.test.tsx +82 -0
- package/src/components/modals/__tests__/GatherContextStep.resource.test.tsx +107 -0
- package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +197 -0
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
- package/src/components/resource/AnnotateView.tsx +59 -38
- package/src/components/resource/BrowseView.tsx +159 -133
- package/src/components/resource/ResourceViewer.tsx +119 -128
- 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 +35 -22
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -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.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 +63 -0
- package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
- package/src/components/resource/panels/CommentsPanel.tsx +4 -0
- package/src/components/resource/panels/HighlightPanel.tsx +4 -0
- package/src/components/resource/panels/ReferencesPanel.tsx +14 -0
- package/src/components/resource/panels/ResourceInfoPanel.tsx +22 -0
- package/src/components/resource/panels/TaggingPanel.tsx +4 -0
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +26 -0
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
- package/src/components/toolbar/Toolbar.css +14 -0
- package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +14 -0
- package/src/features/admin-exchange/state/exchange-state-unit.ts +4 -2
- package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +14 -0
- package/src/features/admin-security/state/admin-security-state-unit.ts +4 -2
- package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +12 -5
- package/src/features/admin-users/state/admin-users-state-unit.ts +4 -2
- package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +11 -0
- package/src/features/auth-welcome/state/welcome-state-unit.ts +1 -1
- package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +14 -0
- package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +4 -2
- package/src/features/resource-compose/components/ResourceComposePage.tsx +13 -7
- package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +13 -0
- package/src/features/resource-compose/state/compose-page-state-unit.ts +5 -2
- package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +15 -9
- package/src/features/resource-discovery/state/discover-state-unit.ts +5 -2
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +119 -17
- package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +11 -0
- package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +30 -0
- package/src/features/resource-viewer/state/resource-loader-state-unit.ts +1 -1
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +7 -16
- package/src/styles/features/resource-viewer.css +19 -0
- package/translations/ar.json +41 -2
- package/translations/bn.json +41 -2
- package/translations/cs.json +41 -2
- package/translations/da.json +41 -2
- package/translations/de.json +41 -2
- package/translations/el.json +41 -2
- package/translations/en.json +39 -0
- package/translations/es.json +41 -2
- package/translations/fa.json +41 -2
- package/translations/fi.json +41 -2
- package/translations/fr.json +41 -2
- package/translations/he.json +41 -2
- package/translations/hi.json +41 -2
- package/translations/id.json +41 -2
- package/translations/it.json +41 -2
- package/translations/ja.json +41 -2
- package/translations/ko.json +41 -2
- package/translations/ms.json +41 -2
- package/translations/nl.json +41 -2
- package/translations/no.json +41 -2
- package/translations/pl.json +41 -2
- package/translations/pt.json +41 -2
- package/translations/ro.json +41 -2
- package/translations/sv.json +41 -2
- package/translations/th.json +41 -2
- package/translations/tr.json +41 -2
- package/translations/uk.json +41 -2
- package/translations/vi.json +41 -2
- package/translations/zh.json +41 -2
- package/dist/PdfAnnotationCanvas.client-U4EVBZEV.js.map +0 -1
- package/dist/ar-WA47UUWA.js.map +0 -1
- package/dist/bn-5ANDRIU6.js.map +0 -1
- package/dist/chunk-GERSK2DY.js.map +0 -1
- package/dist/cs-3RU7F4JX.js.map +0 -1
- package/dist/da-GSW5P5HG.js.map +0 -1
- package/dist/de-JUAUYF4Z.js.map +0 -1
- package/dist/el-JNLWCKEC.js.map +0 -1
- package/dist/es-WPOX225H.js.map +0 -1
- package/dist/fa-TAEP6N77.js.map +0 -1
- package/dist/fi-ZVZHANSP.js.map +0 -1
- package/dist/fr-VLZW7M4N.js.map +0 -1
- package/dist/he-QFAFYA77.js.map +0 -1
- package/dist/hi-AO3DQPCV.js.map +0 -1
- package/dist/id-GTXF42WM.js.map +0 -1
- package/dist/it-AS5GM232.js.map +0 -1
- package/dist/ja-GZ4HLUOF.js.map +0 -1
- package/dist/ko-A4XUXFGJ.js.map +0 -1
- package/dist/ms-YBAO3S6K.js.map +0 -1
- package/dist/nl-3TJGIIIU.js.map +0 -1
- package/dist/no-4AXIQPRQ.js.map +0 -1
- package/dist/pl-5GP6GKXO.js.map +0 -1
- package/dist/pt-26Y6JFG5.js.map +0 -1
- package/dist/ro-C7UXFRWJ.js.map +0 -1
- package/dist/sv-44DVD76T.js.map +0 -1
- package/dist/th-GIQRTBOY.js.map +0 -1
- package/dist/tr-WMQWO4D6.js.map +0 -1
- package/dist/uk-I7ML6RGG.js.map +0 -1
- package/dist/vi-FGWECASQ.js.map +0 -1
- package/dist/zh-L5FN73XC.js.map +0 -1
- /package/dist/{chunk-YBGN4ACY.js.map → chunk-O23TLLXW.js.map} +0 -0
- /package/dist/{en-RBN2GUHF.js.map → en-P2L4X27N.js.map} +0 -0
|
@@ -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 } 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,7 +280,7 @@ 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
286
|
const handleAnnotationClickEvent = useCallback(({ annotationId, motivation }: {
|
|
@@ -323,27 +310,19 @@ export function ResourceViewer({
|
|
|
323
310
|
return;
|
|
324
311
|
}
|
|
325
312
|
|
|
326
|
-
// All annotations open the unified annotations panel
|
|
313
|
+
// All annotations open the unified annotations panel — the host owns the panel.
|
|
327
314
|
// The panel internally switches tabs based on the motivation → tab mapping in UnifiedAnnotationsPanel
|
|
328
|
-
|
|
329
|
-
}, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick,
|
|
315
|
+
onOpenPanel?.({ panel: 'annotations', scrollToAnnotationId: annotationId, motivation });
|
|
316
|
+
}, [highlights, references, assessments, comments, tags, handleAnnotationClick, selectedClick, onOpenPanel]);
|
|
330
317
|
|
|
331
318
|
// Event subscriptions - Combined into single useEventSubscriptions call to prevent hook ordering issues
|
|
332
319
|
// 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
|
-
|
|
320
|
+
useSessionEventSubscriptions(session, {
|
|
337
321
|
// Annotation cache invalidation
|
|
338
322
|
'mark:added': handleAnnotateAdded,
|
|
339
323
|
'mark:removed': handleAnnotateRemoved,
|
|
340
324
|
'mark:body-updated': handleAnnotateBodyUpdated,
|
|
341
325
|
|
|
342
|
-
// Toolbar state
|
|
343
|
-
'mark:selection-changed': handleToolbarSelectionChanged,
|
|
344
|
-
'mark:click-changed': handleToolbarClickChanged,
|
|
345
|
-
'mark:shape-changed': handleToolbarShapeChanged,
|
|
346
|
-
|
|
347
326
|
// Annotation clicks
|
|
348
327
|
'browse:click': handleAnnotationClickEvent,
|
|
349
328
|
});
|
|
@@ -370,7 +349,7 @@ export function ResourceViewer({
|
|
|
370
349
|
}, [references]);
|
|
371
350
|
|
|
372
351
|
return (
|
|
373
|
-
<div ref={documentViewerRef} className=
|
|
352
|
+
<div ref={documentViewerRef} className={`semiont-resource-viewer${inline ? ' semiont-resource-viewer--inline' : ''}`}>
|
|
374
353
|
{/* Content */}
|
|
375
354
|
{activeView === 'annotate' ? (
|
|
376
355
|
<AnnotateView
|
|
@@ -380,9 +359,9 @@ export function ResourceViewer({
|
|
|
380
359
|
annotations={annotationsCollection}
|
|
381
360
|
uiState={uiState}
|
|
382
361
|
onUIStateChange={(updates) => {
|
|
383
|
-
if ('selectedMotivation' in updates)
|
|
384
|
-
if ('selectedClick' in updates)
|
|
385
|
-
if ('selectedShape' in updates)
|
|
362
|
+
if ('selectedMotivation' in updates) changeSelectionMotivation(updates.selectedMotivation ?? null);
|
|
363
|
+
if ('selectedClick' in updates) changeClickAction(updates.selectedClick!);
|
|
364
|
+
if ('selectedShape' in updates) changeShape(updates.selectedShape!);
|
|
386
365
|
}}
|
|
387
366
|
enableWidgets={true}
|
|
388
367
|
getTargetResourceName={getTargetResourceName}
|
|
@@ -390,6 +369,10 @@ export function ResourceViewer({
|
|
|
390
369
|
showLineNumbers={showLineNumbers}
|
|
391
370
|
hoverDelayMs={hoverDelayMs}
|
|
392
371
|
annotateMode={annotateMode}
|
|
372
|
+
onModeChange={changeAnnotateMode}
|
|
373
|
+
showToolbar={showToolbar}
|
|
374
|
+
session={session}
|
|
375
|
+
newAnnotationIds={newAnnotationIds}
|
|
393
376
|
/>
|
|
394
377
|
) : (
|
|
395
378
|
<BrowseView
|
|
@@ -400,6 +383,14 @@ export function ResourceViewer({
|
|
|
400
383
|
selectedClick={selectedClick}
|
|
401
384
|
hoverDelayMs={hoverDelayMs}
|
|
402
385
|
annotateMode={annotateMode}
|
|
386
|
+
onModeChange={changeAnnotateMode}
|
|
387
|
+
onClickActionChange={changeClickAction}
|
|
388
|
+
showToolbar={showToolbar}
|
|
389
|
+
session={session}
|
|
390
|
+
newAnnotationIds={newAnnotationIds}
|
|
391
|
+
onLinkClick={onLinkClick}
|
|
392
|
+
onReferenceHover={onReferenceHover}
|
|
393
|
+
inline={inline}
|
|
403
394
|
/>
|
|
404
395
|
)}
|
|
405
396
|
|
|
@@ -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
|
+
});
|