@weasel-js/labkit 1.0.3 → 1.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 +6 -6
- package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
- package/dist/_dts/{index-CFlDPeZh.d.ts → index-iAP6XbH3.d.ts} +17 -7
- package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useTrialState-DYe2vUwN.d.ts} +62 -34
- package/dist/canvas/index.d.ts +1 -1
- package/dist/chunk-3TYUJR7Z.js +488 -0
- package/dist/chunk-3TYUJR7Z.js.map +1 -0
- package/dist/{chunk-N5KTQKQA.js → chunk-C6GJKPUI.js} +835 -770
- package/dist/chunk-C6GJKPUI.js.map +1 -0
- package/dist/{chunk-3WPOGKUP.js → chunk-DJLDIRFN.js} +543 -226
- package/dist/chunk-DJLDIRFN.js.map +1 -0
- package/dist/{chunk-73PXCRCR.js → chunk-NRKWVTVT.js} +6 -6
- package/dist/chunk-NRKWVTVT.js.map +1 -0
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/index.d.ts +197 -148
- package/dist/index.js +275 -161
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +2 -2
- package/dist/passthrough/weasel-canvas.d.ts +1 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +10 -3
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/state/index.d.ts +14 -14
- package/dist/state/index.js +5 -5
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +179 -14
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +1 -1
- package/package.json +2 -1
- package/src/canvas/AGENTS.md +6 -2
- package/src/dragdrop/dragDrop.test.tsx +4 -7
- package/src/index.test.ts +52 -0
- package/src/index.ts +11 -7
- package/src/instrument/SineWave.smoke.test.tsx +1 -1
- package/src/instrument/capabilityDetector.ts +1 -1
- package/src/instrument/types.ts +18 -6
- package/src/lab/Lab.less +16 -0
- package/src/lab/Lab.stories.tsx +5 -5
- package/src/lab/Lab.test.tsx +25 -25
- package/src/lab/Lab.tsx +56 -44
- package/src/lab/LabContext.ts +12 -11
- package/src/lab/LabShell.tsx +1 -1
- package/src/lab/Workspace.less +40 -0
- package/src/lab/{WorkspaceGrid.stories.tsx → Workspace.stories.tsx} +27 -11
- package/src/lab/Workspace.test.tsx +114 -0
- package/src/lab/Workspace.tsx +196 -0
- package/src/lab/index.ts +2 -4
- package/src/layers/AGENTS.md +3 -3
- package/src/primitives/Toolbar.stories.tsx +2 -2
- package/src/state/SingletonExperiment.test.tsx +8 -8
- package/src/state/SingletonExperiment.tsx +9 -9
- package/src/state/context.tsx +12 -12
- package/src/state/document.test.ts +369 -0
- package/src/state/document.ts +194 -0
- package/src/state/helpers.test.ts +74 -46
- package/src/state/helpers.ts +23 -31
- package/src/state/index.ts +16 -8
- package/src/state/store.test.ts +440 -40
- package/src/state/store.ts +166 -90
- package/src/state/types.ts +30 -10
- package/src/state/{useExperimentState.test.tsx → useTrialState.test.tsx} +18 -18
- package/src/state/useTrialState.ts +29 -0
- package/src/styles.less +2 -2
- package/src/test-setup.ts +19 -0
- package/src/theme/base.less +1 -1
- package/src/{workspace → trial}/DefaultSidebar.tsx +3 -3
- package/src/{workspace → trial}/DefaultStatusBar.tsx +5 -4
- package/src/{workspace → trial}/DefaultToolbar.tsx +9 -8
- package/src/trial/Trial.canvas.test.tsx +80 -0
- package/src/{workspace/Workspace.less → trial/Trial.less} +1 -1
- package/src/{workspace/Workspace.stories.tsx → trial/Trial.stories.tsx} +13 -17
- package/src/{workspace/Workspace.test.tsx → trial/Trial.test.tsx} +26 -25
- package/src/{workspace/Workspace.tsx → trial/Trial.tsx} +48 -43
- package/src/{workspace/WorkspaceChrome.tsx → trial/TrialChrome.tsx} +42 -42
- package/src/{workspace → trial}/index.ts +13 -12
- package/src/{workspace → trial}/slotTypes.ts +16 -16
- package/src/{workspace/workspaceOps.test.ts → trial/trialOps.test.ts} +60 -33
- package/src/trial/trialOps.ts +99 -0
- package/src/ui/format.test.ts +33 -0
- package/src/ui/format.ts +11 -0
- package/src/ui/properties/CurveField.tsx +6 -6
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +3 -3
- package/dist/chunk-3WPOGKUP.js.map +0 -1
- package/dist/chunk-73PXCRCR.js.map +0 -1
- package/dist/chunk-N5KTQKQA.js.map +0 -1
- package/dist/chunk-T7OKNJTY.js +0 -351
- package/dist/chunk-T7OKNJTY.js.map +0 -1
- package/src/lab/WorkspaceGrid.less +0 -8
- package/src/lab/WorkspaceGrid.test.tsx +0 -40
- package/src/lab/WorkspaceGrid.tsx +0 -22
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -13
- package/src/state/useExperimentState.ts +0 -31
- package/src/workspace/workspaceOps.ts +0 -86
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
3
|
+
import { Workspace } from './Workspace';
|
|
4
|
+
|
|
5
|
+
const VIEWPORT = { w: 800, h: 600 };
|
|
6
|
+
|
|
7
|
+
describe('Workspace', () => {
|
|
8
|
+
test('renders all children', () => {
|
|
9
|
+
render(
|
|
10
|
+
<Workspace viewport={VIEWPORT}>
|
|
11
|
+
<div>one</div>
|
|
12
|
+
<div>two</div>
|
|
13
|
+
<div>three</div>
|
|
14
|
+
</Workspace>,
|
|
15
|
+
);
|
|
16
|
+
expect(screen.getByText('one')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('two')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText('three')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('keeps a child with the tile its id names when an earlier one closes', () => {
|
|
22
|
+
const ids = ['a', 'b', 'c'];
|
|
23
|
+
const { rerender, container } = render(
|
|
24
|
+
<Workspace ids={ids} viewport={VIEWPORT}>
|
|
25
|
+
<div>a</div>
|
|
26
|
+
<div>b</div>
|
|
27
|
+
<div>c</div>
|
|
28
|
+
</Workspace>,
|
|
29
|
+
);
|
|
30
|
+
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
31
|
+
|
|
32
|
+
rerender(
|
|
33
|
+
<Workspace ids={['b', 'c']} viewport={VIEWPORT}>
|
|
34
|
+
<div>b</div>
|
|
35
|
+
<div>c</div>
|
|
36
|
+
</Workspace>,
|
|
37
|
+
);
|
|
38
|
+
expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
|
|
39
|
+
expect(container.querySelector('[data-node="a"]')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('uses lk-workspace class', () => {
|
|
43
|
+
const { container } = render(
|
|
44
|
+
<Workspace viewport={VIEWPORT}>
|
|
45
|
+
<div />
|
|
46
|
+
</Workspace>,
|
|
47
|
+
);
|
|
48
|
+
expect((container.firstChild as HTMLElement).className).toContain('lk-workspace');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('renders resize affordances only when resizable', () => {
|
|
52
|
+
const { container, rerender } = render(
|
|
53
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
54
|
+
<div>a</div>
|
|
55
|
+
<div>b</div>
|
|
56
|
+
</Workspace>,
|
|
57
|
+
);
|
|
58
|
+
expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
|
|
59
|
+
|
|
60
|
+
rerender(
|
|
61
|
+
<Workspace ids={['a', 'b']} resizable viewport={VIEWPORT}>
|
|
62
|
+
<div>a</div>
|
|
63
|
+
<div>b</div>
|
|
64
|
+
</Workspace>,
|
|
65
|
+
);
|
|
66
|
+
expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('applies a saved extent to a tile as it registers', () => {
|
|
70
|
+
const { container } = render(
|
|
71
|
+
<Workspace
|
|
72
|
+
ids={['a', 'b']}
|
|
73
|
+
resizable
|
|
74
|
+
viewport={VIEWPORT}
|
|
75
|
+
layout={{ a: { span: { cols: 2 } } }}
|
|
76
|
+
>
|
|
77
|
+
<div>a</div>
|
|
78
|
+
<div>b</div>
|
|
79
|
+
</Workspace>,
|
|
80
|
+
);
|
|
81
|
+
const a = container.querySelector('[data-node="a"]') as HTMLElement;
|
|
82
|
+
const b = container.querySelector('[data-node="b"]') as HTMLElement;
|
|
83
|
+
// `a` holds two columns, so it is wider than the single-column `b`.
|
|
84
|
+
expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('reports the order a drop would produce instead of applying it', () => {
|
|
88
|
+
const onReorder = vi.fn();
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<Workspace ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
|
|
91
|
+
<div>a</div>
|
|
92
|
+
<div>b</div>
|
|
93
|
+
</Workspace>,
|
|
94
|
+
);
|
|
95
|
+
// The grid is controlled: it renders a handle per tile and commits nothing
|
|
96
|
+
// itself. Order still comes from `ids`.
|
|
97
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(2);
|
|
98
|
+
expect(onReorder).not.toHaveBeenCalled();
|
|
99
|
+
const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
|
|
100
|
+
el.getAttribute('data-node'),
|
|
101
|
+
);
|
|
102
|
+
expect(nodes).toEqual(['a', 'b']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('renders no drag handles unless reorderable', () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<Workspace ids={['a', 'b']} viewport={VIEWPORT}>
|
|
108
|
+
<div>a</div>
|
|
109
|
+
<div>b</div>
|
|
110
|
+
</Workspace>,
|
|
111
|
+
);
|
|
112
|
+
expect(container.querySelectorAll('.lk-trial-tile__grip')).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { asNodeId, createNode, gridStrategy, type NodeId, Store } from 'windease';
|
|
11
|
+
import {
|
|
12
|
+
type ChromeMap,
|
|
13
|
+
Container,
|
|
14
|
+
DragHandle,
|
|
15
|
+
DragProvider,
|
|
16
|
+
Provider,
|
|
17
|
+
StrategyRegistryProvider,
|
|
18
|
+
} from 'windease/react';
|
|
19
|
+
|
|
20
|
+
const ZONE_ID = asNodeId('lk-workspace');
|
|
21
|
+
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
|
+
const KIND = 'trial';
|
|
23
|
+
|
|
24
|
+
/** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
|
|
25
|
+
* write `span`; `size` is here because a strategy swap would write that. */
|
|
26
|
+
export type TrialLayout = Record<
|
|
27
|
+
string,
|
|
28
|
+
{ size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export interface WorkspaceProps {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Stable id per child, positionally matched. Supply these whenever a tile
|
|
35
|
+
* can be closed from the middle: without them a tile is identified by its
|
|
36
|
+
* position, so closing one shifts every id after it and the panes inherit
|
|
37
|
+
* each other's dragged extents. `layout` and `reorderable` both key off
|
|
38
|
+
* these, so neither means much without them.
|
|
39
|
+
*/
|
|
40
|
+
ids?: readonly string[];
|
|
41
|
+
/** Draggable seams between tiles. Off by default — an even tiling is the
|
|
42
|
+
* behavior every existing caller has. */
|
|
43
|
+
resizable?: boolean;
|
|
44
|
+
/** Let a tile be dragged to a new position. Off by default. The grid never
|
|
45
|
+
* reorders `children` itself: it reports the order a drop would produce and
|
|
46
|
+
* the caller commits it. */
|
|
47
|
+
reorderable?: boolean;
|
|
48
|
+
/** The full id list a drop would produce, in its new order. */
|
|
49
|
+
onReorder?: (ids: string[]) => void;
|
|
50
|
+
/** Extents from a previous session, applied to tiles as they register. */
|
|
51
|
+
layout?: TrialLayout;
|
|
52
|
+
/** Fires when a tile's extent changes. Persist it and hand it back as
|
|
53
|
+
* `layout` to make a resize survive a reload. */
|
|
54
|
+
onLayoutChange?: (layout: TrialLayout) => void;
|
|
55
|
+
gap?: number;
|
|
56
|
+
padding?: number;
|
|
57
|
+
/**
|
|
58
|
+
* Fixed tiling extent. Omit in an app — the grid measures its own box. Supply
|
|
59
|
+
* it where nothing measures, notably jsdom: at a zero measurement the grid
|
|
60
|
+
* renders no tiles at all.
|
|
61
|
+
*/
|
|
62
|
+
viewport?: { w: number; h: number };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extentOf(store: Store, id: NodeId): TrialLayout[string] | null {
|
|
66
|
+
const p = store.getNode(id)?.membership?.placement as TrialLayout[string] | undefined;
|
|
67
|
+
if (!p) return null;
|
|
68
|
+
const out: TrialLayout[string] = {};
|
|
69
|
+
if (p.size) out.size = p.size;
|
|
70
|
+
if (p.span) out.span = p.span;
|
|
71
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Auto-balanced tiling of trials, `ceil(sqrt(n))` columns wide.
|
|
76
|
+
*
|
|
77
|
+
* Tiles are absolutely positioned at the rects `gridStrategy` computes, not
|
|
78
|
+
* laid out by CSS — `windease/styles.css` (folded into
|
|
79
|
+
* `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
|
|
80
|
+
*/
|
|
81
|
+
export function Workspace({
|
|
82
|
+
children,
|
|
83
|
+
ids,
|
|
84
|
+
resizable = false,
|
|
85
|
+
reorderable = false,
|
|
86
|
+
onReorder,
|
|
87
|
+
layout,
|
|
88
|
+
onLayoutChange,
|
|
89
|
+
gap = 12,
|
|
90
|
+
padding = 0,
|
|
91
|
+
viewport,
|
|
92
|
+
}: WorkspaceProps) {
|
|
93
|
+
const items = Children.toArray(children);
|
|
94
|
+
const idKey = ids ? ids.join(',') : `#${items.length}`;
|
|
95
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
|
|
96
|
+
const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
|
|
97
|
+
|
|
98
|
+
// Held in refs rather than depended on: a fresh object each render would
|
|
99
|
+
// re-run the sync effect, and only a newly registered tile reads `layout`.
|
|
100
|
+
const layoutRef = useRef(layout);
|
|
101
|
+
layoutRef.current = layout;
|
|
102
|
+
const onLayoutChangeRef = useRef(onLayoutChange);
|
|
103
|
+
onLayoutChangeRef.current = onLayoutChange;
|
|
104
|
+
|
|
105
|
+
// One store for the component's lifetime: a tile's dragged extent lives in
|
|
106
|
+
// its node, so rebuilding the store on every add or close would silently
|
|
107
|
+
// reset every pane.
|
|
108
|
+
const storeRef = useRef<Store | null>(null);
|
|
109
|
+
if (storeRef.current === null) {
|
|
110
|
+
const store = new Store();
|
|
111
|
+
store.registerNode(
|
|
112
|
+
createNode({
|
|
113
|
+
kind: 'zone',
|
|
114
|
+
id: ZONE_ID,
|
|
115
|
+
container: { strategyId: 'grid', config: { resizable, gap, padding } },
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
storeRef.current = store;
|
|
119
|
+
}
|
|
120
|
+
const store = storeRef.current;
|
|
121
|
+
|
|
122
|
+
useLayoutEffect(() => {
|
|
123
|
+
store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
|
|
124
|
+
}, [store, resizable, gap, padding]);
|
|
125
|
+
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
const present = new Set(store.getContainerView(ZONE_ID)?.childOrder ?? []);
|
|
128
|
+
const wanted = new Set(nodeIds);
|
|
129
|
+
for (const id of present) {
|
|
130
|
+
if (!wanted.has(id)) store.unregisterNode(id);
|
|
131
|
+
}
|
|
132
|
+
for (const id of nodeIds) {
|
|
133
|
+
if (present.has(id)) continue;
|
|
134
|
+
store.registerNode(createNode({ kind: KIND, id, parentId: ZONE_ID, focus: true }));
|
|
135
|
+
store.showNode(id);
|
|
136
|
+
const saved = layoutRef.current?.[id];
|
|
137
|
+
if (saved) store.patchPlacement(id, saved);
|
|
138
|
+
}
|
|
139
|
+
store.setChildOrder(ZONE_ID, [...nodeIds]);
|
|
140
|
+
}, [store, nodeIds]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!onLayoutChange) return;
|
|
144
|
+
return store.events.on('node.placementChanged', () => {
|
|
145
|
+
const next: TrialLayout = {};
|
|
146
|
+
for (const child of store.getChildren(ZONE_ID)) {
|
|
147
|
+
const extent = extentOf(store, child.id);
|
|
148
|
+
if (extent) next[child.id] = extent;
|
|
149
|
+
}
|
|
150
|
+
onLayoutChangeRef.current?.(next);
|
|
151
|
+
});
|
|
152
|
+
}, [store, onLayoutChange]);
|
|
153
|
+
|
|
154
|
+
const commitOrder = useCallback(
|
|
155
|
+
(nextIds: NodeId[]) => onReorder?.(nextIds.map(String)),
|
|
156
|
+
[onReorder],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const chrome = useMemo<ChromeMap>(() => {
|
|
160
|
+
const byId = new Map<string, ReactNode>(nodeIds.map((id, i) => [id, items[i]]));
|
|
161
|
+
return {
|
|
162
|
+
[KIND]: ({ node }) => {
|
|
163
|
+
const content = byId.get(node.id) ?? null;
|
|
164
|
+
if (!reorderable) return content;
|
|
165
|
+
// A tile is full of controls, so the whole thing can't be the handle.
|
|
166
|
+
return (
|
|
167
|
+
<div className="lk-trial-tile">
|
|
168
|
+
<DragHandle nodeId={node.id} className="lk-trial-tile__grip">
|
|
169
|
+
<span className="lk-trial-tile__grip-dots" aria-hidden="true" />
|
|
170
|
+
</DragHandle>
|
|
171
|
+
<div className="lk-trial-tile__body">{content}</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}, [nodeIds, items, reorderable]);
|
|
177
|
+
|
|
178
|
+
const grid = (
|
|
179
|
+
<Container
|
|
180
|
+
parentId={ZONE_ID}
|
|
181
|
+
chrome={chrome}
|
|
182
|
+
className="lk-workspace windease-zone"
|
|
183
|
+
affordances={resizable}
|
|
184
|
+
viewport={viewport}
|
|
185
|
+
onChildOrderChange={reorderable ? commitOrder : undefined}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<Provider store={store}>
|
|
191
|
+
<StrategyRegistryProvider strategies={STRATEGIES}>
|
|
192
|
+
{reorderable ? <DragProvider>{grid}</DragProvider> : grid}
|
|
193
|
+
</StrategyRegistryProvider>
|
|
194
|
+
</Provider>
|
|
195
|
+
);
|
|
196
|
+
}
|
package/src/lab/index.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
export type { GridDims } from './gridDims';
|
|
2
|
-
export { gridDims } from './gridDims';
|
|
3
1
|
export type { LabProps } from './Lab';
|
|
4
2
|
export { Lab } from './Lab';
|
|
5
3
|
export type { LabContextValue } from './LabContext';
|
|
6
4
|
export { LabContext, useLabContext } from './LabContext';
|
|
7
5
|
export type { LabShellProps } from './LabShell';
|
|
8
6
|
export { LabShell } from './LabShell';
|
|
9
|
-
export type {
|
|
10
|
-
export {
|
|
7
|
+
export type { WorkspaceProps } from './Workspace';
|
|
8
|
+
export { Workspace } from './Workspace';
|
package/src/layers/AGENTS.md
CHANGED
|
@@ -31,9 +31,9 @@ The instrument declares layer ids:
|
|
|
31
31
|
layers: { ids: ['grid', 'plants'] }
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`
|
|
34
|
+
`Trial.tsx` converts each id into a `LayerDescriptor` (with `label === id` by default) and tracks `visibility` and a derived `layerOrder` in local state. The order produced by `onReorder` is then applied to `instrument.canvas.layers` before they're passed to `<CanvasStack>`.
|
|
35
35
|
|
|
36
|
-
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the
|
|
36
|
+
There is no transitive coupling between `layers.ids` and `canvas.layers[].id` — the trial assumes the ids match. If you declare a layer in `canvas` that isn't in `layers.ids`, it stays in default order and is always visible.
|
|
37
37
|
|
|
38
38
|
## `alwaysOn` semantics
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ Pointer capture is acquired on `pointerdown` and released on `pointerup`, so dra
|
|
|
52
52
|
|
|
53
53
|
## Empty state
|
|
54
54
|
|
|
55
|
-
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers".
|
|
55
|
+
If `layers.length === 0`, renders `.lk-layer-list__empty` with the text "No layers". Trial already guards this case (`layerDescriptors.length > 0`) so the empty state is rare in practice.
|
|
56
56
|
|
|
57
57
|
## When to fork
|
|
58
58
|
|
|
@@ -12,7 +12,7 @@ type Story = StoryObj<typeof Toolbar>;
|
|
|
12
12
|
export const Default: Story = {
|
|
13
13
|
render: () => (
|
|
14
14
|
<Toolbar>
|
|
15
|
-
<Toolbar.Title>My
|
|
15
|
+
<Toolbar.Title>My Trial</Toolbar.Title>
|
|
16
16
|
<Toolbar.Button onClick={() => {}}>Undo</Toolbar.Button>
|
|
17
17
|
<Toolbar.Button onClick={() => {}}>Redo</Toolbar.Button>
|
|
18
18
|
<Toolbar.Spacer />
|
|
@@ -24,7 +24,7 @@ export const Default: Story = {
|
|
|
24
24
|
export const WithDisabled: Story = {
|
|
25
25
|
render: () => (
|
|
26
26
|
<Toolbar>
|
|
27
|
-
<Toolbar.Title>Empty
|
|
27
|
+
<Toolbar.Title>Empty trial</Toolbar.Title>
|
|
28
28
|
<Toolbar.Button onClick={() => {}} disabled>
|
|
29
29
|
Undo
|
|
30
30
|
</Toolbar.Button>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { act, render, renderHook } from '@testing-library/react';
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
+
import { labDocumentKey } from './document';
|
|
4
5
|
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
5
|
-
import {
|
|
6
|
+
import { useTrialState } from './useTrialState';
|
|
6
7
|
|
|
7
8
|
interface Config {
|
|
8
9
|
width: number;
|
|
@@ -17,7 +18,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
17
18
|
vi.useRealTimers();
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
it('exposes config and state via
|
|
21
|
+
it('exposes config and state via useTrialState', () => {
|
|
21
22
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
22
23
|
<SingletonExperimentProvider<State, Config>
|
|
23
24
|
id="test"
|
|
@@ -29,7 +30,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
29
30
|
{children}
|
|
30
31
|
</SingletonExperimentProvider>
|
|
31
32
|
);
|
|
32
|
-
const { result } = renderHook(() =>
|
|
33
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
33
34
|
expect(result.current.config).toEqual({ width: 100, bg: '#000' });
|
|
34
35
|
expect(result.current.state).toEqual({ zoom: 1 });
|
|
35
36
|
});
|
|
@@ -38,7 +39,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
38
39
|
vi.useFakeTimers();
|
|
39
40
|
const storage = createMemoryAdapter();
|
|
40
41
|
const Probe = () => {
|
|
41
|
-
const h =
|
|
42
|
+
const h = useTrialState<State, Config>();
|
|
42
43
|
return (
|
|
43
44
|
<button type="button" onClick={() => h.setConfig('width', 200)}>
|
|
44
45
|
go
|
|
@@ -62,15 +63,14 @@ describe('SingletonExperimentProvider', () => {
|
|
|
62
63
|
act(() => {
|
|
63
64
|
vi.advanceTimersByTime(400);
|
|
64
65
|
});
|
|
65
|
-
|
|
66
|
-
const raw = storage.read('lk:test:workspaces');
|
|
66
|
+
const raw = storage.read(labDocumentKey('test'));
|
|
67
67
|
expect(raw).toBeTruthy();
|
|
68
68
|
expect(raw).toContain('"width":200');
|
|
69
69
|
});
|
|
70
70
|
|
|
71
71
|
it('rehydrates from storage on mount', () => {
|
|
72
72
|
const storage = createMemoryAdapter();
|
|
73
|
-
//
|
|
73
|
+
// A pre-document lab, which createLabStore folds forward on hydration.
|
|
74
74
|
storage.write(
|
|
75
75
|
'lk:test:workspaces',
|
|
76
76
|
JSON.stringify([
|
|
@@ -94,7 +94,7 @@ describe('SingletonExperimentProvider', () => {
|
|
|
94
94
|
{children}
|
|
95
95
|
</SingletonExperimentProvider>
|
|
96
96
|
);
|
|
97
|
-
const { result } = renderHook(() =>
|
|
97
|
+
const { result } = renderHook(() => useTrialState<State, Config>(), { wrapper });
|
|
98
98
|
expect(result.current.config).toEqual({ width: 999, bg: '#fff' });
|
|
99
99
|
expect(result.current.state).toEqual({ zoom: 2 });
|
|
100
100
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode, useRef } from 'react';
|
|
2
|
-
import { LabStoreProvider,
|
|
2
|
+
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
3
3
|
import { createLabStore, type LabStore } from './store';
|
|
4
4
|
import type { StorageAdapter } from './types';
|
|
5
5
|
|
|
@@ -7,8 +7,8 @@ const SINGLETON_INSTRUMENT = '__singleton__';
|
|
|
7
7
|
|
|
8
8
|
/** Props for `<SingletonExperimentProvider>`. */
|
|
9
9
|
export interface SingletonExperimentProviderProps<TS, TC> {
|
|
10
|
-
/** Stable id for the synthetic
|
|
11
|
-
*
|
|
10
|
+
/** Stable id for the synthetic trial; also doubles as the
|
|
11
|
+
* TrialIdContext value. */
|
|
12
12
|
id: string;
|
|
13
13
|
initialConfig: TC;
|
|
14
14
|
initialState: TS;
|
|
@@ -18,9 +18,9 @@ export interface SingletonExperimentProviderProps<TS, TC> {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* One-
|
|
22
|
-
* a `LabStoreProvider` + `
|
|
23
|
-
*
|
|
21
|
+
* One-trial `<Lab>` substitute for single-screen experiments. Mounts
|
|
22
|
+
* a `LabStoreProvider` + `TrialIdProvider` with one synthetic
|
|
23
|
+
* trial, so `useTrialState` works without going through the
|
|
24
24
|
* full `<Lab instruments={...}>` runtime.
|
|
25
25
|
*/
|
|
26
26
|
export function SingletonExperimentProvider<TS, TC>({
|
|
@@ -34,8 +34,8 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
34
34
|
const storeRef = useRef<LabStore | null>(null);
|
|
35
35
|
if (storeRef.current === null) {
|
|
36
36
|
const store = createLabStore({ storageKey, storage });
|
|
37
|
-
if (!store.getState().
|
|
38
|
-
store.getState().
|
|
37
|
+
if (!store.getState().trials.some((w) => w.id === id)) {
|
|
38
|
+
store.getState().addTrial({
|
|
39
39
|
id,
|
|
40
40
|
instrumentName: SINGLETON_INSTRUMENT,
|
|
41
41
|
config: initialConfig,
|
|
@@ -47,7 +47,7 @@ export function SingletonExperimentProvider<TS, TC>({
|
|
|
47
47
|
}
|
|
48
48
|
return (
|
|
49
49
|
<LabStoreProvider store={storeRef.current}>
|
|
50
|
-
<
|
|
50
|
+
<TrialIdProvider trialId={id}>{children}</TrialIdProvider>
|
|
51
51
|
</LabStoreProvider>
|
|
52
52
|
);
|
|
53
53
|
}
|
package/src/state/context.tsx
CHANGED
|
@@ -27,25 +27,25 @@ export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']>
|
|
|
27
27
|
return useStore(ctx.store);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Context carrying which
|
|
31
|
-
export const
|
|
30
|
+
/** Context carrying which trial the subtree belongs to. */
|
|
31
|
+
export const TrialIdContext = createContext<string | null>(null);
|
|
32
32
|
|
|
33
|
-
/** Names the
|
|
33
|
+
/** Names the trial its subtree belongs to, so an instrument's hooks can
|
|
34
34
|
* find their own record in the store without being passed an id. */
|
|
35
|
-
export function
|
|
36
|
-
|
|
35
|
+
export function TrialIdProvider({
|
|
36
|
+
trialId,
|
|
37
37
|
children,
|
|
38
38
|
}: {
|
|
39
|
-
|
|
39
|
+
trialId: string;
|
|
40
40
|
children: ReactNode;
|
|
41
41
|
}): ReactElement {
|
|
42
|
-
return <
|
|
42
|
+
return <TrialIdContext.Provider value={trialId}>{children}</TrialIdContext.Provider>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** The id of the
|
|
46
|
-
* `<
|
|
47
|
-
export function
|
|
48
|
-
const id = useContext(
|
|
49
|
-
if (!id) throw new Error('[labkit]
|
|
45
|
+
/** The id of the trial this component is inside. Throws outside a
|
|
46
|
+
* `<TrialIdProvider>`. */
|
|
47
|
+
export function useTrialId(): string {
|
|
48
|
+
const id = useContext(TrialIdContext);
|
|
49
|
+
if (!id) throw new Error('[labkit] useTrialId must be used inside <TrialIdProvider>');
|
|
50
50
|
return id;
|
|
51
51
|
}
|