@weasel-js/labkit 1.3.0 → 1.4.0-pre.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_dts/CanvasStackContext-DtAn_3O-.d.ts +42 -0
- package/dist/_dts/DrawCommand-B3bskUsC.d.ts +564 -0
- package/dist/_dts/{PrefsForm-BYa6cWnO.d.ts → PrefsForm-BkUJZx0A.d.ts} +4 -1
- package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +21 -0
- package/dist/_dts/frac-Cp3NivlC.d.ts +728 -0
- package/dist/_dts/index-BDVzvRzQ.d.ts +236 -0
- package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +87 -0
- package/dist/_dts/{DrawCommand-DBB45NfN.d.ts → types-C-gh9Ap-.d.ts} +1 -582
- package/dist/_dts/{types-DCGEcMO_.d.ts → types-D6s4b7if.d.ts} +1 -1
- package/dist/_dts/{types-1Sdxy_Pv.d.ts → types-DYMaEvM5.d.ts} +26 -1
- package/dist/_dts/{useTrialState-CsGMjhu9.d.ts → useTrialState-gmMvZqPc.d.ts} +7 -2
- package/dist/canvas/index.d.ts +43 -25
- package/dist/canvas/index.js +5 -4
- package/dist/chrome/index.d.ts +9 -6
- package/dist/chrome/index.js +6 -6
- package/dist/chunk-2AYGEN57.js +32 -0
- package/dist/chunk-2AYGEN57.js.map +1 -0
- package/dist/{chunk-QRYPXSGP.js → chunk-3P5TTTA7.js} +16 -8
- package/dist/chunk-3P5TTTA7.js.map +1 -0
- package/dist/{chunk-BDJXIBRZ.js → chunk-3ZELRRBV.js} +44 -27
- package/dist/chunk-3ZELRRBV.js.map +1 -0
- package/dist/{chunk-LGKRIUSW.js → chunk-4EY67BKJ.js} +32 -7
- package/dist/chunk-4EY67BKJ.js.map +1 -0
- package/dist/{chunk-3XXPU73K.js → chunk-5TCEVMPI.js} +9 -7
- package/dist/chunk-5TCEVMPI.js.map +1 -0
- package/dist/{chunk-JDROYQM3.js → chunk-6JMU56CU.js} +130 -131
- package/dist/chunk-6JMU56CU.js.map +1 -0
- package/dist/{chunk-CPUJ3QXL.js → chunk-CQLQPQ4P.js} +2 -2
- package/dist/chunk-CQLQPQ4P.js.map +1 -0
- package/dist/chunk-CTRKTLYZ.js +33 -0
- package/dist/chunk-CTRKTLYZ.js.map +1 -0
- package/dist/chunk-EQIB2PGC.js +458 -0
- package/dist/chunk-EQIB2PGC.js.map +1 -0
- package/dist/chunk-HEORBLVT.js +7128 -0
- package/dist/chunk-HEORBLVT.js.map +1 -0
- package/dist/{chunk-BMW4TDP5.js → chunk-JDDIZSLL.js} +3 -3
- package/dist/{chunk-BMW4TDP5.js.map → chunk-JDDIZSLL.js.map} +1 -1
- package/dist/chunk-LITE5YFB.js +669 -0
- package/dist/chunk-LITE5YFB.js.map +1 -0
- package/dist/{chunk-QCICIHEO.js → chunk-ROQDRXKW.js} +716 -311
- package/dist/chunk-ROQDRXKW.js.map +1 -0
- package/dist/{chunk-4TMMVIDM.js → chunk-XJ6N32QP.js} +50 -6
- package/dist/chunk-XJ6N32QP.js.map +1 -0
- package/dist/{chunk-WTI26YTM.js → chunk-YQJNT4XQ.js} +40 -13
- package/dist/chunk-YQJNT4XQ.js.map +1 -0
- package/dist/controls/index.d.ts +4 -28
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +9 -5
- package/dist/dragdrop/index.js +3 -3
- package/dist/index.d.ts +518 -190
- package/dist/index.js +1753 -75
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +6 -5
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +187 -0
- package/dist/loupe/index.js +7 -0
- package/dist/loupe/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +3 -1
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +138 -91
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.d.ts +3 -1
- package/dist/primitives/index.js +5 -5
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +2 -2
- package/dist/styles.css +229 -1
- package/dist/surface/index.d.ts +18 -2
- package/dist/surface/index.js +2 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +5 -4
- package/package.json +14 -7
- package/src/annotations/AnnotationOverlay.tsx +205 -0
- package/src/annotations/AnnotationTargets.tsx +37 -0
- package/src/annotations/Annotations.less +129 -0
- package/src/annotations/Annotations.meaning.test.tsx +111 -0
- package/src/annotations/Annotations.overlay.test.tsx +142 -0
- package/src/annotations/AnnotationsContext.ts +49 -0
- package/src/annotations/ExportMenu.test.tsx +86 -0
- package/src/annotations/ExportMenu.tsx +182 -0
- package/src/annotations/MarkList.tsx +75 -0
- package/src/annotations/capture.test.ts +140 -0
- package/src/annotations/capture.ts +237 -0
- package/src/annotations/drawOne.test.ts +56 -0
- package/src/annotations/drawOne.ts +47 -0
- package/src/annotations/frac.test.ts +59 -0
- package/src/annotations/frac.ts +66 -0
- package/src/annotations/history.test.ts +82 -0
- package/src/annotations/history.ts +118 -0
- package/src/annotations/index.ts +37 -0
- package/src/annotations/paint.test.ts +125 -0
- package/src/annotations/paint.ts +130 -0
- package/src/annotations/staleness.test.ts +56 -0
- package/src/annotations/staleness.ts +41 -0
- package/src/annotations/store.test.ts +175 -0
- package/src/annotations/store.ts +317 -0
- package/src/annotations/svgNodes.test.ts +77 -0
- package/src/annotations/svgNodes.ts +74 -0
- package/src/annotations/toolMap.test.ts +36 -0
- package/src/annotations/toolMap.ts +54 -0
- package/src/annotations/types.ts +222 -0
- package/src/annotations/view.test.ts +37 -0
- package/src/annotations/view.ts +40 -0
- package/src/canvas/AGENTS.md +55 -5
- package/src/canvas/CanvasStack.test.tsx +27 -1
- package/src/canvas/CanvasStack.tsx +29 -4
- package/src/canvas/CanvasStackContext.ts +23 -4
- package/src/canvas/camera.test.ts +78 -0
- package/src/canvas/camera.ts +62 -0
- package/src/canvas/canvasCoords.test.ts +26 -0
- package/src/canvas/canvasCoords.ts +20 -7
- package/src/canvas/index.ts +5 -1
- package/src/canvas/useLayerScheduler.ts +7 -3
- package/src/canvas/usePanZoom.test.ts +36 -5
- package/src/canvas/usePanZoom.ts +20 -15
- package/src/canvas/worldSpec.test.ts +63 -0
- package/src/canvas/worldSpec.ts +48 -0
- package/src/chrome/builtins.test.ts +31 -1
- package/src/chrome/builtins.tsx +63 -19
- package/src/chrome/regions/PaletteRegion.test.tsx +22 -1
- package/src/chrome/regions/PaletteRegion.tsx +4 -0
- package/src/chrome/regions/SidebarRegion.tsx +37 -13
- package/src/chrome/regions/ToolbarRegion.tsx +2 -1
- package/src/chrome/regions/ViewportRegion.tsx +9 -1
- package/src/chrome/regions/regions.test.tsx +6 -1
- package/src/chrome/types.ts +20 -0
- package/src/controls/ControlPanel.test.tsx +38 -0
- package/src/controls/ControlPanel.tsx +50 -3
- package/src/dragdrop/DragDropRuntime.tsx +8 -2
- package/src/dragdrop/dragDrop.test.tsx +29 -2
- package/src/index.test.ts +22 -0
- package/src/index.ts +87 -2
- package/src/instrument/SineWave.smoke.test.tsx +1 -0
- package/src/instrument/types.ts +24 -1
- package/src/lab/Lab.surface.test.tsx +162 -0
- package/src/lab/Lab.tsx +102 -17
- package/src/lab/Lab.undock.test.tsx +85 -0
- package/src/lab/LabFullChrome.stories.tsx +20 -0
- package/src/lab/LabShell.less +11 -0
- package/src/lab/Workspace.less +73 -0
- package/src/lab/Workspace.surface.test.tsx +2 -0
- package/src/lab/Workspace.tsx +97 -8
- package/src/lab/index.ts +1 -1
- package/src/lab/panelHost.ts +37 -0
- package/src/loupe/AGENTS.md +49 -0
- package/src/loupe/CanvasLoupe.tsx +83 -0
- package/src/loupe/DomLoupe.tsx +62 -0
- package/src/loupe/Loupe.less +38 -0
- package/src/loupe/LoupeBubble.tsx +32 -0
- package/src/loupe/TrialLoupe.tsx +93 -0
- package/src/loupe/canvasLens.test.ts +205 -0
- package/src/loupe/canvasLens.ts +141 -0
- package/src/loupe/index.ts +19 -0
- package/src/loupe/types.test.ts +29 -0
- package/src/loupe/types.ts +90 -0
- package/src/loupe/useHostSize.ts +30 -0
- package/src/loupe/useLoupe.test.tsx +197 -0
- package/src/loupe/useLoupe.ts +189 -0
- package/src/passthrough/weasel-ui.ts +3 -0
- package/src/primitives/FloatingPanel.test.tsx +76 -1
- package/src/primitives/FloatingPanel.tsx +19 -3
- package/src/primitives/ScaleIndicator.test.tsx +7 -2
- package/src/primitives/Sidebar.less +18 -0
- package/src/primitives/Toolbar.less +8 -0
- package/src/primitives/Toolbar.tsx +5 -0
- package/src/primitives/useRovingTabIndex.test.ts +21 -0
- package/src/primitives/useRovingTabIndex.ts +26 -17
- package/src/state/document.test.ts +23 -0
- package/src/state/document.ts +14 -2
- package/src/state/index.ts +2 -0
- package/src/state/store.test.ts +24 -0
- package/src/state/store.ts +35 -1
- package/src/state/types.ts +11 -0
- package/src/state/undock.test.ts +40 -0
- package/src/state/undock.ts +39 -0
- package/src/styles.less +2 -0
- package/src/surface/SurfaceContext.ts +4 -0
- package/src/surface/index.ts +8 -3
- package/src/surface/useSurfaceTile.test.tsx +2 -0
- package/src/surface/useSurfaceTile.ts +7 -1
- package/src/surface/useTiledSurface.test.tsx +58 -0
- package/src/surface/useTiledSurface.ts +38 -3
- package/src/trial/Trial.annotations.persist.test.tsx +166 -0
- package/src/trial/Trial.annotations.test.tsx +121 -0
- package/src/trial/Trial.canvas.test.tsx +76 -1
- package/src/trial/Trial.less +19 -0
- package/src/trial/Trial.loupe.test.tsx +128 -0
- package/src/trial/Trial.stories.tsx +1 -0
- package/src/trial/Trial.test.tsx +12 -0
- package/src/trial/Trial.trialId.test.tsx +36 -0
- package/src/trial/Trial.tsx +215 -40
- package/src/trial/TrialChrome.tsx +35 -0
- package/src/trial/UndockedSections.tsx +65 -0
- package/src/trial/trialOps.test.ts +21 -0
- package/src/trial/trialOps.ts +5 -3
- package/dist/_dts/types-BttTIent.d.ts +0 -314
- package/dist/chunk-3XXPU73K.js.map +0 -1
- package/dist/chunk-4TMMVIDM.js.map +0 -1
- package/dist/chunk-6X5RBC6G.js +0 -366
- package/dist/chunk-6X5RBC6G.js.map +0 -1
- package/dist/chunk-BDJXIBRZ.js.map +0 -1
- package/dist/chunk-CPUJ3QXL.js.map +0 -1
- package/dist/chunk-JDROYQM3.js.map +0 -1
- package/dist/chunk-LGKRIUSW.js.map +0 -1
- package/dist/chunk-QCICIHEO.js.map +0 -1
- package/dist/chunk-QRYPXSGP.js.map +0 -1
- package/dist/chunk-RGT4EJIB.js +0 -6961
- package/dist/chunk-RGT4EJIB.js.map +0 -1
- package/dist/chunk-T66HU3HX.js +0 -17
- package/dist/chunk-T66HU3HX.js.map +0 -1
- package/dist/chunk-WTI26YTM.js.map +0 -1
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { type KeyboardEvent, useCallback, useEffect, useRef } from 'react';
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
3
|
+
/** Which axis a toolbar's arrow keys walk. Matches `aria-orientation`. */
|
|
4
|
+
export type RovingOrientation = 'horizontal' | 'vertical';
|
|
5
|
+
|
|
6
|
+
/** Where a key takes focus within `count` items, or null if it does not move it.
|
|
7
|
+
*
|
|
8
|
+
* Only the arrows on the toolbar's own axis move focus — APG leaves the
|
|
9
|
+
* cross-axis arrows to the page, so a vertical strip does not swallow
|
|
10
|
+
* ArrowLeft. `Home` / `End` work on both. */
|
|
11
|
+
export function nextIndex(
|
|
12
|
+
current: number,
|
|
13
|
+
key: string,
|
|
14
|
+
count: number,
|
|
15
|
+
orientation: RovingOrientation = 'horizontal',
|
|
16
|
+
): number | null {
|
|
5
17
|
if (count === 0) return null;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
case 'End':
|
|
14
|
-
return count - 1;
|
|
15
|
-
default:
|
|
16
|
-
return null;
|
|
17
|
-
}
|
|
18
|
+
const forward = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
|
|
19
|
+
const back = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
|
|
20
|
+
if (key === forward) return (current + 1) % count;
|
|
21
|
+
if (key === back) return (current - 1 + count) % count;
|
|
22
|
+
if (key === 'Home') return 0;
|
|
23
|
+
if (key === 'End') return count - 1;
|
|
24
|
+
return null;
|
|
18
25
|
}
|
|
19
26
|
|
|
20
27
|
/**
|
|
@@ -23,7 +30,9 @@ export function nextIndex(current: number, key: string, count: number): number |
|
|
|
23
30
|
* threaded through props, because the items are arbitrary children — the
|
|
24
31
|
* container never sees them as a list it could index.
|
|
25
32
|
*/
|
|
26
|
-
export function useRovingTabIndex<T extends HTMLElement>(
|
|
33
|
+
export function useRovingTabIndex<T extends HTMLElement>(
|
|
34
|
+
orientation: RovingOrientation = 'horizontal',
|
|
35
|
+
) {
|
|
27
36
|
const ref = useRef<T | null>(null);
|
|
28
37
|
const stop = useRef(0);
|
|
29
38
|
|
|
@@ -66,13 +75,13 @@ export function useRovingTabIndex<T extends HTMLElement>() {
|
|
|
66
75
|
(e: KeyboardEvent<T>) => {
|
|
67
76
|
const found = items();
|
|
68
77
|
const current = found.indexOf(document.activeElement as HTMLElement);
|
|
69
|
-
const next = nextIndex(current === -1 ? 0 : current, e.key, found.length);
|
|
78
|
+
const next = nextIndex(current === -1 ? 0 : current, e.key, found.length, orientation);
|
|
70
79
|
if (next === null) return;
|
|
71
80
|
e.preventDefault();
|
|
72
81
|
found[next]?.focus();
|
|
73
82
|
setTabStop(next);
|
|
74
83
|
},
|
|
75
|
-
[items, setTabStop],
|
|
84
|
+
[items, setTabStop, orientation],
|
|
76
85
|
);
|
|
77
86
|
|
|
78
87
|
return { ref, onKeyDown };
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
MIGRATIONS,
|
|
9
9
|
migrateV0toV1,
|
|
10
10
|
migrateV1toV2,
|
|
11
|
+
migrateV2toV3,
|
|
11
12
|
normalizeDocument,
|
|
12
13
|
quarantineDocument,
|
|
13
14
|
quarantineKey,
|
|
@@ -324,6 +325,7 @@ describe('normalizeDocument', () => {
|
|
|
324
325
|
trials: [],
|
|
325
326
|
saves: [],
|
|
326
327
|
layout: {},
|
|
328
|
+
undockedPanels: {},
|
|
327
329
|
mode: 'auto',
|
|
328
330
|
});
|
|
329
331
|
});
|
|
@@ -367,3 +369,24 @@ describe('normalizeDocument', () => {
|
|
|
367
369
|
expect(out.layout).toEqual({});
|
|
368
370
|
});
|
|
369
371
|
});
|
|
372
|
+
|
|
373
|
+
describe('version 2 to version 3', () => {
|
|
374
|
+
it('gives a document with no undocked panels an empty set', () => {
|
|
375
|
+
const out = migrateV2toV3({ version: 2, trials: [], saves: [], layout: {}, mode: 'auto' });
|
|
376
|
+
expect(out.version).toBe(3);
|
|
377
|
+
expect(out.undockedPanels).toEqual({});
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('leaves the rest of the document alone', () => {
|
|
381
|
+
const out = migrateV2toV3({
|
|
382
|
+
version: 2,
|
|
383
|
+
trials: [{ id: 'a' }],
|
|
384
|
+
saves: [],
|
|
385
|
+
layout: { a: 1 },
|
|
386
|
+
mode: 'dark',
|
|
387
|
+
});
|
|
388
|
+
expect(out.trials).toEqual([{ id: 'a' }]);
|
|
389
|
+
expect(out.layout).toEqual({ a: 1 });
|
|
390
|
+
expect(out.mode).toBe('dark');
|
|
391
|
+
});
|
|
392
|
+
});
|
package/src/state/document.ts
CHANGED
|
@@ -7,9 +7,10 @@ import type {
|
|
|
7
7
|
SerializedTrial,
|
|
8
8
|
StorageAdapter,
|
|
9
9
|
} from './types';
|
|
10
|
+
import type { UndockedPanels } from './undock';
|
|
10
11
|
|
|
11
12
|
/** Bumped whenever the persisted shape changes; every bump needs a migration. */
|
|
12
|
-
export const CURRENT_DOCUMENT_VERSION =
|
|
13
|
+
export const CURRENT_DOCUMENT_VERSION = 3;
|
|
13
14
|
|
|
14
15
|
/** The one key a lab persists under. The `:doc` suffix keeps it out of the
|
|
15
16
|
* legacy bucket namespace, where a lab named `a:saves` would otherwise write
|
|
@@ -28,6 +29,7 @@ export function quarantineKey(storageKey: string): string {
|
|
|
28
29
|
export function emptyDocument(mode: LabMode): LabDocument {
|
|
29
30
|
return {
|
|
30
31
|
version: CURRENT_DOCUMENT_VERSION,
|
|
32
|
+
undockedPanels: {},
|
|
31
33
|
trials: [],
|
|
32
34
|
saves: [],
|
|
33
35
|
layout: {},
|
|
@@ -161,6 +163,10 @@ export function normalizeDocument(
|
|
|
161
163
|
saves: Array.isArray(doc.saves) ? (doc.saves as SavedSnapshot[]) : [],
|
|
162
164
|
layout:
|
|
163
165
|
doc.layout && typeof doc.layout === 'object' ? (doc.layout as Record<string, unknown>) : {},
|
|
166
|
+
undockedPanels:
|
|
167
|
+
doc.undockedPanels && typeof doc.undockedPanels === 'object'
|
|
168
|
+
? (doc.undockedPanels as UndockedPanels)
|
|
169
|
+
: {},
|
|
164
170
|
mode,
|
|
165
171
|
};
|
|
166
172
|
}
|
|
@@ -190,5 +196,11 @@ export function migrateV1toV2(doc: Record<string, unknown>): Record<string, unkn
|
|
|
190
196
|
return { ...rest, trials: Array.isArray(workspaces) ? workspaces : [], version: 2 };
|
|
191
197
|
}
|
|
192
198
|
|
|
199
|
+
/** Version 2 to version 3: undocked sidebar panels became persisted state.
|
|
200
|
+
* Nothing existing moves; the field simply starts empty. */
|
|
201
|
+
export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unknown> {
|
|
202
|
+
return { ...doc, undockedPanels: {}, version: 3 };
|
|
203
|
+
}
|
|
204
|
+
|
|
193
205
|
/** Index `i` migrates a version-`i` document to version `i + 1`. */
|
|
194
|
-
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2];
|
|
206
|
+
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
|
package/src/state/index.ts
CHANGED
package/src/state/store.test.ts
CHANGED
|
@@ -649,3 +649,27 @@ describe('createLabStore — folding without a mutation', () => {
|
|
|
649
649
|
warn.mockRestore();
|
|
650
650
|
});
|
|
651
651
|
});
|
|
652
|
+
|
|
653
|
+
describe('undocking a sidebar panel', () => {
|
|
654
|
+
it('starts with nothing undocked', () => {
|
|
655
|
+
expect(makeStore().getState().undockedPanels).toEqual({});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('records an undock and docks it back', () => {
|
|
659
|
+
const s = makeStore();
|
|
660
|
+
s.getState().undockPanel('t1', 'settings', 'floating');
|
|
661
|
+
expect(Object.values(s.getState().undockedPanels)).toEqual([
|
|
662
|
+
{ trialId: 't1', sectionId: 'settings', as: 'floating' },
|
|
663
|
+
]);
|
|
664
|
+
s.getState().dockPanel('t1', 'settings');
|
|
665
|
+
expect(s.getState().undockedPanels).toEqual({});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it('closing a trial takes its undocked panels with it', () => {
|
|
669
|
+
const s = makeStore();
|
|
670
|
+
s.getState().addTrial({ id: 't1', instrumentName: 'i', config: {}, state: {}, view: null });
|
|
671
|
+
s.getState().undockPanel('t1', 'settings');
|
|
672
|
+
s.getState().removeTrial('t1');
|
|
673
|
+
expect(s.getState().undockedPanels).toEqual({});
|
|
674
|
+
});
|
|
675
|
+
});
|
package/src/state/store.ts
CHANGED
|
@@ -20,6 +20,11 @@ import type {
|
|
|
20
20
|
SavedSnapshot,
|
|
21
21
|
TrialRecord,
|
|
22
22
|
} from './types';
|
|
23
|
+
import {
|
|
24
|
+
dockPanel as dockPanelIn,
|
|
25
|
+
type UndockedPanel,
|
|
26
|
+
undockPanel as undockPanelIn,
|
|
27
|
+
} from './undock';
|
|
23
28
|
|
|
24
29
|
/** Every mutation a lab store supports: managing trials, saving and
|
|
25
30
|
* restoring snapshots, and setting the color mode. */
|
|
@@ -29,6 +34,7 @@ export interface LabStoreActions {
|
|
|
29
34
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
30
35
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
31
36
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
|
+
updateTrialAnnotations: (id: string, doc: unknown) => void;
|
|
32
38
|
updateTrialUndoStack: (
|
|
33
39
|
id: string,
|
|
34
40
|
next: TrialRecord['undoStack'] | ((prev: TrialRecord['undoStack']) => TrialRecord['undoStack']),
|
|
@@ -42,6 +48,10 @@ export interface LabStoreActions {
|
|
|
42
48
|
setLabTool: (id: string | null) => void;
|
|
43
49
|
setTrialTool: (trialId: string, id: string | null) => void;
|
|
44
50
|
setLayout: (layout: Record<string, unknown>) => void;
|
|
51
|
+
/** Tear a sidebar section out of its trial. Default target is a workspace tile. */
|
|
52
|
+
undockPanel: (trialId: string, sectionId: string, as?: UndockedPanel['as']) => void;
|
|
53
|
+
/** Put one section back, or — with no `sectionId` — every panel the trial owns. */
|
|
54
|
+
dockPanel: (trialId: string, sectionId?: string) => void;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/** A lab's store: its state and actions, plus the hook instruments use to
|
|
@@ -72,6 +82,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
72
82
|
savedSnapshots: hydratedSnapshots,
|
|
73
83
|
mode: hydratedMode,
|
|
74
84
|
layout: hydratedLayout,
|
|
85
|
+
undockedPanels: hydrated.undockedPanels,
|
|
75
86
|
activeToolId: null,
|
|
76
87
|
|
|
77
88
|
addTrial: (record) => {
|
|
@@ -82,7 +93,10 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
82
93
|
},
|
|
83
94
|
|
|
84
95
|
removeTrial: (id) => {
|
|
85
|
-
set((s) => ({
|
|
96
|
+
set((s) => ({
|
|
97
|
+
trials: s.trials.filter((w) => w.id !== id),
|
|
98
|
+
undockedPanels: dockPanelIn(s.undockedPanels, id),
|
|
99
|
+
}));
|
|
86
100
|
scheduleFlush();
|
|
87
101
|
},
|
|
88
102
|
|
|
@@ -122,6 +136,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
122
136
|
scheduleFlush();
|
|
123
137
|
},
|
|
124
138
|
|
|
139
|
+
updateTrialAnnotations: (id, doc) => {
|
|
140
|
+
set((s) => ({
|
|
141
|
+
trials: s.trials.map((w) =>
|
|
142
|
+
w.id === id && !Object.is(doc, w.annotations) ? { ...w, annotations: doc } : w,
|
|
143
|
+
),
|
|
144
|
+
}));
|
|
145
|
+
scheduleFlush();
|
|
146
|
+
},
|
|
147
|
+
|
|
125
148
|
updateTrialUndoStack: (id, next) => {
|
|
126
149
|
set((s) => ({
|
|
127
150
|
trials: s.trials.map((w) => {
|
|
@@ -211,6 +234,16 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
211
234
|
trials: s.trials.map((t) => (t.id === trialId ? { ...t, activeToolId: id } : t)),
|
|
212
235
|
})),
|
|
213
236
|
|
|
237
|
+
undockPanel: (trialId, sectionId, as) => {
|
|
238
|
+
set((s) => ({ undockedPanels: undockPanelIn(s.undockedPanels, trialId, sectionId, as) }));
|
|
239
|
+
scheduleFlush();
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
dockPanel: (trialId, sectionId) => {
|
|
243
|
+
set((s) => ({ undockedPanels: dockPanelIn(s.undockedPanels, trialId, sectionId) }));
|
|
244
|
+
scheduleFlush();
|
|
245
|
+
},
|
|
246
|
+
|
|
214
247
|
setLayout: (layout) => {
|
|
215
248
|
set({ layout });
|
|
216
249
|
scheduleFlush();
|
|
@@ -227,6 +260,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
227
260
|
trials: serializeTrials(s.trials, serializers),
|
|
228
261
|
saves: s.savedSnapshots,
|
|
229
262
|
layout: s.layout,
|
|
263
|
+
undockedPanels: s.undockedPanels,
|
|
230
264
|
mode: s.mode,
|
|
231
265
|
};
|
|
232
266
|
const serialized = JSON.stringify(document);
|
package/src/state/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { UndockedPanels } from './undock';
|
|
1
2
|
/** A trial's undo history, as snapshots of its state either side of the
|
|
2
3
|
* present. */
|
|
3
4
|
export interface UndoStack {
|
|
@@ -17,6 +18,11 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
17
18
|
view: TV;
|
|
18
19
|
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
19
20
|
activeToolId?: string | null;
|
|
21
|
+
/** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
|
|
22
|
+
* wrote them. Opaque here, and absent for a trial whose instrument declares
|
|
23
|
+
* no `annotations` — or declares its own `storage`. Not in `state`, which
|
|
24
|
+
* belongs to the instrument and is typed as such. */
|
|
25
|
+
annotations?: unknown;
|
|
20
26
|
undoStack: UndoStack;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -46,6 +52,9 @@ export interface LabStoreState {
|
|
|
46
52
|
/** Per-trial tile extents, keyed by trial id. Opaque here — the
|
|
47
53
|
* shape belongs to whatever lays the trials out. */
|
|
48
54
|
layout: Record<string, unknown>;
|
|
55
|
+
/** Sidebar sections torn out of their trial. A panel here is not rendered in
|
|
56
|
+
* its trial's sidebar; the workspace renders it instead. */
|
|
57
|
+
undockedPanels: UndockedPanels;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
/** Where a lab persists itself. Implementations are keyed string storage and
|
|
@@ -91,6 +100,8 @@ export interface LabDocument {
|
|
|
91
100
|
trials: SerializedTrial[];
|
|
92
101
|
saves: SavedSnapshot[];
|
|
93
102
|
layout: Record<string, unknown>;
|
|
103
|
+
/** Sidebar sections torn out of their trial, keyed by trial and section. */
|
|
104
|
+
undockedPanels: UndockedPanels;
|
|
94
105
|
mode: LabMode;
|
|
95
106
|
}
|
|
96
107
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { dockPanel, panelKey, undockPanel } from './undock';
|
|
3
|
+
|
|
4
|
+
describe('undocked panel bookkeeping', () => {
|
|
5
|
+
it('keys a panel by its trial and section, so two trials can undock the same section', () => {
|
|
6
|
+
expect(panelKey('t1', 'settings')).not.toBe(panelKey('t2', 'settings'));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('records an undock as a tile by default', () => {
|
|
10
|
+
const next = undockPanel({}, 't1', 'settings');
|
|
11
|
+
expect(next[panelKey('t1', 'settings')]).toEqual({
|
|
12
|
+
trialId: 't1',
|
|
13
|
+
sectionId: 'settings',
|
|
14
|
+
as: 'tile',
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('records the requested target', () => {
|
|
19
|
+
const next = undockPanel({}, 't1', 'settings', 'floating');
|
|
20
|
+
expect(next[panelKey('t1', 'settings')]?.as).toBe('floating');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('re-undocking to a different target moves it rather than adding a second', () => {
|
|
24
|
+
const once = undockPanel({}, 't1', 'settings', 'tile');
|
|
25
|
+
const twice = undockPanel(once, 't1', 'settings', 'floating');
|
|
26
|
+
expect(Object.keys(twice)).toHaveLength(1);
|
|
27
|
+
expect(twice[panelKey('t1', 'settings')]?.as).toBe('floating');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('docking removes it and leaves other panels alone', () => {
|
|
31
|
+
const both = undockPanel(undockPanel({}, 't1', 'a'), 't1', 'b');
|
|
32
|
+
const next = dockPanel(both, 't1', 'a');
|
|
33
|
+
expect(Object.keys(next)).toEqual([panelKey('t1', 'b')]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('closing a trial takes its undocked panels with it', () => {
|
|
37
|
+
const panels = undockPanel(undockPanel({}, 't1', 'a'), 't2', 'a');
|
|
38
|
+
expect(Object.keys(dockPanel(panels, 't1'))).toEqual([panelKey('t2', 'a')]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A sidebar section torn out of its trial, and where it went. */
|
|
2
|
+
export interface UndockedPanel {
|
|
3
|
+
trialId: string;
|
|
4
|
+
sectionId: string;
|
|
5
|
+
as: 'tile' | 'floating';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Undocked panels, keyed by {@link panelKey}. */
|
|
9
|
+
export type UndockedPanels = Record<string, UndockedPanel>;
|
|
10
|
+
|
|
11
|
+
export function panelKey(trialId: string, sectionId: string): string {
|
|
12
|
+
return `${trialId}::${sectionId}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function undockPanel(
|
|
16
|
+
panels: UndockedPanels,
|
|
17
|
+
trialId: string,
|
|
18
|
+
sectionId: string,
|
|
19
|
+
as: UndockedPanel['as'] = 'tile',
|
|
20
|
+
): UndockedPanels {
|
|
21
|
+
return { ...panels, [panelKey(trialId, sectionId)]: { trialId, sectionId, as } };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Dock one section back, or — with no `sectionId` — every panel the trial
|
|
25
|
+
* owns, which is what closing a trial wants. */
|
|
26
|
+
export function dockPanel(
|
|
27
|
+
panels: UndockedPanels,
|
|
28
|
+
trialId: string,
|
|
29
|
+
sectionId?: string,
|
|
30
|
+
): UndockedPanels {
|
|
31
|
+
const out: UndockedPanels = {};
|
|
32
|
+
for (const [key, panel] of Object.entries(panels)) {
|
|
33
|
+
if (panel.trialId === trialId && (sectionId === undefined || panel.sectionId === sectionId)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out[key] = panel;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
package/src/styles.less
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
@import './chrome/regions/PaletteRegion.less';
|
|
18
18
|
@import './controls/ControlPanel.less';
|
|
19
19
|
@import './canvas/CanvasStack.less';
|
|
20
|
+
@import './loupe/Loupe.less';
|
|
20
21
|
@import './layers/LayerList.less';
|
|
22
|
+
@import './annotations/Annotations.less';
|
|
21
23
|
@import './dragdrop/Palette.less';
|
|
22
24
|
@import './dragdrop/DragGhost.less';
|
|
@@ -3,3 +3,7 @@ import type { SurfaceHandle } from './useTiledSurface';
|
|
|
3
3
|
|
|
4
4
|
/** Null when no surface owner is above — a lab with no shared surface at all. */
|
|
5
5
|
export const SurfaceContext = createContext<SurfaceHandle | null>(null);
|
|
6
|
+
|
|
7
|
+
/** The buffer the surface's tiles paint into. Null until the owner's canvas is
|
|
8
|
+
* mounted, and for an owner that keeps its surface unpainted. */
|
|
9
|
+
export const SurfaceCanvasContext = createContext<HTMLCanvasElement | null>(null);
|
package/src/surface/index.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
export { composeRects, rectsEqual } from './composeRects';
|
|
2
2
|
export { toDeviceRect } from './deviceRect';
|
|
3
3
|
export type { Box, Rect } from './rect';
|
|
4
|
-
export { SurfaceContext } from './SurfaceContext';
|
|
5
|
-
export { useSurface, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
|
|
6
|
-
export type {
|
|
4
|
+
export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
5
|
+
export { useSurface, useSurfaceCanvas, useSurfaceOptional, useSurfaceTile } from './useSurfaceTile';
|
|
6
|
+
export type {
|
|
7
|
+
SurfaceFrame,
|
|
8
|
+
SurfaceHandle,
|
|
9
|
+
TilePainter,
|
|
10
|
+
UseTiledSurfaceOptions,
|
|
11
|
+
} from './useTiledSurface';
|
|
7
12
|
export { useTiledSurface } from './useTiledSurface';
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { useCallback, useContext } from 'react';
|
|
2
|
-
import { SurfaceContext } from './SurfaceContext';
|
|
2
|
+
import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
3
3
|
import type { SurfaceHandle } from './useTiledSurface';
|
|
4
4
|
|
|
5
|
+
/** The surface's shared buffer, or null where nothing paints one. What a tile
|
|
6
|
+
* hands to `paintInto`. */
|
|
7
|
+
export function useSurfaceCanvas(): HTMLCanvasElement | null {
|
|
8
|
+
return useContext(SurfaceCanvasContext);
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
/** The surface above, or null. Use this where a surface is genuinely optional. */
|
|
6
12
|
export function useSurfaceOptional(): SurfaceHandle | null {
|
|
7
13
|
return useContext(SurfaceContext);
|
|
@@ -228,4 +228,62 @@ describe('useTiledSurface', () => {
|
|
|
228
228
|
|
|
229
229
|
expect(frames[0]?.rects.has('b')).toBe(false);
|
|
230
230
|
});
|
|
231
|
+
|
|
232
|
+
it("calls a dirty tile's painter with that tile's rect", () => {
|
|
233
|
+
const frames: SurfaceFrame[] = [];
|
|
234
|
+
let handle: SurfaceHandle | null = null;
|
|
235
|
+
render(
|
|
236
|
+
<Harness
|
|
237
|
+
frames={frames}
|
|
238
|
+
onHandle={(h) => {
|
|
239
|
+
handle = h;
|
|
240
|
+
}}
|
|
241
|
+
/>,
|
|
242
|
+
);
|
|
243
|
+
flushFrames();
|
|
244
|
+
|
|
245
|
+
const paintA = vi.fn();
|
|
246
|
+
const paintB = vi.fn();
|
|
247
|
+
act(() => {
|
|
248
|
+
handle?.registerPainter('a', paintA);
|
|
249
|
+
handle?.registerPainter('b', paintB);
|
|
250
|
+
handle?.invalidate('a');
|
|
251
|
+
});
|
|
252
|
+
flushFrames();
|
|
253
|
+
|
|
254
|
+
// The rect, not just the call: a painter handed the wrong origin paints
|
|
255
|
+
// the right picture in the wrong place.
|
|
256
|
+
expect(paintA).toHaveBeenCalledWith({ x: 0, y: 0, w: 400, h: 600 }, expect.anything());
|
|
257
|
+
expect(paintB).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('stops calling a painter that unregisters', () => {
|
|
261
|
+
const frames: SurfaceFrame[] = [];
|
|
262
|
+
let handle: SurfaceHandle | null = null;
|
|
263
|
+
render(
|
|
264
|
+
<Harness
|
|
265
|
+
frames={frames}
|
|
266
|
+
onHandle={(h) => {
|
|
267
|
+
handle = h;
|
|
268
|
+
}}
|
|
269
|
+
/>,
|
|
270
|
+
);
|
|
271
|
+
flushFrames();
|
|
272
|
+
|
|
273
|
+
const paint = vi.fn();
|
|
274
|
+
let off: (() => void) | undefined;
|
|
275
|
+
act(() => {
|
|
276
|
+
off = handle?.registerPainter('a', paint);
|
|
277
|
+
handle?.invalidate('a');
|
|
278
|
+
});
|
|
279
|
+
flushFrames();
|
|
280
|
+
expect(paint).toHaveBeenCalledTimes(1);
|
|
281
|
+
|
|
282
|
+
act(() => {
|
|
283
|
+
off?.();
|
|
284
|
+
handle?.invalidate('a');
|
|
285
|
+
});
|
|
286
|
+
flushFrames();
|
|
287
|
+
expect(paint).toHaveBeenCalledTimes(1);
|
|
288
|
+
});
|
|
231
289
|
});
|
|
@@ -23,9 +23,20 @@ export interface SurfaceHandle {
|
|
|
23
23
|
* moved something a ResizeObserver cannot see. */
|
|
24
24
|
invalidateRects: () => void;
|
|
25
25
|
registerTile: (id: string, el: HTMLElement | null) => void;
|
|
26
|
+
/** Subscribe a tile to the frames it is dirty on. A tile paints on its own
|
|
27
|
+
* loop, so this is how the surface wakes one: a resize of the shared buffer
|
|
28
|
+
* clears every tile, not only the one that moved. */
|
|
29
|
+
registerPainter: (id: string, paint: TilePainter) => () => void;
|
|
26
30
|
containerRef: (el: HTMLElement | null) => void;
|
|
31
|
+
/** The element tile rects are measured against, and so the one a tile's own
|
|
32
|
+
* chrome positions itself inside. Null before the owner attaches it. */
|
|
33
|
+
getContainer: () => HTMLElement | null;
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** What a tile's painter is handed: where it sits on the surface now, and the
|
|
37
|
+
* frame that dirtied it. */
|
|
38
|
+
export type TilePainter = (rect: Rect, frame: SurfaceFrame) => void;
|
|
39
|
+
|
|
29
40
|
export interface UseTiledSurfaceOptions {
|
|
30
41
|
onFrame: (frame: SurfaceFrame) => void;
|
|
31
42
|
}
|
|
@@ -35,6 +46,7 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
35
46
|
const tiles = useRef(new Map<string, HTMLElement>());
|
|
36
47
|
const rects = useRef(new Map<string, Rect>());
|
|
37
48
|
const dirty = useRef(new Set<string>());
|
|
49
|
+
const painters = useRef(new Map<string, TilePainter>());
|
|
38
50
|
const needsMeasure = useRef(true);
|
|
39
51
|
const observer = useRef<ResizeObserver | null>(null);
|
|
40
52
|
const lastDpr = useRef(0);
|
|
@@ -76,12 +88,18 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
76
88
|
}
|
|
77
89
|
if (dirty.current.size === 0) return;
|
|
78
90
|
const box = el.getBoundingClientRect();
|
|
79
|
-
|
|
91
|
+
const frame: SurfaceFrame = {
|
|
80
92
|
dirty: new Set(dirty.current),
|
|
81
93
|
rects: new Map(rects.current),
|
|
82
94
|
dpr,
|
|
83
95
|
size: { width: box.width, height: box.height },
|
|
84
|
-
}
|
|
96
|
+
};
|
|
97
|
+
onFrameRef.current(frame);
|
|
98
|
+
// After the owner, which is what resized the buffer this frame.
|
|
99
|
+
for (const id of frame.dirty) {
|
|
100
|
+
const rect = frame.rects.get(id);
|
|
101
|
+
if (rect) painters.current.get(id)?.(rect, frame);
|
|
102
|
+
}
|
|
85
103
|
dirty.current.clear();
|
|
86
104
|
},
|
|
87
105
|
// The host attaches its container through `containerRef`, which may land
|
|
@@ -130,6 +148,15 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
130
148
|
[schedule],
|
|
131
149
|
);
|
|
132
150
|
|
|
151
|
+
const registerPainter = useCallback((id: string, paint: TilePainter) => {
|
|
152
|
+
painters.current.set(id, paint);
|
|
153
|
+
return () => {
|
|
154
|
+
if (painters.current.get(id) === paint) painters.current.delete(id);
|
|
155
|
+
};
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
const getContainer = useCallback(() => container.current, []);
|
|
159
|
+
|
|
133
160
|
const containerRef = useCallback(
|
|
134
161
|
(el: HTMLElement | null) => {
|
|
135
162
|
if (container.current === el) return;
|
|
@@ -157,5 +184,13 @@ export function useTiledSurface({ onFrame }: UseTiledSurfaceOptions): SurfaceHan
|
|
|
157
184
|
};
|
|
158
185
|
}, [schedule, frameLoop]);
|
|
159
186
|
|
|
160
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
invalidate,
|
|
189
|
+
invalidateAll,
|
|
190
|
+
invalidateRects,
|
|
191
|
+
registerTile,
|
|
192
|
+
registerPainter,
|
|
193
|
+
containerRef,
|
|
194
|
+
getContainer,
|
|
195
|
+
};
|
|
161
196
|
}
|