@weasel-js/labkit 0.1.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 +72 -0
- package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
- package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
- package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
- package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
- package/dist/canvas/index.d.ts +34 -0
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -0
- package/dist/chunk-2QNYYL3V.js +86 -0
- package/dist/chunk-2QNYYL3V.js.map +1 -0
- package/dist/chunk-54IQ2DX7.js +222 -0
- package/dist/chunk-54IQ2DX7.js.map +1 -0
- package/dist/chunk-6FO7G5BB.js +6095 -0
- package/dist/chunk-6FO7G5BB.js.map +1 -0
- package/dist/chunk-7BKDG73Z.js +54 -0
- package/dist/chunk-7BKDG73Z.js.map +1 -0
- package/dist/chunk-B5ZUYRKN.js +23330 -0
- package/dist/chunk-B5ZUYRKN.js.map +1 -0
- package/dist/chunk-HXZHVU4G.js +126 -0
- package/dist/chunk-HXZHVU4G.js.map +1 -0
- package/dist/chunk-PWC7AQZM.js +8 -0
- package/dist/chunk-PWC7AQZM.js.map +1 -0
- package/dist/chunk-SD3HDXR7.js +161 -0
- package/dist/chunk-SD3HDXR7.js.map +1 -0
- package/dist/chunk-SSMHPMDV.js +350 -0
- package/dist/chunk-SSMHPMDV.js.map +1 -0
- package/dist/chunk-VLAHRJOC.js +201 -0
- package/dist/chunk-VLAHRJOC.js.map +1 -0
- package/dist/controls/index.d.ts +13 -0
- package/dist/controls/index.js +3 -0
- package/dist/controls/index.js.map +1 -0
- package/dist/dragdrop/index.d.ts +3 -0
- package/dist/dragdrop/index.js +3 -0
- package/dist/dragdrop/index.js.map +1 -0
- package/dist/fonts/oswald-latin-variable.woff2 +0 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +1425 -0
- package/dist/index.js.map +1 -0
- package/dist/layers/index.d.ts +17 -0
- package/dist/layers/index.js +3 -0
- package/dist/layers/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +258 -0
- package/dist/passthrough/weasel-canvas.js +3 -0
- package/dist/passthrough/weasel-canvas.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +2826 -0
- package/dist/passthrough/weasel-ui.js +4 -0
- package/dist/passthrough/weasel-ui.js.map +1 -0
- package/dist/primitives/index.d.ts +57 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/state/index.d.ts +33 -0
- package/dist/state/index.js +34 -0
- package/dist/state/index.js.map +1 -0
- package/dist/styles.css +1224 -0
- package/dist/theme-interstellar.css +25 -0
- package/dist/theme-light.css +13 -0
- package/dist/ui/layers/index.d.ts +42 -0
- package/dist/ui/layers/index.js +5 -0
- package/dist/ui/layers/index.js.map +1 -0
- package/dist/undo/index.d.ts +30 -0
- package/dist/undo/index.js +3 -0
- package/dist/undo/index.js.map +1 -0
- package/package.json +99 -0
- package/src/canvas/AGENTS.md +72 -0
- package/src/canvas/CanvasStack.less +22 -0
- package/src/canvas/CanvasStack.stories.tsx +63 -0
- package/src/canvas/CanvasStack.test.tsx +82 -0
- package/src/canvas/CanvasStack.tsx +108 -0
- package/src/canvas/CanvasStackContext.ts +8 -0
- package/src/canvas/canvasCoords.test.ts +30 -0
- package/src/canvas/canvasCoords.ts +15 -0
- package/src/canvas/index.ts +6 -0
- package/src/canvas/useLayerScheduler.ts +72 -0
- package/src/canvas/usePanZoom.ts +98 -0
- package/src/controls/ControlPanel.less +61 -0
- package/src/controls/ControlPanel.stories.tsx +77 -0
- package/src/controls/ControlPanel.test.tsx +149 -0
- package/src/controls/ControlPanel.tsx +249 -0
- package/src/controls/index.ts +12 -0
- package/src/controls/types.ts +61 -0
- package/src/dragdrop/DragDropRuntime.tsx +135 -0
- package/src/dragdrop/DragGhost.less +17 -0
- package/src/dragdrop/DragGhost.tsx +24 -0
- package/src/dragdrop/Palette.less +34 -0
- package/src/dragdrop/Palette.tsx +27 -0
- package/src/dragdrop/dragDrop.test.tsx +145 -0
- package/src/dragdrop/index.ts +1 -0
- package/src/fonts/oswald-latin-variable.woff2 +0 -0
- package/src/index.ts +70 -0
- package/src/instrument/SineWave.smoke.test.tsx +94 -0
- package/src/instrument/capabilityDetector.test.ts +64 -0
- package/src/instrument/capabilityDetector.ts +17 -0
- package/src/instrument/defineInstrument.test.ts +27 -0
- package/src/instrument/defineInstrument.ts +5 -0
- package/src/instrument/index.ts +19 -0
- package/src/instrument/types.ts +65 -0
- package/src/instrument/validateConfigSchema.test.ts +125 -0
- package/src/instrument/validateConfigSchema.ts +65 -0
- package/src/lab/Lab.less +3 -0
- package/src/lab/Lab.stories.tsx +51 -0
- package/src/lab/Lab.test.tsx +126 -0
- package/src/lab/Lab.tsx +196 -0
- package/src/lab/LabContext.test.ts +11 -0
- package/src/lab/LabContext.ts +28 -0
- package/src/lab/LabShell.less +45 -0
- package/src/lab/LabShell.stories.tsx +39 -0
- package/src/lab/LabShell.test.tsx +59 -0
- package/src/lab/LabShell.tsx +33 -0
- package/src/lab/WorkspaceGrid.less +8 -0
- package/src/lab/WorkspaceGrid.stories.tsx +61 -0
- package/src/lab/WorkspaceGrid.test.tsx +40 -0
- package/src/lab/WorkspaceGrid.tsx +20 -0
- package/src/lab/gridDims.test.ts +35 -0
- package/src/lab/gridDims.ts +11 -0
- package/src/lab/index.ts +10 -0
- package/src/layers/AGENTS.md +65 -0
- package/src/layers/LayerList.less +53 -0
- package/src/layers/LayerList.test.tsx +49 -0
- package/src/layers/LayerList.tsx +105 -0
- package/src/layers/index.ts +3 -0
- package/src/passthrough/weasel-canvas.test.ts +30 -0
- package/src/passthrough/weasel-canvas.ts +23 -0
- package/src/passthrough/weasel-ui.test.ts +79 -0
- package/src/passthrough/weasel-ui.ts +150 -0
- package/src/primitives/FpsMeter.less +14 -0
- package/src/primitives/FpsMeter.stories.tsx +14 -0
- package/src/primitives/FpsMeter.test.tsx +38 -0
- package/src/primitives/FpsMeter.tsx +36 -0
- package/src/primitives/ScaleIndicator.less +25 -0
- package/src/primitives/ScaleIndicator.stories.tsx +16 -0
- package/src/primitives/ScaleIndicator.test.tsx +46 -0
- package/src/primitives/ScaleIndicator.tsx +48 -0
- package/src/primitives/Sidebar.less +57 -0
- package/src/primitives/Sidebar.stories.tsx +38 -0
- package/src/primitives/Sidebar.test.tsx +74 -0
- package/src/primitives/Sidebar.tsx +32 -0
- package/src/primitives/StatusBar.less +21 -0
- package/src/primitives/StatusBar.stories.tsx +24 -0
- package/src/primitives/StatusBar.test.tsx +25 -0
- package/src/primitives/StatusBar.tsx +18 -0
- package/src/primitives/Toolbar.less +43 -0
- package/src/primitives/Toolbar.stories.tsx +36 -0
- package/src/primitives/Toolbar.test.tsx +65 -0
- package/src/primitives/Toolbar.tsx +44 -0
- package/src/primitives/fpsAverage.test.ts +21 -0
- package/src/primitives/fpsAverage.ts +5 -0
- package/src/primitives/index.ts +9 -0
- package/src/state/SingletonExperiment.test.tsx +101 -0
- package/src/state/SingletonExperiment.tsx +52 -0
- package/src/state/adapters.test.ts +94 -0
- package/src/state/adapters.ts +102 -0
- package/src/state/context.tsx +42 -0
- package/src/state/helpers.test.ts +89 -0
- package/src/state/helpers.ts +61 -0
- package/src/state/index.ts +40 -0
- package/src/state/store.test.ts +243 -0
- package/src/state/store.ts +225 -0
- package/src/state/types.ts +53 -0
- package/src/state/useExperimentState.test.tsx +102 -0
- package/src/state/useExperimentState.ts +28 -0
- package/src/styles.less +20 -0
- package/src/test-setup.ts +17 -0
- package/src/theme/Interstellar.stories.less +92 -0
- package/src/theme/Interstellar.stories.tsx +243 -0
- package/src/theme/base.less +122 -0
- package/src/theme/interstellar.less +34 -0
- package/src/theme/light.less +14 -0
- package/src/theme/tokens.less +85 -0
- package/src/ui/format.ts +19 -0
- package/src/ui/layers/LayerStack.less +143 -0
- package/src/ui/layers/LayerStack.stories.tsx +45 -0
- package/src/ui/layers/LayerStack.test.tsx +170 -0
- package/src/ui/layers/LayerStack.tsx +215 -0
- package/src/ui/layers/index.ts +1 -0
- package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
- package/src/ui/properties/ColorRow.stories.tsx +106 -0
- package/src/ui/properties/CurveField.less +48 -0
- package/src/ui/properties/CurveField.stories.tsx +20 -0
- package/src/ui/properties/CurveField.test.tsx +127 -0
- package/src/ui/properties/CurveField.tsx +181 -0
- package/src/ui/properties/EffectCard.tsx +312 -0
- package/src/ui/properties/Gallery.stories.tsx +178 -0
- package/src/ui/properties/NumberRow.stories.tsx +65 -0
- package/src/ui/properties/PropertyGroup.less +50 -0
- package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
- package/src/ui/properties/PropertyGroup.test.tsx +33 -0
- package/src/ui/properties/PropertyGroup.tsx +44 -0
- package/src/ui/properties/PropertyList.stories.tsx +83 -0
- package/src/ui/properties/PropertyPanel.less +592 -0
- package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
- package/src/ui/properties/PropertyPanel.test.tsx +193 -0
- package/src/ui/properties/PropertyPanel.tsx +441 -0
- package/src/ui/properties/PropertyRow.stories.tsx +53 -0
- package/src/ui/properties/SelectRow.stories.tsx +58 -0
- package/src/ui/properties/SliderRow.stories.tsx +74 -0
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
- package/src/ui/properties/TextRow.stories.tsx +62 -0
- package/src/ui/properties/ToggleRow.stories.tsx +57 -0
- package/src/ui/properties/index.ts +39 -0
- package/src/ui/properties/storyLayouts.tsx +36 -0
- package/src/undo/eventBus.test.ts +49 -0
- package/src/undo/eventBus.ts +32 -0
- package/src/undo/index.ts +5 -0
- package/src/undo/undoStack.test.ts +70 -0
- package/src/undo/undoStack.ts +38 -0
- package/src/workspace/DefaultSidebar.tsx +26 -0
- package/src/workspace/DefaultStatusBar.tsx +15 -0
- package/src/workspace/DefaultToolbar.tsx +88 -0
- package/src/workspace/Workspace.less +58 -0
- package/src/workspace/Workspace.stories.tsx +78 -0
- package/src/workspace/Workspace.test.tsx +186 -0
- package/src/workspace/Workspace.tsx +255 -0
- package/src/workspace/WorkspaceChrome.tsx +150 -0
- package/src/workspace/index.ts +24 -0
- package/src/workspace/slotTypes.ts +44 -0
- package/src/workspace/workspaceOps.test.ts +142 -0
- package/src/workspace/workspaceOps.ts +78 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { clearUndo, emptyStack, pushSnapshot, redo, undo } from './undoStack';
|
|
3
|
+
|
|
4
|
+
describe('UndoStack', () => {
|
|
5
|
+
it('emptyStack has empty past and future', () => {
|
|
6
|
+
expect(emptyStack()).toEqual({ past: [], future: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('pushSnapshot adds to past and clears future', () => {
|
|
10
|
+
const s = { past: [1], future: [9] };
|
|
11
|
+
const next = pushSnapshot(s, 2, 10);
|
|
12
|
+
expect(next.past).toEqual([1, 2]);
|
|
13
|
+
expect(next.future).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('pushSnapshot evicts past[0] at maxDepth (FIFO)', () => {
|
|
17
|
+
let s = emptyStack();
|
|
18
|
+
s = pushSnapshot(s, 'a', 3);
|
|
19
|
+
s = pushSnapshot(s, 'b', 3);
|
|
20
|
+
s = pushSnapshot(s, 'c', 3);
|
|
21
|
+
s = pushSnapshot(s, 'd', 3);
|
|
22
|
+
expect(s.past).toEqual(['b', 'c', 'd']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('undo restores last past snapshot and pushes current to future', () => {
|
|
26
|
+
// Caller pushes pre-mutation snapshots: push 'pre-a' before applying 'a',
|
|
27
|
+
// push 'pre-b' before applying 'b'. Current state at undo time is 'b'.
|
|
28
|
+
let s = emptyStack();
|
|
29
|
+
s = pushSnapshot(s, 'pre-a', 5);
|
|
30
|
+
s = pushSnapshot(s, 'pre-b', 5);
|
|
31
|
+
const r = undo(s, 'b');
|
|
32
|
+
expect(r).not.toBeNull();
|
|
33
|
+
expect(r?.snapshot).toBe('pre-b');
|
|
34
|
+
expect(r?.stack.past).toEqual(['pre-a']);
|
|
35
|
+
expect(r?.stack.future).toEqual(['b']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('undo on empty stack returns null', () => {
|
|
39
|
+
expect(undo(emptyStack(), 'anything')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('redo restores last future snapshot and pushes current to past', () => {
|
|
43
|
+
let s = emptyStack();
|
|
44
|
+
s = pushSnapshot(s, 'pre-a', 5);
|
|
45
|
+
const u = undo(s, 'a');
|
|
46
|
+
if (!u) throw new Error('expected undo');
|
|
47
|
+
// After undo we are at 'pre-a'; redo should advance back to 'a'.
|
|
48
|
+
const r = redo(u.stack, u.snapshot);
|
|
49
|
+
expect(r?.snapshot).toBe('a');
|
|
50
|
+
expect(r?.stack.past).toEqual(['pre-a']);
|
|
51
|
+
expect(r?.stack.future).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('redo on empty future returns null', () => {
|
|
55
|
+
expect(redo(emptyStack(), 'anything')).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('push after undo invalidates redo', () => {
|
|
59
|
+
let s = emptyStack();
|
|
60
|
+
s = pushSnapshot(s, 'pre-a', 5);
|
|
61
|
+
const u = undo(s, 'a');
|
|
62
|
+
if (!u) throw new Error();
|
|
63
|
+
s = pushSnapshot(u.stack, 'pre-b', 5);
|
|
64
|
+
expect(redo(s, 'b')).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('clearUndo zeros both arrays', () => {
|
|
68
|
+
expect(clearUndo({ past: [1, 2], future: [3] })).toEqual({ past: [], future: [] });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface UndoStack {
|
|
2
|
+
past: unknown[];
|
|
3
|
+
future: unknown[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function emptyStack(): UndoStack {
|
|
7
|
+
return { past: [], future: [] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function pushSnapshot(stack: UndoStack, snapshot: unknown, maxDepth: number): UndoStack {
|
|
11
|
+
const past = stack.past.length >= maxDepth ? stack.past.slice(1) : stack.past.slice();
|
|
12
|
+
past.push(snapshot);
|
|
13
|
+
return { past, future: [] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function undo(
|
|
17
|
+
stack: UndoStack,
|
|
18
|
+
current: unknown,
|
|
19
|
+
): { stack: UndoStack; snapshot: unknown } | null {
|
|
20
|
+
if (stack.past.length === 0) return null;
|
|
21
|
+
const past = stack.past.slice();
|
|
22
|
+
const snapshot = past.pop();
|
|
23
|
+
return { stack: { past, future: [...stack.future, current] }, snapshot };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function redo(
|
|
27
|
+
stack: UndoStack,
|
|
28
|
+
current: unknown,
|
|
29
|
+
): { stack: UndoStack; snapshot: unknown } | null {
|
|
30
|
+
if (stack.future.length === 0) return null;
|
|
31
|
+
const future = stack.future.slice();
|
|
32
|
+
const snapshot = future.pop();
|
|
33
|
+
return { stack: { past: [...stack.past, current], future }, snapshot };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearUndo(_stack: UndoStack): UndoStack {
|
|
37
|
+
return emptyStack();
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ControlPanel } from '../controls/ControlPanel';
|
|
2
|
+
import { Sidebar } from '../primitives/Sidebar';
|
|
3
|
+
import type { WorkspaceSidebarContext } from './slotTypes';
|
|
4
|
+
|
|
5
|
+
export interface DefaultSidebarProps {
|
|
6
|
+
ctx: WorkspaceSidebarContext;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function DefaultSidebar({ ctx }: DefaultSidebarProps) {
|
|
10
|
+
if (ctx.configFields.length === 0) {
|
|
11
|
+
return (
|
|
12
|
+
<Sidebar title={ctx.instrumentName}>
|
|
13
|
+
<div className="lk-sidebar__placeholder">{ctx.instrumentName} has no config fields.</div>
|
|
14
|
+
</Sidebar>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return (
|
|
18
|
+
<Sidebar title={ctx.instrumentName}>
|
|
19
|
+
<ControlPanel
|
|
20
|
+
fields={ctx.configFields}
|
|
21
|
+
config={ctx.config as Record<string, unknown>}
|
|
22
|
+
setConfig={(key, value) => ctx.setConfig(key as string, value)}
|
|
23
|
+
/>
|
|
24
|
+
</Sidebar>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { StatusBar } from '../primitives/StatusBar';
|
|
2
|
+
import type { WorkspaceStatusBarContext } from './slotTypes';
|
|
3
|
+
|
|
4
|
+
export interface DefaultStatusBarProps {
|
|
5
|
+
ctx: WorkspaceStatusBarContext;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function DefaultStatusBar({ ctx }: DefaultStatusBarProps) {
|
|
9
|
+
return (
|
|
10
|
+
<StatusBar>
|
|
11
|
+
<StatusBar.Section>{ctx.instrumentName}</StatusBar.Section>
|
|
12
|
+
<StatusBar.Section>{Math.round(ctx.zoom * 100)}%</StatusBar.Section>
|
|
13
|
+
</StatusBar>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Toolbar } from '../primitives/Toolbar';
|
|
2
|
+
import type { WorkspaceToolbarContext } from './slotTypes';
|
|
3
|
+
|
|
4
|
+
export interface DefaultToolbarProps {
|
|
5
|
+
ctx: WorkspaceToolbarContext;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function DefaultToolbar({ ctx }: DefaultToolbarProps) {
|
|
9
|
+
return (
|
|
10
|
+
<Toolbar>
|
|
11
|
+
{ctx.hasUndo && (
|
|
12
|
+
<>
|
|
13
|
+
<Toolbar.Button onClick={ctx.undo} disabled={!ctx.canUndo} title="Undo (Cmd/Ctrl+Z)">
|
|
14
|
+
Undo
|
|
15
|
+
</Toolbar.Button>
|
|
16
|
+
<Toolbar.Button
|
|
17
|
+
onClick={ctx.redo}
|
|
18
|
+
disabled={!ctx.canRedo}
|
|
19
|
+
title="Redo (Cmd/Ctrl+Shift+Z)"
|
|
20
|
+
>
|
|
21
|
+
Redo
|
|
22
|
+
</Toolbar.Button>
|
|
23
|
+
<Toolbar.Spacer />
|
|
24
|
+
</>
|
|
25
|
+
)}
|
|
26
|
+
|
|
27
|
+
{ctx.hasCanvas && (
|
|
28
|
+
<>
|
|
29
|
+
<Toolbar.Button onClick={ctx.zoomOut} title="Zoom out">
|
|
30
|
+
−
|
|
31
|
+
</Toolbar.Button>
|
|
32
|
+
<span className="lk-toolbar__zoom-label">{Math.round(ctx.zoom * 100)}%</span>
|
|
33
|
+
<Toolbar.Button onClick={ctx.zoomIn} title="Zoom in">
|
|
34
|
+
+
|
|
35
|
+
</Toolbar.Button>
|
|
36
|
+
<Toolbar.Button onClick={ctx.resetZoom} title="Reset zoom">
|
|
37
|
+
1:1
|
|
38
|
+
</Toolbar.Button>
|
|
39
|
+
<Toolbar.Spacer />
|
|
40
|
+
</>
|
|
41
|
+
)}
|
|
42
|
+
|
|
43
|
+
<Toolbar.Button onClick={() => ctx.saveSnapshot()} title="Save snapshot (Cmd/Ctrl+S)">
|
|
44
|
+
Save
|
|
45
|
+
</Toolbar.Button>
|
|
46
|
+
|
|
47
|
+
{ctx.savedSnapshots.length > 0 && (
|
|
48
|
+
<select
|
|
49
|
+
className="lk-toolbar__load-select"
|
|
50
|
+
aria-label="Load snapshot"
|
|
51
|
+
defaultValue=""
|
|
52
|
+
onChange={(e) => {
|
|
53
|
+
const id = e.target.value;
|
|
54
|
+
if (id) {
|
|
55
|
+
ctx.loadSnapshot(id);
|
|
56
|
+
e.target.value = '';
|
|
57
|
+
}
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<option value="" disabled>
|
|
61
|
+
Load…
|
|
62
|
+
</option>
|
|
63
|
+
{ctx.savedSnapshots.map((sn) => (
|
|
64
|
+
<option key={sn.id} value={sn.id}>
|
|
65
|
+
{sn.name}
|
|
66
|
+
</option>
|
|
67
|
+
))}
|
|
68
|
+
</select>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
<Toolbar.Spacer />
|
|
72
|
+
|
|
73
|
+
<Toolbar.Button onClick={ctx.clone} title="Clone workspace">
|
|
74
|
+
Clone
|
|
75
|
+
</Toolbar.Button>
|
|
76
|
+
<Toolbar.Button onClick={ctx.reset} title="Reset workspace">
|
|
77
|
+
Reset
|
|
78
|
+
</Toolbar.Button>
|
|
79
|
+
<Toolbar.Button
|
|
80
|
+
onClick={ctx.close}
|
|
81
|
+
disabled={ctx.isLastWorkspace}
|
|
82
|
+
title={ctx.isLastWorkspace ? 'Cannot close the last workspace' : 'Close workspace'}
|
|
83
|
+
>
|
|
84
|
+
Close
|
|
85
|
+
</Toolbar.Button>
|
|
86
|
+
</Toolbar>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
.lk-workspace {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
height: 100%;
|
|
5
|
+
background: var(--lk-bg);
|
|
6
|
+
border: 1px solid var(--lk-border);
|
|
7
|
+
border-radius: var(--lk-radius);
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
|
|
10
|
+
&__body {
|
|
11
|
+
display: flex;
|
|
12
|
+
flex: 1;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
&__sidebar {
|
|
17
|
+
flex-shrink: 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&__content {
|
|
21
|
+
flex: 1;
|
|
22
|
+
overflow: auto;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
&__toolbar {
|
|
26
|
+
flex-shrink: 0;
|
|
27
|
+
border-bottom: 1px solid var(--lk-divider);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&__status {
|
|
31
|
+
flex-shrink: 0;
|
|
32
|
+
border-top: 1px solid var(--lk-divider);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&--unknown {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
color: var(--lk-text-muted);
|
|
40
|
+
font-size: var(--lk-font-size-sm);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.lk-toolbar__zoom-label {
|
|
45
|
+
font-size: var(--lk-font-size-sm);
|
|
46
|
+
color: var(--lk-text-muted);
|
|
47
|
+
padding: 0 var(--lk-space-2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.lk-toolbar__load-select {
|
|
51
|
+
font-size: var(--lk-font-size-sm);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.lk-sidebar__placeholder {
|
|
55
|
+
padding: var(--lk-space-2);
|
|
56
|
+
color: var(--lk-text-muted);
|
|
57
|
+
font-size: var(--lk-font-size-sm);
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import type { Instrument } from '../instrument/types';
|
|
3
|
+
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
4
|
+
import { noneAdapter } from '../state/adapters';
|
|
5
|
+
import { LabStoreContext } from '../state/context';
|
|
6
|
+
import { createLabStore } from '../state/store';
|
|
7
|
+
import type { WorkspaceRecord } from '../state/types';
|
|
8
|
+
import { WorkspaceChrome } from './WorkspaceChrome';
|
|
9
|
+
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
|
|
12
|
+
const stub: Instrument = {
|
|
13
|
+
name: 'Stub',
|
|
14
|
+
defaultConfig: () => ({}),
|
|
15
|
+
initialState: () => ({}),
|
|
16
|
+
render: () => <div className="lk-stub-display">stub experiment area</div>,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const record: WorkspaceRecord = {
|
|
20
|
+
id: 'ws-demo',
|
|
21
|
+
instrumentName: 'Stub',
|
|
22
|
+
config: {},
|
|
23
|
+
state: {},
|
|
24
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
25
|
+
undoStack: { past: [], future: [] },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function Harness() {
|
|
29
|
+
const store = createLabStore({ storageKey: 'sb', storage: noneAdapter });
|
|
30
|
+
const lab: LabContextValue = {
|
|
31
|
+
instruments: [stub],
|
|
32
|
+
workspaces: [record],
|
|
33
|
+
addWorkspace: noop,
|
|
34
|
+
cloneWorkspace: noop,
|
|
35
|
+
closeWorkspace: noop,
|
|
36
|
+
resetWorkspace: noop,
|
|
37
|
+
savedSnapshots: [],
|
|
38
|
+
saveSnapshot: noop,
|
|
39
|
+
loadSnapshot: noop,
|
|
40
|
+
deleteSnapshot: noop,
|
|
41
|
+
theme: 'auto',
|
|
42
|
+
setTheme: noop,
|
|
43
|
+
};
|
|
44
|
+
return (
|
|
45
|
+
<LabStoreContext.Provider value={{ store }}>
|
|
46
|
+
<LabContext.Provider value={lab}>
|
|
47
|
+
<div style={{ height: '500px' }}>
|
|
48
|
+
<WorkspaceChrome
|
|
49
|
+
workspaceId="ws-demo"
|
|
50
|
+
record={record}
|
|
51
|
+
instrument={stub}
|
|
52
|
+
isLastWorkspace={true}
|
|
53
|
+
>
|
|
54
|
+
{stub.render({
|
|
55
|
+
state: {},
|
|
56
|
+
config: {},
|
|
57
|
+
setState: () => {},
|
|
58
|
+
setConfig: () => {},
|
|
59
|
+
workspace: { id: 'ws-demo', zoom: 1, setZoom: () => {} },
|
|
60
|
+
emit: () => {},
|
|
61
|
+
})}
|
|
62
|
+
</WorkspaceChrome>
|
|
63
|
+
</div>
|
|
64
|
+
</LabContext.Provider>
|
|
65
|
+
</LabStoreContext.Provider>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const meta: Meta<typeof Harness> = {
|
|
70
|
+
title: 'labkit/Workspace/Workspace',
|
|
71
|
+
component: Harness,
|
|
72
|
+
parameters: { layout: 'fullscreen' },
|
|
73
|
+
};
|
|
74
|
+
export default meta;
|
|
75
|
+
|
|
76
|
+
type Story = StoryObj<typeof Harness>;
|
|
77
|
+
|
|
78
|
+
export const Default: Story = {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { Instrument } from '../instrument/types';
|
|
5
|
+
import { Lab } from '../lab/Lab';
|
|
6
|
+
import { LabContext, type LabContextValue } from '../lab/LabContext';
|
|
7
|
+
import { LabStoreContext } from '../state/context';
|
|
8
|
+
import { createLabStore } from '../state/store';
|
|
9
|
+
import type { WorkspaceRecord } from '../state/types';
|
|
10
|
+
import { DefaultToolbar } from './DefaultToolbar';
|
|
11
|
+
import type { WorkspaceToolbarContext } from './slotTypes';
|
|
12
|
+
import { WorkspaceChrome } from './WorkspaceChrome';
|
|
13
|
+
|
|
14
|
+
function makeCtx(overrides: Partial<WorkspaceToolbarContext> = {}): WorkspaceToolbarContext {
|
|
15
|
+
return {
|
|
16
|
+
workspaceId: 'ws-1',
|
|
17
|
+
instrumentName: 'Stub',
|
|
18
|
+
hasUndo: false,
|
|
19
|
+
canUndo: false,
|
|
20
|
+
canRedo: false,
|
|
21
|
+
undo: vi.fn(),
|
|
22
|
+
redo: vi.fn(),
|
|
23
|
+
zoom: 1,
|
|
24
|
+
setZoom: vi.fn(),
|
|
25
|
+
zoomIn: vi.fn(),
|
|
26
|
+
zoomOut: vi.fn(),
|
|
27
|
+
resetZoom: vi.fn(),
|
|
28
|
+
hasCanvas: false,
|
|
29
|
+
savedSnapshots: [],
|
|
30
|
+
saveSnapshot: vi.fn(),
|
|
31
|
+
loadSnapshot: vi.fn(),
|
|
32
|
+
clone: vi.fn(),
|
|
33
|
+
reset: vi.fn(),
|
|
34
|
+
close: vi.fn(),
|
|
35
|
+
isLastWorkspace: false,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('<DefaultToolbar>', () => {
|
|
41
|
+
it('renders close button', () => {
|
|
42
|
+
render(<DefaultToolbar ctx={makeCtx()} />);
|
|
43
|
+
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('disables close when isLastWorkspace is true', () => {
|
|
47
|
+
render(<DefaultToolbar ctx={makeCtx({ isLastWorkspace: true })} />);
|
|
48
|
+
expect(screen.getByRole('button', { name: /close/i })).toBeDisabled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('omits undo/redo buttons when hasUndo is false', () => {
|
|
52
|
+
render(<DefaultToolbar ctx={makeCtx({ hasUndo: false })} />);
|
|
53
|
+
expect(screen.queryByRole('button', { name: /undo/i })).toBeNull();
|
|
54
|
+
expect(screen.queryByRole('button', { name: /redo/i })).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('shows undo/redo buttons when hasUndo is true', () => {
|
|
58
|
+
render(<DefaultToolbar ctx={makeCtx({ hasUndo: true, canUndo: true, canRedo: false })} />);
|
|
59
|
+
expect(screen.getByRole('button', { name: /undo/i })).not.toBeDisabled();
|
|
60
|
+
expect(screen.getByRole('button', { name: /redo/i })).toBeDisabled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('omits zoom buttons when hasCanvas is false', () => {
|
|
64
|
+
render(<DefaultToolbar ctx={makeCtx({ hasCanvas: false })} />);
|
|
65
|
+
expect(screen.queryByTitle('Zoom in')).toBeNull();
|
|
66
|
+
expect(screen.queryByTitle('Zoom out')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('shows zoom controls when hasCanvas is true', () => {
|
|
70
|
+
render(<DefaultToolbar ctx={makeCtx({ hasCanvas: true, zoom: 1.5 })} />);
|
|
71
|
+
expect(screen.getByTitle('Zoom in')).toBeInTheDocument();
|
|
72
|
+
expect(screen.getByTitle('Zoom out')).toBeInTheDocument();
|
|
73
|
+
expect(screen.getByText('150%')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows load select when snapshots exist', () => {
|
|
77
|
+
const ctx = makeCtx({
|
|
78
|
+
savedSnapshots: [
|
|
79
|
+
{
|
|
80
|
+
id: 's1',
|
|
81
|
+
name: 'First',
|
|
82
|
+
workspaceId: 'ws-1',
|
|
83
|
+
instrumentName: 'Stub',
|
|
84
|
+
config: {},
|
|
85
|
+
state: {},
|
|
86
|
+
savedAt: 1,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
render(<DefaultToolbar ctx={ctx} />);
|
|
91
|
+
expect(screen.getByRole('combobox', { name: /load snapshot/i })).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const stubInstrument: Instrument = {
|
|
96
|
+
name: 'Stub',
|
|
97
|
+
defaultConfig: () => ({}),
|
|
98
|
+
initialState: () => ({}),
|
|
99
|
+
render: () => null,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const stubRecord: WorkspaceRecord = {
|
|
103
|
+
id: 'ws-1',
|
|
104
|
+
instrumentName: 'Stub',
|
|
105
|
+
config: {},
|
|
106
|
+
state: {},
|
|
107
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
108
|
+
undoStack: { past: [], future: [] },
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type ChromeProps = Parameters<typeof WorkspaceChrome>[0];
|
|
112
|
+
|
|
113
|
+
function ChromeHarness({
|
|
114
|
+
children,
|
|
115
|
+
labOverrides,
|
|
116
|
+
...props
|
|
117
|
+
}: { children?: ReactNode; labOverrides?: Partial<LabContextValue> } & Partial<ChromeProps>) {
|
|
118
|
+
const store = createLabStore({
|
|
119
|
+
storageKey: 'test',
|
|
120
|
+
storage: { read: () => null, write: () => {} },
|
|
121
|
+
});
|
|
122
|
+
const labCtx: LabContextValue = {
|
|
123
|
+
instruments: [stubInstrument],
|
|
124
|
+
workspaces: [stubRecord],
|
|
125
|
+
addWorkspace: vi.fn(),
|
|
126
|
+
cloneWorkspace: vi.fn(),
|
|
127
|
+
closeWorkspace: vi.fn(),
|
|
128
|
+
resetWorkspace: vi.fn(),
|
|
129
|
+
savedSnapshots: [],
|
|
130
|
+
saveSnapshot: vi.fn(),
|
|
131
|
+
loadSnapshot: vi.fn(),
|
|
132
|
+
deleteSnapshot: vi.fn(),
|
|
133
|
+
theme: 'auto',
|
|
134
|
+
setTheme: vi.fn(),
|
|
135
|
+
...labOverrides,
|
|
136
|
+
};
|
|
137
|
+
return (
|
|
138
|
+
<LabStoreContext.Provider value={{ store }}>
|
|
139
|
+
<LabContext.Provider value={labCtx}>
|
|
140
|
+
<WorkspaceChrome
|
|
141
|
+
workspaceId="ws-1"
|
|
142
|
+
record={stubRecord}
|
|
143
|
+
instrument={stubInstrument}
|
|
144
|
+
isLastWorkspace={false}
|
|
145
|
+
{...props}
|
|
146
|
+
>
|
|
147
|
+
{children ?? <div data-testid="content">content</div>}
|
|
148
|
+
</WorkspaceChrome>
|
|
149
|
+
</LabContext.Provider>
|
|
150
|
+
</LabStoreContext.Provider>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('<WorkspaceChrome>', () => {
|
|
155
|
+
it('renders children in the content area', () => {
|
|
156
|
+
render(<ChromeHarness />);
|
|
157
|
+
expect(screen.getByTestId('content')).toBeInTheDocument();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('renders default toolbar when no toolbar prop is given', () => {
|
|
161
|
+
render(<ChromeHarness />);
|
|
162
|
+
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders custom toolbar when toolbar prop is provided', () => {
|
|
166
|
+
render(<ChromeHarness toolbar={() => <div data-testid="custom-toolbar">custom</div>} />);
|
|
167
|
+
expect(screen.getByTestId('custom-toolbar')).toBeInTheDocument();
|
|
168
|
+
expect(screen.queryByRole('button', { name: /close/i })).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('Cmd+S triggers saveSnapshot', () => {
|
|
172
|
+
const saveSnapshot = vi.fn();
|
|
173
|
+
render(<ChromeHarness labOverrides={{ saveSnapshot }} />);
|
|
174
|
+
const region = screen.getByRole('region', { name: /workspace/i });
|
|
175
|
+
fireEvent.keyDown(region, { key: 's', metaKey: true });
|
|
176
|
+
expect(saveSnapshot).toHaveBeenCalledWith('ws-1', undefined);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('<Lab> + WorkspaceChrome integration', () => {
|
|
181
|
+
it('Lab provides context for nested WorkspaceChrome', () => {
|
|
182
|
+
render(<Lab instruments={[stubInstrument]} defaultInstrument="Stub" />);
|
|
183
|
+
// Lab seeds a workspace; no chrome rendered without children — sanity check Lab mounts.
|
|
184
|
+
expect(document.querySelector('.lk-lab')).toBeTruthy();
|
|
185
|
+
});
|
|
186
|
+
});
|