@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-VIEWER-COMPLETION Phase 2b — `onReferenceHover`.
|
|
3
|
+
*
|
|
4
|
+
* Hovering a RESOLVED reference span fires `onReferenceHover({annotation,
|
|
5
|
+
* referent, anchorRect})` once — after the viewer's dwell AND the referent's
|
|
6
|
+
* cached descriptor resolves. A stub fires nothing (not even null); leaving
|
|
7
|
+
* before the descriptor resolves cancels (no fire, and no stray null); leaving
|
|
8
|
+
* after a fire sends `null`; the beckon:hover panel-highlight emit is untouched.
|
|
9
|
+
*
|
|
10
|
+
* Started RED (no `onReferenceHover` prop) and GREEN once Phase 2b lands.
|
|
11
|
+
* Test mechanics mirror BrowseView.test.tsx: mock target with `closest()`,
|
|
12
|
+
* fake timers for the dwell, a BehaviorSubject standing in for the cached
|
|
13
|
+
* `browse.resource` observable so the test controls resolve timing.
|
|
14
|
+
*/
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
16
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
17
|
+
import '@testing-library/jest-dom';
|
|
18
|
+
import { BehaviorSubject } from 'rxjs';
|
|
19
|
+
import type { Annotation, AnnotationId, ResourceDescriptor } from '@semiont/core';
|
|
20
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
21
|
+
import { BrowseView } from '../BrowseView';
|
|
22
|
+
|
|
23
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
24
|
+
vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
|
|
25
|
+
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
|
26
|
+
// jsdom has no Range API — the overlay is not under test here.
|
|
27
|
+
vi.mock('../../../lib/annotation-overlay', () => ({
|
|
28
|
+
buildSourceToRenderedMap: vi.fn(() => new Map()),
|
|
29
|
+
buildTextNodeIndex: vi.fn(() => []),
|
|
30
|
+
resolveAnnotationRanges: vi.fn(() => new Map()),
|
|
31
|
+
applyHighlights: vi.fn(),
|
|
32
|
+
clearHighlights: vi.fn(),
|
|
33
|
+
toOverlayAnnotations: vi.fn(() => []),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const REFERENT = { '@id': 'res-target', name: 'Target Doc' } as unknown as ResourceDescriptor;
|
|
37
|
+
const DWELL = 100;
|
|
38
|
+
|
|
39
|
+
const makeAnnotation = (id: string, body: Annotation['body']): Annotation => ({
|
|
40
|
+
'@context': 'http://www.w3.org/ns/anno.jsonld',
|
|
41
|
+
id: id as AnnotationId,
|
|
42
|
+
type: 'Annotation',
|
|
43
|
+
motivation: 'linking',
|
|
44
|
+
creator: { '@type': 'Person', name: 'u' },
|
|
45
|
+
created: '2024-01-01T00:00:00Z',
|
|
46
|
+
target: { source: 'res-1', selector: { type: 'TextPositionSelector', start: 0, end: 4 } },
|
|
47
|
+
body,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const resolvedRef = makeAnnotation('ref-resolved', [{ type: 'SpecificResource', source: 'res-target', purpose: 'linking' }]);
|
|
51
|
+
const stubRef = makeAnnotation('ref-stub', []);
|
|
52
|
+
|
|
53
|
+
function setup(onReferenceHover?: (h: unknown) => void) {
|
|
54
|
+
const referent$ = new BehaviorSubject<ResourceDescriptor | undefined>(undefined);
|
|
55
|
+
const beckonHover = vi.fn();
|
|
56
|
+
const browseResource = vi.fn(() => referent$);
|
|
57
|
+
const session = {
|
|
58
|
+
client: {
|
|
59
|
+
browse: { click: vi.fn(), resource: browseResource },
|
|
60
|
+
beckon: { hover: beckonHover },
|
|
61
|
+
},
|
|
62
|
+
subscribe: () => () => {},
|
|
63
|
+
} as unknown as SemiontSession;
|
|
64
|
+
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<BrowseView
|
|
67
|
+
content="some text"
|
|
68
|
+
mimeType="text/plain"
|
|
69
|
+
resourceUri="res-1"
|
|
70
|
+
annotations={{ highlights: [], references: [resolvedRef, stubRef], assessments: [], comments: [], tags: [] }}
|
|
71
|
+
annotateMode={false}
|
|
72
|
+
hoverDelayMs={DWELL}
|
|
73
|
+
session={session}
|
|
74
|
+
{...(onReferenceHover ? { onReferenceHover } : {})}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
const content = container.querySelector('.semiont-browse-view__content')!;
|
|
78
|
+
|
|
79
|
+
const hoverTarget = (id: string) => {
|
|
80
|
+
const el = document.createElement('span');
|
|
81
|
+
el.setAttribute('data-annotation-id', id);
|
|
82
|
+
return { el, target: { closest: vi.fn(() => el) } as unknown as Element };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return { content, referent$, beckonHover, browseResource, hoverTarget };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe('BrowseView — onReferenceHover (Phase 2b)', () => {
|
|
89
|
+
beforeEach(() => { vi.useFakeTimers(); });
|
|
90
|
+
afterEach(() => { vi.useRealTimers(); });
|
|
91
|
+
|
|
92
|
+
it('fires once after dwell + descriptor resolve, with {annotation, referent, anchorRect}', () => {
|
|
93
|
+
const onReferenceHover = vi.fn();
|
|
94
|
+
const { content, referent$, hoverTarget } = setup(onReferenceHover);
|
|
95
|
+
|
|
96
|
+
fireEvent.mouseOver(content, { target: hoverTarget('ref-resolved').target });
|
|
97
|
+
vi.advanceTimersByTime(DWELL + 10); // dwell fires → subscribe starts
|
|
98
|
+
expect(onReferenceHover).not.toHaveBeenCalled(); // descriptor not resolved yet
|
|
99
|
+
|
|
100
|
+
referent$.next(REFERENT); // descriptor lands
|
|
101
|
+
expect(onReferenceHover).toHaveBeenCalledTimes(1);
|
|
102
|
+
const hover = onReferenceHover.mock.calls[0]![0];
|
|
103
|
+
expect(hover.annotation.id).toBe('ref-resolved');
|
|
104
|
+
expect(hover.referent).toBe(REFERENT);
|
|
105
|
+
expect(typeof hover.anchorRect.top).toBe('number'); // DOMRect from the hovered span
|
|
106
|
+
|
|
107
|
+
referent$.next({ ...REFERENT }); // later cache emissions must NOT re-fire
|
|
108
|
+
expect(onReferenceHover).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('a stub reference fires nothing — not even null on leave', () => {
|
|
112
|
+
const onReferenceHover = vi.fn();
|
|
113
|
+
const { content, beckonHover, hoverTarget } = setup(onReferenceHover);
|
|
114
|
+
|
|
115
|
+
const { target } = hoverTarget('ref-stub');
|
|
116
|
+
fireEvent.mouseOver(content, { target });
|
|
117
|
+
vi.advanceTimersByTime(DWELL + 10);
|
|
118
|
+
fireEvent.mouseOut(content, { target });
|
|
119
|
+
|
|
120
|
+
expect(onReferenceHover).not.toHaveBeenCalled();
|
|
121
|
+
expect(beckonHover).toHaveBeenCalledWith('ref-stub'); // panel-highlight path unaffected
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('leaving before the descriptor resolves cancels: no fire, and a late resolve stays silent', () => {
|
|
125
|
+
const onReferenceHover = vi.fn();
|
|
126
|
+
const { content, referent$, hoverTarget } = setup(onReferenceHover);
|
|
127
|
+
|
|
128
|
+
const { target } = hoverTarget('ref-resolved');
|
|
129
|
+
fireEvent.mouseOver(content, { target });
|
|
130
|
+
vi.advanceTimersByTime(DWELL + 10); // dwell fired, load in flight
|
|
131
|
+
fireEvent.mouseOut(content, { target }); // leave first
|
|
132
|
+
|
|
133
|
+
referent$.next(REFERENT); // late resolve
|
|
134
|
+
expect(onReferenceHover).not.toHaveBeenCalled(); // cancelled — and no stray null either
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('leaving after a fire sends null', () => {
|
|
138
|
+
const onReferenceHover = vi.fn();
|
|
139
|
+
const { content, referent$, hoverTarget } = setup(onReferenceHover);
|
|
140
|
+
|
|
141
|
+
const { target } = hoverTarget('ref-resolved');
|
|
142
|
+
fireEvent.mouseOver(content, { target });
|
|
143
|
+
vi.advanceTimersByTime(DWELL + 10);
|
|
144
|
+
referent$.next(REFERENT);
|
|
145
|
+
expect(onReferenceHover).toHaveBeenCalledTimes(1);
|
|
146
|
+
|
|
147
|
+
fireEvent.mouseOut(content, { target });
|
|
148
|
+
expect(onReferenceHover).toHaveBeenCalledTimes(2);
|
|
149
|
+
expect(onReferenceHover).toHaveBeenLastCalledWith(null);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('without onReferenceHover, hover still emits beckon:hover and loads nothing', () => {
|
|
153
|
+
const { content, beckonHover, browseResource, hoverTarget } = setup();
|
|
154
|
+
|
|
155
|
+
fireEvent.mouseOver(content, { target: hoverTarget('ref-resolved').target });
|
|
156
|
+
vi.advanceTimersByTime(DWELL + 10);
|
|
157
|
+
|
|
158
|
+
expect(beckonHover).toHaveBeenCalledWith('ref-resolved');
|
|
159
|
+
expect(browseResource).not.toHaveBeenCalled(); // no handler → no referent load
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-RESOURCE-VIEWER step 3 — overridable browse media-renderer registry.
|
|
3
|
+
*
|
|
4
|
+
* A consumer can swap a media renderer via the `renderers` prop (e.g. its own PDF
|
|
5
|
+
* viewer) without forking BrowseView. AnnotateToolbar is mocked (unrelated here).
|
|
6
|
+
*
|
|
7
|
+
* Started RED (tsc: no `renderers` prop) and GREEN once step 3 lands.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { render, screen } from '@testing-library/react';
|
|
11
|
+
import '@testing-library/jest-dom';
|
|
12
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
13
|
+
import { BrowseView } from '../BrowseView';
|
|
14
|
+
import type { MediaRendererProps } from '../browse-renderers';
|
|
15
|
+
|
|
16
|
+
vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
|
|
17
|
+
|
|
18
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
19
|
+
|
|
20
|
+
function fakeSession(): SemiontSession {
|
|
21
|
+
return {
|
|
22
|
+
client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
|
|
23
|
+
subscribe: () => () => {},
|
|
24
|
+
} as unknown as SemiontSession;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('BrowseView — media-renderer registry', () => {
|
|
28
|
+
it('uses a custom renderer from the `renderers` override', () => {
|
|
29
|
+
const CustomText = ({ content }: MediaRendererProps) => <div>custom-render: {content}</div>;
|
|
30
|
+
render(
|
|
31
|
+
<BrowseView
|
|
32
|
+
content="the-body"
|
|
33
|
+
mimeType="text/plain"
|
|
34
|
+
resourceUri="res-1"
|
|
35
|
+
annotations={emptyAnnotations}
|
|
36
|
+
annotateMode={false}
|
|
37
|
+
session={fakeSession()}
|
|
38
|
+
renderers={{ text: CustomText }}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
expect(screen.getByText(/custom-render: the-body/)).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -3,17 +3,15 @@ import React from 'react';
|
|
|
3
3
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
4
4
|
import { BrowseView } from '../BrowseView';
|
|
5
5
|
import type { EventBus } from '@semiont/core';
|
|
6
|
+
import type { SemiontClient, SemiontSession } from '@semiont/sdk';
|
|
6
7
|
import { createTestSemiontWrapper } from '../../../test-utils';
|
|
7
8
|
|
|
8
9
|
import type { Annotation, AnnotationId } from '@semiont/core';
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
newAnnotationIds: mockNewAnnotationIds,
|
|
15
|
-
})),
|
|
16
|
-
}));
|
|
11
|
+
// BrowseView takes its `session` + `newAnnotationIds` as props now (step 1a) — no
|
|
12
|
+
// ResourceAnnotationsContext / SemiontProvider reach-in. `makeSession` (below)
|
|
13
|
+
// wraps the fake client so browse:click / beckon:hover land on the bus the
|
|
14
|
+
// trackers listen on, and session.subscribe registers beckon:* on that bus.
|
|
17
15
|
|
|
18
16
|
// Mock @semiont/core utilities. The media-type registry (`capabilitiesOf`)
|
|
19
17
|
// is NOT mocked — BrowseView dispatches on the real registry's render mode,
|
|
@@ -72,12 +70,8 @@ vi.mock('../../../lib/annotation-registry', () => ({
|
|
|
72
70
|
},
|
|
73
71
|
}));
|
|
74
72
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
ImageViewer: ({ resourceUri }: { resourceUri: string }) => (
|
|
78
|
-
<img data-testid="image-viewer" src={resourceUri} alt="Resource content" />
|
|
79
|
-
),
|
|
80
|
-
}));
|
|
73
|
+
// Image browse renders the read-only SvgDrawingCanvas (real component — jsdom
|
|
74
|
+
// renders its container fine; shape painting is covered by the dispatch spec).
|
|
81
75
|
|
|
82
76
|
// Mock AnnotateToolbar
|
|
83
77
|
vi.mock('../../annotation/AnnotateToolbar', () => ({
|
|
@@ -116,34 +110,47 @@ function createEventTracker() {
|
|
|
116
110
|
};
|
|
117
111
|
}
|
|
118
112
|
|
|
113
|
+
// BrowseView now takes its session as a prop. Wrap the fake client so
|
|
114
|
+
// browse:click / beckon:hover emit on the SAME bus the trackers listen on, and
|
|
115
|
+
// session.subscribe registers beckon:* there (mirrors test-utils' fake session).
|
|
116
|
+
function makeSession(client: SemiontClient): SemiontSession {
|
|
117
|
+
return {
|
|
118
|
+
client,
|
|
119
|
+
subscribe: (channel: string, handler: (p: never) => void) => {
|
|
120
|
+
const sub = (client.bus.get(channel as never) as { subscribe(fn: (p: never) => void): { unsubscribe(): void } }).subscribe(handler);
|
|
121
|
+
return () => sub.unsubscribe();
|
|
122
|
+
},
|
|
123
|
+
} as unknown as SemiontSession;
|
|
124
|
+
}
|
|
125
|
+
|
|
119
126
|
const renderWithProviders = (
|
|
120
|
-
component: React.ReactElement
|
|
127
|
+
component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
|
|
121
128
|
options: { newAnnotationIds?: Set<string> } = {}
|
|
122
129
|
) => {
|
|
123
|
-
|
|
124
|
-
mockNewAnnotationIds = options.newAnnotationIds;
|
|
125
|
-
}
|
|
126
|
-
const { SemiontWrapper } = createTestSemiontWrapper();
|
|
130
|
+
const { SemiontWrapper, client } = createTestSemiontWrapper();
|
|
127
131
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
128
132
|
<SemiontWrapper>{children}</SemiontWrapper>
|
|
129
133
|
);
|
|
130
|
-
return render(
|
|
134
|
+
return render(
|
|
135
|
+
React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
|
|
136
|
+
{ wrapper: Wrapper },
|
|
137
|
+
);
|
|
131
138
|
};
|
|
132
139
|
|
|
133
140
|
const renderWithEventTracking = (
|
|
134
|
-
component: React.ReactElement
|
|
141
|
+
component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
|
|
135
142
|
tracker: ReturnType<typeof createEventTracker>,
|
|
136
143
|
options: { newAnnotationIds?: Set<string> } = {}
|
|
137
144
|
) => {
|
|
138
|
-
|
|
139
|
-
mockNewAnnotationIds = options.newAnnotationIds;
|
|
140
|
-
}
|
|
141
|
-
const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
|
|
145
|
+
const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
|
|
142
146
|
tracker._attach(eventBus);
|
|
143
147
|
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
144
148
|
<SemiontWrapper>{children}</SemiontWrapper>
|
|
145
149
|
);
|
|
146
|
-
return render(
|
|
150
|
+
return render(
|
|
151
|
+
React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
|
|
152
|
+
{ wrapper: Wrapper },
|
|
153
|
+
);
|
|
147
154
|
};
|
|
148
155
|
|
|
149
156
|
// Test data fixtures
|
|
@@ -179,11 +186,13 @@ describe('BrowseView Component', () => {
|
|
|
179
186
|
hoveredAnnotationId: null,
|
|
180
187
|
selectedClick: 'detail' as const,
|
|
181
188
|
annotateMode: false,
|
|
189
|
+
// Placeholder — the render helpers override this with a session bound to the
|
|
190
|
+
// fake client's bus (so emits/subscriptions land where the trackers listen).
|
|
191
|
+
session: null as SemiontSession | null,
|
|
182
192
|
};
|
|
183
193
|
|
|
184
194
|
beforeEach(() => {
|
|
185
195
|
vi.clearAllMocks();
|
|
186
|
-
mockNewAnnotationIds = new Set();
|
|
187
196
|
|
|
188
197
|
// Mock scrollIntoView for jsdom
|
|
189
198
|
if (typeof Element !== 'undefined') {
|
|
@@ -211,10 +220,12 @@ describe('BrowseView Component', () => {
|
|
|
211
220
|
expect(screen.getByTestId('annotate-toolbar')).toBeInTheDocument();
|
|
212
221
|
});
|
|
213
222
|
|
|
214
|
-
it('should render
|
|
215
|
-
|
|
223
|
+
it('should render the read-only annotation canvas for image mime types', () => {
|
|
224
|
+
// Was a bare ImageViewer, which silently dropped the annotations prop
|
|
225
|
+
// (bugs/image-browse-renderer-drops-annotations.md).
|
|
226
|
+
const { container } = renderWithProviders(<BrowseView {...defaultProps} mimeType="image/png" />);
|
|
216
227
|
|
|
217
|
-
expect(
|
|
228
|
+
expect(container.querySelector('.semiont-svg-drawing-canvas')).toBeInTheDocument();
|
|
218
229
|
});
|
|
219
230
|
|
|
220
231
|
it('should render unsupported message for unsupported mime types', () => {
|
|
@@ -391,12 +402,16 @@ describe('BrowseView Component', () => {
|
|
|
391
402
|
});
|
|
392
403
|
});
|
|
393
404
|
|
|
394
|
-
it('should emit browse:click
|
|
405
|
+
it('should emit browse:click for every motivation', async () => {
|
|
406
|
+
// browse:click is the platform's one "user clicked an annotation" signal;
|
|
407
|
+
// text browse mode must emit it for whatever the click resolves, matching
|
|
408
|
+
// the image/PDF/annotate emitters (BROWSE-CLICK-ALL-MOTIVATIONS.md).
|
|
395
409
|
const tracker = createEventTracker();
|
|
396
410
|
const annotations = {
|
|
397
411
|
...defaultProps.annotations,
|
|
398
412
|
references: [createMockAnnotation('linking', 'ref-1')],
|
|
399
413
|
highlights: [createMockAnnotation('highlighting', 'highlight-1')],
|
|
414
|
+
comments: [createMockAnnotation('commenting', 'comment-1')],
|
|
400
415
|
};
|
|
401
416
|
|
|
402
417
|
const { container } = renderWithEventTracking(
|
|
@@ -404,36 +419,101 @@ describe('BrowseView Component', () => {
|
|
|
404
419
|
tracker
|
|
405
420
|
);
|
|
406
421
|
|
|
407
|
-
const
|
|
408
|
-
mockReferenceElement.setAttribute('data-annotation-id', 'ref-1');
|
|
409
|
-
mockReferenceElement.setAttribute('data-annotation-type', 'reference');
|
|
422
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
410
423
|
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
424
|
+
const clickCases = [
|
|
425
|
+
{ id: 'ref-1', type: 'reference', motivation: 'linking' },
|
|
426
|
+
{ id: 'highlight-1', type: 'highlight', motivation: 'highlighting' },
|
|
427
|
+
{ id: 'comment-1', type: 'comment', motivation: 'commenting' },
|
|
428
|
+
] as const;
|
|
414
429
|
|
|
415
|
-
const
|
|
416
|
-
|
|
430
|
+
for (const { id, type, motivation } of clickCases) {
|
|
431
|
+
const el = document.createElement('span');
|
|
432
|
+
el.setAttribute('data-annotation-id', id);
|
|
433
|
+
el.setAttribute('data-annotation-type', type);
|
|
434
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
435
|
+
|
|
436
|
+
tracker.clear();
|
|
437
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
438
|
+
|
|
439
|
+
await waitFor(() => {
|
|
440
|
+
expect(tracker.events.some(e =>
|
|
441
|
+
e.event === 'browse:click' &&
|
|
442
|
+
e.payload?.annotationId === id &&
|
|
443
|
+
e.payload?.motivation === motivation
|
|
444
|
+
)).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
417
448
|
|
|
418
|
-
|
|
449
|
+
it('should include the clicked span rect as anchorRect', async () => {
|
|
450
|
+
// A1 anchor thread (HEADLESS-ANNOTATION-PANELS Phase 3): the emission
|
|
451
|
+
// site owns the geometry — the clicked span's viewport rect rides the
|
|
452
|
+
// event so hosts can anchor popovers. Runtime-only; no schema pin.
|
|
453
|
+
const tracker = createEventTracker();
|
|
454
|
+
const annotations = {
|
|
455
|
+
...defaultProps.annotations,
|
|
456
|
+
highlights: [createMockAnnotation('highlighting', 'highlight-1')],
|
|
457
|
+
};
|
|
419
458
|
|
|
420
|
-
|
|
459
|
+
const { container } = renderWithEventTracking(
|
|
460
|
+
<BrowseView {...defaultProps} annotations={annotations} />,
|
|
461
|
+
tracker
|
|
462
|
+
);
|
|
421
463
|
|
|
422
|
-
|
|
423
|
-
|
|
464
|
+
const el = document.createElement('span');
|
|
465
|
+
el.setAttribute('data-annotation-id', 'highlight-1');
|
|
466
|
+
el.setAttribute('data-annotation-type', 'highlight');
|
|
467
|
+
const RECT: DOMRect = {
|
|
468
|
+
x: 10, y: 20, width: 30, height: 40,
|
|
469
|
+
top: 20, right: 40, bottom: 60, left: 10,
|
|
470
|
+
toJSON: () => ({}),
|
|
471
|
+
};
|
|
472
|
+
el.getBoundingClientRect = () => RECT;
|
|
473
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
474
|
+
|
|
475
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
476
|
+
tracker.clear();
|
|
477
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
424
478
|
|
|
425
479
|
await waitFor(() => {
|
|
426
480
|
expect(tracker.events.some(e =>
|
|
427
481
|
e.event === 'browse:click' &&
|
|
428
|
-
e.payload?.annotationId === '
|
|
429
|
-
e.payload?.
|
|
482
|
+
e.payload?.annotationId === 'highlight-1' &&
|
|
483
|
+
e.payload?.anchorRect?.left === 10 &&
|
|
484
|
+
e.payload?.anchorRect?.width === 30
|
|
430
485
|
)).toBe(true);
|
|
431
486
|
});
|
|
487
|
+
});
|
|
432
488
|
|
|
433
|
-
|
|
489
|
+
it('should not emit browse:click when the click completes a text selection', async () => {
|
|
490
|
+
// Browse mode is the reading surface: a drag-select that starts and ends
|
|
491
|
+
// inside one annotated span fires click on it. The guard applies to
|
|
492
|
+
// references too — a copy-drag inside a reference span must not emit
|
|
493
|
+
// (deliberate behavior change; previously it navigated under `follow`).
|
|
494
|
+
const tracker = createEventTracker();
|
|
495
|
+
const annotations = {
|
|
496
|
+
...defaultProps.annotations,
|
|
497
|
+
references: [createMockAnnotation('linking', 'ref-1')],
|
|
498
|
+
};
|
|
434
499
|
|
|
435
|
-
|
|
436
|
-
|
|
500
|
+
const { container } = renderWithEventTracking(
|
|
501
|
+
<BrowseView {...defaultProps} annotations={annotations} />,
|
|
502
|
+
tracker
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
const el = document.createElement('span');
|
|
506
|
+
el.setAttribute('data-annotation-id', 'ref-1');
|
|
507
|
+
el.setAttribute('data-annotation-type', 'reference');
|
|
508
|
+
const mockTarget = { closest: vi.fn(() => el) } as unknown as EventTarget;
|
|
509
|
+
|
|
510
|
+
vi.spyOn(window, 'getSelection').mockReturnValue({
|
|
511
|
+
isCollapsed: false,
|
|
512
|
+
} as Selection);
|
|
513
|
+
|
|
514
|
+
const browseContainer = container.querySelector('.semiont-browse-view__content');
|
|
515
|
+
tracker.clear();
|
|
516
|
+
fireEvent.click(browseContainer!, { target: mockTarget });
|
|
437
517
|
|
|
438
518
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
439
519
|
expect(tracker.events.filter(e => e.event === 'browse:click').length).toBe(0);
|
|
@@ -499,7 +579,7 @@ describe('BrowseView Component', () => {
|
|
|
499
579
|
tags: [],
|
|
500
580
|
};
|
|
501
581
|
|
|
502
|
-
const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
|
|
582
|
+
const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
|
|
503
583
|
eventBus.get('beckon:hover').subscribe((payload: any) => {
|
|
504
584
|
eventTracker.push({ event: 'beckon:hover', annotationId: payload?.annotationId ?? null });
|
|
505
585
|
});
|
|
@@ -509,7 +589,7 @@ describe('BrowseView Component', () => {
|
|
|
509
589
|
|
|
510
590
|
const { container } = render(
|
|
511
591
|
<SemiontWrapper>
|
|
512
|
-
<BrowseView {...defaultProps} annotations={manyAnnotations} />
|
|
592
|
+
<BrowseView {...defaultProps} annotations={manyAnnotations} session={makeSession(client)} />
|
|
513
593
|
</SemiontWrapper>
|
|
514
594
|
);
|
|
515
595
|
|
|
@@ -539,13 +619,13 @@ describe('BrowseView Component', () => {
|
|
|
539
619
|
expect(eventTracker.some(e => e.event === 'browse:click' && e.annotationId === 'ref-1')).toBe(true);
|
|
540
620
|
});
|
|
541
621
|
|
|
542
|
-
//
|
|
622
|
+
// Highlights emit through the same delegated handler (all motivations)
|
|
543
623
|
eventTracker.length = 0; // Clear tracker
|
|
544
624
|
fireEvent.click(browseContainer!, { target: mockHighlightTarget });
|
|
545
625
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
626
|
+
await waitFor(() => {
|
|
627
|
+
expect(eventTracker.some(e => e.event === 'browse:click' && e.annotationId === 'highlight-1')).toBe(true);
|
|
628
|
+
});
|
|
549
629
|
|
|
550
630
|
// The key insight: With event delegation, we can handle 100 annotations
|
|
551
631
|
// with only container-level listeners, not 100+ individual listeners
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-RESOURCE-VIEWER — keystone acceptance spec.
|
|
3
|
+
*
|
|
4
|
+
* The consumer's (my-chat) actual requirement: render + interact with a resource
|
|
5
|
+
* fed ONLY a `SemiontSession`, with NO SemiontProvider / TranslationProvider /
|
|
6
|
+
* cache context mounted. This is the definition of done for provider-free
|
|
7
|
+
* rendering — the "an external host can import the pieces" half of the plan's
|
|
8
|
+
* dual acceptance test.
|
|
9
|
+
*
|
|
10
|
+
* GREEN as of step 1c: the browse-mode subtree (ResourceViewer → BrowseView →
|
|
11
|
+
* AnnotateToolbar) renders provider-free from a bare session. The annotate-mode
|
|
12
|
+
* subtree is covered by AnnotateView.embeddable.test.tsx.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
15
|
+
import { render, screen, act, waitFor } from '@testing-library/react';
|
|
16
|
+
import '@testing-library/jest-dom';
|
|
17
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
18
|
+
import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
|
|
19
|
+
import { createTestSemiontWrapper } from '../../../test-utils';
|
|
20
|
+
import { ResourceViewer } from '../ResourceViewer';
|
|
21
|
+
|
|
22
|
+
// Minimal bring-your-own-session double: just the surface ResourceViewer + its
|
|
23
|
+
// subtree touch — `client.browse` / `client.mark` and generic-channel `subscribe`.
|
|
24
|
+
function fakeSession(): SemiontSession {
|
|
25
|
+
const client = {
|
|
26
|
+
baseUrl: 'http://localhost:4000',
|
|
27
|
+
browse: { invalidateAnnotationList: vi.fn(), click: vi.fn() },
|
|
28
|
+
mark: { delete: vi.fn() },
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
client,
|
|
32
|
+
subscribe: () => () => {},
|
|
33
|
+
} as unknown as SemiontSession;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const resource: SemiontResource & { content: string } = {
|
|
37
|
+
'@context': 'https://www.w3.org/ns/activitystreams',
|
|
38
|
+
'@id': 'res-1' as ResourceId,
|
|
39
|
+
name: 'Doc',
|
|
40
|
+
created: '2024-01-01T00:00:00Z',
|
|
41
|
+
entityTypes: [],
|
|
42
|
+
archived: false,
|
|
43
|
+
representations: [{ mediaType: 'text/plain', byteSize: 10 }],
|
|
44
|
+
content: 'Embeddable content.',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
48
|
+
|
|
49
|
+
describe('ResourceViewer — embeddable (bring-your-own-session, no providers)', () => {
|
|
50
|
+
// GREEN: the whole browse-mode subtree (ResourceViewer → BrowseView →
|
|
51
|
+
// AnnotateToolbar) renders provider-free from a bare session.
|
|
52
|
+
it('renders content fed only a session, with NO providers mounted', () => {
|
|
53
|
+
render(
|
|
54
|
+
<ResourceViewer
|
|
55
|
+
session={fakeSession()}
|
|
56
|
+
resource={resource}
|
|
57
|
+
annotations={annotations}
|
|
58
|
+
onOpenResource={vi.fn()}
|
|
59
|
+
onOpenPanel={vi.fn()}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
expect(screen.getByText('Embeddable content.')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// A1 anchor thread: the viewer is a pass-through for view geometry — an
|
|
66
|
+
// anchorRect arriving on browse:click reaches the host's onOpenPanel
|
|
67
|
+
// untouched (detail routing, the default click action).
|
|
68
|
+
it('forwards the browse:click anchorRect into onOpenPanel', async () => {
|
|
69
|
+
const { session, eventBus } = createTestSemiontWrapper();
|
|
70
|
+
const onOpenPanel = vi.fn();
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<ResourceViewer
|
|
74
|
+
session={session}
|
|
75
|
+
resource={resource}
|
|
76
|
+
annotations={annotations}
|
|
77
|
+
onOpenResource={vi.fn()}
|
|
78
|
+
onOpenPanel={onOpenPanel}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const anchorRect = {
|
|
83
|
+
x: 5, y: 6, width: 7, height: 8,
|
|
84
|
+
top: 6, right: 12, bottom: 14, left: 5,
|
|
85
|
+
};
|
|
86
|
+
act(() => {
|
|
87
|
+
eventBus.get('browse:click').next({
|
|
88
|
+
annotationId: 'ann-1',
|
|
89
|
+
motivation: 'highlighting',
|
|
90
|
+
anchorRect,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(onOpenPanel).toHaveBeenCalledWith(expect.objectContaining({
|
|
96
|
+
panel: 'annotations',
|
|
97
|
+
scrollToAnnotationId: 'ann-1',
|
|
98
|
+
anchorRect: expect.objectContaining({ left: 5, width: 7 }),
|
|
99
|
+
}));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|