@weasel-js/labkit 1.0.2 → 1.0.4
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/dist/_dts/{DrawCommand-CitWPxMY.d.ts → DrawCommand-BkZztJsW.d.ts} +59 -1
- package/dist/_dts/{index-C6Yze7sQ.d.ts → index-CFlDPeZh.d.ts} +30 -1
- package/dist/_dts/{types-Si4Fw-1F.d.ts → types-x92Kfeme.d.ts} +14 -0
- package/dist/_dts/{useExperimentState-CJn2hHzd.d.ts → useExperimentState-D7EQnnwJ.d.ts} +47 -0
- package/dist/canvas/index.d.ts +13 -2
- package/dist/canvas/index.js +2 -2
- package/dist/{chunk-54IQ2DX7.js → chunk-574LJAV4.js} +3 -3
- package/dist/chunk-574LJAV4.js.map +1 -0
- package/dist/{chunk-VLAHRJOC.js → chunk-5R2ATYPJ.js} +2 -2
- package/dist/{chunk-VLAHRJOC.js.map → chunk-5R2ATYPJ.js.map} +1 -1
- package/dist/{chunk-R4TACNW7.js → chunk-BAPZPDDA.js} +631 -591
- package/dist/chunk-BAPZPDDA.js.map +1 -0
- package/dist/{chunk-PWC7AQZM.js → chunk-CPUJ3QXL.js} +2 -2
- package/dist/chunk-CPUJ3QXL.js.map +1 -0
- package/dist/{chunk-2ZRE7WGQ.js → chunk-DEWXYFEU.js} +6 -6
- package/dist/chunk-DEWXYFEU.js.map +1 -0
- package/dist/{chunk-3P56ZCCJ.js → chunk-DWV7SFKR.js} +601 -269
- package/dist/chunk-DWV7SFKR.js.map +1 -0
- package/dist/{chunk-53XSBIUK.js → chunk-KSTEW2AF.js} +18 -2
- package/dist/chunk-KSTEW2AF.js.map +1 -0
- package/dist/{chunk-HXZHVU4G.js → chunk-PMAU3SEE.js} +3 -3
- package/dist/chunk-PMAU3SEE.js.map +1 -0
- package/dist/{chunk-7BKDG73Z.js → chunk-RL2LOLNI.js} +2 -2
- package/dist/chunk-RL2LOLNI.js.map +1 -0
- package/dist/{chunk-2QNYYL3V.js → chunk-SFL7NFKN.js} +2 -2
- package/dist/chunk-SFL7NFKN.js.map +1 -0
- package/dist/controls/index.d.ts +4 -2
- package/dist/controls/index.js +1 -1
- package/dist/dragdrop/index.d.ts +2 -2
- package/dist/index.d.ts +180 -15
- package/dist/index.js +146 -37
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -3
- package/dist/layers/index.js +1 -1
- package/dist/passthrough/weasel-canvas.d.ts +5 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +444 -7
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +13 -0
- package/dist/primitives/index.js +2 -2
- package/dist/state/index.d.ts +16 -3
- package/dist/state/index.js +2 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +163 -6
- package/dist/ui/layers/index.d.ts +5 -0
- package/dist/ui/layers/index.js +3 -3
- package/dist/undo/index.d.ts +17 -2
- package/dist/undo/index.js +1 -1
- package/package.json +2 -1
- package/src/canvas/CanvasStack.tsx +4 -0
- package/src/canvas/CanvasStackContext.ts +3 -0
- package/src/canvas/canvasCoords.ts +2 -0
- package/src/canvas/useLayerScheduler.ts +2 -0
- package/src/controls/ControlPanel.tsx +2 -0
- package/src/controls/types.ts +14 -0
- package/src/instrument/capabilityDetector.ts +3 -0
- package/src/instrument/defineInstrument.ts +2 -0
- package/src/instrument/types.ts +29 -0
- package/src/instrument/validateConfigSchema.ts +5 -0
- package/src/lab/Lab.tsx +17 -2
- package/src/lab/LabContext.ts +6 -0
- package/src/lab/LabShell.tsx +4 -0
- package/src/lab/WorkspaceGrid.less +37 -5
- package/src/lab/WorkspaceGrid.stories.tsx +16 -0
- package/src/lab/WorkspaceGrid.test.tsx +84 -10
- package/src/lab/WorkspaceGrid.tsx +188 -12
- package/src/lab/index.ts +0 -2
- package/src/layers/LayerList.tsx +3 -0
- package/src/primitives/FpsMeter.tsx +1 -0
- package/src/primitives/ScaleIndicator.tsx +4 -0
- package/src/primitives/Sidebar.tsx +3 -0
- package/src/primitives/StatusBar.tsx +3 -0
- package/src/primitives/Toolbar.tsx +6 -0
- package/src/state/SingletonExperiment.tsx +1 -0
- package/src/state/adapters.ts +8 -0
- package/src/state/context.tsx +9 -0
- package/src/state/helpers.ts +13 -1
- package/src/state/store.test.ts +2 -1
- package/src/state/store.ts +25 -0
- package/src/state/types.ts +20 -0
- package/src/state/useExperimentState.ts +3 -0
- package/src/test-setup.ts +19 -0
- package/src/ui/layers/LayerStack.tsx +5 -0
- package/src/ui/properties/CurveField.tsx +4 -1
- package/src/ui/properties/EffectCard.tsx +12 -0
- package/src/ui/properties/PropertyGroup.tsx +1 -0
- package/src/ui/properties/PropertyPanel.tsx +28 -0
- package/src/undo/eventBus.ts +4 -0
- package/src/undo/undoStack.ts +11 -0
- package/src/workspace/DefaultSidebar.tsx +3 -0
- package/src/workspace/DefaultStatusBar.tsx +3 -0
- package/src/workspace/DefaultToolbar.tsx +3 -0
- package/src/workspace/Workspace.stories.tsx +1 -0
- package/src/workspace/Workspace.test.tsx +1 -0
- package/src/workspace/Workspace.tsx +3 -0
- package/src/workspace/WorkspaceChrome.tsx +4 -0
- package/src/workspace/index.ts +1 -0
- package/src/workspace/slotTypes.ts +9 -0
- package/src/workspace/workspaceOps.test.ts +34 -1
- package/src/workspace/workspaceOps.ts +24 -0
- package/dist/chunk-2QNYYL3V.js.map +0 -1
- package/dist/chunk-2ZRE7WGQ.js.map +0 -1
- package/dist/chunk-3P56ZCCJ.js.map +0 -1
- package/dist/chunk-53XSBIUK.js.map +0 -1
- package/dist/chunk-54IQ2DX7.js.map +0 -1
- package/dist/chunk-7BKDG73Z.js.map +0 -1
- package/dist/chunk-HXZHVU4G.js.map +0 -1
- package/dist/chunk-PWC7AQZM.js.map +0 -1
- package/dist/chunk-R4TACNW7.js.map +0 -1
- package/src/lab/gridDims.test.ts +0 -35
- package/src/lab/gridDims.ts +0 -11
|
@@ -1,20 +1,196 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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-workspaces');
|
|
21
|
+
const STRATEGIES = { grid: gridStrategy as never };
|
|
22
|
+
const KIND = 'workspace';
|
|
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 WorkspaceLayout = Record<
|
|
27
|
+
string,
|
|
28
|
+
{ size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
|
|
29
|
+
>;
|
|
3
30
|
|
|
4
31
|
export interface WorkspaceGridProps {
|
|
5
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?: WorkspaceLayout;
|
|
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: WorkspaceLayout) => 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): WorkspaceLayout[string] | null {
|
|
66
|
+
const p = store.getNode(id)?.membership?.placement as WorkspaceLayout[string] | undefined;
|
|
67
|
+
if (!p) return null;
|
|
68
|
+
const out: WorkspaceLayout[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;
|
|
6
72
|
}
|
|
7
73
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Auto-balanced tiling of workspaces, `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 WorkspaceGrid({
|
|
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
|
+
}: WorkspaceGridProps) {
|
|
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: WorkspaceLayout = {};
|
|
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-workspace-tile">
|
|
168
|
+
<DragHandle nodeId={node.id} className="lk-workspace-tile__grip">
|
|
169
|
+
<span className="lk-workspace-tile__grip-dots" aria-hidden="true" />
|
|
170
|
+
</DragHandle>
|
|
171
|
+
<div className="lk-workspace-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-grid windease-zone"
|
|
183
|
+
affordances={resizable}
|
|
184
|
+
viewport={viewport}
|
|
185
|
+
onChildOrderChange={reorderable ? commitOrder : undefined}
|
|
186
|
+
/>
|
|
187
|
+
);
|
|
188
|
+
|
|
15
189
|
return (
|
|
16
|
-
<
|
|
17
|
-
{
|
|
18
|
-
|
|
190
|
+
<Provider store={store}>
|
|
191
|
+
<StrategyRegistryProvider strategies={STRATEGIES}>
|
|
192
|
+
{reorderable ? <DragProvider>{grid}</DragProvider> : grid}
|
|
193
|
+
</StrategyRegistryProvider>
|
|
194
|
+
</Provider>
|
|
19
195
|
);
|
|
20
196
|
}
|
package/src/lab/index.ts
CHANGED
package/src/layers/LayerList.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PointerEvent, useRef, useState } from 'react';
|
|
2
2
|
import type { LayerDescriptor } from '../instrument/types';
|
|
3
3
|
|
|
4
|
+
/** Props for `<LayerList>`. */
|
|
4
5
|
export interface LayerListProps {
|
|
5
6
|
layers: LayerDescriptor[];
|
|
6
7
|
visibility: Record<string, boolean>;
|
|
@@ -15,6 +16,8 @@ interface DragState {
|
|
|
15
16
|
startY: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** A reorderable list of layers with per-layer visibility toggles. Layers
|
|
20
|
+
* marked `alwaysOn` are pinned and cannot be reordered or hidden. */
|
|
18
21
|
export function LayerList({ layers, visibility, onReorder, onToggle, className }: LayerListProps) {
|
|
19
22
|
const reorderable = layers.filter((l) => !l.alwaysOn);
|
|
20
23
|
const pinned = layers.filter((l) => l.alwaysOn);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useContext } from 'react';
|
|
2
2
|
import { CanvasStackContext } from '../canvas/CanvasStackContext';
|
|
3
3
|
|
|
4
|
+
/** Props for `<ScaleIndicator>`. */
|
|
4
5
|
export interface ScaleIndicatorProps {
|
|
5
6
|
/** Current view zoom factor. If omitted, reads from CanvasStackContext. Defaults to 1. */
|
|
6
7
|
zoom?: number;
|
|
@@ -25,6 +26,9 @@ function niceNumber(n: number): number {
|
|
|
25
26
|
return nice * 10 ** exp;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/** A scale bar: a labeled rule showing how far a round number of world units
|
|
30
|
+
* is at the current zoom. Reads the zoom from the surrounding canvas stack
|
|
31
|
+
* unless one is passed. */
|
|
28
32
|
export function ScaleIndicator({
|
|
29
33
|
zoom,
|
|
30
34
|
pixelsPerUnit = 1,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<Sidebar>`. */
|
|
3
4
|
export interface SidebarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
title?: string;
|
|
@@ -7,6 +8,8 @@ export interface SidebarProps {
|
|
|
7
8
|
onToggle?: () => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
/** A collapsible side panel with an optional title. Collapse state is the
|
|
12
|
+
* caller's to hold. */
|
|
10
13
|
export function Sidebar({ children, title, collapsed = false, onToggle }: SidebarProps) {
|
|
11
14
|
const className = `lk-sidebar${collapsed ? ' lk-sidebar--collapsed' : ''}`;
|
|
12
15
|
return (
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<StatusBar>`. */
|
|
3
4
|
export interface StatusBarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** A footer strip for readouts. Fill it with `<StatusBar.Section>`. */
|
|
7
9
|
export function StatusBar({ children }: StatusBarProps) {
|
|
8
10
|
return <div className="lk-status-bar">{children}</div>;
|
|
9
11
|
}
|
|
@@ -15,4 +17,5 @@ function Section({ children }: SectionProps) {
|
|
|
15
17
|
return <span className="lk-status-bar-section">{children}</span>;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/** One readout within a status bar. */
|
|
18
21
|
StatusBar.Section = Section;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { MouseEventHandler, ReactNode } from 'react';
|
|
2
2
|
|
|
3
|
+
/** Props for `<Toolbar>`. */
|
|
3
4
|
export interface ToolbarProps {
|
|
4
5
|
children: ReactNode;
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** A horizontal bar of controls. Fill it with `<Toolbar.Title>`,
|
|
9
|
+
* `<Toolbar.Button>` and `<Toolbar.Spacer>`. */
|
|
7
10
|
export function Toolbar({ children }: ToolbarProps) {
|
|
8
11
|
return <div className="lk-toolbar">{children}</div>;
|
|
9
12
|
}
|
|
@@ -39,6 +42,9 @@ function Spacer() {
|
|
|
39
42
|
return <span className="lk-toolbar-spacer" aria-hidden="true" />;
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/** A label within a toolbar. */
|
|
42
46
|
Toolbar.Title = Title;
|
|
47
|
+
/** A button within a toolbar. */
|
|
43
48
|
Toolbar.Button = Button;
|
|
49
|
+
/** Flexible space that pushes what follows to the far end of the toolbar. */
|
|
44
50
|
Toolbar.Spacer = Spacer;
|
|
@@ -5,6 +5,7 @@ import type { StorageAdapter } from './types';
|
|
|
5
5
|
|
|
6
6
|
const SINGLETON_INSTRUMENT = '__singleton__';
|
|
7
7
|
|
|
8
|
+
/** Props for `<SingletonExperimentProvider>`. */
|
|
8
9
|
export interface SingletonExperimentProviderProps<TS, TC> {
|
|
9
10
|
/** Stable id for the synthetic workspace; also doubles as the
|
|
10
11
|
* WorkspaceIdContext value. */
|
package/src/state/adapters.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { decodeUrlHash, encodeUrlHash } from './helpers';
|
|
2
2
|
import type { StorageAdapter } from './types';
|
|
3
3
|
|
|
4
|
+
/** Persist to `localStorage` — state survives a reload and a new tab. Falls
|
|
5
|
+
* back to no-ops when storage is unavailable. */
|
|
4
6
|
export const localStorageAdapter: StorageAdapter = {
|
|
5
7
|
read: (key) => {
|
|
6
8
|
try {
|
|
@@ -25,6 +27,7 @@ export const localStorageAdapter: StorageAdapter = {
|
|
|
25
27
|
},
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
/** Persist to `sessionStorage` — state survives a reload but not a new tab. */
|
|
28
31
|
export const sessionStorageAdapter: StorageAdapter = {
|
|
29
32
|
read: (key) => {
|
|
30
33
|
try {
|
|
@@ -68,6 +71,8 @@ function writeHashMap(map: Record<string, string>): void {
|
|
|
68
71
|
window.history.replaceState(null, '', `#${encoded}`);
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
/** Persist into the URL fragment, so the page's link carries its state and
|
|
75
|
+
* can be shared or bookmarked. */
|
|
71
76
|
export const urlHashAdapter: StorageAdapter = {
|
|
72
77
|
read: (key) => readHashMap()[key] ?? null,
|
|
73
78
|
write: (key, value) => {
|
|
@@ -82,6 +87,8 @@ export const urlHashAdapter: StorageAdapter = {
|
|
|
82
87
|
},
|
|
83
88
|
};
|
|
84
89
|
|
|
90
|
+
/** An in-memory store, discarded on reload. For tests, and for labs that
|
|
91
|
+
* should start fresh every time. */
|
|
85
92
|
export function createMemoryAdapter(): StorageAdapter {
|
|
86
93
|
const store = new Map<string, string>();
|
|
87
94
|
return {
|
|
@@ -95,6 +102,7 @@ export function createMemoryAdapter(): StorageAdapter {
|
|
|
95
102
|
};
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/** Persists nothing and reads back nothing. */
|
|
98
106
|
export const noneAdapter: StorageAdapter = {
|
|
99
107
|
read: () => null,
|
|
100
108
|
write: () => {},
|
package/src/state/context.tsx
CHANGED
|
@@ -5,8 +5,11 @@ import type { LabStoreState } from './types';
|
|
|
5
5
|
|
|
6
6
|
type LabStoreCtx = { store: LabStore } | null;
|
|
7
7
|
|
|
8
|
+
/** Context carrying the lab store. Prefer `useLabStore`; this is exported for
|
|
9
|
+
* code that needs to read the context without subscribing. */
|
|
8
10
|
export const LabStoreContext = createContext<LabStoreCtx>(null);
|
|
9
11
|
|
|
12
|
+
/** Provides a lab store to its subtree. */
|
|
10
13
|
export function LabStoreProvider({
|
|
11
14
|
store,
|
|
12
15
|
children,
|
|
@@ -17,14 +20,18 @@ export function LabStoreProvider({
|
|
|
17
20
|
return <LabStoreContext.Provider value={{ store }}>{children}</LabStoreContext.Provider>;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/** Subscribe to the whole lab store. Throws outside a `<LabStoreProvider>`. */
|
|
20
24
|
export function useLabStore(): LabStoreState & ReturnType<LabStore['getState']> {
|
|
21
25
|
const ctx = useContext(LabStoreContext);
|
|
22
26
|
if (!ctx) throw new Error('[labkit] useLabStore must be used inside <LabStoreProvider>');
|
|
23
27
|
return useStore(ctx.store);
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
/** Context carrying which workspace the subtree belongs to. */
|
|
26
31
|
export const WorkspaceIdContext = createContext<string | null>(null);
|
|
27
32
|
|
|
33
|
+
/** Names the workspace its subtree belongs to, so an instrument's hooks can
|
|
34
|
+
* find their own record in the store without being passed an id. */
|
|
28
35
|
export function WorkspaceIdProvider({
|
|
29
36
|
workspaceId,
|
|
30
37
|
children,
|
|
@@ -35,6 +42,8 @@ export function WorkspaceIdProvider({
|
|
|
35
42
|
return <WorkspaceIdContext.Provider value={workspaceId}>{children}</WorkspaceIdContext.Provider>;
|
|
36
43
|
}
|
|
37
44
|
|
|
45
|
+
/** The id of the workspace this component is inside. Throws outside a
|
|
46
|
+
* `<WorkspaceIdProvider>`. */
|
|
38
47
|
export function useWorkspaceId(): string {
|
|
39
48
|
const id = useContext(WorkspaceIdContext);
|
|
40
49
|
if (!id) throw new Error('[labkit] useWorkspaceId must be used inside <WorkspaceIdProvider>');
|
package/src/state/helpers.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import type { InstrumentSerializers, UndoStack, WorkspaceRecord } from './types';
|
|
2
2
|
|
|
3
|
+
/** The storage key a lab writes one of its buckets under. Namespaced by
|
|
4
|
+
* `storageKey` so two labs sharing an origin do not collide. */
|
|
3
5
|
export function labStorageKey(
|
|
4
6
|
storageKey: string,
|
|
5
|
-
bucket: 'workspaces' | 'saves' | 'theme',
|
|
7
|
+
bucket: 'workspaces' | 'saves' | 'theme' | 'layout',
|
|
6
8
|
): string {
|
|
7
9
|
return `lk:${storageKey}:${bucket}`;
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/** Encode a string for the URL fragment. */
|
|
10
13
|
export function encodeUrlHash(value: string): string {
|
|
11
14
|
return btoa(encodeURIComponent(value));
|
|
12
15
|
}
|
|
13
16
|
|
|
17
|
+
/** Decode a URL fragment written by `encodeUrlHash`, or `null` if it is
|
|
18
|
+
* malformed. */
|
|
14
19
|
export function decodeUrlHash(hash: string): string | null {
|
|
15
20
|
if (!hash) return null;
|
|
16
21
|
try {
|
|
@@ -20,12 +25,16 @@ export function decodeUrlHash(hash: string): string | null {
|
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
27
|
|
|
28
|
+
/** A fresh, empty undo history. */
|
|
23
29
|
export function emptyUndoStack(): UndoStack {
|
|
24
30
|
return { past: [], future: [] };
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
type SerializedRecord = Omit<WorkspaceRecord, 'undoStack'>;
|
|
28
34
|
|
|
35
|
+
/** Serialize workspaces for storage, running each instrument's own serializer
|
|
36
|
+
* over its state. Undo history is deliberately dropped — it does not survive
|
|
37
|
+
* a reload. */
|
|
29
38
|
export function serializeWorkspaces(
|
|
30
39
|
workspaces: WorkspaceRecord[],
|
|
31
40
|
serializers: InstrumentSerializers,
|
|
@@ -40,6 +49,9 @@ export function serializeWorkspaces(
|
|
|
40
49
|
return JSON.stringify(records);
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** Rebuild workspaces from storage, running each instrument's deserializer
|
|
53
|
+
* over its state and starting each with an empty undo history. Returns an
|
|
54
|
+
* empty list rather than throwing on malformed input. */
|
|
43
55
|
export function deserializeWorkspaces(
|
|
44
56
|
raw: string,
|
|
45
57
|
deserializers: InstrumentSerializers,
|
package/src/state/store.test.ts
CHANGED
|
@@ -245,7 +245,8 @@ describe('persistence — debounced writes', () => {
|
|
|
245
245
|
vi.advanceTimersByTime(400);
|
|
246
246
|
const writesAfter = writeSpy.mock.calls.length;
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
// One flush, one write per storage key: workspaces, saves, theme, layout.
|
|
249
|
+
expect(writesAfter - writesBefore).toBe(4);
|
|
249
250
|
vi.useRealTimers();
|
|
250
251
|
});
|
|
251
252
|
});
|
package/src/state/store.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
WorkspaceRecord,
|
|
15
15
|
} from './types';
|
|
16
16
|
|
|
17
|
+
/** Every mutation a lab store supports: managing workspaces, saving and
|
|
18
|
+
* restoring snapshots, and setting the color mode. */
|
|
17
19
|
export interface LabStoreActions {
|
|
18
20
|
addWorkspace: (record: Omit<WorkspaceRecord, 'undoStack'>) => void;
|
|
19
21
|
removeWorkspace: (id: string) => void;
|
|
@@ -32,12 +34,17 @@ export interface LabStoreActions {
|
|
|
32
34
|
deleteSnapshot: (snapshotId: string) => void;
|
|
33
35
|
listSnapshots: (workspaceId?: string) => SavedSnapshot[];
|
|
34
36
|
setMode: (mode: LabMode) => void;
|
|
37
|
+
setLayout: (layout: Record<string, unknown>) => void;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/** A lab's store: its state and actions, plus the hook instruments use to
|
|
41
|
+
* register how their state is serialized. */
|
|
37
42
|
export type LabStore = StoreApi<LabStoreState & LabStoreActions> & {
|
|
38
43
|
registerSerializers: (s: InstrumentSerializers) => void;
|
|
39
44
|
};
|
|
40
45
|
|
|
46
|
+
/** Build a lab store, hydrating from storage if anything was saved under the
|
|
47
|
+
* same key. Writes back are debounced. */
|
|
41
48
|
export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
42
49
|
let serializers: InstrumentSerializers = {};
|
|
43
50
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -59,6 +66,17 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
const layoutRaw = options.storage.read(labStorageKey(options.storageKey, 'layout'));
|
|
70
|
+
let hydratedLayout: Record<string, unknown> = {};
|
|
71
|
+
if (layoutRaw) {
|
|
72
|
+
try {
|
|
73
|
+
hydratedLayout = JSON.parse(layoutRaw) as Record<string, unknown>;
|
|
74
|
+
} catch {
|
|
75
|
+
console.warn('[labkit] failed to parse saved layout, starting empty');
|
|
76
|
+
hydratedLayout = {};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
62
80
|
const modeRaw = options.storage.read(labStorageKey(options.storageKey, 'theme'));
|
|
63
81
|
// `interstellar` was the dark mode's name back when it was a theme.
|
|
64
82
|
const stored = modeRaw === 'interstellar' ? 'dark' : modeRaw;
|
|
@@ -73,6 +91,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
73
91
|
workspaces: hydratedWorkspaces,
|
|
74
92
|
savedSnapshots: hydratedSnapshots,
|
|
75
93
|
mode: hydratedMode,
|
|
94
|
+
layout: hydratedLayout,
|
|
76
95
|
|
|
77
96
|
addWorkspace: (record) => {
|
|
78
97
|
set((s) => ({
|
|
@@ -201,6 +220,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
201
220
|
set({ mode });
|
|
202
221
|
scheduleFlush();
|
|
203
222
|
},
|
|
223
|
+
|
|
224
|
+
setLayout: (layout) => {
|
|
225
|
+
set({ layout });
|
|
226
|
+
scheduleFlush();
|
|
227
|
+
},
|
|
204
228
|
}));
|
|
205
229
|
|
|
206
230
|
function scheduleFlush(): void {
|
|
@@ -216,6 +240,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
216
240
|
JSON.stringify(s.savedSnapshots),
|
|
217
241
|
);
|
|
218
242
|
options.storage.write(labStorageKey(options.storageKey, 'theme'), s.mode);
|
|
243
|
+
options.storage.write(labStorageKey(options.storageKey, 'layout'), JSON.stringify(s.layout));
|
|
219
244
|
flushTimer = null;
|
|
220
245
|
}, 300);
|
|
221
246
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
/** A workspace's undo history, as snapshots of its state either side of the
|
|
2
|
+
* present. */
|
|
1
3
|
export interface UndoStack {
|
|
2
4
|
past: unknown[];
|
|
3
5
|
future: unknown[];
|
|
4
6
|
}
|
|
5
7
|
|
|
8
|
+
/** One workspace as the store holds it: which instrument it runs, that
|
|
9
|
+
* instrument's config and state, the camera, and the undo history. */
|
|
6
10
|
export interface WorkspaceRecord<TS = unknown, TC = unknown> {
|
|
7
11
|
id: string;
|
|
8
12
|
instrumentName: string;
|
|
@@ -12,6 +16,8 @@ export interface WorkspaceRecord<TS = unknown, TC = unknown> {
|
|
|
12
16
|
undoStack: UndoStack;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
/** A named, saved copy of a workspace's config and state, restorable into any
|
|
20
|
+
* workspace running the same instrument. */
|
|
15
21
|
export interface SavedSnapshot {
|
|
16
22
|
id: string;
|
|
17
23
|
name: string;
|
|
@@ -25,18 +31,28 @@ export interface SavedSnapshot {
|
|
|
25
31
|
/** `auto` follows the OS; the other two are an explicit choice. */
|
|
26
32
|
export type LabMode = 'auto' | 'light' | 'dark';
|
|
27
33
|
|
|
34
|
+
/** Everything a lab persists: its workspaces, its saved snapshots, and the
|
|
35
|
+
* chosen color mode. */
|
|
28
36
|
export interface LabStoreState {
|
|
29
37
|
workspaces: WorkspaceRecord[];
|
|
30
38
|
savedSnapshots: SavedSnapshot[];
|
|
31
39
|
mode: LabMode;
|
|
40
|
+
/** Per-workspace tile extents, keyed by workspace id. Opaque here — the
|
|
41
|
+
* shape belongs to whatever lays the workspaces out. */
|
|
42
|
+
layout: Record<string, unknown>;
|
|
32
43
|
}
|
|
33
44
|
|
|
45
|
+
/** Where a lab persists itself. Implementations are keyed string storage and
|
|
46
|
+
* nothing more, so the same store works against localStorage, the URL hash,
|
|
47
|
+
* or memory. */
|
|
34
48
|
export interface StorageAdapter {
|
|
35
49
|
read(key: string): string | null;
|
|
36
50
|
write(key: string, value: string): void;
|
|
37
51
|
delete?(key: string): void;
|
|
38
52
|
}
|
|
39
53
|
|
|
54
|
+
/** What `useExperimentState` hands an instrument: its state and config, with
|
|
55
|
+
* a setter for each. */
|
|
40
56
|
export interface ExperimentStateHandle<TS, TC> {
|
|
41
57
|
state: TS;
|
|
42
58
|
setState: (next: TS | ((prev: TS) => TS)) => void;
|
|
@@ -44,12 +60,16 @@ export interface ExperimentStateHandle<TS, TC> {
|
|
|
44
60
|
setConfig: (key: keyof TC, value: TC[keyof TC]) => void;
|
|
45
61
|
}
|
|
46
62
|
|
|
63
|
+
/** Options for `createLabStore`. `storageKey` namespaces the keys written, so
|
|
64
|
+
* two labs on one origin do not collide. */
|
|
47
65
|
export interface CreateLabStoreOptions {
|
|
48
66
|
storageKey: string;
|
|
49
67
|
storage: StorageAdapter;
|
|
50
68
|
initialMode?: LabMode;
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
/** Per-instrument serialize/deserialize hooks, keyed by instrument name. An
|
|
72
|
+
* instrument whose state is already JSON-safe needs no entry. */
|
|
53
73
|
export type InstrumentSerializers = Record<
|
|
54
74
|
string,
|
|
55
75
|
{ serialize?: (state: unknown) => unknown; deserialize?: (data: unknown) => unknown } | undefined
|
|
@@ -3,6 +3,9 @@ import { useStore } from 'zustand/react';
|
|
|
3
3
|
import { LabStoreContext, WorkspaceIdContext } from './context';
|
|
4
4
|
import type { ExperimentStateHandle } from './types';
|
|
5
5
|
|
|
6
|
+
/** An instrument's own state and config, plus setters. Reads the surrounding
|
|
7
|
+
* workspace id, so an instrument never has to know which workspace it is
|
|
8
|
+
* running in. Throws outside a lab store and workspace. */
|
|
6
9
|
export function useExperimentState<TS = unknown, TC = unknown>(): ExperimentStateHandle<TS, TC> {
|
|
7
10
|
const ctx = useContext(LabStoreContext);
|
|
8
11
|
if (!ctx) throw new Error('[labkit] useExperimentState must be used inside <LabStoreProvider>');
|
package/src/test-setup.ts
CHANGED
|
@@ -12,6 +12,25 @@ if (jsdomWin && typeof globalThis.localStorage === 'undefined') {
|
|
|
12
12
|
Object.defineProperty(globalThis, 'sessionStorage', { value: jsdomWin.sessionStorage });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
// windease's ContainerHost calls `new ResizeObserver` unguarded, and jsdom
|
|
16
|
+
// ships none. The stub must report a non-zero box: a windease container
|
|
17
|
+
// renders no children at all until something measures it, and jsdom's own
|
|
18
|
+
// geometry is always 0, so every tiled tile would vanish from the DOM.
|
|
19
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
20
|
+
globalThis.ResizeObserver = class {
|
|
21
|
+
#cb: ResizeObserverCallback;
|
|
22
|
+
constructor(cb: ResizeObserverCallback) {
|
|
23
|
+
this.#cb = cb;
|
|
24
|
+
}
|
|
25
|
+
observe(target: Element) {
|
|
26
|
+
const contentRect = { width: 1024, height: 768, x: 0, y: 0, top: 0, left: 0 };
|
|
27
|
+
this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
|
|
28
|
+
}
|
|
29
|
+
unobserve() {}
|
|
30
|
+
disconnect() {}
|
|
31
|
+
} as unknown as typeof ResizeObserver;
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
afterEach(() => {
|
|
16
35
|
cleanup();
|
|
17
36
|
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CSSProperties, type ReactNode, type RefCallback, useEffect, useState } from 'react';
|
|
2
2
|
import { dlog, useReorderDragList } from '../../passthrough/weasel-ui';
|
|
3
3
|
|
|
4
|
+
/** One card in a layer stack: its identity, the label shown when collapsed,
|
|
5
|
+
* and the optional select hoisted into its header. */
|
|
4
6
|
export interface LayerStackItem {
|
|
5
7
|
/** Stable id used for keys, onRemove, onReorder. Numeric to match
|
|
6
8
|
* common id-from-nextId conventions; string ids also work. */
|
|
@@ -22,6 +24,7 @@ export interface LayerStackItem {
|
|
|
22
24
|
defaultExpanded?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/** Props for `<LayerStack>`. */
|
|
25
28
|
export interface LayerStackProps {
|
|
26
29
|
title: string;
|
|
27
30
|
items: LayerStackItem[];
|
|
@@ -38,6 +41,8 @@ export interface LayerStackProps {
|
|
|
38
41
|
hideHead?: boolean;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
/** A drag-reorderable stack of expandable cards, with a palette in the header
|
|
45
|
+
* for adding more. The body of each card is the caller's to render. */
|
|
41
46
|
export function LayerStack({
|
|
42
47
|
title,
|
|
43
48
|
items,
|