@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,200 @@
1
+ /**
2
+ * TOOLBAR-PREFS-AS-PROPS — keystone + per-pref specs.
3
+ *
4
+ * Preferences are state, not events: each of the four toolbar prefs (mode, click
5
+ * action, selection motivation, shape) is a controlled/uncontrolled prop pair.
6
+ * Controlled instances render the given value and report intents via the callback —
7
+ * never self-mutate, never touch localStorage, never hear other instances.
8
+ * Uncontrolled instances hold a plain internal default (false / 'detail' /
9
+ * 'linking' / 'rectangle') — NOT the legacy localStorage+bus behavior (that lives
10
+ * in the useToolbarPrefs() policy layer).
11
+ *
12
+ * RED ledger: authored `it.fails` (observed: 5 expected fail), flipped to `it` at
13
+ * Phase 1 GREEN. Provider-free; real AnnotateToolbar (its controls are the subject).
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import { render, fireEvent, within, waitFor } from '@testing-library/react';
17
+ import '@testing-library/jest-dom';
18
+ import type { SemiontSession } from '@semiont/sdk';
19
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
20
+ import { ResourceViewer } from '../ResourceViewer';
21
+
22
+ vi.mock('../../CodeMirrorRenderer', () => ({ CodeMirrorRenderer: () => <div>cm-mock</div> }));
23
+ vi.mock('../../image-annotation/SvgDrawingCanvas', () => ({ SvgDrawingCanvas: () => <div>svg-mock</div> }));
24
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
25
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
26
+
27
+ function makeResource(mediaType = 'text/plain'): SemiontResource & { content: string } {
28
+ return {
29
+ '@context': 'https://www.w3.org/ns/activitystreams',
30
+ '@id': 'res-1' as ResourceId,
31
+ name: 'Doc',
32
+ created: '2024-01-01T00:00:00Z',
33
+ entityTypes: [],
34
+ archived: false,
35
+ representations: [{ mediaType, byteSize: 10 }],
36
+ content: 'Prefs content.',
37
+ };
38
+ }
39
+
40
+ const annotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
41
+
42
+ /** Session double with a live subscribe registry so specs can fire legacy broadcasts. */
43
+ function liveSession() {
44
+ const handlers = new Map<string, Set<(p: unknown) => void>>();
45
+ const client = {
46
+ browse: { click: vi.fn(), invalidateAnnotationList: vi.fn() },
47
+ beckon: { hover: vi.fn() },
48
+ mark: {
49
+ toggleMode: vi.fn(), delete: vi.fn(), request: vi.fn(),
50
+ changeSelection: vi.fn(), changeClick: vi.fn(), changeShape: vi.fn(),
51
+ },
52
+ };
53
+ const session = {
54
+ client,
55
+ subscribe: (channel: string, handler: (p: unknown) => void) => {
56
+ if (!handlers.has(channel)) handlers.set(channel, new Set());
57
+ handlers.get(channel)!.add(handler);
58
+ return () => { handlers.get(channel)!.delete(handler); };
59
+ },
60
+ } as unknown as SemiontSession;
61
+ const fire = (channel: string, payload?: unknown) =>
62
+ handlers.get(channel)?.forEach((h) => h(payload));
63
+ return { session, client, fire };
64
+ }
65
+
66
+ const isAnnotate = (c: HTMLElement) => !!c.querySelector('.semiont-annotate-view');
67
+ const isBrowse = (c: HTMLElement) => !!c.querySelector('.semiont-browse-view');
68
+
69
+ /** Open a bar dropdown group by aria-label and click an option in its menu. */
70
+ async function pick(container: HTMLElement, group: string, option: string) {
71
+ const trigger = within(container).getByLabelText(group);
72
+ fireEvent.mouseEnter(trigger.parentElement!);
73
+ await waitFor(() => {
74
+ const menu = within(container).getByRole('menu');
75
+ fireEvent.click(within(menu).getByText(option));
76
+ });
77
+ }
78
+
79
+ const PREF_KEYS = ['annotateMode', 'semiont-toolbar-click', 'semiont-toolbar-selection'];
80
+ let getSpy: ReturnType<typeof vi.spyOn>;
81
+ let setSpy: ReturnType<typeof vi.spyOn>;
82
+ const prefKeyTouched = (spy: ReturnType<typeof vi.spyOn>) =>
83
+ spy.mock.calls.some((call: unknown[]) => {
84
+ const key = String(call[0]);
85
+ return PREF_KEYS.includes(key) || key.includes('shape');
86
+ });
87
+
88
+ describe('ResourceViewer — toolbar prefs as props', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ localStorage.clear();
92
+ getSpy = vi.spyOn(Storage.prototype, 'getItem');
93
+ setSpy = vi.spyOn(Storage.prototype, 'setItem');
94
+ });
95
+ afterEach(() => {
96
+ getSpy.mockRestore();
97
+ setSpy.mockRestore();
98
+ });
99
+
100
+ // ── Keystone: per-instance isolation across three viewers on one session ──
101
+ it('keystone: three viewers — controlled A/B independent, C uncontrolled; B\'s Mode click fires only B\'s callback', async () => {
102
+ const { session, client } = liveSession();
103
+ const onB = vi.fn();
104
+ const a = render(
105
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
106
+ annotateMode={true} clickAction="follow" onAnnotateModeChange={vi.fn()} onClickActionChange={vi.fn()} />,
107
+ );
108
+ const b = render(
109
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
110
+ annotateMode={false} onAnnotateModeChange={onB} />,
111
+ );
112
+ const c = render(
113
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
114
+ );
115
+
116
+ expect(isAnnotate(a.container)).toBe(true); // A renders its controlled mode
117
+ expect(isBrowse(b.container)).toBe(true);
118
+ expect(isBrowse(c.container)).toBe(true); // C: plain default (false)
119
+
120
+ await pick(b.container, 'Mode', 'Annotate');
121
+
122
+ expect(onB).toHaveBeenCalledWith(true); // B reports intent…
123
+ expect(isBrowse(b.container)).toBe(true); // …and does not self-flip
124
+ expect(isAnnotate(a.container)).toBe(true); // A unchanged
125
+ expect(isBrowse(c.container)).toBe(true); // C unchanged
126
+ expect(client.mark.toggleMode).not.toHaveBeenCalled(); // no global emit
127
+ expect(prefKeyTouched(getSpy)).toBe(false); // no localStorage traffic
128
+ expect(prefKeyTouched(setSpy)).toBe(false);
129
+ });
130
+
131
+ // ── Per-pref: mode ──
132
+ it('mode: uncontrolled uses the plain default and is inert to the legacy broadcast', () => {
133
+ localStorage.setItem('annotateMode', 'true'); // legacy key must be IGNORED
134
+ getSpy.mockClear(); setSpy.mockClear();
135
+ const { session, fire } = liveSession();
136
+ const u = render(
137
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations} />,
138
+ );
139
+ expect(isBrowse(u.container)).toBe(true); // plain default, not the stored 'true'
140
+ fire('mark:mode-toggled');
141
+ expect(isBrowse(u.container)).toBe(true); // inert: preference events are gone
142
+ expect(prefKeyTouched(getSpy)).toBe(false);
143
+ expect(prefKeyTouched(setSpy)).toBe(false);
144
+ });
145
+
146
+ // ── Per-pref: click action ──
147
+ it('clickAction: controlled renders the value; the bar control reports and does not mutate', async () => {
148
+ const { session, client } = liveSession();
149
+ const onChange = vi.fn();
150
+ const v = render(
151
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
152
+ clickAction="follow" onClickActionChange={onChange} />,
153
+ );
154
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
155
+ expect(within(bar).getByText('Follow')).toBeInTheDocument(); // renders the prop value
156
+
157
+ await pick(v.container, 'Click', 'Detail');
158
+ expect(onChange).toHaveBeenCalledWith('detail'); // reports intent
159
+ expect(within(bar).getByText('Follow')).toBeInTheDocument(); // does not self-mutate
160
+ expect(client.mark.changeClick).not.toHaveBeenCalled();
161
+ });
162
+
163
+ // ── Per-pref: selection motivation (annotate-mode bar) ──
164
+ it('selectionMotivation: controlled renders the value; picking reports; re-picking the current reports null', async () => {
165
+ const { session, client } = liveSession();
166
+ const onChange = vi.fn();
167
+ const v = render(
168
+ <ResourceViewer session={session} resource={makeResource()} annotations={annotations}
169
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
170
+ selectionMotivation="highlighting" onSelectionMotivationChange={onChange} />,
171
+ );
172
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
173
+ expect(within(bar).getByText('Highlight')).toBeInTheDocument(); // renders the prop value
174
+
175
+ await pick(v.container, 'Motivation', 'Comment');
176
+ expect(onChange).toHaveBeenCalledWith('commenting');
177
+
178
+ await pick(v.container, 'Motivation', 'Highlight'); // current value → toggles off
179
+ expect(onChange).toHaveBeenCalledWith(null);
180
+ expect(client.mark.changeSelection).not.toHaveBeenCalled();
181
+ });
182
+
183
+ // ── Per-pref: shape (media-gated; image annotate bar) ──
184
+ it('shape: controlled renders the value; picking reports and does not mutate', async () => {
185
+ const { session, client } = liveSession();
186
+ const onChange = vi.fn();
187
+ const v = render(
188
+ <ResourceViewer session={session} resource={makeResource('image/png')} annotations={annotations}
189
+ annotateMode={true} onAnnotateModeChange={vi.fn()}
190
+ shape="circle" onShapeChange={onChange} />,
191
+ );
192
+ const bar = v.container.querySelector('.semiont-annotate-toolbar') as HTMLElement;
193
+ expect(within(bar).getByText('Circle')).toBeInTheDocument(); // renders the prop value
194
+
195
+ await pick(v.container, 'Shape', 'Polygon');
196
+ expect(onChange).toHaveBeenCalledWith('polygon');
197
+ expect(within(bar).getByText('Circle')).toBeInTheDocument(); // does not self-mutate
198
+ expect(client.mark.changeShape).not.toHaveBeenCalled();
199
+ });
200
+ });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 0 — regression: the browse `text` default
3
+ * renders markdown as **formatted prose**, not raw source.
4
+ *
5
+ * The other BrowseView suites mock `react-markdown` away for simplicity, so
6
+ * nothing currently pins that the default text renderer actually *formats*
7
+ * markdown. This spec uses the real renderer and guards against a regression to
8
+ * raw/source rendering.
9
+ */
10
+ import { describe, it, expect } from 'vitest';
11
+ import { render } from '@testing-library/react';
12
+ import '@testing-library/jest-dom';
13
+ import { TextBrowseRenderer, defaultBrowseRenderers } from '../browse-renderers';
14
+
15
+ const base = { mimeType: 'text/markdown', resourceUri: 'res-1', annotations: [] };
16
+
17
+ describe('browse-renderers — markdown-as-prose (Phase 0 regression)', () => {
18
+ it('TextBrowseRenderer is the default `text` renderer', () => {
19
+ expect(defaultBrowseRenderers.text).toBe(TextBrowseRenderer);
20
+ });
21
+
22
+ it('renders markdown as formatted HTML, not raw source', () => {
23
+ const { container } = render(
24
+ <TextBrowseRenderer content={'# Big Heading\n\nsome **bold** and a [link](https://x.test).'} {...base} />,
25
+ );
26
+ // Formatted: heading / strong / anchor elements exist (react-markdown output).
27
+ expect(container.querySelector('h1')?.textContent).toBe('Big Heading');
28
+ expect(container.querySelector('strong')?.textContent).toBe('bold');
29
+ expect(container.querySelector('a')?.getAttribute('href')).toBe('https://x.test');
30
+ // NOT raw: the literal markdown syntax must not survive to the text content.
31
+ expect(container.textContent).not.toContain('# Big Heading');
32
+ expect(container.textContent).not.toContain('**bold**');
33
+ });
34
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 1 — inline embedding.
3
+ *
4
+ * `inline` is a display variant (default: today's pane behavior, frontend
5
+ * untouched): the viewer renders at content height in a bare container — no
6
+ * inner scroll container, no pane chrome. jsdom can't compute stylesheet
7
+ * layout, so this spec pins the two testable seams: (1) the components emit
8
+ * the `--inline` modifier classes; (2) the stylesheet contains the inline
9
+ * overrides (height:auto / overflow:visible / padding drop). The visual
10
+ * auto-height check is the plan's live smoke-test.
11
+ *
12
+ * Started RED (no `inline` prop) and GREEN once Phase 1 lands.
13
+ */
14
+ import { describe, it, expect, vi } from 'vitest';
15
+ import { render } from '@testing-library/react';
16
+ import '@testing-library/jest-dom';
17
+ import { readFileSync } from 'node:fs';
18
+ import { resolve } from 'node:path';
19
+ import type { SemiontSession } from '@semiont/sdk';
20
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
21
+ import { BrowseView } from '../BrowseView';
22
+ import { ResourceViewer } from '../ResourceViewer';
23
+
24
+ vi.mock('../../annotation/AnnotateToolbar', () => ({ AnnotateToolbar: () => null }));
25
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
26
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
27
+
28
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
29
+
30
+ function fakeSession(): SemiontSession {
31
+ return {
32
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
33
+ subscribe: () => () => {},
34
+ } as unknown as SemiontSession;
35
+ }
36
+
37
+ const resource: SemiontResource & { content: string } = {
38
+ '@context': 'https://www.w3.org/ns/activitystreams',
39
+ '@id': 'res-1' as ResourceId,
40
+ name: 'Doc',
41
+ created: '2024-01-01T00:00:00Z',
42
+ entityTypes: [],
43
+ archived: false,
44
+ representations: [{ mediaType: 'text/plain', byteSize: 10 }],
45
+ content: 'Inline content.',
46
+ };
47
+
48
+ describe('inline embedding (Phase 1)', () => {
49
+ it('BrowseView: `inline` adds the modifier class; default does not', () => {
50
+ const props = {
51
+ content: 'x', mimeType: 'text/plain', resourceUri: 'res-1',
52
+ annotations: emptyAnnotations, annotateMode: false, session: fakeSession(),
53
+ };
54
+ const { container: pane } = render(<BrowseView {...props} />);
55
+ expect(pane.querySelector('.semiont-browse-view')).not.toHaveClass('semiont-browse-view--inline');
56
+
57
+ const { container } = render(<BrowseView {...props} inline />);
58
+ expect(container.querySelector('.semiont-browse-view')).toHaveClass('semiont-browse-view--inline');
59
+ });
60
+
61
+ it('ResourceViewer: `inline` marks its root and threads to BrowseView', () => {
62
+ const { container } = render(
63
+ <ResourceViewer
64
+ resource={resource} annotations={emptyAnnotations}
65
+ session={fakeSession()} inline
66
+ />,
67
+ );
68
+ expect(container.querySelector('.semiont-resource-viewer')).toHaveClass('semiont-resource-viewer--inline');
69
+ expect(container.querySelector('.semiont-browse-view')).toHaveClass('semiont-browse-view--inline');
70
+ });
71
+
72
+ it('stylesheet carries the inline overrides (static gate — layout itself is the live check)', () => {
73
+ // vitest runs with cwd = the package root (same in CI's per-workspace run).
74
+ const css = readFileSync(resolve(process.cwd(), 'src/styles/features/resource-viewer.css'), 'utf8');
75
+ // Auto-height roots, and the content area stops being a scroll container / drops the pane gutter.
76
+ expect(css).toMatch(/\.semiont-resource-viewer--inline\s*\{[^}]*height:\s*auto/);
77
+ expect(css).toMatch(/\.semiont-browse-view--inline\s*\{[^}]*height:\s*auto/);
78
+ expect(css).toMatch(/\.semiont-browse-view--inline\s+\.semiont-browse-view__content\s*\{[^}]*overflow:\s*visible/);
79
+ expect(css).toMatch(/\.semiont-browse-view--inline\s+\.semiont-browse-view__content\s*\{[^}]*padding:\s*0/);
80
+ });
81
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * EMBEDDABLE-VIEWER-COMPLETION Phase 4 — media-completeness sweep.
3
+ *
4
+ * ONE mounted component (`ResourceViewer`, inline, bare session, no providers)
5
+ * renders every standard media type from the DEFAULTS — text, markdown, image,
6
+ * pdf — with the annotation-overlay container present and zero per-type wiring.
7
+ * (Formatted-markdown correctness is pinned by browse-renderers.test; visual
8
+ * overlay alignment is the plan's live smoke-test. This sweep pins the routing
9
+ * parity: every type reaches its renderer from one component.)
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { render, screen } from '@testing-library/react';
13
+ import '@testing-library/jest-dom';
14
+ import type { SemiontSession } from '@semiont/sdk';
15
+ import type { ResourceDescriptor as SemiontResource, ResourceId } from '@semiont/core';
16
+ import { ResourceViewer } from '../ResourceViewer';
17
+
18
+ vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div data-testid="md">{children}</div> }));
19
+ vi.mock('remark-gfm', () => ({ default: () => ({}) }));
20
+ // pdfjs can't load in jsdom — the sweep pins ROUTING to the pdf renderer, not pdf.js itself.
21
+ vi.mock('../../pdf-annotation/PdfAnnotationCanvas.client', () => ({
22
+ PdfAnnotationCanvas: ({ pdfUrl }: { pdfUrl: string }) => <div data-testid="pdf-canvas">{pdfUrl}</div>,
23
+ }));
24
+
25
+ const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
26
+
27
+ function fakeSession(): SemiontSession {
28
+ return {
29
+ client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
30
+ subscribe: () => () => {},
31
+ } as unknown as SemiontSession;
32
+ }
33
+
34
+ function makeResource(mediaType: string, content: string): SemiontResource & { content: string } {
35
+ return {
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, byteSize: 10 }],
43
+ content,
44
+ };
45
+ }
46
+
47
+ function mount(mediaType: string, content: string) {
48
+ return render(
49
+ <ResourceViewer
50
+ resource={makeResource(mediaType, content)}
51
+ annotations={emptyAnnotations}
52
+ session={fakeSession()}
53
+ inline
54
+ />,
55
+ );
56
+ }
57
+
58
+ describe('media-completeness sweep (Phase 4) — one component, all types, inline, defaults only', () => {
59
+ it.each([
60
+ ['text/plain', 'plain body'],
61
+ ['text/markdown', '# md body'],
62
+ ])('%s renders via the text default with the overlay container', (mt, content) => {
63
+ const { container } = mount(mt, content);
64
+ expect(container.querySelector('.semiont-browse-view[data-mime-type="text"]')).toBeInTheDocument();
65
+ expect(screen.getByTestId('md')).toHaveTextContent(content.replace('# ', '# ').trim());
66
+ expect(container.querySelector('.semiont-browse-view__content')).toBeInTheDocument(); // overlay anchor
67
+ expect(container.querySelector('.semiont-browse-view')).toHaveClass('semiont-browse-view--inline');
68
+ });
69
+
70
+ it('image/png renders via the image default', () => {
71
+ const { container } = mount('image/png', 'https://media.test/img.png?token=t');
72
+ expect(container.querySelector('.semiont-browse-view[data-mime-type="image"]')).toBeInTheDocument();
73
+ expect(container.querySelector('img')).toBeInTheDocument();
74
+ expect(container.querySelector('.semiont-browse-view__content')).toBeInTheDocument();
75
+ });
76
+
77
+ it('application/pdf routes to the pdf default', async () => {
78
+ const { container } = mount('application/pdf', 'https://media.test/doc.pdf?token=t');
79
+ expect(container.querySelector('.semiont-browse-view[data-mime-type="pdf"]')).toBeInTheDocument();
80
+ expect(await screen.findByTestId('pdf-canvas')).toHaveTextContent('https://media.test/doc.pdf?token=t');
81
+ });
82
+
83
+ it('an unknown type degrades to metadata + download (no crash, no per-type wiring)', () => {
84
+ const { container } = mount('application/octet-stream', '');
85
+ expect(container.querySelector('[data-mime-type="unsupported"]')).toBeInTheDocument();
86
+ expect(screen.getByText(/Preview not available/)).toBeInTheDocument();
87
+ });
88
+ });
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import { lazy, Suspense, memo, type ComponentType } from 'react';
4
+ import ReactMarkdown from 'react-markdown';
5
+ import remarkGfm from 'remark-gfm';
6
+ import type { Annotation } from '@semiont/core';
7
+ import { ImageViewer } from '../viewers';
8
+
9
+ // Lazy-load the PDF component to avoid SSR issues with browser PDF.js loading.
10
+ const PdfAnnotationCanvas = lazy(() => import('../pdf-annotation/PdfAnnotationCanvas.client').then(mod => ({ default: mod.PdfAnnotationCanvas })));
11
+
12
+ /**
13
+ * Common props for a read-only ("browse") media renderer. `content` is decoded
14
+ * text for the text renderer, or a media URL for the image / PDF renderers.
15
+ * A consumer can implement this interface to swap in a custom renderer.
16
+ */
17
+ export interface MediaRendererProps {
18
+ content: string;
19
+ mimeType: string;
20
+ resourceUri: string;
21
+ annotations: Annotation[];
22
+ }
23
+
24
+ /** Read-only media dispatch, keyed by the registry render mode. */
25
+ export type BrowseMediaRenderers = Partial<Record<string, ComponentType<MediaRendererProps>>>;
26
+
27
+ const MemoizedMarkdown = memo(function MemoizedMarkdown({ content }: { content: string }) {
28
+ // No annotation plugins: annotations are applied as a DOM overlay after paint.
29
+ return <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>;
30
+ });
31
+
32
+ export function TextBrowseRenderer({ content }: MediaRendererProps) {
33
+ return <MemoizedMarkdown content={content} />;
34
+ }
35
+
36
+ export function ImageBrowseRenderer({ content, mimeType }: MediaRendererProps) {
37
+ return <ImageViewer imageUrl={content} mimeType={mimeType} alt="Resource content" />;
38
+ }
39
+
40
+ export function PdfBrowseRenderer({ content, resourceUri, annotations }: MediaRendererProps) {
41
+ return (
42
+ <Suspense fallback={<div className="semiont-browse-view__loading">Loading PDF viewer...</div>}>
43
+ <PdfAnnotationCanvas
44
+ pdfUrl={content}
45
+ resourceUri={resourceUri}
46
+ existingAnnotations={annotations}
47
+ drawingMode={null}
48
+ selectedMotivation={null}
49
+ />
50
+ </Suspense>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Default read-only media renderers. `BrowseView` merges a caller's `renderers`
56
+ * override on top of these, so a host can swap one renderer (e.g. its own PDF
57
+ * viewer) or add a mode without forking the view.
58
+ */
59
+ export const defaultBrowseRenderers: Record<'text' | 'image' | 'pdf', ComponentType<MediaRendererProps>> = {
60
+ text: TextBrowseRenderer,
61
+ image: ImageBrowseRenderer,
62
+ pdf: PdfBrowseRenderer,
63
+ };
@@ -40,6 +40,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
40
40
  }
41
41
 
42
42
  interface AssessmentPanelProps {
43
+ /** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
44
+ resourceId: string;
43
45
  annotations: Annotation[];
44
46
  pendingAnnotation: PendingAnnotation | null;
45
47
  isAssisting?: boolean;
@@ -61,6 +63,7 @@ interface AssessmentPanelProps {
61
63
  * @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
62
64
  */
63
65
  export function AssessmentPanel({
66
+ resourceId,
64
67
  annotations,
65
68
  pendingAnnotation,
66
69
  isAssisting = false,
@@ -151,6 +154,7 @@ export function AssessmentPanel({
151
154
  : undefined;
152
155
 
153
156
  session?.client.mark.submit({
157
+ source: resourceId,
154
158
  motivation: 'assessing',
155
159
  selector: pendingAnnotation.selector,
156
160
  ...(body !== undefined ? { body } : {}),
@@ -40,6 +40,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
40
40
  }
41
41
 
42
42
  interface CommentsPanelProps {
43
+ /** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
44
+ resourceId: string;
43
45
  annotations: Annotation[];
44
46
  pendingAnnotation: PendingAnnotation | null;
45
47
  annotateMode?: boolean;
@@ -61,6 +63,7 @@ interface CommentsPanelProps {
61
63
  * @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
62
64
  */
63
65
  export function CommentsPanel({
66
+ resourceId,
64
67
  annotations,
65
68
  pendingAnnotation,
66
69
  annotateMode = true,
@@ -171,6 +174,7 @@ export function CommentsPanel({
171
174
  const handleSaveNewComment = () => {
172
175
  if (newCommentText.trim() && pendingAnnotation) {
173
176
  session?.client.mark.submit({
177
+ source: resourceId,
174
178
  motivation: 'commenting',
175
179
  selector: pendingAnnotation.selector,
176
180
  body: [{ type: 'TextualBody', value: newCommentText, purpose: 'commenting' }],
@@ -23,6 +23,8 @@ interface PendingAnnotation {
23
23
  }
24
24
 
25
25
  interface HighlightPanelProps {
26
+ /** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
27
+ resourceId: string;
26
28
  annotations: Annotation[];
27
29
  pendingAnnotation: PendingAnnotation | null;
28
30
  isAssisting?: boolean;
@@ -42,6 +44,7 @@ interface HighlightPanelProps {
42
44
  * @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
43
45
  */
44
46
  export function HighlightPanel({
47
+ resourceId,
45
48
  annotations,
46
49
  pendingAnnotation,
47
50
  isAssisting = false,
@@ -136,6 +139,7 @@ export function HighlightPanel({
136
139
  useEffect(() => {
137
140
  if (pendingAnnotation && pendingAnnotation.motivation === 'highlighting') {
138
141
  session?.client.mark.submit({
142
+ source: resourceId,
139
143
  motivation: 'highlighting',
140
144
  selector: pendingAnnotation.selector,
141
145
  });
@@ -43,6 +43,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
43
43
  }
44
44
 
45
45
  interface Props {
46
+ /** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
47
+ resourceId: string;
46
48
  // Generic panel props
47
49
  annotations?: Annotation[];
48
50
  isAssisting: boolean;
@@ -76,6 +78,7 @@ interface Props {
76
78
  * @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
77
79
  */
78
80
  export function ReferencesPanel({
81
+ resourceId,
79
82
  annotations = [],
80
83
  isAssisting,
81
84
  progress,
@@ -255,6 +258,7 @@ export function ReferencesPanel({
255
258
  if (pendingAnnotation) {
256
259
  const entityType = pendingEntityTypes.join(',') || undefined;
257
260
  session?.client.mark.submit({
261
+ source: resourceId,
258
262
  motivation: 'linking',
259
263
  selector: pendingAnnotation.selector,
260
264
  body: entityType ? [{ type: 'TextualBody', value: entityType, purpose: 'tagging' }] : [],
@@ -39,6 +39,8 @@ function getSelectorDisplayText(selector: Selector | Selector[]): string | null
39
39
  }
40
40
 
41
41
  interface TaggingPanelProps {
42
+ /** The '@id' of the panel's resource — stamped as `source` on mark:submit (multi-viewer routing). */
43
+ resourceId: string;
42
44
  annotations: Annotation[];
43
45
  annotateMode?: boolean;
44
46
  isAssisting?: boolean;
@@ -62,6 +64,7 @@ interface TaggingPanelProps {
62
64
  * @subscribes browse:click - Annotation clicked. Payload: { annotationId: string }
63
65
  */
64
66
  export function TaggingPanel({
67
+ resourceId,
65
68
  annotations,
66
69
  annotateMode = true,
67
70
  isAssisting = false,
@@ -309,6 +312,7 @@ export function TaggingPanel({
309
312
  onChange={(e) => {
310
313
  if (e.target.value && pendingAnnotation) {
311
314
  session?.client.mark.submit({
315
+ source: resourceId,
312
316
  motivation: 'tagging',
313
317
  selector: pendingAnnotation.selector,
314
318
  body: [
@@ -59,8 +59,9 @@ interface UnifiedAnnotationsPanelProps {
59
59
  referencedBy?: any[];
60
60
  referencedByLoading?: boolean;
61
61
 
62
- // Resource context
63
- resourceId?: string;
62
+ // Resource context — threaded to every per-motivation panel, which stamps it
63
+ // as `source` on mark:submit (multi-viewer routing).
64
+ resourceId: string;
64
65
  initialTab?: TabKey;
65
66
  initialTabGeneration?: number; // Generation counter for tab switching
66
67
 
@@ -244,6 +245,7 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
244
245
 
245
246
  // Common props for all annotation panels
246
247
  const commonProps = {
248
+ resourceId: props.resourceId,
247
249
  annotations,
248
250
  pendingAnnotation: props.pendingAnnotation,
249
251
  isAssisting,
@@ -268,6 +270,7 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
268
270
  if (activeTab === 'reference') {
269
271
  return (
270
272
  <ReferencesPanel
273
+ resourceId={commonProps.resourceId}
271
274
  annotations={commonProps.annotations}
272
275
  pendingAnnotation={commonProps.pendingAnnotation}
273
276
  isAssisting={commonProps.isAssisting}
@@ -140,6 +140,7 @@ const createPendingAnnotation = (exact: string) => ({
140
140
 
141
141
  describe('AssessmentPanel Component', () => {
142
142
  const defaultProps = {
143
+ resourceId: 'res-1',
143
144
  annotations: mockAssessments.empty,
144
145
  pendingAnnotation: null,
145
146
  };
@@ -141,6 +141,7 @@ const createPendingAnnotation = (exact: string) => ({
141
141
 
142
142
  describe('CommentsPanel Component', () => {
143
143
  const defaultProps = {
144
+ resourceId: 'res-1',
144
145
  annotations: mockComments.empty,
145
146
  pendingAnnotation: null,
146
147
  };