@semiont/react-ui 0.5.10 → 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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +4 -1
  3. package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
  4. package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
  5. package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
  6. package/dist/index.css +150 -112
  7. package/dist/index.css.map +1 -1
  8. package/dist/index.d.ts +287 -39
  9. package/dist/index.js +2525 -2284
  10. package/dist/index.js.map +1 -1
  11. package/dist/test-utils.js +7 -2
  12. package/dist/test-utils.js.map +1 -1
  13. package/package.json +6 -6
  14. package/src/components/ResourceTagsInline.tsx +94 -11
  15. package/src/components/StatusDisplay.css +36 -0
  16. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  17. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  18. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  19. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  20. package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
  21. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
  22. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
  23. package/src/components/resource/AnnotateView.tsx +59 -38
  24. package/src/components/resource/BrowseView.tsx +159 -133
  25. package/src/components/resource/ResourceViewer.tsx +119 -128
  26. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  27. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  28. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  29. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  30. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  31. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  32. package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
  33. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
  34. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  35. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  36. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  37. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  38. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  39. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  40. package/src/components/resource/browse-renderers.tsx +63 -0
  41. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  42. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  43. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  44. package/src/components/resource/panels/ReferencesPanel.tsx +4 -0
  45. package/src/components/resource/panels/TaggingPanel.tsx +4 -0
  46. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
  47. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
  48. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
  49. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
  50. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  51. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
  52. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
  53. package/src/components/toolbar/Toolbar.css +14 -0
  54. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  55. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +40 -17
  56. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  57. package/src/styles/features/resource-viewer.css +19 -0
  58. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.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
- // Mock ResourceAnnotationsContext - keep this simple
11
- let mockNewAnnotationIds = new Set<string>();
12
- vi.mock('../../../contexts/ResourceAnnotationsContext', () => ({
13
- useResourceAnnotations: vi.fn(() => ({
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,
@@ -116,34 +114,47 @@ function createEventTracker() {
116
114
  };
117
115
  }
118
116
 
117
+ // BrowseView now takes its session as a prop. Wrap the fake client so
118
+ // browse:click / beckon:hover emit on the SAME bus the trackers listen on, and
119
+ // session.subscribe registers beckon:* there (mirrors test-utils' fake session).
120
+ function makeSession(client: SemiontClient): SemiontSession {
121
+ return {
122
+ client,
123
+ subscribe: (channel: string, handler: (p: never) => void) => {
124
+ const sub = (client.bus.get(channel as never) as { subscribe(fn: (p: never) => void): { unsubscribe(): void } }).subscribe(handler);
125
+ return () => sub.unsubscribe();
126
+ },
127
+ } as unknown as SemiontSession;
128
+ }
129
+
119
130
  const renderWithProviders = (
120
- component: React.ReactElement,
131
+ component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
121
132
  options: { newAnnotationIds?: Set<string> } = {}
122
133
  ) => {
123
- if (options.newAnnotationIds) {
124
- mockNewAnnotationIds = options.newAnnotationIds;
125
- }
126
- const { SemiontWrapper } = createTestSemiontWrapper();
134
+ const { SemiontWrapper, client } = createTestSemiontWrapper();
127
135
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
128
136
  <SemiontWrapper>{children}</SemiontWrapper>
129
137
  );
130
- return render(component, { wrapper: Wrapper });
138
+ return render(
139
+ React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
140
+ { wrapper: Wrapper },
141
+ );
131
142
  };
132
143
 
133
144
  const renderWithEventTracking = (
134
- component: React.ReactElement,
145
+ component: React.ReactElement<React.ComponentProps<typeof BrowseView>>,
135
146
  tracker: ReturnType<typeof createEventTracker>,
136
147
  options: { newAnnotationIds?: Set<string> } = {}
137
148
  ) => {
138
- if (options.newAnnotationIds) {
139
- mockNewAnnotationIds = options.newAnnotationIds;
140
- }
141
- const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
149
+ const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
142
150
  tracker._attach(eventBus);
143
151
  const Wrapper = ({ children }: { children: React.ReactNode }) => (
144
152
  <SemiontWrapper>{children}</SemiontWrapper>
145
153
  );
146
- return render(component, { wrapper: Wrapper });
154
+ return render(
155
+ React.cloneElement(component, { session: makeSession(client), newAnnotationIds: options.newAnnotationIds }),
156
+ { wrapper: Wrapper },
157
+ );
147
158
  };
148
159
 
149
160
  // Test data fixtures
@@ -179,11 +190,13 @@ describe('BrowseView Component', () => {
179
190
  hoveredAnnotationId: null,
180
191
  selectedClick: 'detail' as const,
181
192
  annotateMode: false,
193
+ // Placeholder — the render helpers override this with a session bound to the
194
+ // fake client's bus (so emits/subscriptions land where the trackers listen).
195
+ session: null as SemiontSession | null,
182
196
  };
183
197
 
184
198
  beforeEach(() => {
185
199
  vi.clearAllMocks();
186
- mockNewAnnotationIds = new Set();
187
200
 
188
201
  // Mock scrollIntoView for jsdom
189
202
  if (typeof Element !== 'undefined') {
@@ -499,7 +512,7 @@ describe('BrowseView Component', () => {
499
512
  tags: [],
500
513
  };
501
514
 
502
- const { SemiontWrapper, eventBus } = createTestSemiontWrapper();
515
+ const { SemiontWrapper, eventBus, client } = createTestSemiontWrapper();
503
516
  eventBus.get('beckon:hover').subscribe((payload: any) => {
504
517
  eventTracker.push({ event: 'beckon:hover', annotationId: payload?.annotationId ?? null });
505
518
  });
@@ -509,7 +522,7 @@ describe('BrowseView Component', () => {
509
522
 
510
523
  const { container } = render(
511
524
  <SemiontWrapper>
512
- <BrowseView {...defaultProps} annotations={manyAnnotations} />
525
+ <BrowseView {...defaultProps} annotations={manyAnnotations} session={makeSession(client)} />
513
526
  </SemiontWrapper>
514
527
  );
515
528
 
@@ -0,0 +1,63 @@
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 } 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 { ResourceViewer } from '../ResourceViewer';
20
+
21
+ // Minimal bring-your-own-session double: just the surface ResourceViewer + its
22
+ // subtree touch — `client.browse` / `client.mark` and generic-channel `subscribe`.
23
+ function fakeSession(): SemiontSession {
24
+ const client = {
25
+ baseUrl: 'http://localhost:4000',
26
+ browse: { invalidateAnnotationList: vi.fn(), click: vi.fn() },
27
+ mark: { delete: vi.fn() },
28
+ };
29
+ return {
30
+ client,
31
+ subscribe: () => () => {},
32
+ } as unknown as SemiontSession;
33
+ }
34
+
35
+ const resource: SemiontResource & { content: string } = {
36
+ '@context': 'https://www.w3.org/ns/activitystreams',
37
+ '@id': 'res-1' as ResourceId,
38
+ name: 'Doc',
39
+ created: '2024-01-01T00:00:00Z',
40
+ entityTypes: [],
41
+ archived: false,
42
+ representations: [{ mediaType: 'text/plain', byteSize: 10 }],
43
+ content: 'Embeddable content.',
44
+ };
45
+
46
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
47
+
48
+ describe('ResourceViewer — embeddable (bring-your-own-session, no providers)', () => {
49
+ // GREEN: the whole browse-mode subtree (ResourceViewer → BrowseView →
50
+ // AnnotateToolbar) renders provider-free from a bare session.
51
+ it('renders content fed only a session, with NO providers mounted', () => {
52
+ render(
53
+ <ResourceViewer
54
+ session={fakeSession()}
55
+ resource={resource}
56
+ annotations={annotations}
57
+ onOpenResource={vi.fn()}
58
+ onOpenPanel={vi.fn()}
59
+ />,
60
+ );
61
+ expect(screen.getByText('Embeddable content.')).toBeInTheDocument();
62
+ });
63
+ });
@@ -19,14 +19,10 @@ import { TranslationProvider } from '../../../contexts/TranslationContext';
19
19
  import { ResourceAnnotationsProvider } from '../../../contexts/ResourceAnnotationsContext';
20
20
  import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
21
21
 
22
- // Mock dependencies
23
- vi.mock('../../../hooks/useObservableBrowse', () => ({
24
- useObservableExternalNavigation: () => vi.fn(),
25
- }));
26
-
27
- // ResourceViewer (and ResourceAnnotationsContext) now resolve the client via
22
+ // ResourceViewer now takes its session as a prop; its children (BrowseView /
23
+ // AnnotateView) and ResourceAnnotationsContext still resolve the client via
28
24
  // useSemiont(). Mock useSemiont to emit a minimal session carrying a stub
29
- // client with the methods the subject component touches. The session also
25
+ // client with the methods those children touch. The session also
30
26
  // exposes `on` and `emit` stubs so useEventSubscription(s) don't explode.
31
27
  const stubClient = {
32
28
  browse: { invalidateAnnotationList: vi.fn() },
@@ -115,6 +111,7 @@ describe('ResourceViewer - Mode Switching', () => {
115
111
  <ResourceViewer
116
112
  resource={mockResource}
117
113
  annotations={mockAnnotations}
114
+ session={null}
118
115
  />
119
116
  </TestWrapper>
120
117
  );
@@ -128,6 +125,7 @@ describe('ResourceViewer - Mode Switching', () => {
128
125
  <ResourceViewer
129
126
  resource={mockResource}
130
127
  annotations={mockAnnotations}
128
+ session={null}
131
129
  />
132
130
  </TestWrapper>
133
131
  );
@@ -144,6 +142,7 @@ describe('ResourceViewer - Mode Switching', () => {
144
142
  <ResourceViewer
145
143
  resource={mockResource}
146
144
  annotations={mockAnnotations}
145
+ session={null}
147
146
  />
148
147
  </TestWrapper>
149
148
  );
@@ -158,6 +157,7 @@ describe('ResourceViewer - Mode Switching', () => {
158
157
  <ResourceViewer
159
158
  resource={mockResource}
160
159
  annotations={mockAnnotations}
160
+ session={null}
161
161
  />
162
162
  </TestWrapper>
163
163
  );
@@ -169,6 +169,7 @@ describe('ResourceViewer - Mode Switching', () => {
169
169
  <ResourceViewer
170
170
  resource={mockResource}
171
171
  annotations={mockAnnotations}
172
+ session={null}
172
173
  />
173
174
  </TestWrapper>
174
175
  );
@@ -181,6 +182,7 @@ describe('ResourceViewer - Mode Switching', () => {
181
182
  <ResourceViewer
182
183
  resource={mockResource}
183
184
  annotations={mockAnnotations}
185
+ session={null}
184
186
  />
185
187
  </TestWrapper>
186
188
  );
@@ -193,6 +195,7 @@ describe('ResourceViewer - Mode Switching', () => {
193
195
  <ResourceViewer
194
196
  resource={mockResource}
195
197
  annotations={mockAnnotations}
198
+ session={null}
196
199
  />
197
200
  </TestWrapper>
198
201
  );
@@ -209,6 +212,7 @@ describe('ResourceViewer - Mode Switching', () => {
209
212
  <ResourceViewer
210
213
  resource={mockResource}
211
214
  annotations={mockAnnotations}
215
+ session={null}
212
216
  />
213
217
  </TestWrapper>
214
218
  );
@@ -221,6 +225,7 @@ describe('ResourceViewer - Mode Switching', () => {
221
225
  <ResourceViewer
222
226
  resource={mockResource}
223
227
  annotations={mockAnnotations}
228
+ session={null}
224
229
  />
225
230
  </TestWrapper>
226
231
  );
@@ -0,0 +1,135 @@
1
+ /**
2
+ * VIEWER-TOOLBAR-OPT-OUT — `showToolbar={false}` is a supported opt-out.
3
+ *
4
+ * Tier-2 hosts (controlled props, host-composed controls) hide the built-in
5
+ * bar by CONTRACT, not by CSS-ing react-ui's internals. Hiding the bar must
6
+ * not disable any seam: the keystone here proves annotate-mode selection
7
+ * capture still emits mark.request with the bar gone.
8
+ *
9
+ * Started RED (the prop doesn't exist; bars render everywhere) → GREEN.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
12
+ import { render, fireEvent, screen, within } from '@testing-library/react';
13
+ import '@testing-library/jest-dom';
14
+ import type { SemiontSession } from '@semiont/sdk';
15
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
16
+ import { ResourceViewer } from '../ResourceViewer';
17
+
18
+ // Render the real content so jsdom text selection can target it.
19
+ vi.mock('../../CodeMirrorRenderer', () => ({
20
+ CodeMirrorRenderer: ({ content }: { content: string }) => <div className="codemirror-renderer">{content}</div>,
21
+ }));
22
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => <div>svg-mock</div> }));
23
+ vi.mock('../../pdf-annotation/PdfAnnotationCanvas.client', () => ({ PdfAnnotationCanvas: () => <div>pdf-mock</div> }));
24
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
25
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
26
+
27
+ const CONTENT = 'hello world selectme end';
28
+
29
+ function makeResource(mediaType = 'text/plain'): SemiontResource & { content: string } {
30
+ return {
31
+ '@context': 'https://www.w3.org/ns/activitystreams',
32
+ '@id': 'res-1' as ResourceId,
33
+ name: 'Doc',
34
+ created: '2024-01-01T00:00:00Z',
35
+ entityTypes: [],
36
+ archived: false,
37
+ representations: [{ mediaType, byteSize: 10 }],
38
+ content: CONTENT,
39
+ };
40
+ }
41
+
42
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
43
+
44
+ function fakeSession() {
45
+ const client = {
46
+ browse: { click: vi.fn(), invalidateAnnotationList: vi.fn() },
47
+ beckon: { hover: vi.fn() },
48
+ mark: { request: vi.fn(), delete: vi.fn() },
49
+ };
50
+ return { session: { client, subscribe: () => () => {} } as unknown as SemiontSession, client };
51
+ }
52
+
53
+ const bars = (c: HTMLElement) => c.querySelectorAll('.semiont-annotate-toolbar').length;
54
+
55
+ describe('ResourceViewer — showToolbar opt-out', () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ window.getSelection()?.removeAllRanges();
59
+ });
60
+
61
+ it('default pins today’s behavior: the bar renders in browse and annotate modes', () => {
62
+ const { session } = fakeSession();
63
+ const browse = render(
64
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
65
+ );
66
+ expect(bars(browse.container)).toBe(1);
67
+
68
+ const annotate = render(
69
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
70
+ annotateMode={true} onAnnotateModeChange={vi.fn()} />,
71
+ );
72
+ expect(bars(annotate.container)).toBe(1);
73
+ });
74
+
75
+ it('showToolbar={false}: no bar in browse mode', () => {
76
+ const { session } = fakeSession();
77
+ const { container } = render(
78
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
79
+ showToolbar={false} />,
80
+ );
81
+ expect(bars(container)).toBe(0);
82
+ expect(screen.getByText(CONTENT)).toBeInTheDocument(); // content still renders
83
+ });
84
+
85
+ it('showToolbar={false}: no bar in any annotate render mode (text / image / pdf)', async () => {
86
+ const { session } = fakeSession();
87
+ const text = render(
88
+ <ResourceViewer session={session} resource={makeResource('text/plain')} annotations={annotations}
89
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
90
+ );
91
+ expect(bars(text.container)).toBe(0);
92
+
93
+ const image = render(
94
+ <ResourceViewer session={session} resource={makeResource('image/png')} annotations={annotations}
95
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
96
+ );
97
+ expect(within(image.container).getByText('svg-mock')).toBeInTheDocument();
98
+ expect(bars(image.container)).toBe(0);
99
+
100
+ const pdf = render(
101
+ <ResourceViewer session={session} resource={makeResource('application/pdf')} annotations={annotations}
102
+ annotateMode={true} onAnnotateModeChange={vi.fn()} showToolbar={false} />,
103
+ );
104
+ await within(pdf.container).findByText('pdf-mock'); // flush the lazy canvas
105
+ expect(bars(pdf.container)).toBe(0);
106
+ });
107
+
108
+ it('keystone: with the bar hidden, annotate-mode selection still emits mark.request', () => {
109
+ const { session, client } = fakeSession();
110
+ const { container } = render(
111
+ <ResourceViewer session={session} resource={makeResource('text/plain')} annotations={annotations}
112
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
113
+ selectionMotivation="highlighting" onSelectionMotivationChange={vi.fn()}
114
+ showToolbar={false} />,
115
+ );
116
+ expect(bars(container)).toBe(0);
117
+
118
+ // Select "world" (offsets 6..11) inside the rendered content, then mouseup.
119
+ const contentEl = within(container).getByText(CONTENT);
120
+ const range = document.createRange();
121
+ range.setStart(contentEl.firstChild!, 6);
122
+ range.setEnd(contentEl.firstChild!, 11);
123
+ const selection = window.getSelection()!;
124
+ selection.removeAllRanges();
125
+ selection.addRange(range);
126
+
127
+ fireEvent.mouseUp(contentEl);
128
+
129
+ expect(client.mark.request).toHaveBeenCalledTimes(1);
130
+ const [source, selectors, motivation] = client.mark.request.mock.calls[0]!;
131
+ expect(String(source)).toBe('res-1');
132
+ expect(selectors).toBeTruthy();
133
+ expect(motivation).toBe('highlighting');
134
+ });
135
+ });