@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.
Files changed (81) hide show
  1. package/README.md +1 -1
  2. package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-75GY2EDR.js} +15 -6
  3. package/dist/PdfAnnotationCanvas.client-75GY2EDR.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/{chunk-O2MD7TGE.js → chunk-VXASXU4K.js} +10 -2
  7. package/dist/chunk-VXASXU4K.js.map +1 -0
  8. package/dist/index.css +150 -112
  9. package/dist/index.css.map +1 -1
  10. package/dist/index.d.ts +334 -48
  11. package/dist/index.js +2494 -2201
  12. package/dist/index.js.map +1 -1
  13. package/dist/test-utils.d.ts +5 -1
  14. package/dist/test-utils.js +10 -4
  15. package/dist/test-utils.js.map +1 -1
  16. package/package.json +7 -7
  17. package/src/components/ResourceTagsInline.tsx +94 -11
  18. package/src/components/StatusDisplay.css +36 -0
  19. package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
  20. package/src/components/annotation/AnnotateToolbar.tsx +53 -28
  21. package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
  22. package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
  23. package/src/components/image-annotation/AnnotationOverlay.tsx +6 -6
  24. package/src/components/image-annotation/SvgDrawingCanvas.tsx +17 -5
  25. package/src/components/image-annotation/__tests__/AnnotationOverlay.click.test.tsx +137 -0
  26. package/src/components/image-annotation/__tests__/SvgDrawingCanvas.click.test.tsx +108 -0
  27. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +17 -4
  28. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +143 -7
  29. package/src/components/resource/AnnotateView.tsx +59 -38
  30. package/src/components/resource/BrowseView.tsx +172 -138
  31. package/src/components/resource/ResourceViewer.tsx +123 -130
  32. package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
  33. package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
  34. package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
  35. package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
  36. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
  37. package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
  38. package/src/components/resource/__tests__/BrowseView.test.tsx +133 -53
  39. package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +102 -0
  40. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
  41. package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
  42. package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
  43. package/src/components/resource/__tests__/browse-renderers.dispatch.test.tsx +113 -0
  44. package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
  45. package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
  46. package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
  47. package/src/components/resource/browse-renderers.tsx +85 -0
  48. package/src/components/resource/panels/AssessmentEntry.tsx +5 -4
  49. package/src/components/resource/panels/AssessmentPanel.tsx +12 -5
  50. package/src/components/resource/panels/AssistSection.tsx +4 -3
  51. package/src/components/resource/panels/CommentEntry.tsx +5 -4
  52. package/src/components/resource/panels/CommentsPanel.tsx +12 -5
  53. package/src/components/resource/panels/HighlightEntry.tsx +5 -4
  54. package/src/components/resource/panels/HighlightPanel.tsx +12 -5
  55. package/src/components/resource/panels/ReferenceEntry.tsx +9 -10
  56. package/src/components/resource/panels/ReferencesPanel.tsx +15 -6
  57. package/src/components/resource/panels/ResourceInfoPanel.tsx +4 -3
  58. package/src/components/resource/panels/TagEntry.tsx +5 -3
  59. package/src/components/resource/panels/TaggingPanel.tsx +11 -4
  60. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +15 -2
  61. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +28 -20
  62. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +39 -28
  63. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +46 -17
  64. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +65 -57
  65. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +54 -24
  66. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +27 -17
  67. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +23 -17
  68. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +48 -77
  69. package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +87 -0
  70. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +15 -9
  71. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +78 -59
  72. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +11 -4
  73. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +33 -19
  74. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +16 -7
  75. package/src/components/toolbar/Toolbar.css +14 -0
  76. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
  77. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +43 -17
  78. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
  79. package/src/styles/features/resource-viewer.css +19 -0
  80. package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
  81. 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
- // 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,
@@ -72,12 +70,8 @@ vi.mock('../../../lib/annotation-registry', () => ({
72
70
  },
73
71
  }));
74
72
 
75
- // Mock ImageViewer
76
- vi.mock('../../viewers', () => ({
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
- if (options.newAnnotationIds) {
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(component, { wrapper: Wrapper });
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
- if (options.newAnnotationIds) {
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(component, { wrapper: Wrapper });
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 image viewer for image mime types', () => {
215
- renderWithProviders(<BrowseView {...defaultProps} mimeType="image/png" />);
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(screen.getByTestId('image-viewer')).toBeInTheDocument();
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 only for reference annotations', async () => {
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 mockReferenceElement = document.createElement('span');
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 mockHighlightElement = document.createElement('span');
412
- mockHighlightElement.setAttribute('data-annotation-id', 'highlight-1');
413
- mockHighlightElement.setAttribute('data-annotation-type', 'highlight');
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 mockRefTarget = { closest: vi.fn(() => mockReferenceElement) } as any;
416
- const mockHighlightTarget = { closest: vi.fn(() => mockHighlightElement) } as any;
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
- const browseContainer = container.querySelector('.semiont-browse-view__content');
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
- tracker.clear();
459
+ const { container } = renderWithEventTracking(
460
+ <BrowseView {...defaultProps} annotations={annotations} />,
461
+ tracker
462
+ );
421
463
 
422
- // Click reference - should emit
423
- fireEvent.click(browseContainer!, { target: mockRefTarget });
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 === 'ref-1' &&
429
- e.payload?.motivation === 'linking'
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
- tracker.clear();
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
- // Click highlight - should not emit
436
- fireEvent.click(browseContainer!, { target: mockHighlightTarget });
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
- // Verify highlight doesn't trigger click events
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
- // Should not have any click events for highlights
547
- await new Promise(resolve => setTimeout(resolve, 50));
548
- expect(eventTracker.some(e => e.event === 'browse:click')).toBe(false);
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
+ });