@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semiont/react-ui",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.11",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=24.0.0"
|
|
6
6
|
},
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"@types/react-dom": "^19",
|
|
99
99
|
"@vitest/coverage-v8": "^4.1.8",
|
|
100
100
|
"autoprefixer": "^10.5.2",
|
|
101
|
-
"fast-check": "^4.8.0",
|
|
102
101
|
"axe-core": "^4.12.1",
|
|
103
102
|
"cssnano": "^8.0.2",
|
|
103
|
+
"fast-check": "^4.8.0",
|
|
104
104
|
"jest-axe": "^10.0.0",
|
|
105
105
|
"jsdom": "^29.1.1",
|
|
106
106
|
"postcss-import": "^16.1.1",
|
|
@@ -122,10 +122,10 @@
|
|
|
122
122
|
"directory": "packages/react-ui"
|
|
123
123
|
},
|
|
124
124
|
"dependencies": {
|
|
125
|
-
"@semiont/core": "0.5.
|
|
126
|
-
"@semiont/http-transport": "0.5.
|
|
127
|
-
"@semiont/sdk": "0.5.
|
|
128
|
-
"pdfjs-dist": "^6.
|
|
125
|
+
"@semiont/core": "0.5.11",
|
|
126
|
+
"@semiont/http-transport": "0.5.11",
|
|
127
|
+
"@semiont/sdk": "0.5.11",
|
|
128
|
+
"pdfjs-dist": "^6.1.200",
|
|
129
129
|
"react-error-boundary": "^6.1.2",
|
|
130
130
|
"react-markdown": "^10.1.0",
|
|
131
131
|
"remark-gfm": "^4.0.1",
|
|
@@ -1,34 +1,117 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
3
5
|
interface ResourceTagsInlineProps {
|
|
4
6
|
resourceId: string;
|
|
5
7
|
tags: string[];
|
|
6
8
|
isEditing: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Commit: called ONCE per edit with the FULL new tag set (the host diffs —
|
|
11
|
+
* its SDK call takes `(current, updated)`). Awaited; the editor renders
|
|
12
|
+
* inert while the returned promise is pending.
|
|
13
|
+
*/
|
|
7
14
|
onUpdate: (tags: string[]) => Promise<void>;
|
|
8
15
|
disabled?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* The KB's registered entity types — the CONTROLLED VOCABULARY, host-supplied
|
|
18
|
+
* (the component is session-less; hosts fetch via `browse.entityTypes()`).
|
|
19
|
+
* Adds are a picker constrained to it: no free-text minting. Without it the
|
|
20
|
+
* editor is REMOVAL-ONLY (it cannot offer valid candidates). Removal is never
|
|
21
|
+
* vocabulary-gated — stale/unregistered tags must stay removable.
|
|
22
|
+
*/
|
|
23
|
+
vocabulary?: string[];
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
export function ResourceTagsInline({
|
|
12
|
-
tags
|
|
27
|
+
tags,
|
|
28
|
+
isEditing,
|
|
29
|
+
onUpdate,
|
|
30
|
+
disabled = false,
|
|
31
|
+
vocabulary,
|
|
13
32
|
}: ResourceTagsInlineProps) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
33
|
+
const [busy, setBusy] = useState(false);
|
|
34
|
+
const [picking, setPicking] = useState(false);
|
|
35
|
+
|
|
36
|
+
// Browse mode: exactly the historical read-only rendering (null when empty).
|
|
37
|
+
if (!isEditing) {
|
|
38
|
+
if (tags.length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return (
|
|
42
|
+
<div className="semiont-resource-tags-inline">
|
|
43
|
+
<div className="semiont-resource-tags-list">
|
|
44
|
+
{tags.map((tag) => (
|
|
45
|
+
<span key={tag} className="semiont-resource-tag">
|
|
46
|
+
{tag}
|
|
47
|
+
</span>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
17
52
|
}
|
|
18
53
|
|
|
54
|
+
// Edit mode: the empty strip still renders — tagging an UNTAGGED resource is
|
|
55
|
+
// the primary case; the null short-circuit is browse-only.
|
|
56
|
+
const inert = disabled || busy;
|
|
57
|
+
const candidates = (vocabulary ?? []).filter((v) => !tags.includes(v));
|
|
58
|
+
|
|
59
|
+
const commit = async (next: string[]) => {
|
|
60
|
+
if (inert) return; // in-flight or host-disabled: block further commits
|
|
61
|
+
setBusy(true);
|
|
62
|
+
try {
|
|
63
|
+
await onUpdate(next);
|
|
64
|
+
} finally {
|
|
65
|
+
setBusy(false);
|
|
66
|
+
setPicking(false);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
19
70
|
return (
|
|
20
|
-
<div className="semiont-resource-tags-inline">
|
|
71
|
+
<div className="semiont-resource-tags-inline semiont-resource-tags-inline--editing">
|
|
21
72
|
<div className="semiont-resource-tags-list">
|
|
22
|
-
{/* Display existing tags */}
|
|
23
73
|
{tags.map((tag) => (
|
|
24
|
-
<span
|
|
25
|
-
key={tag}
|
|
26
|
-
className="semiont-resource-tag"
|
|
27
|
-
>
|
|
74
|
+
<span key={tag} className="semiont-resource-tag semiont-resource-tag--editable">
|
|
28
75
|
{tag}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
className="semiont-resource-tag__remove"
|
|
79
|
+
aria-label={`Remove ${tag}`}
|
|
80
|
+
disabled={inert}
|
|
81
|
+
onClick={() => void commit(tags.filter((t) => t !== tag))}
|
|
82
|
+
>
|
|
83
|
+
✕
|
|
84
|
+
</button>
|
|
29
85
|
</span>
|
|
30
86
|
))}
|
|
87
|
+
{vocabulary && (
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
className="semiont-resource-tag semiont-resource-tag--add"
|
|
91
|
+
aria-label="Add tag"
|
|
92
|
+
aria-expanded={picking}
|
|
93
|
+
disabled={inert || candidates.length === 0}
|
|
94
|
+
onClick={() => setPicking((v) => !v)}
|
|
95
|
+
>
|
|
96
|
+
+
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
31
99
|
</div>
|
|
100
|
+
{picking && candidates.length > 0 && (
|
|
101
|
+
<div className="semiont-form__entity-type-buttons">
|
|
102
|
+
{candidates.map((entityType) => (
|
|
103
|
+
<button
|
|
104
|
+
key={entityType}
|
|
105
|
+
type="button"
|
|
106
|
+
className="semiont-form__entity-type-button"
|
|
107
|
+
disabled={inert}
|
|
108
|
+
onClick={() => void commit([...tags, entityType])}
|
|
109
|
+
>
|
|
110
|
+
{entityType}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
32
115
|
</div>
|
|
33
116
|
);
|
|
34
|
-
}
|
|
117
|
+
}
|
|
@@ -209,6 +209,42 @@
|
|
|
209
209
|
font-size: var(--semiont-font-size-xs);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/* Editing affordances (RESOURCE-TAGS-INLINE-EDITING) */
|
|
213
|
+
.semiont-resource-tag__remove {
|
|
214
|
+
display: inline-flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
padding: 0;
|
|
217
|
+
border: none;
|
|
218
|
+
background: none;
|
|
219
|
+
color: inherit;
|
|
220
|
+
font-size: var(--semiont-font-size-xs);
|
|
221
|
+
cursor: pointer;
|
|
222
|
+
opacity: 0.7;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.semiont-resource-tag__remove:hover:not(:disabled) {
|
|
226
|
+
opacity: 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.semiont-resource-tag__remove:disabled {
|
|
230
|
+
cursor: default;
|
|
231
|
+
opacity: 0.4;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.semiont-resource-tag--add {
|
|
235
|
+
cursor: pointer;
|
|
236
|
+
background: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.semiont-resource-tag--add:disabled {
|
|
240
|
+
cursor: default;
|
|
241
|
+
opacity: 0.4;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.semiont-resource-tags-inline--editing .semiont-form__entity-type-buttons {
|
|
245
|
+
margin-top: var(--semiont-spacing-xs);
|
|
246
|
+
}
|
|
247
|
+
|
|
212
248
|
/* Dark mode for ResourceTagsInline - Fixed to respect data-theme attribute */
|
|
213
249
|
[data-theme="dark"] .semiont-resource-tags-inline {
|
|
214
250
|
border-top-color: var(--semiont-color-neutral-700);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
3
|
import { ResourceTagsInline } from '../ResourceTagsInline';
|
|
4
4
|
|
|
5
5
|
describe('ResourceTagsInline', () => {
|
|
6
|
-
const mockOnUpdate = vi.fn();
|
|
6
|
+
const mockOnUpdate = vi.fn(async () => {});
|
|
7
7
|
const defaultProps = {
|
|
8
8
|
resourceId: 'doc-123',
|
|
9
9
|
tags: ['tag1', 'tag2', 'tag3'],
|
|
@@ -11,6 +11,11 @@ describe('ResourceTagsInline', () => {
|
|
|
11
11
|
onUpdate: mockOnUpdate,
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
mockOnUpdate.mockClear();
|
|
16
|
+
mockOnUpdate.mockImplementation(async () => {});
|
|
17
|
+
});
|
|
18
|
+
|
|
14
19
|
describe('Rendering', () => {
|
|
15
20
|
it('should render tags', () => {
|
|
16
21
|
render(<ResourceTagsInline {...defaultProps} />);
|
|
@@ -177,4 +182,97 @@ describe('ResourceTagsInline', () => {
|
|
|
177
182
|
expect(container.firstChild).toBeNull();
|
|
178
183
|
});
|
|
179
184
|
});
|
|
185
|
+
|
|
186
|
+
// ── RESOURCE-TAGS-INLINE-EDITING: the declared contract, implemented ──
|
|
187
|
+
|
|
188
|
+
describe('Editing mode', () => {
|
|
189
|
+
it('browse mode renders no editing affordances (pins today’s rendering)', () => {
|
|
190
|
+
const { container } = render(
|
|
191
|
+
<ResourceTagsInline {...defaultProps} isEditing={false} vocabulary={['Person']} />,
|
|
192
|
+
);
|
|
193
|
+
expect(container.querySelector('button')).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('renders the editor for EMPTY tags while editing (the primary tagging case)', () => {
|
|
197
|
+
const { container } = render(
|
|
198
|
+
<ResourceTagsInline {...defaultProps} tags={[]} isEditing={true} vocabulary={['Person']} />,
|
|
199
|
+
);
|
|
200
|
+
// No null short-circuit in edit mode: the strip renders with an add affordance.
|
|
201
|
+
expect(container.firstChild).not.toBeNull();
|
|
202
|
+
expect(screen.getByLabelText('Add tag')).toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('removing a tag commits ONCE with the full new set', async () => {
|
|
206
|
+
render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
|
|
207
|
+
fireEvent.click(screen.getByLabelText('Remove tag2'));
|
|
208
|
+
await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
|
|
209
|
+
expect(mockOnUpdate).toHaveBeenCalledWith(['tag1', 'tag3']);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('disabled renders the editor inert', () => {
|
|
213
|
+
render(
|
|
214
|
+
<ResourceTagsInline {...defaultProps} isEditing={true} disabled={true} vocabulary={['Person']} />,
|
|
215
|
+
);
|
|
216
|
+
fireEvent.click(screen.getByLabelText('Remove tag1'));
|
|
217
|
+
expect(mockOnUpdate).not.toHaveBeenCalled();
|
|
218
|
+
expect(screen.getByLabelText('Remove tag1')).toBeDisabled();
|
|
219
|
+
expect(screen.getByLabelText('Add tag')).toBeDisabled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('an in-flight onUpdate blocks a second commit', async () => {
|
|
223
|
+
let resolveUpdate!: () => void;
|
|
224
|
+
mockOnUpdate.mockImplementation(() => new Promise<void>((r) => { resolveUpdate = r; }));
|
|
225
|
+
render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
|
|
226
|
+
|
|
227
|
+
fireEvent.click(screen.getByLabelText('Remove tag1'));
|
|
228
|
+
fireEvent.click(screen.getByLabelText('Remove tag2')); // while pending — must not fire
|
|
229
|
+
expect(mockOnUpdate).toHaveBeenCalledTimes(1);
|
|
230
|
+
|
|
231
|
+
resolveUpdate();
|
|
232
|
+
await waitFor(() => expect(screen.getByLabelText('Remove tag2')).not.toBeDisabled());
|
|
233
|
+
fireEvent.click(screen.getByLabelText('Remove tag2')); // settled — fires again
|
|
234
|
+
expect(mockOnUpdate).toHaveBeenCalledTimes(2);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('Vocabulary (controlled — adds are a picker, never free text)', () => {
|
|
239
|
+
it('the add affordance offers ONLY vocabulary entries minus already-applied tags', () => {
|
|
240
|
+
render(
|
|
241
|
+
<ResourceTagsInline {...defaultProps} tags={['Person']} isEditing={true}
|
|
242
|
+
vocabulary={['Person', 'Place', 'Topic']} />,
|
|
243
|
+
);
|
|
244
|
+
fireEvent.click(screen.getByLabelText('Add tag'));
|
|
245
|
+
expect(screen.getByRole('button', { name: 'Place' })).toBeInTheDocument();
|
|
246
|
+
expect(screen.getByRole('button', { name: 'Topic' })).toBeInTheDocument();
|
|
247
|
+
// Already applied → not a candidate (the chip renders as text, not a candidate button)
|
|
248
|
+
expect(screen.queryByRole('button', { name: 'Person' })).not.toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('picking a candidate commits the full set including the added tag', async () => {
|
|
252
|
+
render(
|
|
253
|
+
<ResourceTagsInline {...defaultProps} tags={['Person']} isEditing={true}
|
|
254
|
+
vocabulary={['Person', 'Place']} />,
|
|
255
|
+
);
|
|
256
|
+
fireEvent.click(screen.getByLabelText('Add tag'));
|
|
257
|
+
fireEvent.click(screen.getByRole('button', { name: 'Place' }));
|
|
258
|
+
await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
|
|
259
|
+
expect(mockOnUpdate).toHaveBeenCalledWith(['Person', 'Place']);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('without a vocabulary the editor is REMOVAL-ONLY (no add affordance)', () => {
|
|
263
|
+
render(<ResourceTagsInline {...defaultProps} isEditing={true} />);
|
|
264
|
+
expect(screen.queryByLabelText('Add tag')).not.toBeInTheDocument();
|
|
265
|
+
expect(screen.getByLabelText('Remove tag1')).toBeInTheDocument();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('removal is never vocabulary-gated — a stale tag not in the vocabulary is removable', async () => {
|
|
269
|
+
render(
|
|
270
|
+
<ResourceTagsInline {...defaultProps} tags={['LegacyType', 'Person']} isEditing={true}
|
|
271
|
+
vocabulary={['Person']} />,
|
|
272
|
+
);
|
|
273
|
+
fireEvent.click(screen.getByLabelText('Remove LegacyType'));
|
|
274
|
+
await waitFor(() => expect(mockOnUpdate).toHaveBeenCalledTimes(1));
|
|
275
|
+
expect(mockOnUpdate).toHaveBeenCalledWith(['Person']);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
180
278
|
});
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useRef, useEffect } from 'react';
|
|
4
4
|
import { useTranslations } from '../../contexts/TranslationContext';
|
|
5
|
-
import { useSemiont } from '../../session/SemiontProvider';
|
|
6
|
-
import { useObservable } from '../../hooks/useObservable';
|
|
7
5
|
import { getSupportedShapes } from '../../lib/media-shapes';
|
|
8
6
|
import type { Annotator } from '../../lib/annotation-registry';
|
|
9
7
|
import './annotations.css';
|
|
@@ -14,12 +12,13 @@ export type SelectionMotivation = 'linking' | 'highlighting' | 'assessing' | 'co
|
|
|
14
12
|
export type ClickAction = 'detail' | 'follow' | 'jsonld' | 'deleting';
|
|
15
13
|
export type ShapeType = 'rectangle' | 'circle' | 'polygon';
|
|
16
14
|
|
|
15
|
+
/** The bar's composable controls, in fixed render order. */
|
|
16
|
+
export type ToolbarPart = 'clickAction' | 'mode' | 'selection' | 'shape';
|
|
17
|
+
|
|
17
18
|
interface AnnotateToolbarProps {
|
|
18
19
|
selectedMotivation: SelectionMotivation | null;
|
|
19
20
|
selectedClick: ClickAction;
|
|
20
|
-
showSelectionGroup?: boolean;
|
|
21
21
|
showDeleteButton?: boolean;
|
|
22
|
-
showShapeGroup?: boolean;
|
|
23
22
|
selectedShape?: ShapeType;
|
|
24
23
|
mediaType?: string | null; // MIME type to determine supported shapes
|
|
25
24
|
|
|
@@ -28,6 +27,26 @@ interface AnnotateToolbarProps {
|
|
|
28
27
|
|
|
29
28
|
// Annotators for emoji lookup
|
|
30
29
|
annotators: Record<string, Annotator>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Which controls to render (composition over CSS hiding). Default: all four.
|
|
33
|
+
* Order is fixed (click, mode, selection, shape); shape stays media-gated.
|
|
34
|
+
*/
|
|
35
|
+
parts?: ToolbarPart[];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Purely presentational: each control reports the chosen value via its
|
|
39
|
+
* callback; the owner (viewer instance or host) applies it. The bar holds no
|
|
40
|
+
* pref state, emits no bus events, and touches no storage.
|
|
41
|
+
*/
|
|
42
|
+
onModeChange?: (mode: boolean) => void;
|
|
43
|
+
onClickActionChange?: (action: ClickAction) => void;
|
|
44
|
+
onSelectionChange?: (motivation: SelectionMotivation | null) => void;
|
|
45
|
+
onShapeChange?: (shape: ShapeType) => void;
|
|
46
|
+
|
|
47
|
+
// Compact display form (display-only): icon-only, tight, chromeless — for inline
|
|
48
|
+
// embeds. The bar's functionality is identical; only its form changes.
|
|
49
|
+
compact?: boolean;
|
|
31
50
|
}
|
|
32
51
|
|
|
33
52
|
interface DropdownGroupProps {
|
|
@@ -101,26 +120,26 @@ function DropdownGroup({
|
|
|
101
120
|
}
|
|
102
121
|
|
|
103
122
|
/**
|
|
104
|
-
* Toolbar for annotation controls with mode, selection, click, and shape options
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* @emits mark:click-changed - Click action mode changed. Payload: { action: ClickAction }
|
|
108
|
-
* @emits mark:shape-changed - Drawing shape changed. Payload: { shape: ShapeType }
|
|
109
|
-
* @emits mark:mode-toggled - View mode toggled between browse and annotate. Payload: undefined
|
|
123
|
+
* Toolbar for annotation controls with mode, selection, click, and shape options.
|
|
124
|
+
* Purely presentational (TOOLBAR-PREFS-AS-PROPS): renders the given values for the
|
|
125
|
+
* given `parts` and reports choices via the on*Change callbacks — no bus, no storage.
|
|
110
126
|
*/
|
|
111
127
|
export function AnnotateToolbar({
|
|
112
128
|
selectedMotivation,
|
|
113
129
|
selectedClick,
|
|
114
|
-
showSelectionGroup = true,
|
|
115
130
|
showDeleteButton = true,
|
|
116
|
-
showShapeGroup = false,
|
|
117
131
|
selectedShape = 'rectangle',
|
|
118
132
|
mediaType,
|
|
119
133
|
annotateMode = false,
|
|
120
|
-
annotators
|
|
134
|
+
annotators,
|
|
135
|
+
parts = ['clickAction', 'mode', 'selection', 'shape'],
|
|
136
|
+
onModeChange,
|
|
137
|
+
onClickActionChange,
|
|
138
|
+
onSelectionChange,
|
|
139
|
+
onShapeChange,
|
|
140
|
+
compact = false,
|
|
121
141
|
}: AnnotateToolbarProps) {
|
|
122
142
|
const t = useTranslations('AnnotateToolbar');
|
|
123
|
-
const session = useObservable(useSemiont().activeSession$);
|
|
124
143
|
|
|
125
144
|
// Helper to get emoji from annotators by motivation (with fallback for safety)
|
|
126
145
|
const getMotivationEmoji = (motivation: SelectionMotivation): string => {
|
|
@@ -188,32 +207,28 @@ export function AnnotateToolbar({
|
|
|
188
207
|
|
|
189
208
|
const handleSelectionClick = (motivation: SelectionMotivation | null) => {
|
|
190
209
|
// If null is clicked, always deselect. Otherwise toggle.
|
|
191
|
-
|
|
192
|
-
session?.client.mark.changeSelection(null);
|
|
193
|
-
} else {
|
|
194
|
-
session?.client.mark.changeSelection(selectedMotivation === motivation ? null : motivation);
|
|
195
|
-
}
|
|
210
|
+
onSelectionChange?.(motivation === null ? null : (selectedMotivation === motivation ? null : motivation));
|
|
196
211
|
// Close dropdown after selection
|
|
197
212
|
setSelectionPinned(false);
|
|
198
213
|
setSelectionHovered(false);
|
|
199
214
|
};
|
|
200
215
|
|
|
201
216
|
const handleClickClick = (action: ClickAction) => {
|
|
202
|
-
|
|
217
|
+
onClickActionChange?.(action);
|
|
203
218
|
// Close dropdown after selection
|
|
204
219
|
setClickPinned(false);
|
|
205
220
|
setClickHovered(false);
|
|
206
221
|
};
|
|
207
222
|
|
|
208
223
|
const handleShapeClick = (shape: ShapeType) => {
|
|
209
|
-
|
|
224
|
+
onShapeChange?.(shape);
|
|
210
225
|
// Close dropdown after selection
|
|
211
226
|
setShapePinned(false);
|
|
212
227
|
setShapeHovered(false);
|
|
213
228
|
};
|
|
214
229
|
|
|
215
230
|
const handleModeToggle = () => {
|
|
216
|
-
|
|
231
|
+
onModeChange?.(!annotateMode);
|
|
217
232
|
setModePinned(false);
|
|
218
233
|
setModeHovered(false);
|
|
219
234
|
};
|
|
@@ -288,9 +303,16 @@ export function AnnotateToolbar({
|
|
|
288
303
|
const supportedShapes = getSupportedShapes(mediaType);
|
|
289
304
|
const shapeTypes = allShapeTypes.filter(st => supportedShapes.includes(st.shape));
|
|
290
305
|
|
|
306
|
+
// Part gates (fixed order: click, mode, selection, shape); shape stays media-gated.
|
|
307
|
+
const showClick = parts.includes('clickAction');
|
|
308
|
+
const showMode = parts.includes('mode');
|
|
309
|
+
const showSelection = parts.includes('selection');
|
|
310
|
+
const showShape = parts.includes('shape') && shapeTypes.length > 0;
|
|
311
|
+
|
|
291
312
|
return (
|
|
292
|
-
<div className=
|
|
313
|
+
<div className={`semiont-annotate-toolbar${compact ? ' semiont-annotate-toolbar--compact' : ''}`}>
|
|
293
314
|
{/* Click Group */}
|
|
315
|
+
{showClick && (
|
|
294
316
|
<DropdownGroup
|
|
295
317
|
label={t('clickGroup')}
|
|
296
318
|
isExpanded={clickExpanded}
|
|
@@ -320,11 +342,13 @@ export function AnnotateToolbar({
|
|
|
320
342
|
</>
|
|
321
343
|
}
|
|
322
344
|
/>
|
|
345
|
+
)}
|
|
323
346
|
|
|
324
347
|
{/* Separator */}
|
|
325
|
-
<div className="semiont-toolbar-separator" />
|
|
348
|
+
{showMode && showClick && <div className="semiont-toolbar-separator" />}
|
|
326
349
|
|
|
327
350
|
{/* Mode Group */}
|
|
351
|
+
{showMode && (
|
|
328
352
|
<DropdownGroup
|
|
329
353
|
label={t('modeGroup')}
|
|
330
354
|
isExpanded={modeExpanded}
|
|
@@ -346,12 +370,13 @@ export function AnnotateToolbar({
|
|
|
346
370
|
</>
|
|
347
371
|
}
|
|
348
372
|
/>
|
|
373
|
+
)}
|
|
349
374
|
|
|
350
375
|
{/* Separator */}
|
|
351
|
-
{
|
|
376
|
+
{showSelection && (showClick || showMode) && <div className="semiont-toolbar-separator" />}
|
|
352
377
|
|
|
353
378
|
{/* Selection Group */}
|
|
354
|
-
{
|
|
379
|
+
{showSelection && (
|
|
355
380
|
<DropdownGroup
|
|
356
381
|
label={t('selectionGroup')}
|
|
357
382
|
isExpanded={selectionExpanded}
|
|
@@ -394,10 +419,10 @@ export function AnnotateToolbar({
|
|
|
394
419
|
)}
|
|
395
420
|
|
|
396
421
|
{/* Separator */}
|
|
397
|
-
{
|
|
422
|
+
{showShape && (showClick || showMode || showSelection) && <div className="semiont-toolbar-separator" />}
|
|
398
423
|
|
|
399
424
|
{/* Shape Group */}
|
|
400
|
-
{
|
|
425
|
+
{showShape && (
|
|
401
426
|
<DropdownGroup
|
|
402
427
|
label={t('shapeGroup')}
|
|
403
428
|
isExpanded={shapeExpanded}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EMBEDDABLE-VIEWER-COMPLETION Phase 3 — Annotate Bar display forms.
|
|
3
|
+
*
|
|
4
|
+
* Behavior is mostly fixed (the bar IS the annotation capability); the host
|
|
5
|
+
* gets freedom over its display form. `compact` is a display-only variant:
|
|
6
|
+
* icon-only, tight, chromeless — the functional groups stay present and wired.
|
|
7
|
+
* BrowseView's `inline` embed shows the compact bar automatically. Theming
|
|
8
|
+
* (semiont-* classes + CSS vars) and labels (i18n) already exist — no new API
|
|
9
|
+
* for those.
|
|
10
|
+
*
|
|
11
|
+
* Started RED (no `compact` prop) and GREEN once Phase 3 lands.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
14
|
+
import { render, screen } from '@testing-library/react';
|
|
15
|
+
import '@testing-library/jest-dom';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
19
|
+
import { AnnotateToolbar } from '../AnnotateToolbar';
|
|
20
|
+
import { ANNOTATORS } from '../../../lib/annotation-registry';
|
|
21
|
+
import { BrowseView } from '../../resource/BrowseView';
|
|
22
|
+
|
|
23
|
+
vi.mock('react-markdown', () => ({ default: ({ children }: { children: string }) => <div>{children}</div> }));
|
|
24
|
+
vi.mock('remark-gfm', () => ({ default: () => ({}) }));
|
|
25
|
+
|
|
26
|
+
const emptyAnnotations = { highlights: [], references: [], assessments: [], comments: [], tags: [] };
|
|
27
|
+
|
|
28
|
+
function fakeSession(): SemiontSession {
|
|
29
|
+
return {
|
|
30
|
+
client: { browse: { click: vi.fn() }, beckon: { hover: vi.fn() } },
|
|
31
|
+
subscribe: () => () => {},
|
|
32
|
+
} as unknown as SemiontSession;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toolbarProps = {
|
|
36
|
+
selectedMotivation: null,
|
|
37
|
+
selectedClick: 'detail' as const,
|
|
38
|
+
annotateMode: false,
|
|
39
|
+
annotators: ANNOTATORS,
|
|
40
|
+
session: null,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('Annotate Bar display forms (Phase 3)', () => {
|
|
44
|
+
it('`compact` adds the display modifier; default does not', () => {
|
|
45
|
+
const { container: normal } = render(<AnnotateToolbar {...toolbarProps} />);
|
|
46
|
+
expect(normal.querySelector('.semiont-annotate-toolbar')).not.toHaveClass('semiont-annotate-toolbar--compact');
|
|
47
|
+
|
|
48
|
+
const { container } = render(<AnnotateToolbar {...toolbarProps} compact />);
|
|
49
|
+
expect(container.querySelector('.semiont-annotate-toolbar')).toHaveClass('semiont-annotate-toolbar--compact');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('compact is display-only: the functional groups are still present', () => {
|
|
53
|
+
render(<AnnotateToolbar {...toolbarProps} compact />);
|
|
54
|
+
// Groups render with their aria labels (default-English translations, no provider).
|
|
55
|
+
expect(screen.getByLabelText(/mode/i)).toBeInTheDocument();
|
|
56
|
+
expect(screen.getByLabelText(/click/i)).toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('BrowseView inline embeds show the compact bar automatically', () => {
|
|
60
|
+
const props = {
|
|
61
|
+
content: 'x', mimeType: 'text/plain', resourceUri: 'res-1',
|
|
62
|
+
annotations: emptyAnnotations, annotateMode: false, session: fakeSession(),
|
|
63
|
+
};
|
|
64
|
+
const { container: pane } = render(<BrowseView {...props} />);
|
|
65
|
+
expect(pane.querySelector('.semiont-annotate-toolbar')).not.toHaveClass('semiont-annotate-toolbar--compact');
|
|
66
|
+
|
|
67
|
+
const { container } = render(<BrowseView {...props} inline />);
|
|
68
|
+
expect(container.querySelector('.semiont-annotate-toolbar')).toHaveClass('semiont-annotate-toolbar--compact');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('stylesheet carries the compact form (static gate — icon-only, chromeless)', () => {
|
|
72
|
+
const css = readFileSync(resolve(process.cwd(), 'src/components/toolbar/Toolbar.css'), 'utf8');
|
|
73
|
+
expect(css).toMatch(/\.semiont-annotate-toolbar--compact\s*\{/);
|
|
74
|
+
expect(css).toMatch(/\.semiont-annotate-toolbar--compact\s+\.semiont-dropdown-label\s*\{[^}]*display:\s*none/);
|
|
75
|
+
});
|
|
76
|
+
});
|