@weasel-js/labkit 1.1.0 → 1.3.0-pre.0
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 +27 -0
- package/dist/_dts/{DrawCommand-BkZztJsW.d.ts → DrawCommand-DBB45NfN.d.ts} +244 -58
- package/dist/_dts/PrefsForm-BYa6cWnO.d.ts +201 -0
- package/dist/_dts/index-DgcNdEdh.d.ts +314 -0
- package/dist/_dts/types-1Sdxy_Pv.d.ts +90 -0
- package/dist/_dts/types-AHlQxBNN.d.ts +162 -0
- package/dist/_dts/types-DJ79Tg5J.d.ts +56 -0
- package/dist/_dts/{useTrialState-DYe2vUwN.d.ts → useTrialState-CsGMjhu9.d.ts} +6 -92
- package/dist/canvas/index.d.ts +8 -3
- package/dist/canvas/index.js +2 -1
- package/dist/chrome/index.d.ts +86 -0
- package/dist/chrome/index.js +8 -0
- package/dist/chrome/index.js.map +1 -0
- package/dist/{chunk-3TYUJR7Z.js → chunk-4TMMVIDM.js} +9 -3
- package/dist/chunk-4TMMVIDM.js.map +1 -0
- package/dist/chunk-HFDVGF4X.js +170 -0
- package/dist/chunk-HFDVGF4X.js.map +1 -0
- package/dist/chunk-LN6JDUGB.js +106 -0
- package/dist/chunk-LN6JDUGB.js.map +1 -0
- package/dist/{chunk-574LJAV4.js → chunk-NS54R52R.js} +31 -21
- package/dist/chunk-NS54R52R.js.map +1 -0
- package/dist/{chunk-NRKWVTVT.js → chunk-O2BULFHX.js} +5 -14
- package/dist/chunk-O2BULFHX.js.map +1 -0
- package/dist/chunk-O7NVSCLN.js +266 -0
- package/dist/chunk-O7NVSCLN.js.map +1 -0
- package/dist/chunk-PO6L3NSH.js +366 -0
- package/dist/chunk-PO6L3NSH.js.map +1 -0
- package/dist/chunk-PQJ5B2U2.js +27 -0
- package/dist/chunk-PQJ5B2U2.js.map +1 -0
- package/dist/chunk-RVNN4CHQ.js +212 -0
- package/dist/chunk-RVNN4CHQ.js.map +1 -0
- package/dist/chunk-TLGRYALP.js +6151 -0
- package/dist/chunk-TLGRYALP.js.map +1 -0
- package/dist/{chunk-SFL7NFKN.js → chunk-V7PRSIRQ.js} +15 -7
- package/dist/chunk-V7PRSIRQ.js.map +1 -0
- package/dist/{chunk-DJLDIRFN.js → chunk-Z6HJ5FEM.js} +9626 -6050
- package/dist/chunk-Z6HJ5FEM.js.map +1 -0
- package/dist/chunk-ZZAYVX4X.js +505 -0
- package/dist/chunk-ZZAYVX4X.js.map +1 -0
- package/dist/controls/index.d.ts +19 -5
- package/dist/controls/index.js +3 -1
- package/dist/dragdrop/index.d.ts +5 -2
- package/dist/index.d.ts +307 -107
- package/dist/index.js +797 -540
- package/dist/index.js.map +1 -1
- package/dist/job/index.d.ts +13 -0
- package/dist/job/index.js +3 -0
- package/dist/job/index.js.map +1 -0
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +2 -1
- package/dist/passthrough/weasel-canvas.d.ts +69 -13
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +3804 -3661
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +102 -8
- package/dist/primitives/index.js +4 -1
- package/dist/state/index.d.ts +3 -2
- package/dist/state/index.js +2 -2
- package/dist/styles.css +543 -144
- package/dist/surface/index.d.ts +77 -0
- package/dist/surface/index.js +4 -0
- package/dist/surface/index.js.map +1 -0
- package/dist/ui/layers/index.js +4 -3
- package/dist/undo/index.d.ts +5 -2
- package/package.json +24 -7
- package/src/canvas/AGENTS.md +12 -0
- package/src/canvas/CanvasStack.tsx +6 -2
- package/src/canvas/useLayerScheduler.ts +22 -15
- package/src/canvas/useOrbit.test.ts +71 -0
- package/src/canvas/useOrbit.ts +149 -0
- package/src/canvas/usePanZoom.test.ts +80 -0
- package/src/canvas/usePanZoom.ts +16 -2
- package/src/chrome/ChromeRegions.stories.tsx +106 -0
- package/src/chrome/builtins.test.ts +153 -0
- package/src/chrome/builtins.tsx +205 -0
- package/src/chrome/index.ts +25 -0
- package/src/chrome/merge.test.ts +51 -0
- package/src/chrome/merge.ts +52 -0
- package/src/chrome/regions/PaletteRegion.less +14 -0
- package/src/chrome/regions/PaletteRegion.test.tsx +36 -0
- package/src/chrome/regions/PaletteRegion.tsx +41 -0
- package/src/chrome/regions/SidebarRegion.tsx +56 -0
- package/src/chrome/regions/StatusRegion.tsx +31 -0
- package/src/chrome/regions/TitleBarRegion.tsx +41 -0
- package/src/chrome/regions/ToolbarRegion.test.tsx +82 -0
- package/src/chrome/regions/ToolbarRegion.tsx +71 -0
- package/src/chrome/regions/ViewportRegion.less +42 -0
- package/src/chrome/regions/ViewportRegion.tsx +37 -0
- package/src/chrome/regions/regions.test.tsx +81 -0
- package/src/chrome/types.ts +127 -0
- package/src/config/builder.test.ts +87 -0
- package/src/config/builder.ts +224 -0
- package/src/config/fromConfigField.test.ts +93 -0
- package/src/config/fromConfigField.ts +74 -0
- package/src/config/index.ts +22 -0
- package/src/config/resolve.test.ts +120 -0
- package/src/config/resolve.ts +67 -0
- package/src/config/rules.ts +68 -0
- package/src/config/types.ts +108 -0
- package/src/config/useConfigSchema.ts +30 -0
- package/src/config/visible.ts +20 -0
- package/src/controls/ControlPanel.less +4 -58
- package/src/controls/ControlPanel.stories.tsx +86 -0
- package/src/controls/ControlPanel.test.tsx +223 -10
- package/src/controls/ControlPanel.tsx +202 -203
- package/src/dragdrop/DragDropRuntime.tsx +36 -18
- package/src/dragdrop/DragGhost.less +1 -1
- package/src/dragdrop/Palette.less +1 -1
- package/src/index.test.ts +18 -0
- package/src/index.ts +38 -3
- package/src/instrument/SineWave.smoke.test.tsx +9 -1
- package/src/instrument/defineInstrument.test.ts +38 -1
- package/src/instrument/defineInstrument.ts +31 -4
- package/src/instrument/index.ts +1 -0
- package/src/instrument/types.ts +38 -3
- package/src/instrument/validateConfigSchema.test.ts +13 -3
- package/src/instrument/validateConfigSchema.ts +7 -10
- package/src/job/index.ts +3 -0
- package/src/job/types.ts +47 -0
- package/src/job/useJob.test.tsx +210 -0
- package/src/job/useJob.ts +134 -0
- package/src/lab/Lab.chrome.test.tsx +65 -0
- package/src/lab/Lab.stories.tsx +1 -2
- package/src/lab/Lab.tsx +58 -15
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabFullChrome.stories.tsx +308 -0
- package/src/lab/LabHeader.test.tsx +42 -0
- package/src/lab/LabHeader.tsx +57 -0
- package/src/lab/LabPalette.test.tsx +35 -0
- package/src/lab/LabPalette.tsx +34 -0
- package/src/lab/LabShell.less +50 -3
- package/src/lab/Workspace.less +1 -30
- package/src/lab/Workspace.surface.test.tsx +49 -0
- package/src/lab/Workspace.test.tsx +20 -10
- package/src/lab/Workspace.tsx +19 -9
- package/src/lab/index.ts +2 -0
- package/src/layers/AGENTS.md +1 -1
- package/src/layers/LayerList.less +31 -7
- package/src/layers/LayerList.tsx +17 -4
- package/src/passthrough/weasel-ui.ts +23 -0
- package/src/primitives/DragHandleGlyph.tsx +29 -0
- package/src/primitives/FloatingPanel.less +28 -0
- package/src/primitives/FloatingPanel.stories.tsx +46 -0
- package/src/primitives/FloatingPanel.test.tsx +240 -0
- package/src/primitives/FloatingPanel.tsx +190 -0
- package/src/primitives/FpsMeter.test.tsx +44 -0
- package/src/primitives/FpsMeter.tsx +20 -9
- package/src/primitives/JobProgress.less +69 -0
- package/src/primitives/JobProgress.stories.tsx +48 -0
- package/src/primitives/JobProgress.tsx +65 -0
- package/src/primitives/Legend.less +47 -0
- package/src/primitives/Legend.stories.tsx +32 -0
- package/src/primitives/Legend.test.tsx +57 -0
- package/src/primitives/Legend.tsx +40 -0
- package/src/primitives/Sidebar.less +32 -2
- package/src/primitives/StatusBar.less +7 -0
- package/src/primitives/StatusBar.tsx +10 -3
- package/src/primitives/Toolbar.less +50 -3
- package/src/primitives/Toolbar.test.tsx +67 -1
- package/src/primitives/Toolbar.tsx +72 -6
- package/src/primitives/ZoomControl.less +31 -0
- package/src/primitives/ZoomControl.tsx +76 -0
- package/src/primitives/floating.entry.test.tsx +14 -0
- package/src/primitives/index.ts +10 -2
- package/src/primitives/useRovingTabIndex.test.ts +27 -0
- package/src/primitives/useRovingTabIndex.ts +79 -0
- package/src/state/store.ts +16 -2
- package/src/state/toolSlot.test.ts +36 -0
- package/src/state/types.ts +8 -2
- package/src/state/view.test.ts +127 -0
- package/src/state/view.ts +18 -0
- package/src/styles.less +6 -0
- package/src/surface/AGENTS.md +64 -0
- package/src/surface/SurfaceContext.ts +5 -0
- package/src/surface/composeRects.test.ts +50 -0
- package/src/surface/composeRects.ts +19 -0
- package/src/surface/deviceRect.test.ts +40 -0
- package/src/surface/deviceRect.ts +19 -0
- package/src/surface/index.ts +7 -0
- package/src/surface/rect.ts +16 -0
- package/src/surface/useSurfaceTile.test.tsx +67 -0
- package/src/surface/useSurfaceTile.ts +32 -0
- package/src/surface/useTiledSurface.test.tsx +231 -0
- package/src/surface/useTiledSurface.ts +161 -0
- package/src/test-setup.ts +26 -0
- package/src/theme/base.less +19 -2
- package/src/theme/interstellar.tokens.json +6 -2
- package/src/tools/types.ts +24 -0
- package/src/trial/Trial.config.test.tsx +113 -0
- package/src/trial/Trial.job.test.tsx +73 -0
- package/src/trial/Trial.less +121 -4
- package/src/trial/Trial.stories.tsx +9 -1
- package/src/trial/Trial.test.tsx +67 -96
- package/src/trial/Trial.tsx +112 -45
- package/src/trial/TrialChrome.tsx +110 -64
- package/src/trial/TrialDragContext.ts +10 -0
- package/src/trial/TrialTitleBar.tsx +41 -0
- package/src/trial/index.ts +1 -15
- package/src/trial/trialChrome.borders.test.ts +38 -0
- package/src/ui/layers/LayerStack.less +9 -9
- package/src/ui/layers/LayerStack.tsx +1 -13
- package/src/ui/properties/CurveField.less +2 -2
- package/src/ui/properties/CurveField.test.tsx +11 -11
- package/src/ui/properties/PropertyGroup.less +2 -2
- package/src/ui/properties/PropertyPanel.less +55 -41
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +4 -2
- package/src/ui/properties/storyLayouts.tsx +2 -2
- package/dist/_dts/index-iAP6XbH3.d.ts +0 -132
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/chunk-3TYUJR7Z.js.map +0 -1
- package/dist/chunk-574LJAV4.js.map +0 -1
- package/dist/chunk-5R2ATYPJ.js +0 -201
- package/dist/chunk-5R2ATYPJ.js.map +0 -1
- package/dist/chunk-C6GJKPUI.js +0 -5986
- package/dist/chunk-C6GJKPUI.js.map +0 -1
- package/dist/chunk-DJLDIRFN.js.map +0 -1
- package/dist/chunk-NRKWVTVT.js.map +0 -1
- package/dist/chunk-PMAU3SEE.js +0 -126
- package/dist/chunk-PMAU3SEE.js.map +0 -1
- package/dist/chunk-SFL7NFKN.js.map +0 -1
- package/src/instrument/capabilityDetector.test.ts +0 -64
- package/src/instrument/capabilityDetector.ts +0 -20
- package/src/trial/DefaultSidebar.tsx +0 -29
- package/src/trial/DefaultStatusBar.tsx +0 -19
- package/src/trial/DefaultToolbar.tsx +0 -92
- package/src/trial/slotTypes.ts +0 -53
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { CrosshairIcon, HandIcon, PencilIcon } from '@weasel-js/ui';
|
|
3
|
+
import type { ConfigField } from '../controls/types';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { Lab } from '../lab/Lab';
|
|
6
|
+
|
|
7
|
+
interface DemoConfig extends Record<string, unknown> {
|
|
8
|
+
radius: number;
|
|
9
|
+
filled: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface DemoState extends Record<string, unknown> {
|
|
13
|
+
spokes: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const fields: ConfigField[] = [
|
|
17
|
+
{ key: 'radius', label: 'Radius', type: 'slider', default: 60, min: 10, max: 120, step: 1 },
|
|
18
|
+
{ key: 'filled', label: 'Filled', type: 'checkbox', default: true },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Declares every capability that contributes chrome, so all five trial
|
|
22
|
+
* regions render at once. */
|
|
23
|
+
const FullInstrument: Instrument<DemoState, DemoConfig> = {
|
|
24
|
+
name: 'Every Region',
|
|
25
|
+
defaultConfig: () => ({ radius: 60, filled: true }),
|
|
26
|
+
initialState: () => ({ spokes: 7 }),
|
|
27
|
+
configSchema: () => fields,
|
|
28
|
+
undo: {},
|
|
29
|
+
layers: { ids: ['wheel'] },
|
|
30
|
+
tools: {
|
|
31
|
+
tools: [
|
|
32
|
+
{ id: 'draw', label: 'Draw', icon: PencilIcon, shortcut: 'D' },
|
|
33
|
+
{ id: 'pan', label: 'Pan', icon: HandIcon, shortcut: 'H' },
|
|
34
|
+
{ id: 'measure', label: 'Measure', icon: CrosshairIcon, shortcut: 'M' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
canvas: {
|
|
38
|
+
layers: [
|
|
39
|
+
{
|
|
40
|
+
id: 'wheel',
|
|
41
|
+
draw: (ctx, { state, config }) => {
|
|
42
|
+
ctx.strokeStyle = '#8ab';
|
|
43
|
+
ctx.fillStyle = 'rgba(136, 170, 187, 0.25)';
|
|
44
|
+
ctx.lineWidth = 1.5;
|
|
45
|
+
ctx.beginPath();
|
|
46
|
+
ctx.arc(160, 120, config.radius, 0, Math.PI * 2);
|
|
47
|
+
if (config.filled) ctx.fill();
|
|
48
|
+
ctx.stroke();
|
|
49
|
+
for (let i = 0; i < state.spokes; i++) {
|
|
50
|
+
const a = (i / state.spokes) * Math.PI * 2;
|
|
51
|
+
ctx.beginPath();
|
|
52
|
+
ctx.moveTo(160, 120);
|
|
53
|
+
ctx.lineTo(160 + Math.cos(a) * config.radius, 120 + Math.sin(a) * config.radius);
|
|
54
|
+
ctx.stroke();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
render: ({ trial }) => (
|
|
61
|
+
<div className="lk-region-demo-readout">tool: {trial.activeToolId ?? 'none'}</div>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const meta: Meta<typeof Lab> = {
|
|
66
|
+
title: 'labkit/Chrome/Regions',
|
|
67
|
+
component: Lab,
|
|
68
|
+
parameters: { layout: 'fullscreen' },
|
|
69
|
+
};
|
|
70
|
+
export default meta;
|
|
71
|
+
|
|
72
|
+
type Story = StoryObj<typeof Lab>;
|
|
73
|
+
|
|
74
|
+
export const EveryRegion: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
instruments: [FullInstrument],
|
|
77
|
+
defaultInstrument: 'Every Region',
|
|
78
|
+
title: 'Chrome Regions',
|
|
79
|
+
storage: null,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A consumer dropping a built-in and adding its own in its place. */
|
|
84
|
+
export const SuppressedAndReplaced: Story = {
|
|
85
|
+
args: {
|
|
86
|
+
instruments: [FullInstrument],
|
|
87
|
+
defaultInstrument: 'Every Region',
|
|
88
|
+
title: 'Suppress + Replace',
|
|
89
|
+
storage: null,
|
|
90
|
+
suppress: ['snapshot'],
|
|
91
|
+
chrome: [
|
|
92
|
+
{
|
|
93
|
+
id: 'export',
|
|
94
|
+
region: 'toolbar',
|
|
95
|
+
group: 'trial',
|
|
96
|
+
end: true,
|
|
97
|
+
item: {
|
|
98
|
+
icon: CrosshairIcon,
|
|
99
|
+
label: 'Export',
|
|
100
|
+
showLabel: true,
|
|
101
|
+
onActivate: () => {},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { f } from '../config/builder';
|
|
3
|
+
import { fromConfigFields } from '../config/fromConfigField';
|
|
4
|
+
import { resolveConfigSchema } from '../config/resolve';
|
|
5
|
+
import type { Instrument } from '../instrument/types';
|
|
6
|
+
import { builtinContributions } from './builtins';
|
|
7
|
+
import type { TrialChromeContext } from './types';
|
|
8
|
+
|
|
9
|
+
const ctx: TrialChromeContext = {
|
|
10
|
+
trialId: 't1',
|
|
11
|
+
instrumentName: 'Stub',
|
|
12
|
+
isLastTrial: false,
|
|
13
|
+
zoom: 1,
|
|
14
|
+
setZoom: () => {},
|
|
15
|
+
canUndo: true,
|
|
16
|
+
canRedo: false,
|
|
17
|
+
undo: () => {},
|
|
18
|
+
redo: () => {},
|
|
19
|
+
configFields: [],
|
|
20
|
+
configSchema: fromConfigFields([]),
|
|
21
|
+
config: {},
|
|
22
|
+
setConfig: () => {},
|
|
23
|
+
savedSnapshots: [],
|
|
24
|
+
saveSnapshot: () => {},
|
|
25
|
+
loadSnapshot: () => {},
|
|
26
|
+
clone: () => {},
|
|
27
|
+
reset: () => {},
|
|
28
|
+
close: () => {},
|
|
29
|
+
activeToolId: null,
|
|
30
|
+
setActiveTool: () => {},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const bare: Instrument = {
|
|
34
|
+
name: 'Stub',
|
|
35
|
+
defaultConfig: () => ({}),
|
|
36
|
+
initialState: () => ({}),
|
|
37
|
+
render: () => null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const withCanvas: Instrument = { ...bare, canvas: { layers: [] } };
|
|
41
|
+
|
|
42
|
+
const ctxWithZoom = (zoom: number | null): TrialChromeContext => ({ ...ctx, zoom });
|
|
43
|
+
|
|
44
|
+
function ids(instrument: Instrument, c: TrialChromeContext = ctx): string[] {
|
|
45
|
+
return builtinContributions(instrument, c).map((x) => x.id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('builtinContributions', () => {
|
|
49
|
+
it('always contributes the trial actions', () => {
|
|
50
|
+
expect(ids(bare)).toEqual(['snapshot', 'clone', 'reset', 'close']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('contributes undo and redo only when the instrument declares undo', () => {
|
|
54
|
+
expect(ids(bare)).not.toContain('undo');
|
|
55
|
+
expect(ids({ ...bare, undo: {} })).toContain('undo');
|
|
56
|
+
expect(ids({ ...bare, undo: {} })).toContain('redo');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('puts zoom controls in the viewport region, not the toolbar', () => {
|
|
60
|
+
const zoomIn = builtinContributions(withCanvas, ctx).find((c) => c.id === 'zoom-in');
|
|
61
|
+
expect(zoomIn?.region).toBe('viewport');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('contributes no viewport controls when the view is not 2D', () => {
|
|
65
|
+
expect(ids(withCanvas, ctxWithZoom(null))).not.toContain('zoom-in');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('contributes a settings section only when the schema has fields', () => {
|
|
69
|
+
expect(ids(bare)).not.toContain('settings');
|
|
70
|
+
const withFields = {
|
|
71
|
+
...ctx,
|
|
72
|
+
configSchema: fromConfigFields([
|
|
73
|
+
{ key: 'n', label: 'N', type: 'number' as const, default: 1 },
|
|
74
|
+
]),
|
|
75
|
+
};
|
|
76
|
+
expect(ids(bare, withFields)).toContain('settings');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('contributes a settings section for a builder schema too', () => {
|
|
80
|
+
const withSchema = {
|
|
81
|
+
...ctx,
|
|
82
|
+
configSchema: resolveConfigSchema(f.schema({ showGrid: f.boolean(true) }), []),
|
|
83
|
+
};
|
|
84
|
+
expect(ids(bare, withSchema)).toContain('settings');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('reflects undo availability in the item, not by omitting it', () => {
|
|
88
|
+
const withUndo: Instrument = { ...bare, undo: {} };
|
|
89
|
+
const list = builtinContributions(withUndo, { ...ctx, canUndo: false });
|
|
90
|
+
const undo = list.find((c) => c.id === 'undo');
|
|
91
|
+
expect(undo?.item).toMatchObject({ disabled: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('marks close as danger and disables it on the last trial', () => {
|
|
95
|
+
const list = builtinContributions(bare, { ...ctx, isLastTrial: true });
|
|
96
|
+
expect(list.find((c) => c.id === 'close')?.item).toMatchObject({
|
|
97
|
+
danger: true,
|
|
98
|
+
disabled: true,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('offers the snapshot loader only once a snapshot has been taken', () => {
|
|
103
|
+
expect(ids(bare)).not.toContain('snapshot-load');
|
|
104
|
+
const withSaves = {
|
|
105
|
+
...ctx,
|
|
106
|
+
savedSnapshots: [
|
|
107
|
+
{
|
|
108
|
+
id: 's1',
|
|
109
|
+
name: 'First',
|
|
110
|
+
trialId: 't1',
|
|
111
|
+
instrumentName: 'Stub',
|
|
112
|
+
config: {},
|
|
113
|
+
state: {},
|
|
114
|
+
savedAt: 1,
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
expect(ids(bare, withSaves)).toContain('snapshot-load');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('produces no duplicate ids for a fully-declared instrument', () => {
|
|
122
|
+
const full: Instrument = {
|
|
123
|
+
...bare,
|
|
124
|
+
undo: {},
|
|
125
|
+
canvas: { layers: [] },
|
|
126
|
+
layers: { ids: ['a'] },
|
|
127
|
+
};
|
|
128
|
+
const list = builtinContributions(full, ctx);
|
|
129
|
+
expect(new Set(list.map((c) => c.id)).size).toBe(list.length);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('view readouts', () => {
|
|
134
|
+
it('contributes fps and scale to the status bar when the trial has a canvas', () => {
|
|
135
|
+
const out = builtinContributions(withCanvas, ctxWithZoom(1));
|
|
136
|
+
const status = out.filter((c) => c.region === 'status').map((c) => c.id);
|
|
137
|
+
expect(status).toContain('fps');
|
|
138
|
+
expect(status).toContain('scale');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('contributes the zoom control to the viewport, replacing the readout', () => {
|
|
142
|
+
const out = builtinContributions(withCanvas, ctxWithZoom(1));
|
|
143
|
+
const viewport = out.filter((c) => c.region === 'viewport').map((c) => c.id);
|
|
144
|
+
expect(viewport).toContain('zoom-control');
|
|
145
|
+
expect(out.map((c) => c.id)).not.toContain('zoom-readout');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('contributes none of them to a trial with no canvas', () => {
|
|
149
|
+
const out = builtinContributions(bare, ctxWithZoom(null));
|
|
150
|
+
expect(out.map((c) => c.id)).not.toContain('fps');
|
|
151
|
+
expect(out.map((c) => c.id)).not.toContain('zoom-control');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CloneIcon,
|
|
3
|
+
CloseIcon,
|
|
4
|
+
FitIcon,
|
|
5
|
+
RedoIcon,
|
|
6
|
+
ResetIcon,
|
|
7
|
+
SnapshotIcon,
|
|
8
|
+
UndoIcon,
|
|
9
|
+
ZoomInIcon,
|
|
10
|
+
ZoomOutIcon,
|
|
11
|
+
} from '@weasel-js/ui';
|
|
12
|
+
import type { ControlRenderer } from '../config/types';
|
|
13
|
+
import { ControlPanel } from '../controls/ControlPanel';
|
|
14
|
+
import type { Instrument } from '../instrument/types';
|
|
15
|
+
import { Select } from '../passthrough/weasel-ui';
|
|
16
|
+
import { FpsMeter } from '../primitives/FpsMeter';
|
|
17
|
+
import { ScaleIndicator } from '../primitives/ScaleIndicator';
|
|
18
|
+
import { ZoomControl } from '../primitives/ZoomControl';
|
|
19
|
+
import type { TrialChromeContext, TrialContribution } from './types';
|
|
20
|
+
|
|
21
|
+
const ZOOM_STEP = 1.25;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The contributions a trial gets from what its instrument declared. This is
|
|
25
|
+
* the whole "declaring a capability provides the chrome" rule — it replaces
|
|
26
|
+
* the presence checks that used to be spread across the runtime.
|
|
27
|
+
*/
|
|
28
|
+
export function builtinContributions(
|
|
29
|
+
instrument: Instrument,
|
|
30
|
+
ctx: TrialChromeContext,
|
|
31
|
+
controls?: Record<string, ControlRenderer>,
|
|
32
|
+
): TrialContribution[] {
|
|
33
|
+
const out: TrialContribution[] = [];
|
|
34
|
+
const zoom = ctx.zoom;
|
|
35
|
+
|
|
36
|
+
if (instrument.undo != null) {
|
|
37
|
+
out.push({
|
|
38
|
+
id: 'undo',
|
|
39
|
+
region: 'toolbar',
|
|
40
|
+
group: 'history',
|
|
41
|
+
item: {
|
|
42
|
+
icon: UndoIcon,
|
|
43
|
+
label: 'Undo',
|
|
44
|
+
shortcut: 'Mod+Z',
|
|
45
|
+
disabled: !ctx.canUndo,
|
|
46
|
+
onActivate: ctx.undo,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
out.push({
|
|
50
|
+
id: 'redo',
|
|
51
|
+
region: 'toolbar',
|
|
52
|
+
group: 'history',
|
|
53
|
+
item: {
|
|
54
|
+
icon: RedoIcon,
|
|
55
|
+
label: 'Redo',
|
|
56
|
+
shortcut: 'Mod+Shift+Z',
|
|
57
|
+
disabled: !ctx.canRedo,
|
|
58
|
+
onActivate: ctx.redo,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A tool id shares the contribution namespace, so a tool called `close`
|
|
64
|
+
// collides with the built-in close button and throws. Both are contributions
|
|
65
|
+
// to one trial's chrome.
|
|
66
|
+
for (const t of instrument.tools?.tools ?? []) {
|
|
67
|
+
out.push({
|
|
68
|
+
id: t.id,
|
|
69
|
+
region: 'palette',
|
|
70
|
+
group: t.group,
|
|
71
|
+
item: { icon: t.icon, label: t.label, shortcut: t.shortcut },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Zoom acts on the view of the trial, so it is a viewport control. A trial
|
|
76
|
+
// holding a non-2D view reports zoom as null and gets none of this.
|
|
77
|
+
if (instrument.canvas != null && zoom !== null) {
|
|
78
|
+
out.push({
|
|
79
|
+
id: 'zoom-out',
|
|
80
|
+
region: 'viewport',
|
|
81
|
+
group: 'zoom',
|
|
82
|
+
item: {
|
|
83
|
+
icon: ZoomOutIcon,
|
|
84
|
+
label: 'Zoom out',
|
|
85
|
+
onActivate: () => ctx.setZoom(zoom / ZOOM_STEP),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
out.push({
|
|
89
|
+
id: 'zoom-in',
|
|
90
|
+
region: 'viewport',
|
|
91
|
+
group: 'zoom',
|
|
92
|
+
item: {
|
|
93
|
+
icon: ZoomInIcon,
|
|
94
|
+
label: 'Zoom in',
|
|
95
|
+
onActivate: () => ctx.setZoom(zoom * ZOOM_STEP),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
out.push({
|
|
99
|
+
id: 'actual-size',
|
|
100
|
+
region: 'viewport',
|
|
101
|
+
group: 'zoom',
|
|
102
|
+
item: { icon: FitIcon, label: 'Actual size', onActivate: () => ctx.setZoom(1) },
|
|
103
|
+
});
|
|
104
|
+
out.push({
|
|
105
|
+
id: 'zoom-control',
|
|
106
|
+
region: 'viewport',
|
|
107
|
+
group: 'zoom',
|
|
108
|
+
render: (c) => <ZoomControl zoom={c.zoom ?? 1} onZoomChange={c.setZoom} />,
|
|
109
|
+
});
|
|
110
|
+
out.push({
|
|
111
|
+
id: 'scale',
|
|
112
|
+
region: 'status',
|
|
113
|
+
group: 'view',
|
|
114
|
+
render: () => <ScaleIndicator />,
|
|
115
|
+
});
|
|
116
|
+
out.push({
|
|
117
|
+
id: 'fps',
|
|
118
|
+
region: 'status',
|
|
119
|
+
group: 'view',
|
|
120
|
+
end: true,
|
|
121
|
+
render: () => <FpsMeter />,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (Object.keys(ctx.configSchema.group.children).length > 0) {
|
|
126
|
+
out.push({
|
|
127
|
+
id: 'settings',
|
|
128
|
+
region: 'sidebar',
|
|
129
|
+
item: {
|
|
130
|
+
title: 'Settings',
|
|
131
|
+
body: (
|
|
132
|
+
<ControlPanel
|
|
133
|
+
schema={ctx.configSchema}
|
|
134
|
+
config={ctx.config as Record<string, unknown>}
|
|
135
|
+
setConfig={(key, value) => ctx.setConfig(String(key), value)}
|
|
136
|
+
renderers={controls}
|
|
137
|
+
/>
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
out.push({
|
|
144
|
+
id: 'snapshot',
|
|
145
|
+
region: 'toolbar',
|
|
146
|
+
group: 'history',
|
|
147
|
+
item: {
|
|
148
|
+
icon: SnapshotIcon,
|
|
149
|
+
label: 'Save snapshot',
|
|
150
|
+
shortcut: 'Mod+S',
|
|
151
|
+
onActivate: () => ctx.saveSnapshot(),
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
if (ctx.savedSnapshots.length > 0) {
|
|
155
|
+
// A picker is not an icon button, so it takes the render escape. Held at
|
|
156
|
+
// `selectedKey={null}` so it stays a "load one" action rather than
|
|
157
|
+
// drifting into a display of what was loaded last.
|
|
158
|
+
out.push({
|
|
159
|
+
id: 'snapshot-load',
|
|
160
|
+
region: 'toolbar',
|
|
161
|
+
group: 'trial',
|
|
162
|
+
end: true,
|
|
163
|
+
render: (c) => (
|
|
164
|
+
<Select
|
|
165
|
+
className="lk-toolbar__load-select"
|
|
166
|
+
aria-label="Load snapshot"
|
|
167
|
+
placeholder="Load…"
|
|
168
|
+
selectedKey={null}
|
|
169
|
+
options={c.savedSnapshots.map((sn) => ({ value: sn.id, label: sn.name }))}
|
|
170
|
+
onSelectionChange={(id) => {
|
|
171
|
+
if (id != null) c.loadSnapshot(String(id));
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
out.push({
|
|
178
|
+
id: 'clone',
|
|
179
|
+
region: 'toolbar',
|
|
180
|
+
group: 'trial',
|
|
181
|
+
end: true,
|
|
182
|
+
item: { icon: CloneIcon, label: 'Clone trial', onActivate: ctx.clone },
|
|
183
|
+
});
|
|
184
|
+
out.push({
|
|
185
|
+
id: 'reset',
|
|
186
|
+
region: 'toolbar',
|
|
187
|
+
group: 'trial',
|
|
188
|
+
end: true,
|
|
189
|
+
item: { icon: ResetIcon, label: 'Reset trial', onActivate: ctx.reset },
|
|
190
|
+
});
|
|
191
|
+
out.push({
|
|
192
|
+
id: 'close',
|
|
193
|
+
region: 'titlebar',
|
|
194
|
+
end: true,
|
|
195
|
+
item: {
|
|
196
|
+
icon: CloseIcon,
|
|
197
|
+
label: ctx.isLastTrial ? 'Cannot close the last trial' : 'Close trial',
|
|
198
|
+
danger: true,
|
|
199
|
+
disabled: ctx.isLastTrial,
|
|
200
|
+
onActivate: ctx.close,
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { builtinContributions } from './builtins';
|
|
2
|
+
export { mergeContributions, suppressContributions } from './merge';
|
|
3
|
+
export type { PaletteRegionProps } from './regions/PaletteRegion';
|
|
4
|
+
export { PaletteRegion } from './regions/PaletteRegion';
|
|
5
|
+
export type { SidebarRegionProps } from './regions/SidebarRegion';
|
|
6
|
+
export { SidebarRegion } from './regions/SidebarRegion';
|
|
7
|
+
export type { StatusRegionProps } from './regions/StatusRegion';
|
|
8
|
+
export { StatusRegion } from './regions/StatusRegion';
|
|
9
|
+
export type { TitleBarRegionProps } from './regions/TitleBarRegion';
|
|
10
|
+
export { TitleBarRegion } from './regions/TitleBarRegion';
|
|
11
|
+
export type { ToolbarRegionProps } from './regions/ToolbarRegion';
|
|
12
|
+
export { ToolbarRegion } from './regions/ToolbarRegion';
|
|
13
|
+
export type { ViewportRegionProps } from './regions/ViewportRegion';
|
|
14
|
+
export { ViewportRegion } from './regions/ViewportRegion';
|
|
15
|
+
export type {
|
|
16
|
+
IconComponent,
|
|
17
|
+
SidebarSection,
|
|
18
|
+
StatusReadout,
|
|
19
|
+
ToolbarItem,
|
|
20
|
+
ToolItem,
|
|
21
|
+
TrialChromeContext,
|
|
22
|
+
TrialContribution,
|
|
23
|
+
TrialRegion,
|
|
24
|
+
ViewportControl,
|
|
25
|
+
} from './types';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mergeContributions, suppressContributions } from './merge';
|
|
3
|
+
import type { TrialContribution } from './types';
|
|
4
|
+
|
|
5
|
+
const Glyph = () => null;
|
|
6
|
+
|
|
7
|
+
function toolbarItem(id: string): TrialContribution {
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
region: 'toolbar',
|
|
11
|
+
item: { icon: Glyph, label: id, onActivate: () => {} },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('mergeContributions', () => {
|
|
16
|
+
it('concatenates bundles in order', () => {
|
|
17
|
+
const out = mergeContributions([toolbarItem('a')], [toolbarItem('b'), toolbarItem('c')]);
|
|
18
|
+
expect(out.map((c) => c.id)).toEqual(['a', 'b', 'c']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('throws on a duplicate id, naming it', () => {
|
|
22
|
+
expect(() => mergeContributions([toolbarItem('undo')], [toolbarItem('undo')])).toThrow(
|
|
23
|
+
/duplicate contribution id "undo"/,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('accepts no bundles', () => {
|
|
28
|
+
expect(mergeContributions()).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('suppressContributions', () => {
|
|
33
|
+
it('removes the named ids', () => {
|
|
34
|
+
const out = suppressContributions(
|
|
35
|
+
[toolbarItem('a'), toolbarItem('b'), toolbarItem('c')],
|
|
36
|
+
['b'],
|
|
37
|
+
);
|
|
38
|
+
expect(out.map((c) => c.id)).toEqual(['a', 'c']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws on an id that is not there', () => {
|
|
42
|
+
expect(() => suppressContributions([toolbarItem('a')], ['snapshto'])).toThrow(
|
|
43
|
+
/cannot suppress "snapshto"/,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is a no-op for an empty suppress list', () => {
|
|
48
|
+
const bundle = [toolbarItem('a')];
|
|
49
|
+
expect(suppressContributions(bundle, [])).toEqual(bundle);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TrialContribution } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Concatenate contribution bundles into one list, preserving order — order
|
|
5
|
+
* decides how a region lays its contributions out, so it is part of the
|
|
6
|
+
* result, not an accident of it.
|
|
7
|
+
*
|
|
8
|
+
* Throws on a duplicate id rather than dropping one: a contribution silently
|
|
9
|
+
* losing to a later bundle is the failure a registry exists to prevent.
|
|
10
|
+
*/
|
|
11
|
+
export function mergeContributions(
|
|
12
|
+
...bundles: readonly TrialContribution[][]
|
|
13
|
+
): TrialContribution[] {
|
|
14
|
+
const out: TrialContribution[] = [];
|
|
15
|
+
const seen = new Set<string>();
|
|
16
|
+
for (const bundle of bundles) {
|
|
17
|
+
for (const entry of bundle) {
|
|
18
|
+
if (seen.has(entry.id)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`[labkit] mergeContributions: duplicate contribution id "${entry.id}". ` +
|
|
21
|
+
'Two bundles registered the same id; rename one, or suppress the ' +
|
|
22
|
+
'built-in before adding yours.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
seen.add(entry.id);
|
|
26
|
+
out.push(entry);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Drop contributions by id. Throws when an id is not present: a typo that
|
|
34
|
+
* silently suppresses nothing is the same class of bug as a duplicate id
|
|
35
|
+
* silently winning.
|
|
36
|
+
*/
|
|
37
|
+
export function suppressContributions(
|
|
38
|
+
bundle: readonly TrialContribution[],
|
|
39
|
+
ids: readonly string[],
|
|
40
|
+
): TrialContribution[] {
|
|
41
|
+
const present = new Set(bundle.map((c) => c.id));
|
|
42
|
+
for (const id of ids) {
|
|
43
|
+
if (!present.has(id)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`[labkit] cannot suppress "${id}": no contribution with that id. ` +
|
|
46
|
+
`Present ids: ${[...present].join(', ')}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const drop = new Set(ids);
|
|
51
|
+
return bundle.filter((c) => !drop.has(c.id));
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.lk-palette-region {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
padding: var(--wzl-space-xs);
|
|
5
|
+
border-right: var(--wzl-border-w) solid var(--wzl-line-subtle);
|
|
6
|
+
background: var(--wzl-surface-sunken);
|
|
7
|
+
|
|
8
|
+
// labkit's `:where(button)` default sets an explicit height, and because
|
|
9
|
+
// :where() carries no specificity it also wins over ToolButton, which sizes
|
|
10
|
+
// itself from padding.
|
|
11
|
+
button {
|
|
12
|
+
height: auto;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
4
|
+
import { PaletteRegion } from './PaletteRegion';
|
|
5
|
+
|
|
6
|
+
const Glyph = () => <svg />;
|
|
7
|
+
|
|
8
|
+
function ctxWith(activeToolId: string | null, setActiveTool = vi.fn()) {
|
|
9
|
+
return { trialId: 't1', activeToolId, setActiveTool } as unknown as TrialChromeContext;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tool(id: string): TrialContribution {
|
|
13
|
+
return { id, region: 'palette', item: { icon: Glyph, label: id } };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('PaletteRegion', () => {
|
|
17
|
+
it('renders nothing when empty', () => {
|
|
18
|
+
const { container } = render(<PaletteRegion contributions={[]} ctx={ctxWith(null)} />);
|
|
19
|
+
expect(container).toBeEmptyDOMElement();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('marks the resolved tool as current', () => {
|
|
23
|
+
render(
|
|
24
|
+
<PaletteRegion contributions={[tool('brush'), tool('eraser')]} ctx={ctxWith('brush')} />,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByRole('button', { name: 'brush' })).toHaveAttribute('aria-current', 'true');
|
|
27
|
+
expect(screen.getByRole('button', { name: 'eraser' })).not.toHaveAttribute('aria-current');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sets the tool on click', () => {
|
|
31
|
+
const setActiveTool = vi.fn();
|
|
32
|
+
render(<PaletteRegion contributions={[tool('brush')]} ctx={ctxWith(null, setActiveTool)} />);
|
|
33
|
+
screen.getByRole('button', { name: 'brush' }).click();
|
|
34
|
+
expect(setActiveTool).toHaveBeenCalledWith('brush');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ToolButton, ToolGroup } from '../../passthrough/weasel-ui';
|
|
2
|
+
import type { TrialChromeContext, TrialContribution } from '../types';
|
|
3
|
+
|
|
4
|
+
/** Props for `<PaletteRegion>`. */
|
|
5
|
+
export interface PaletteRegionProps {
|
|
6
|
+
contributions: readonly TrialContribution[];
|
|
7
|
+
ctx: TrialChromeContext;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A trial's tool strip. Selection lives in the trial's or the lab's tool
|
|
11
|
+
* slot; this region only reflects it. */
|
|
12
|
+
export function PaletteRegion({ contributions, ctx }: PaletteRegionProps) {
|
|
13
|
+
if (contributions.length === 0) return null;
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
className="lk-palette-region"
|
|
17
|
+
role="toolbar"
|
|
18
|
+
aria-label="Tools"
|
|
19
|
+
aria-orientation="vertical"
|
|
20
|
+
>
|
|
21
|
+
<ToolGroup orientation="vertical">
|
|
22
|
+
{contributions.map((c) => {
|
|
23
|
+
if (c.render) return <span key={c.id}>{c.render(ctx)}</span>;
|
|
24
|
+
if (c.region !== 'palette' || !c.item) return null;
|
|
25
|
+
const { icon: Icon, label, shortcut, disabled } = c.item;
|
|
26
|
+
return (
|
|
27
|
+
<ToolButton
|
|
28
|
+
key={c.id}
|
|
29
|
+
icon={<Icon size={16} />}
|
|
30
|
+
label={label}
|
|
31
|
+
shortcut={shortcut}
|
|
32
|
+
active={ctx.activeToolId === c.id}
|
|
33
|
+
disabled={disabled}
|
|
34
|
+
onClick={() => ctx.setActiveTool(c.id)}
|
|
35
|
+
/>
|
|
36
|
+
);
|
|
37
|
+
})}
|
|
38
|
+
</ToolGroup>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|