@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.
- package/README.md +1 -1
- package/dist/{PdfAnnotationCanvas.client-P37SXBWQ.js → PdfAnnotationCanvas.client-KWQ7XDWT.js} +4 -1
- package/dist/PdfAnnotationCanvas.client-KWQ7XDWT.js.map +1 -0
- package/dist/{chunk-5LFFAVDI.js → chunk-PFUXQPWJ.js} +96 -96
- package/dist/{chunk-5LFFAVDI.js.map → chunk-PFUXQPWJ.js.map} +1 -1
- package/dist/index.css +150 -112
- package/dist/index.css.map +1 -1
- package/dist/index.d.ts +287 -39
- package/dist/index.js +2525 -2284
- package/dist/index.js.map +1 -1
- package/dist/test-utils.js +7 -2
- package/dist/test-utils.js.map +1 -1
- package/package.json +6 -6
- package/src/components/ResourceTagsInline.tsx +94 -11
- package/src/components/StatusDisplay.css +36 -0
- package/src/components/__tests__/ResourceTagsInline.test.tsx +101 -3
- package/src/components/annotation/AnnotateToolbar.tsx +53 -28
- package/src/components/annotation/__tests__/AnnotateToolbar.display.test.tsx +76 -0
- package/src/components/annotation/__tests__/AnnotateToolbar.test.tsx +124 -360
- package/src/components/image-annotation/SvgDrawingCanvas.tsx +5 -1
- package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +5 -0
- package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +6 -6
- package/src/components/resource/AnnotateView.tsx +59 -38
- package/src/components/resource/BrowseView.tsx +159 -133
- package/src/components/resource/ResourceViewer.tsx +119 -128
- package/src/components/resource/__tests__/AnnotateView.embeddable.test.tsx +55 -0
- package/src/components/resource/__tests__/BrowseView.embeddable.test.tsx +49 -0
- package/src/components/resource/__tests__/BrowseView.links.test.tsx +60 -0
- package/src/components/resource/__tests__/BrowseView.overlay-async.test.tsx +113 -0
- package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +161 -0
- package/src/components/resource/__tests__/BrowseView.registry.test.tsx +43 -0
- package/src/components/resource/__tests__/BrowseView.test.tsx +35 -22
- package/src/components/resource/__tests__/ResourceViewer.embeddable.test.tsx +63 -0
- package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +12 -7
- package/src/components/resource/__tests__/ResourceViewer.toolbar-opt-out.test.tsx +135 -0
- package/src/components/resource/__tests__/ResourceViewer.toolbar-prefs.test.tsx +200 -0
- package/src/components/resource/__tests__/browse-renderers.test.tsx +34 -0
- package/src/components/resource/__tests__/inline-embedding.test.tsx +81 -0
- package/src/components/resource/__tests__/media-completeness.test.tsx +88 -0
- package/src/components/resource/browse-renderers.tsx +63 -0
- package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
- package/src/components/resource/panels/CommentsPanel.tsx +4 -0
- package/src/components/resource/panels/HighlightPanel.tsx +4 -0
- package/src/components/resource/panels/ReferencesPanel.tsx +4 -0
- package/src/components/resource/panels/TaggingPanel.tsx +4 -0
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +5 -2
- package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +16 -16
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +1 -0
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +1 -0
- package/src/components/toolbar/Toolbar.css +14 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +7 -17
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +40 -17
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +2 -14
- package/src/styles/features/resource-viewer.css +19 -0
- package/dist/PdfAnnotationCanvas.client-P37SXBWQ.js.map +0 -1
|
@@ -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
|
-
|
|
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}
|