@weasel-js/labkit 1.8.0 → 1.8.1
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 +24 -0
- package/dist/_dts/{CanvasStackContext-C0fzFsQg.d.ts → CanvasStackContext-B05ZM04l.d.ts} +3 -1
- package/dist/_dts/{Stage-Cvwxdnfp.d.ts → Stage-CIJlbjrE.d.ts} +5 -3
- package/dist/_dts/{index-DRaswz91.d.ts → index-8geQNQet.d.ts} +83 -11
- package/dist/_dts/{index-DEHRoTXK.d.ts → index-DRi3V5sN.d.ts} +5 -3
- package/dist/_dts/{useTrialState-CS4TdWPW.d.ts → useTrialState-Ar0AFpCe.d.ts} +94 -87
- package/dist/canvas/index.d.ts +4 -4
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +2 -2
- package/dist/chrome/index.js +7 -7
- package/dist/{chunk-YX75W6EK.js → chunk-4RCVWX4M.js} +8 -8
- package/dist/chunk-4RCVWX4M.js.map +1 -0
- package/dist/{chunk-WIG6XHJ7.js → chunk-6IIKLXBZ.js} +13 -4
- package/dist/chunk-6IIKLXBZ.js.map +1 -0
- package/dist/{chunk-F4ACIK7Z.js → chunk-6Q6ACJ3H.js} +3 -3
- package/dist/{chunk-F4ACIK7Z.js.map → chunk-6Q6ACJ3H.js.map} +1 -1
- package/dist/{chunk-PMLZP7RT.js → chunk-7ALJOVGP.js} +5 -9
- package/dist/chunk-7ALJOVGP.js.map +1 -0
- package/dist/{chunk-XE5HJOPL.js → chunk-7FMZT2WL.js} +3 -3
- package/dist/{chunk-XE5HJOPL.js.map → chunk-7FMZT2WL.js.map} +1 -1
- package/dist/{chunk-XVUKDSYV.js → chunk-AVOILDN2.js} +2 -2
- package/dist/{chunk-XVUKDSYV.js.map → chunk-AVOILDN2.js.map} +1 -1
- package/dist/{chunk-2N7UBSS2.js → chunk-J5MDJEOZ.js} +3 -3
- package/dist/{chunk-2N7UBSS2.js.map → chunk-J5MDJEOZ.js.map} +1 -1
- package/dist/{chunk-NVKBL7ZE.js → chunk-KHB3KRRQ.js} +7 -7
- package/dist/chunk-KHB3KRRQ.js.map +1 -0
- package/dist/{chunk-XXACAPVU.js → chunk-NHIO2O3O.js} +31 -30
- package/dist/chunk-NHIO2O3O.js.map +1 -0
- package/dist/{chunk-EESJUSYR.js → chunk-QMAY74TP.js} +56 -5
- package/dist/chunk-QMAY74TP.js.map +1 -0
- package/dist/{chunk-YDE6XATS.js → chunk-RN7T7OBO.js} +44 -22
- package/dist/chunk-RN7T7OBO.js.map +1 -0
- package/dist/{chunk-3L4MLLQB.js → chunk-RXO5UEGT.js} +4 -11
- package/dist/chunk-RXO5UEGT.js.map +1 -0
- package/dist/{chunk-MOTDMMMN.js → chunk-SQX35X5V.js} +3 -3
- package/dist/{chunk-MOTDMMMN.js.map → chunk-SQX35X5V.js.map} +1 -1
- package/dist/{chunk-J5HJJDLF.js → chunk-T5OEUNCB.js} +7 -2
- package/dist/chunk-T5OEUNCB.js.map +1 -0
- package/dist/{chunk-RL2LOLNI.js → chunk-UX66USTN.js} +2 -2
- package/dist/chunk-UX66USTN.js.map +1 -0
- package/dist/{chunk-WURZTJGQ.js → chunk-V4SMGEYY.js} +4858 -4806
- package/dist/chunk-V4SMGEYY.js.map +1 -0
- package/dist/{chunk-E23Q6A3Y.js → chunk-VCK5T6EP.js} +3 -3
- package/dist/{chunk-E23Q6A3Y.js.map → chunk-VCK5T6EP.js.map} +1 -1
- package/dist/{chunk-T6DLGEJB.js → chunk-XWR2P6MR.js} +23 -6
- package/dist/chunk-XWR2P6MR.js.map +1 -0
- package/dist/{chunk-7KAETQSS.js → chunk-YZA73U4A.js} +7 -7
- package/dist/chunk-YZA73U4A.js.map +1 -0
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.js +2 -2
- package/dist/controls/index.js +5 -5
- package/dist/dragdrop/index.d.ts +1 -1
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +157 -15
- package/dist/index.js +893 -258
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +1 -1
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +175 -27
- package/dist/loupe/index.js +547 -141
- package/dist/loupe/index.js.map +1 -1
- package/dist/overview/index.js +5 -5
- package/dist/passthrough/weasel-ui.js +3 -3
- package/dist/primitives/index.d.ts +4 -19
- package/dist/primitives/index.js +7 -7
- package/dist/state/index.d.ts +5 -7
- package/dist/state/index.js +5 -5
- package/dist/styles.css +173 -38
- package/dist/surface/index.d.ts +20 -1
- package/dist/surface/index.js +2 -2
- package/dist/undo/index.d.ts +18 -7
- package/dist/undo/index.js +1 -1
- package/package.json +11 -11
- package/src/annotations/AnnotationOverlay.abandon.test.tsx +4 -0
- package/src/annotations/AnnotationOverlay.tsx +10 -1
- package/src/annotations/Annotations.less +2 -0
- package/src/annotations/Annotations.overlay.test.tsx +29 -0
- package/src/canvas/CanvasStack.test.tsx +35 -0
- package/src/canvas/CanvasStack.tsx +12 -1
- package/src/canvas/useLayerScheduler.ts +14 -0
- package/src/chrome/builtins.test.ts +14 -0
- package/src/chrome/builtins.tsx +17 -2
- package/src/chrome/regions/StatusRegion.tsx +20 -14
- package/src/chrome/regions/regions.test.tsx +28 -0
- package/src/chrome/types.ts +8 -0
- package/src/clock/clock.browser.test.less +5 -0
- package/src/clock/clock.browser.test.tsx +77 -0
- package/src/clock/clock.integration.test.tsx +95 -0
- package/src/clock/clockRegistry.test.ts +33 -0
- package/src/clock/clockRegistry.ts +46 -0
- package/src/clock/hooks.ts +44 -0
- package/src/clock/index.ts +2 -0
- package/src/clock/trialClock.test.ts +265 -0
- package/src/clock/trialClock.ts +245 -0
- package/src/clock/useClockLoop.ts +45 -0
- package/src/config/resolve.test.ts +12 -0
- package/src/config/resolve.ts +5 -0
- package/src/controls/ControlMatrix.tsx +56 -54
- package/src/index.ts +3 -2
- package/src/instrument/types.ts +21 -1
- package/src/lab/Lab.chrome.test.tsx +8 -1
- package/src/lab/Lab.presentation.test.tsx +113 -0
- package/src/lab/Lab.surface.test.tsx +20 -9
- package/src/lab/Lab.tsx +132 -257
- package/src/lab/LabLightbox.browser.test.less +17 -0
- package/src/lab/LabLightbox.browser.test.tsx +216 -0
- package/src/lab/LabShell.less +10 -3
- package/src/lab/LabSurface.tsx +129 -0
- package/src/lab/Workspace.stories.tsx +18 -0
- package/src/lab/Workspace.surface.test.tsx +4 -0
- package/src/lab/Workspace.test.tsx +37 -1
- package/src/lab/Workspace.tsx +34 -6
- package/src/lab/index.ts +2 -0
- package/src/lab/nebula.ts +30 -0
- package/src/lab/openLab.test.ts +81 -0
- package/src/lab/openLab.ts +119 -0
- package/src/lab/presentation.browser.test.less +5 -0
- package/src/lab/presentation.browser.test.tsx +119 -0
- package/src/lab/presentation.less +64 -0
- package/src/lab/presentation.tsx +47 -0
- package/src/lab/useFocusPick.ts +43 -0
- package/src/lightbox/Lightbox.browser.test.less +14 -0
- package/src/lightbox/Lightbox.browser.test.tsx +113 -0
- package/src/lightbox/Lightbox.less +128 -0
- package/src/lightbox/Lightbox.test.tsx +195 -0
- package/src/lightbox/Lightbox.tsx +205 -0
- package/src/lightbox/LightboxContext.ts +38 -0
- package/src/lightbox/LightboxLayers.tsx +40 -0
- package/src/lightbox/index.ts +8 -0
- package/src/lightbox/modal.ts +94 -0
- package/src/loupe/AGENTS.md +40 -2
- package/src/loupe/CanvasLoupe.tsx +5 -4
- package/src/loupe/DomLoupe.tsx +7 -5
- package/src/loupe/Loupe.less +15 -0
- package/src/loupe/LoupeBubble.tsx +35 -13
- package/src/loupe/SourceLoupe.browser.test.tsx +204 -0
- package/src/loupe/SourceLoupe.tsx +60 -0
- package/src/loupe/TrialLoupe.browser.test.tsx +342 -0
- package/src/loupe/TrialLoupe.tsx +126 -28
- package/src/loupe/canvasLens.ts +20 -17
- package/src/loupe/index.ts +26 -2
- package/src/loupe/sourceLens.test.ts +65 -0
- package/src/loupe/sourceLens.ts +97 -0
- package/src/loupe/types.test.ts +6 -0
- package/src/loupe/types.ts +109 -7
- package/src/loupe/useLoupe.test.tsx +188 -3
- package/src/loupe/useLoupe.ts +106 -11
- package/src/primitives/index.ts +2 -2
- package/src/specimen/Specimen.tsx +0 -7
- package/src/state/helpers.test.ts +4 -11
- package/src/state/helpers.ts +2 -8
- package/src/state/index.ts +0 -2
- package/src/state/openLabStore.test.ts +10 -8
- package/src/state/openLabStore.ts +1 -1
- package/src/state/store.test.ts +2 -2
- package/src/state/store.ts +15 -16
- package/src/state/types.ts +9 -9
- package/src/styles.less +2 -1
- package/src/surface/AGENTS.md +13 -0
- package/src/surface/index.ts +1 -0
- package/src/surface/useSurfaceTile.test.tsx +4 -0
- package/src/surface/useSurfaceTile.ts +17 -1
- package/src/surface/useTiledSurface.test.tsx +65 -0
- package/src/surface/useTiledSurface.ts +68 -1
- package/src/trial/Trial.stories.tsx +0 -1
- package/src/trial/Trial.test.tsx +40 -3
- package/src/trial/Trial.tsx +87 -43
- package/src/trial/TrialChrome.tsx +20 -2
- package/src/trial/trialOps.test.ts +1 -2
- package/src/trial/trialOps.ts +2 -3
- package/src/undo/stateOp.ts +21 -0
- package/src/undo/undoStack.ts +18 -6
- package/dist/chunk-3L4MLLQB.js.map +0 -1
- package/dist/chunk-7KAETQSS.js.map +0 -1
- package/dist/chunk-EESJUSYR.js.map +0 -1
- package/dist/chunk-J5HJJDLF.js.map +0 -1
- package/dist/chunk-NVKBL7ZE.js.map +0 -1
- package/dist/chunk-PMLZP7RT.js.map +0 -1
- package/dist/chunk-RL2LOLNI.js.map +0 -1
- package/dist/chunk-T6DLGEJB.js.map +0 -1
- package/dist/chunk-WIG6XHJ7.js.map +0 -1
- package/dist/chunk-WURZTJGQ.js.map +0 -1
- package/dist/chunk-XXACAPVU.js.map +0 -1
- package/dist/chunk-YDE6XATS.js.map +0 -1
- package/dist/chunk-YX75W6EK.js.map +0 -1
- package/src/primitives/StatusBar.less +0 -34
- package/src/primitives/StatusBar.stories.tsx +0 -24
- package/src/primitives/StatusBar.test.tsx +0 -25
- package/src/primitives/StatusBar.tsx +0 -28
|
@@ -109,6 +109,40 @@ describe('useTiledSurface', () => {
|
|
|
109
109
|
expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it('narrows to the tiles inside a scope, retiling, and widens back', () => {
|
|
113
|
+
const frames: SurfaceFrame[] = [];
|
|
114
|
+
let handle: SurfaceHandle | null = null;
|
|
115
|
+
const h = (): SurfaceHandle | null => handle;
|
|
116
|
+
const { getByTestId } = render(
|
|
117
|
+
<Harness
|
|
118
|
+
frames={frames}
|
|
119
|
+
onHandle={(h) => {
|
|
120
|
+
handle = h;
|
|
121
|
+
}}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
flushFrames();
|
|
125
|
+
const heard = vi.fn();
|
|
126
|
+
act(() => {
|
|
127
|
+
h()?.subscribeScope(heard);
|
|
128
|
+
});
|
|
129
|
+
frames.length = 0;
|
|
130
|
+
|
|
131
|
+
act(() => h()?.scope(getByTestId('a')));
|
|
132
|
+
flushFrames();
|
|
133
|
+
expect(heard).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(h()?.inScope('a')).toBe(true);
|
|
135
|
+
expect(h()?.inScope('b')).toBe(false);
|
|
136
|
+
expect([...(frames[0]?.rects.keys() ?? [])]).toEqual(['a']);
|
|
137
|
+
expect(frames[0]?.retiled).toBe(true);
|
|
138
|
+
|
|
139
|
+
frames.length = 0;
|
|
140
|
+
act(() => h()?.scope(null));
|
|
141
|
+
flushFrames();
|
|
142
|
+
expect(h()?.inScope('b')).toBe(true);
|
|
143
|
+
expect([...(frames[0]?.rects.keys() ?? [])].sort()).toEqual(['a', 'b']);
|
|
144
|
+
});
|
|
145
|
+
|
|
112
146
|
it('fires nothing on a clean tick', () => {
|
|
113
147
|
const frames: SurfaceFrame[] = [];
|
|
114
148
|
render(<Harness frames={frames} onHandle={() => {}} />);
|
|
@@ -207,6 +241,37 @@ describe('useTiledSurface', () => {
|
|
|
207
241
|
expect(frames[0]?.rects.get('b')).toEqual({ x: 320, y: 0, w: 400, h: 600 });
|
|
208
242
|
});
|
|
209
243
|
|
|
244
|
+
it('marks only the tiles a viewport box overlaps, measured from the container', () => {
|
|
245
|
+
const frames: SurfaceFrame[] = [];
|
|
246
|
+
let handle: SurfaceHandle | null = null;
|
|
247
|
+
const { getByTestId } = render(
|
|
248
|
+
<Harness
|
|
249
|
+
frames={frames}
|
|
250
|
+
onHandle={(h) => {
|
|
251
|
+
handle = h;
|
|
252
|
+
}}
|
|
253
|
+
/>,
|
|
254
|
+
);
|
|
255
|
+
flushFrames();
|
|
256
|
+
frames.length = 0;
|
|
257
|
+
|
|
258
|
+
// The page scrolled: the container now sits at (100, 50). x 450 is 350 into it, inside a.
|
|
259
|
+
stubBox(getByTestId('stage'), 100, 50, 800, 600);
|
|
260
|
+
act(() => handle?.invalidateBox({ left: 450, top: 100, width: 40, height: 40 }));
|
|
261
|
+
flushFrames();
|
|
262
|
+
expect([...(frames[0]?.dirty ?? [])]).toEqual(['a']);
|
|
263
|
+
|
|
264
|
+
frames.length = 0;
|
|
265
|
+
act(() => handle?.invalidateBox({ left: 480, top: 100, width: 40, height: 40 }));
|
|
266
|
+
flushFrames();
|
|
267
|
+
expect([...(frames[0]?.dirty ?? [])].sort()).toEqual(['a', 'b']);
|
|
268
|
+
|
|
269
|
+
frames.length = 0;
|
|
270
|
+
act(() => handle?.invalidateBox({ left: 0, top: 0, width: 40, height: 40 }));
|
|
271
|
+
flushFrames();
|
|
272
|
+
expect(frames).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
210
275
|
it('drops a tile that unregisters', () => {
|
|
211
276
|
const frames: SurfaceFrame[] = [];
|
|
212
277
|
let handle: SurfaceHandle | null = null;
|
|
@@ -27,6 +27,9 @@ export interface SurfaceHandle {
|
|
|
27
27
|
invalidate: (id: string) => void;
|
|
28
28
|
/** Mark every tile — what a resize or a tile-set change means. */
|
|
29
29
|
invalidateAll: () => void;
|
|
30
|
+
/** Mark every tile overlapping `box`, in viewport px as `getBoundingClientRect`
|
|
31
|
+
* gives them: what something drawn over the tiles leaves behind when it moves. */
|
|
32
|
+
invalidateBox: (box: Box) => void;
|
|
30
33
|
/** Re-measure before the next frame. The escape hatch for a host that knows it
|
|
31
34
|
* moved something a ResizeObserver cannot see. */
|
|
32
35
|
invalidateRects: () => void;
|
|
@@ -45,6 +48,15 @@ export interface SurfaceHandle {
|
|
|
45
48
|
/** The element tile rects are measured against, and so the one a tile's own
|
|
46
49
|
* chrome positions itself inside. Null before the owner attaches it. */
|
|
47
50
|
getContainer: () => HTMLElement | null;
|
|
51
|
+
/** Narrow the surface to the tiles inside `el`, or widen it back with
|
|
52
|
+
* `null`. A tile outside drops out of the frame's `rects` and is not
|
|
53
|
+
* painted, and the frame is retiled so the buffer is cleared of it — what
|
|
54
|
+
* an expanded tile needs, with the buffers lifted over the rest. */
|
|
55
|
+
scope: (el: HTMLElement | null) => void;
|
|
56
|
+
/** Whether the tile is inside the current scope. Everything is, unscoped. */
|
|
57
|
+
inScope: (id: string) => boolean;
|
|
58
|
+
/** Called whenever the scope changes. Returns the unsubscribe. */
|
|
59
|
+
subscribeScope: (listener: () => void) => () => void;
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
/** What a tile's painter is handed: where it sits on the surface now, and the
|
|
@@ -78,6 +90,8 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
78
90
|
const needsMeasure = useRef(true);
|
|
79
91
|
const observer = useRef<ResizeObserver | null>(null);
|
|
80
92
|
const lastDpr = useRef(0);
|
|
93
|
+
const scopeEl = useRef<HTMLElement | null>(null);
|
|
94
|
+
const scopeListeners = useRef(new Set<() => void>());
|
|
81
95
|
|
|
82
96
|
// Held in a ref so a caller passing an inline closure does not re-create every
|
|
83
97
|
// callback below on each render.
|
|
@@ -87,7 +101,10 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
87
101
|
const el = container.current;
|
|
88
102
|
if (!el) return false;
|
|
89
103
|
const boxes = new Map<string, Box>();
|
|
90
|
-
for (const [id, tile] of tiles.current)
|
|
104
|
+
for (const [id, tile] of tiles.current) {
|
|
105
|
+
if (scopeEl.current && !scopeEl.current.contains(tile)) continue;
|
|
106
|
+
boxes.set(id, tile.getBoundingClientRect());
|
|
107
|
+
}
|
|
91
108
|
const next = composeRects(el.getBoundingClientRect(), boxes);
|
|
92
109
|
let changed = next.size !== rects.current.size;
|
|
93
110
|
for (const [id, rect] of next) {
|
|
@@ -176,6 +193,25 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
176
193
|
schedule();
|
|
177
194
|
}, [schedule]);
|
|
178
195
|
|
|
196
|
+
const invalidateBox = useCallback(
|
|
197
|
+
(box: Box) => {
|
|
198
|
+
const el = container.current;
|
|
199
|
+
if (!el) return;
|
|
200
|
+
const at = el.getBoundingClientRect();
|
|
201
|
+
const x = box.left - at.left;
|
|
202
|
+
const y = box.top - at.top;
|
|
203
|
+
let hit = false;
|
|
204
|
+
for (const [id, r] of rects.current) {
|
|
205
|
+
if (x < r.x + r.w && r.x < x + box.width && y < r.y + r.h && r.y < y + box.height) {
|
|
206
|
+
dirty.current.add(id);
|
|
207
|
+
hit = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (hit) schedule();
|
|
211
|
+
},
|
|
212
|
+
[schedule],
|
|
213
|
+
);
|
|
214
|
+
|
|
179
215
|
const invalidateRects = useCallback(() => {
|
|
180
216
|
needsMeasure.current = true;
|
|
181
217
|
schedule();
|
|
@@ -216,6 +252,29 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
216
252
|
|
|
217
253
|
const getContainer = useCallback(() => container.current, []);
|
|
218
254
|
|
|
255
|
+
const scope = useCallback(
|
|
256
|
+
(el: HTMLElement | null) => {
|
|
257
|
+
if (scopeEl.current === el) return;
|
|
258
|
+
scopeEl.current = el;
|
|
259
|
+
needsMeasure.current = true;
|
|
260
|
+
schedule();
|
|
261
|
+
for (const listener of scopeListeners.current) listener();
|
|
262
|
+
},
|
|
263
|
+
[schedule],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const inScope = useCallback((id: string) => {
|
|
267
|
+
const tile = tiles.current.get(id);
|
|
268
|
+
return !scopeEl.current || (tile !== undefined && scopeEl.current.contains(tile));
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
const subscribeScope = useCallback((listener: () => void) => {
|
|
272
|
+
scopeListeners.current.add(listener);
|
|
273
|
+
return () => {
|
|
274
|
+
scopeListeners.current.delete(listener);
|
|
275
|
+
};
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
219
278
|
const containerRef = useCallback(
|
|
220
279
|
(el: HTMLElement | null) => {
|
|
221
280
|
if (container.current === el) return;
|
|
@@ -247,22 +306,30 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
247
306
|
() => ({
|
|
248
307
|
invalidate,
|
|
249
308
|
invalidateAll,
|
|
309
|
+
invalidateBox,
|
|
250
310
|
invalidateRects,
|
|
251
311
|
registerTile,
|
|
252
312
|
registerClear,
|
|
253
313
|
registerPainter,
|
|
254
314
|
containerRef,
|
|
255
315
|
getContainer,
|
|
316
|
+
scope,
|
|
317
|
+
inScope,
|
|
318
|
+
subscribeScope,
|
|
256
319
|
}),
|
|
257
320
|
[
|
|
258
321
|
invalidate,
|
|
259
322
|
invalidateAll,
|
|
323
|
+
invalidateBox,
|
|
260
324
|
invalidateRects,
|
|
261
325
|
registerTile,
|
|
262
326
|
registerClear,
|
|
263
327
|
registerPainter,
|
|
264
328
|
containerRef,
|
|
265
329
|
getContainer,
|
|
330
|
+
scope,
|
|
331
|
+
inScope,
|
|
332
|
+
subscribeScope,
|
|
266
333
|
],
|
|
267
334
|
);
|
|
268
335
|
}
|
package/src/trial/Trial.test.tsx
CHANGED
|
@@ -27,7 +27,6 @@ const stubRecord: TrialRecord = {
|
|
|
27
27
|
config: {},
|
|
28
28
|
state: {},
|
|
29
29
|
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
30
|
-
undoStack: { past: [], future: [] },
|
|
31
30
|
};
|
|
32
31
|
|
|
33
32
|
type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
@@ -37,8 +36,7 @@ type ChromeProps = Parameters<typeof TrialChrome>[0];
|
|
|
37
36
|
* keeps its own store and hands it to both. */
|
|
38
37
|
function makeChromeStore() {
|
|
39
38
|
const store = createLabStore();
|
|
40
|
-
|
|
41
|
-
store.getState().addTrial(seed);
|
|
39
|
+
store.getState().addTrial(stubRecord);
|
|
42
40
|
return store;
|
|
43
41
|
}
|
|
44
42
|
|
|
@@ -357,3 +355,42 @@ describe('<TrialChrome> — state that outlives a mount', () => {
|
|
|
357
355
|
expect(screen.getByTestId('notes-body')).toBeInTheDocument();
|
|
358
356
|
});
|
|
359
357
|
});
|
|
358
|
+
|
|
359
|
+
describe('state undo in a mounted lab', () => {
|
|
360
|
+
const counter: Instrument<{ n: number }> = {
|
|
361
|
+
name: 'Counter',
|
|
362
|
+
defaultConfig: () => ({}),
|
|
363
|
+
initialState: () => ({ n: 0 }),
|
|
364
|
+
undo: { snapshotOn: ['bump'] },
|
|
365
|
+
render: (ctx) => (
|
|
366
|
+
<button
|
|
367
|
+
type="button"
|
|
368
|
+
onClick={() => {
|
|
369
|
+
ctx.emit('bump');
|
|
370
|
+
ctx.setState((s) => ({ n: s.n + 1 }));
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
{`n=${ctx.state.n}`}
|
|
374
|
+
</button>
|
|
375
|
+
),
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
it('undoes and redoes a change through the trial history', async () => {
|
|
379
|
+
await renderSettled(<Lab title="T" instruments={[counter]} defaultInstrument="Counter" />);
|
|
380
|
+
const undo = screen.getByRole('button', { name: 'Undo' });
|
|
381
|
+
const redo = screen.getByRole('button', { name: 'Redo' });
|
|
382
|
+
expect(undo).toBeDisabled();
|
|
383
|
+
fireEvent.click(screen.getByRole('button', { name: 'n=0' }));
|
|
384
|
+
fireEvent.click(screen.getByRole('button', { name: 'n=1' }));
|
|
385
|
+
expect(undo).not.toBeDisabled();
|
|
386
|
+
fireEvent.click(undo);
|
|
387
|
+
expect(screen.getByRole('button', { name: 'n=1' })).toBeInTheDocument();
|
|
388
|
+
fireEvent.click(undo);
|
|
389
|
+
expect(screen.getByRole('button', { name: 'n=0' })).toBeInTheDocument();
|
|
390
|
+
expect(undo).toBeDisabled();
|
|
391
|
+
fireEvent.click(redo);
|
|
392
|
+
fireEvent.click(redo);
|
|
393
|
+
expect(screen.getByRole('button', { name: 'n=2' })).toBeInTheDocument();
|
|
394
|
+
expect(redo).toBeDisabled();
|
|
395
|
+
});
|
|
396
|
+
});
|
package/src/trial/Trial.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
createHistory,
|
|
2
3
|
createPointerStore,
|
|
4
|
+
type History,
|
|
3
5
|
PointerContextProvider,
|
|
4
6
|
useLatest,
|
|
5
7
|
WeaselProvider,
|
|
@@ -13,6 +15,7 @@ import {
|
|
|
13
15
|
useMemo,
|
|
14
16
|
useRef,
|
|
15
17
|
useState,
|
|
18
|
+
useSyncExternalStore,
|
|
16
19
|
} from 'react';
|
|
17
20
|
import { useStore } from 'zustand/react';
|
|
18
21
|
import { AnnotationsContext } from '../annotations/AnnotationsContext';
|
|
@@ -28,6 +31,8 @@ import { fitStage, Stage } from '../canvas/Stage';
|
|
|
28
31
|
import type { CanvasLayerDescriptor } from '../canvas/useLayerScheduler';
|
|
29
32
|
import { applyCamera, type ViewportSize } from '../canvas/worldSpec';
|
|
30
33
|
import type { TrialContribution } from '../chrome/types';
|
|
34
|
+
import { ClockRegistryContext, TrialClockContext } from '../clock/clockRegistry';
|
|
35
|
+
import { createTrialClock } from '../clock/trialClock';
|
|
31
36
|
import { useConfigSchema } from '../config/useConfigSchema';
|
|
32
37
|
import { useResolvedConfig } from '../config/useResolvedConfig';
|
|
33
38
|
import { DragOverlay, useDragDrop } from '../dragdrop/DragDropRuntime';
|
|
@@ -48,7 +53,7 @@ import type { TrialRecord } from '../state/types';
|
|
|
48
53
|
import { as2DView, DEFAULT_VIEW, withZoom } from '../state/view';
|
|
49
54
|
import { resolveLabTool } from '../tools/labTool';
|
|
50
55
|
import { createEventBus, type EventBus } from '../undo/eventBus';
|
|
51
|
-
import {
|
|
56
|
+
import { stateOp } from '../undo/stateOp';
|
|
52
57
|
import { LoupeSwitchContext } from './loupeSwitch';
|
|
53
58
|
import type { LoupeBindings, UndoBindings } from './TrialChrome';
|
|
54
59
|
import { TrialChrome } from './TrialChrome';
|
|
@@ -161,7 +166,8 @@ function TrialRuntime({
|
|
|
161
166
|
const updateTrialState = useStore(store, (s) => s.updateTrialState);
|
|
162
167
|
const updateTrialConfig = useStore(store, (s) => s.updateTrialConfig);
|
|
163
168
|
const updateTrialView = useStore(store, (s) => s.updateTrialView);
|
|
164
|
-
const
|
|
169
|
+
const setTrialHistory = useStore(store, (s) => s.setTrialHistory);
|
|
170
|
+
const updateTrialClock = useStore(store, (s) => s.updateTrialClock);
|
|
165
171
|
const updateTrialAnnotations = useStore(store, (s) => s.updateTrialAnnotations);
|
|
166
172
|
const labToolId = useStore(store, (s) => s.activeToolId);
|
|
167
173
|
const setLabTool = useStore(store, (s) => s.setLabTool);
|
|
@@ -194,6 +200,28 @@ function TrialRuntime({
|
|
|
194
200
|
[instrument.canvas, layerVisibility],
|
|
195
201
|
);
|
|
196
202
|
|
|
203
|
+
// Read once: `record.clock` is where a reopened trial's clock stood, and the
|
|
204
|
+
// clock is the truth from then on. A swap mounts a new trial under a new id.
|
|
205
|
+
const [clockHandle] = useState(() =>
|
|
206
|
+
instrument.clock ? createTrialClock(instrument.clock, record.clock) : null,
|
|
207
|
+
);
|
|
208
|
+
const clocks = useContext(ClockRegistryContext);
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!clockHandle || !clocks) return;
|
|
211
|
+
return clocks.register(record.id, clockHandle);
|
|
212
|
+
}, [clocks, clockHandle, record.id]);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!clockHandle) return;
|
|
215
|
+
const { clock } = clockHandle;
|
|
216
|
+
return clock.subscribe(() =>
|
|
217
|
+
updateTrialClock(record.id, { elapsed: clock.elapsed, rate: clock.rate }),
|
|
218
|
+
);
|
|
219
|
+
}, [clockHandle, record.id, updateTrialClock]);
|
|
220
|
+
const ticks = useMemo(
|
|
221
|
+
() => (clockHandle ? (fn: () => void) => clockHandle.onFrame(() => fn()) : undefined),
|
|
222
|
+
[clockHandle],
|
|
223
|
+
);
|
|
224
|
+
|
|
197
225
|
const undoCap = instrument.undo;
|
|
198
226
|
const undoEvents = useMemo(() => new Set(undoCap?.snapshotOn ?? ['state.change']), [undoCap]);
|
|
199
227
|
const maxDepth = undoCap?.maxDepth ?? 50;
|
|
@@ -202,8 +230,14 @@ function TrialRuntime({
|
|
|
202
230
|
if (!undoCap || !undoEvents.has(event)) return;
|
|
203
231
|
const current = store.getState().trials.find((w) => w.id === record.id);
|
|
204
232
|
if (!current) return;
|
|
205
|
-
|
|
206
|
-
|
|
233
|
+
let history = current.history;
|
|
234
|
+
if (!history) {
|
|
235
|
+
history = createHistory(null, { historyLimit: maxDepth });
|
|
236
|
+
setTrialHistory(record.id, history);
|
|
237
|
+
}
|
|
238
|
+
const read = () => store.getState().trials.find((w) => w.id === record.id)?.state;
|
|
239
|
+
const write = (state: unknown) => updateTrialState(record.id, state as never);
|
|
240
|
+
history.recordEntry([stateOp(structuredClone(current.state), read, write)], event);
|
|
207
241
|
};
|
|
208
242
|
|
|
209
243
|
const setView = (v: unknown): void => updateTrialView(record.id, v);
|
|
@@ -383,6 +417,7 @@ function TrialRuntime({
|
|
|
383
417
|
activeToolId: resolvedToolId,
|
|
384
418
|
visibleLayers,
|
|
385
419
|
pointer,
|
|
420
|
+
...(clockHandle ? { clock: clockHandle.clock } : {}),
|
|
386
421
|
},
|
|
387
422
|
emit: (event) => {
|
|
388
423
|
snapshotIfNeeded(event);
|
|
@@ -395,31 +430,20 @@ function TrialRuntime({
|
|
|
395
430
|
// stack — the spec's rule, and the reason a mark scene is the truth. A trial
|
|
396
431
|
// declaring both takes the marks first: the most recent thing the user did
|
|
397
432
|
// is what undo is for, and only a mark change moves the mark history.
|
|
398
|
-
const
|
|
399
|
-
const result = undoUndo(record.undoStack, structuredClone(record.state));
|
|
400
|
-
if (!result) return;
|
|
401
|
-
updateTrialState(record.id, result.snapshot as never);
|
|
402
|
-
updateTrialUndoStack(record.id, result.stack);
|
|
403
|
-
};
|
|
404
|
-
const redoState = () => {
|
|
405
|
-
const result = undoRedo(record.undoStack, structuredClone(record.state));
|
|
406
|
-
if (!result) return;
|
|
407
|
-
updateTrialState(record.id, result.snapshot as never);
|
|
408
|
-
updateTrialUndoStack(record.id, result.stack);
|
|
409
|
-
};
|
|
433
|
+
const stateHistory = useHistoryVersion(record.history);
|
|
410
434
|
|
|
411
435
|
const undoBindings: UndoBindings | undefined =
|
|
412
436
|
undoCap || annotationsCap
|
|
413
437
|
? {
|
|
414
|
-
canUndo: markMoves.undo || (undoCap ?
|
|
415
|
-
canRedo: markMoves.redo || (undoCap ?
|
|
438
|
+
canUndo: markMoves.undo || (undoCap ? (stateHistory?.canUndo() ?? false) : false),
|
|
439
|
+
canRedo: markMoves.redo || (undoCap ? (stateHistory?.canRedo() ?? false) : false),
|
|
416
440
|
undo: () => {
|
|
417
441
|
if (annotationsCap && annotations.undo()) return;
|
|
418
|
-
if (undoCap)
|
|
442
|
+
if (undoCap) stateHistory?.undo();
|
|
419
443
|
},
|
|
420
444
|
redo: () => {
|
|
421
445
|
if (annotationsCap && annotations.redo()) return;
|
|
422
|
-
if (undoCap)
|
|
446
|
+
if (undoCap) stateHistory?.redo();
|
|
423
447
|
},
|
|
424
448
|
}
|
|
425
449
|
: undefined;
|
|
@@ -433,17 +457,26 @@ function TrialRuntime({
|
|
|
433
457
|
const ordered = layerOrder
|
|
434
458
|
? [...baseLayers].sort((a, b) => layerOrder.indexOf(a.id) - layerOrder.indexOf(b.id))
|
|
435
459
|
: baseLayers;
|
|
460
|
+
const clock = clockHandle?.clock;
|
|
436
461
|
return ordered.map((layer) => ({
|
|
437
462
|
id: layer.id,
|
|
438
463
|
visible: layerVisibility[layer.id] !== false,
|
|
464
|
+
timed: layer.timed === true,
|
|
439
465
|
render: (ctx, view, frame) => {
|
|
440
466
|
// Camera applied here, so a layer draws in world coordinates. `zoom`
|
|
441
467
|
// stays in the args for line widths, which must not scale with it.
|
|
442
468
|
applyCamera(ctx, view, frame);
|
|
443
|
-
layer.draw(ctx, {
|
|
469
|
+
layer.draw(ctx, {
|
|
470
|
+
state: record.state,
|
|
471
|
+
config,
|
|
472
|
+
zoom: view.zoom,
|
|
473
|
+
elapsed: clock?.elapsed ?? 0,
|
|
474
|
+
pass: clock?.pass ?? 0,
|
|
475
|
+
phase: clock?.phase ?? 0,
|
|
476
|
+
});
|
|
444
477
|
},
|
|
445
478
|
}));
|
|
446
|
-
}, [instrument.canvas, record.state, config, layerVisibility, layerOrder]);
|
|
479
|
+
}, [instrument.canvas, record.state, config, layerVisibility, layerOrder, clockHandle]);
|
|
447
480
|
|
|
448
481
|
const layerDescriptors: LayerDescriptor[] = useMemo(() => {
|
|
449
482
|
if (!instrument.layers) return [];
|
|
@@ -528,6 +561,7 @@ function TrialRuntime({
|
|
|
528
561
|
onResize={placeView}
|
|
529
562
|
minZoom={instrument.canvas.minZoom}
|
|
530
563
|
maxZoom={instrument.canvas.maxZoom}
|
|
564
|
+
ticks={ticks}
|
|
531
565
|
>
|
|
532
566
|
{instrument.render(renderCtx)}
|
|
533
567
|
</CanvasStack>
|
|
@@ -625,27 +659,37 @@ function TrialRuntime({
|
|
|
625
659
|
|
|
626
660
|
return (
|
|
627
661
|
<TrialIdProvider trialId={record.id}>
|
|
628
|
-
<
|
|
629
|
-
<
|
|
630
|
-
<
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
662
|
+
<TrialClockContext.Provider value={clockHandle}>
|
|
663
|
+
<AnnotationsContext.Provider value={annotationsCap ? provided : null}>
|
|
664
|
+
<CameraWheelContext.Provider value={wheelSlot}>
|
|
665
|
+
<TrialChrome
|
|
666
|
+
job={jobCap ? job : undefined}
|
|
667
|
+
loupe={loupeBindings}
|
|
668
|
+
trialId={record.id}
|
|
669
|
+
record={record}
|
|
670
|
+
instrument={instrument}
|
|
671
|
+
isLastTrial={isLast}
|
|
672
|
+
undoBindings={undoBindings}
|
|
673
|
+
trialChrome={extraChrome}
|
|
674
|
+
chrome={chrome}
|
|
675
|
+
suppress={suppress}
|
|
676
|
+
activeToolId={resolvedToolId}
|
|
677
|
+
setActiveTool={setActiveTool}
|
|
678
|
+
>
|
|
679
|
+
{scopedBody}
|
|
680
|
+
{annotationOverlays}
|
|
681
|
+
</TrialChrome>
|
|
682
|
+
</CameraWheelContext.Provider>
|
|
683
|
+
</AnnotationsContext.Provider>
|
|
684
|
+
</TrialClockContext.Provider>
|
|
649
685
|
</TrialIdProvider>
|
|
650
686
|
);
|
|
651
687
|
}
|
|
688
|
+
|
|
689
|
+
const NO_SUBSCRIBE = () => () => {};
|
|
690
|
+
|
|
691
|
+
/** `history`, re-rendering the caller whenever it changes. */
|
|
692
|
+
function useHistoryVersion(history: History | undefined): History | undefined {
|
|
693
|
+
useSyncExternalStore(history?.subscribe ?? NO_SUBSCRIBE, () => history?.getVersion() ?? 0);
|
|
694
|
+
return history;
|
|
695
|
+
}
|
|
@@ -24,6 +24,8 @@ import { useResolvedConfig } from '../config/useResolvedConfig';
|
|
|
24
24
|
import type { Instrument } from '../instrument/types';
|
|
25
25
|
import type { JobHandle } from '../job/types';
|
|
26
26
|
import { useLabContext } from '../lab/LabContext';
|
|
27
|
+
import { usePresentedTrial } from '../lab/presentation';
|
|
28
|
+
import { useLightboxControl } from '../lightbox/LightboxContext';
|
|
27
29
|
import { JobProgress } from '../primitives/JobProgress';
|
|
28
30
|
import { LabStoreContext } from '../state/context';
|
|
29
31
|
import type { TrialRecord } from '../state/types';
|
|
@@ -98,6 +100,7 @@ export function TrialChrome({
|
|
|
98
100
|
children,
|
|
99
101
|
}: TrialChromeProps) {
|
|
100
102
|
const lab = useLabContext();
|
|
103
|
+
const { presented, ref: presentedRef } = usePresentedTrial(trialId);
|
|
101
104
|
const storeCtx = useContext(LabStoreContext);
|
|
102
105
|
if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
|
|
103
106
|
const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
|
|
@@ -141,6 +144,9 @@ export function TrialChrome({
|
|
|
141
144
|
() => cameras?.get(trialId) ?? null,
|
|
142
145
|
);
|
|
143
146
|
|
|
147
|
+
// The title bar carries the expand toggle, so the lightbox drops its own.
|
|
148
|
+
const lightbox = useLightboxControl();
|
|
149
|
+
|
|
144
150
|
const ctx = useMemo<TrialChromeContext>(() => {
|
|
145
151
|
const setZoom = (z: number): void => {
|
|
146
152
|
if (camera) zoomCameraTo(camera, z);
|
|
@@ -161,6 +167,9 @@ export function TrialChrome({
|
|
|
161
167
|
hasLoupe: loupe != null,
|
|
162
168
|
loupeOn: loupe?.on ?? false,
|
|
163
169
|
toggleLoupe: loupe?.toggle ?? NO_OP,
|
|
170
|
+
canExpand: lightbox != null,
|
|
171
|
+
expanded: lightbox?.expanded ?? false,
|
|
172
|
+
toggleExpanded: lightbox?.toggle ?? NO_OP,
|
|
164
173
|
configSchema,
|
|
165
174
|
configFields: instrument.config ? [] : (instrument.configSchema?.() ?? []),
|
|
166
175
|
config: record.config,
|
|
@@ -210,6 +219,7 @@ export function TrialChrome({
|
|
|
210
219
|
updateTrialState,
|
|
211
220
|
undoBindings,
|
|
212
221
|
loupe,
|
|
222
|
+
lightbox,
|
|
213
223
|
view2d,
|
|
214
224
|
activeToolId,
|
|
215
225
|
setActiveTool,
|
|
@@ -245,7 +255,8 @@ export function TrialChrome({
|
|
|
245
255
|
|
|
246
256
|
const handleKeyDown = (e: KeyboardEvent<HTMLElement>): void => {
|
|
247
257
|
const mod = e.metaKey || e.ctrlKey;
|
|
248
|
-
|
|
258
|
+
// A presented trial has no chrome, so nothing the chrome's keys act on.
|
|
259
|
+
if (!mod || presented) return;
|
|
249
260
|
if (e.key === 'z' || e.key === 'Z') {
|
|
250
261
|
e.preventDefault();
|
|
251
262
|
if (e.shiftKey) ctx.redo();
|
|
@@ -258,9 +269,11 @@ export function TrialChrome({
|
|
|
258
269
|
|
|
259
270
|
return (
|
|
260
271
|
<section
|
|
272
|
+
ref={presentedRef}
|
|
261
273
|
className="lk-trial"
|
|
262
274
|
data-trial-id={trialId}
|
|
263
275
|
data-focused={lab.trials.length > 1 && lab.focusedTrialId === trialId ? 'true' : undefined}
|
|
276
|
+
data-lk-presented={presented ? '' : undefined}
|
|
264
277
|
aria-label={`Trial ${title}`}
|
|
265
278
|
tabIndex={-1}
|
|
266
279
|
onKeyDown={handleKeyDown}
|
|
@@ -280,7 +293,12 @@ export function TrialChrome({
|
|
|
280
293
|
width={record.sidebarWidth}
|
|
281
294
|
onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
|
|
282
295
|
contentClassName={
|
|
283
|
-
|
|
296
|
+
[
|
|
297
|
+
instrument.canvas || instrument.stage ? 'lk-trial__content--flush' : '',
|
|
298
|
+
presented ? 'lk-trial__stage' : '',
|
|
299
|
+
]
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.join(' ') || undefined
|
|
284
302
|
}
|
|
285
303
|
sidebar={
|
|
286
304
|
<>
|
|
@@ -258,7 +258,6 @@ describe('reorderTrials', () => {
|
|
|
258
258
|
config: {},
|
|
259
259
|
state: {},
|
|
260
260
|
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
261
|
-
undoStack: { past: [], future: [] },
|
|
262
261
|
}) as never;
|
|
263
262
|
|
|
264
263
|
it('reorders to match the given ids', () => {
|
|
@@ -322,7 +321,7 @@ describe('swapTrial', () => {
|
|
|
322
321
|
expect(swapped.config).toEqual({ subject: 'moon', zoom: 1 });
|
|
323
322
|
expect(swapped.state).toEqual({ label: 'MOON' });
|
|
324
323
|
expect(swapped.configSeed).toEqual({ subject: 'moon' });
|
|
325
|
-
expect(swapped.
|
|
324
|
+
expect(swapped.history).toBeUndefined();
|
|
326
325
|
});
|
|
327
326
|
|
|
328
327
|
it('keeps the width the trial’s sidebar was dragged to', () => {
|
package/src/trial/trialOps.ts
CHANGED
|
@@ -89,7 +89,6 @@ export function addTrial(
|
|
|
89
89
|
config,
|
|
90
90
|
state,
|
|
91
91
|
view: initialView(instrument),
|
|
92
|
-
undoStack: { past: [], future: [] },
|
|
93
92
|
};
|
|
94
93
|
if (options.config) record.configSeed = seeded.config;
|
|
95
94
|
if (seeded.autoPaths.length > 0) record.autoSeed = seeded.autoPaths;
|
|
@@ -103,13 +102,13 @@ export function cloneTrial(trials: TrialRecord[], id: string): TrialRecord[] {
|
|
|
103
102
|
const sourceIdx = trials.findIndex((w) => w.id === id);
|
|
104
103
|
const source = trials[sourceIdx];
|
|
105
104
|
if (!source) return trials;
|
|
105
|
+
const { history: _history, ...rest } = source;
|
|
106
106
|
const clone: TrialRecord = {
|
|
107
|
-
...
|
|
107
|
+
...rest,
|
|
108
108
|
id: newId(),
|
|
109
109
|
config: structuredClone(source.config),
|
|
110
110
|
state: structuredClone(source.state),
|
|
111
111
|
view: structuredClone(source.view),
|
|
112
|
-
undoStack: { past: [], future: [] },
|
|
113
112
|
};
|
|
114
113
|
return [...trials.slice(0, sourceIdx + 1), clone, ...trials.slice(sourceIdx + 1)];
|
|
115
114
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Op } from '@weasel-js/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An undo entry over a trial's whole `state`: it holds the state as it stood
|
|
5
|
+
* `before`, and catches the state to redo to each time it is undone, since a
|
|
6
|
+
* snapshot is taken when an undoable event fires, before the change it
|
|
7
|
+
* announces has necessarily landed. Never serialized: trial undo is
|
|
8
|
+
* session-only.
|
|
9
|
+
*/
|
|
10
|
+
export function stateOp(before: unknown, read: () => unknown, write: (state: unknown) => void): Op {
|
|
11
|
+
let after: unknown;
|
|
12
|
+
const forward: Op = {
|
|
13
|
+
label: 'state',
|
|
14
|
+
apply: () => write(structuredClone(after)),
|
|
15
|
+
invert: () => {
|
|
16
|
+
after = structuredClone(read());
|
|
17
|
+
return { label: 'state', apply: () => write(structuredClone(before)), invert: () => forward };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
return forward;
|
|
21
|
+
}
|