@weasel-js/labkit 1.4.4 → 1.5.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 +54 -6
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CJNMS6mz.d.ts} +1 -1
- package/dist/_dts/{PrefsForm.d-DDHFANkc.d.ts → PrefsForm.d-DPy9I8It.d.ts} +4 -0
- package/dist/_dts/Select.d-Gwr8fLpK.d.ts +453 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-B4okC2lX.d.ts} +291 -31
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-BNdz-tNd.d.ts} +105 -17
- package/dist/_dts/index-CSJoj4LC.d.ts +248 -0
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-DzoNY1pO.d.ts} +91 -4
- package/dist/_dts/usePanZoom-Pzf9f2jh.d.ts +160 -0
- package/dist/_dts/useTrialState-uLlmaccn.d.ts +212 -0
- package/dist/canvas/index.d.ts +18 -129
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +5 -93
- package/dist/chrome/index.js +10 -6
- package/dist/chunk-7PFH3CNM.js +150 -0
- package/dist/chunk-7PFH3CNM.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-CEIUBPPW.js} +143 -17
- package/dist/chunk-CEIUBPPW.js.map +1 -0
- package/dist/chunk-EH7UEQV7.js +590 -0
- package/dist/chunk-EH7UEQV7.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EHGVROVV.js} +125 -27
- package/dist/chunk-EHGVROVV.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ERFMC6WU.js} +160 -78
- package/dist/chunk-ERFMC6WU.js.map +1 -0
- package/dist/{chunk-CTRKTLYZ.js → chunk-H6ZAOWNE.js} +11 -11
- package/dist/chunk-H6ZAOWNE.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-IHUVQ3TX.js} +250 -160
- package/dist/chunk-IHUVQ3TX.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-IJJUK4FU.js} +53 -46
- package/dist/chunk-IJJUK4FU.js.map +1 -0
- package/dist/chunk-IXTQI7JX.js +737 -0
- package/dist/chunk-IXTQI7JX.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MDVTK5U3.js} +4 -4
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MDVTK5U3.js.map} +1 -1
- package/dist/chunk-PHK4W562.js +72 -0
- package/dist/chunk-PHK4W562.js.map +1 -0
- package/dist/chunk-PJ7WEQBJ.js +18 -0
- package/dist/chunk-PJ7WEQBJ.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-T3EC2DXD.js +12628 -0
- package/dist/chunk-T3EC2DXD.js.map +1 -0
- package/dist/{chunk-SMHP6XZ4.js → chunk-UN4U4P63.js} +10 -8
- package/dist/chunk-UN4U4P63.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/chunk-Z76KEZAX.js +15 -0
- package/dist/chunk-Z76KEZAX.js.map +1 -0
- package/dist/chunk-ZDHY47EV.js +363 -0
- package/dist/chunk-ZDHY47EV.js.map +1 -0
- package/dist/config/index.d.ts +276 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +3 -4
- package/dist/controls/index.js +4 -3
- package/dist/dragdrop/index.d.ts +3 -5
- package/dist/index.d.ts +266 -413
- package/dist/index.js +1775 -2251
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -6
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +69 -20
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +175 -309
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +55 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +13 -8
- package/dist/state/index.js +18 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +223 -76
- package/dist/surface/index.d.ts +44 -7
- package/dist/surface/index.js +2 -1
- package/dist/ui/layers/index.js +1 -1
- package/dist/undo/index.d.ts +3 -5
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +56 -5
- package/src/annotations/Annotations.less +4 -0
- package/src/annotations/Annotations.overlay.test.tsx +105 -6
- package/src/annotations/drawOne.test.ts +1 -1
- package/src/annotations/drawOne.ts +2 -2
- package/src/annotations/paint.test.ts +2 -2
- package/src/annotations/paint.ts +1 -1
- package/src/annotations/preload.ts +6 -0
- package/src/annotations/store.test.ts +18 -0
- package/src/annotations/store.ts +5 -4
- package/src/annotations/svgNodes.test.ts +2 -2
- package/src/annotations/svgNodes.ts +1 -0
- package/src/annotations/toolMap.test.ts +9 -5
- package/src/annotations/toolMap.ts +6 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- package/src/canvas/CanvasStack.stories.tsx +1 -1
- package/src/canvas/CanvasStack.test.tsx +25 -0
- package/src/canvas/CanvasStack.tsx +31 -4
- package/src/canvas/Stage.less +18 -0
- package/src/canvas/Stage.test.tsx +186 -0
- package/src/canvas/Stage.tsx +134 -0
- package/src/canvas/camera.test.ts +17 -2
- package/src/canvas/camera.ts +9 -9
- package/src/canvas/index.ts +4 -0
- package/src/canvas/usePanZoom.test.ts +28 -0
- package/src/canvas/usePanZoom.ts +21 -9
- package/src/chrome/ChromeRegions.stories.tsx +1 -4
- package/src/chrome/LabChrome.tsx +130 -0
- package/src/chrome/builtins.test.ts +1 -0
- package/src/chrome/builtins.tsx +21 -31
- package/src/chrome/index.ts +16 -0
- package/src/chrome/labRegions.test.tsx +208 -0
- package/src/chrome/labTypes.ts +50 -0
- package/src/chrome/merge.ts +7 -9
- package/src/chrome/regions/PaletteRegion.test.tsx +6 -4
- package/src/chrome/regions/PaletteRegion.tsx +16 -9
- package/src/chrome/regions/SidebarRegion.stories.tsx +44 -0
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +34 -16
- package/src/chrome/types.ts +50 -20
- package/src/config/auto.test.ts +12 -0
- package/src/config/auto.ts +15 -0
- package/src/config/autoConfig.test.ts +107 -0
- package/src/config/autoConfig.ts +153 -0
- package/src/config/builder.test.ts +42 -0
- package/src/config/builder.ts +68 -17
- package/src/config/declarationEmit.fixture.ts +11 -0
- package/src/config/declarationEmit.test.ts +51 -0
- package/src/config/entry.test.ts +46 -0
- package/src/config/index.ts +16 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/path.ts +7 -2
- package/src/config/resolve.test.ts +25 -0
- package/src/config/resolve.ts +17 -1
- package/src/config/types.ts +34 -2
- package/src/config/useResolvedConfig.ts +26 -0
- package/src/controls/AutoControls.stories.tsx +88 -0
- package/src/controls/ControlPanel.stories.tsx +42 -1
- package/src/controls/ControlPanel.test.tsx +530 -4
- package/src/controls/ControlPanel.tsx +562 -82
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +66 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +40 -1
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.chrome.test.tsx +16 -0
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +1 -3
- package/src/lab/Lab.surface.test.tsx +71 -3
- package/src/lab/Lab.test.tsx +168 -16
- package/src/lab/Lab.tsx +434 -129
- package/src/lab/LabContext.ts +8 -0
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +1 -4
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -14
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabRoot.test.tsx +56 -0
- package/src/lab/LabRoot.tsx +47 -0
- package/src/lab/LabShell.less +73 -15
- package/src/lab/LabShell.stories.tsx +50 -1
- package/src/lab/LabShell.tsx +18 -27
- package/src/lab/LabSwitcher.less +18 -11
- package/src/lab/LabSwitcher.test.tsx +14 -9
- package/src/lab/LabSwitcher.tsx +8 -4
- package/src/lab/Workspace.less +1 -1
- package/src/lab/Workspace.stories.tsx +1 -1
- package/src/lab/Workspace.surface.test.tsx +1 -0
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/lab/index.ts +2 -0
- package/src/layers/LayerList.stories.tsx +1 -1
- package/src/loupe/AGENTS.md +17 -5
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/LoupeGestures.tsx +67 -0
- package/src/loupe/TrialLoupe.tsx +43 -28
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/index.ts +4 -0
- package/src/loupe/loupeActions.ts +98 -0
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +60 -3
- package/src/loupe/useLoupe.ts +29 -49
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +14 -2
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/FpsMeter.stories.tsx +1 -1
- package/src/primitives/JobProgress.stories.tsx +1 -1
- package/src/primitives/Legend.stories.tsx +1 -1
- package/src/primitives/ScaleIndicator.stories.tsx +1 -1
- package/src/primitives/Sidebar.less +7 -0
- package/src/primitives/Sidebar.stories.tsx +1 -1
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +133 -0
- package/src/primitives/Split.tsx +175 -0
- package/src/primitives/StatusBar.stories.tsx +1 -1
- package/src/primitives/Toolbar.stories.tsx +1 -1
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -0
- package/src/specimen/Specimen.less +95 -0
- package/src/specimen/Specimen.stories.tsx +13 -0
- package/src/specimen/Specimen.test.tsx +13 -0
- package/src/specimen/Specimen.tsx +888 -0
- package/src/state/Persistence.stories.tsx +75 -0
- package/src/state/Persistence.tsx +33 -0
- package/src/state/SingletonExperiment.test.tsx +47 -53
- package/src/state/SingletonExperiment.tsx +24 -13
- package/src/state/adapterContract.ts +116 -0
- package/src/state/adapters.test.ts +113 -61
- package/src/state/adapters.ts +328 -69
- package/src/state/document.test.ts +71 -96
- package/src/state/document.ts +66 -52
- package/src/state/helpers.test.ts +1 -1
- package/src/state/index.ts +8 -0
- package/src/state/labRecords.test.ts +105 -0
- package/src/state/labRecords.ts +160 -0
- package/src/state/openLabStore.test.ts +658 -0
- package/src/state/openLabStore.ts +403 -0
- package/src/state/records.test.ts +234 -0
- package/src/state/records.ts +219 -0
- package/src/state/store.test.ts +169 -635
- package/src/state/store.ts +146 -163
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +65 -17
- package/src/state/useOpenOnce.ts +68 -0
- package/src/state/usePersistedState.test.tsx +125 -0
- package/src/state/usePersistedState.ts +66 -0
- package/src/state/useTrialState.test.tsx +87 -3
- package/src/state/useTrialState.ts +9 -1
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +3 -0
- package/src/surface/AGENTS.md +39 -7
- package/src/surface/SurfaceContext.ts +26 -3
- package/src/surface/index.ts +2 -0
- package/src/surface/useSurfaceTile.test.tsx +1 -0
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +231 -1
- package/src/surface/useTiledSurface.ts +76 -15
- package/src/theme/Interstellar.stories.tsx +9 -9
- package/src/theme/interstellar.test.ts +17 -6
- package/src/theme/interstellar.theme.json +180 -0
- package/src/theme/interstellar.ts +8 -5
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +59 -7
- package/src/trial/Trial.canvas.test.tsx +32 -0
- package/src/trial/Trial.config.test.tsx +75 -2
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +5 -26
- package/src/trial/Trial.stories.tsx +5 -3
- package/src/trial/Trial.test.tsx +31 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +139 -52
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +24 -7
- package/src/trial/trialOps.test.ts +101 -1
- package/src/trial/trialOps.ts +67 -7
- package/dist/_dts/types-BP2OCcpg.d.ts +0 -155
- package/dist/_dts/types-x92Kfeme.d.ts +0 -62
- package/dist/_dts/useTrialState-D6Vb3T-g.d.ts +0 -99
- package/dist/chunk-2P6PP5N4.js +0 -553
- package/dist/chunk-2P6PP5N4.js.map +0 -1
- package/dist/chunk-64ZCN3DA.js.map +0 -1
- package/dist/chunk-CTRKTLYZ.js.map +0 -1
- package/dist/chunk-E44SU6XS.js +0 -50
- package/dist/chunk-E44SU6XS.js.map +0 -1
- package/dist/chunk-FQMJUVHJ.js.map +0 -1
- package/dist/chunk-I6JCVE24.js.map +0 -1
- package/dist/chunk-MOM3GOVY.js.map +0 -1
- package/dist/chunk-SMHP6XZ4.js.map +0 -1
- package/dist/chunk-TJ7QY3OC.js.map +0 -1
- package/dist/chunk-U3IYHIAE.js +0 -343
- package/dist/chunk-U3IYHIAE.js.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
- package/src/theme/interstellar.tokens.json +0 -61
package/src/state/document.ts
CHANGED
|
@@ -10,11 +10,10 @@ import type {
|
|
|
10
10
|
import type { UndockedPanels } from './undock';
|
|
11
11
|
|
|
12
12
|
/** Bumped whenever the persisted shape changes; every bump needs a migration. */
|
|
13
|
-
export const CURRENT_DOCUMENT_VERSION =
|
|
13
|
+
export const CURRENT_DOCUMENT_VERSION = 4;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
* its document over lab `a`'s saves bucket. */
|
|
15
|
+
/** Where a version-3 lab kept its one document. Read once, to fold it into
|
|
16
|
+
* records, then deleted. */
|
|
18
17
|
export function labDocumentKey(storageKey: string): string {
|
|
19
18
|
return `lk:${storageKey}:doc`;
|
|
20
19
|
}
|
|
@@ -69,83 +68,92 @@ export function runMigrations(
|
|
|
69
68
|
|
|
70
69
|
const LEGACY_BUCKETS = ['workspaces', 'saves', 'layout', 'theme'] as const;
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
/** The four keys a lab used before it was one document. */
|
|
72
|
+
export function legacyKeys(storageKey: string): string[] {
|
|
73
|
+
return LEGACY_BUCKETS.map((bucket) => labStorageKey(storageKey, bucket));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A value some substrate returned for an old key: already parsed, or a JSON
|
|
77
|
+
* string from a store that does not parse. `undefined` when it is neither. */
|
|
78
|
+
export function parseStored(value: unknown): unknown {
|
|
79
|
+
if (typeof value !== 'string') return value;
|
|
74
80
|
try {
|
|
75
|
-
return JSON.parse(
|
|
81
|
+
return JSON.parse(value);
|
|
76
82
|
} catch {
|
|
77
|
-
|
|
78
|
-
return fallback;
|
|
83
|
+
return undefined;
|
|
79
84
|
}
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
function bucketOr<T>(value: unknown, fallback: T, bucket: string, shape: 'array' | 'object'): T {
|
|
88
|
+
if (value === undefined) return fallback;
|
|
89
|
+
const parsed = parseStored(value);
|
|
90
|
+
const fits = shape === 'array' ? Array.isArray(parsed) : !!parsed && typeof parsed === 'object';
|
|
91
|
+
if (fits) return parsed as T;
|
|
92
|
+
console.warn(`[labkit] unparseable legacy bucket "${bucket}", dropping it`);
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Assemble a version-0 document out of the four pre-document keys, or null
|
|
83
97
|
* if none of them is present. `fallbackMode` is used when the theme key is
|
|
84
98
|
* absent or holds something unrecognized; `interstellar` passes through
|
|
85
99
|
* untouched for `migrateV0toV1` to coerce. */
|
|
86
|
-
export function readLegacyDocument(
|
|
87
|
-
|
|
100
|
+
export async function readLegacyDocument(
|
|
101
|
+
read: (key: string) => Promise<unknown>,
|
|
88
102
|
storageKey: string,
|
|
89
103
|
fallbackMode: LabMode,
|
|
90
|
-
): Record<string, unknown> | null {
|
|
91
|
-
const
|
|
92
|
-
|
|
104
|
+
): Promise<Record<string, unknown> | null> {
|
|
105
|
+
const [workspaces, saves, layout, theme] = await Promise.all(
|
|
106
|
+
LEGACY_BUCKETS.map((bucket) => read(labStorageKey(storageKey, bucket))),
|
|
107
|
+
);
|
|
108
|
+
if ([workspaces, saves, layout, theme].every((v) => v === undefined)) return null;
|
|
93
109
|
|
|
94
|
-
const rawMode = storage.read(labStorageKey(storageKey, 'theme'));
|
|
95
110
|
const mode =
|
|
96
|
-
|
|
97
|
-
?
|
|
111
|
+
theme === 'light' || theme === 'dark' || theme === 'auto' || theme === 'interstellar'
|
|
112
|
+
? theme
|
|
98
113
|
: fallbackMode;
|
|
99
114
|
|
|
100
115
|
return {
|
|
101
116
|
version: 0,
|
|
102
|
-
workspaces:
|
|
103
|
-
saves:
|
|
104
|
-
layout:
|
|
117
|
+
workspaces: bucketOr(workspaces, [], 'workspaces', 'array'),
|
|
118
|
+
saves: bucketOr(saves, [], 'saves', 'array'),
|
|
119
|
+
layout: bucketOr(layout, {}, 'layout', 'object'),
|
|
105
120
|
mode,
|
|
106
121
|
};
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
* is
|
|
117
|
-
export function
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
if (actual !== expectedDocument) {
|
|
124
|
-
console.warn(
|
|
125
|
-
`[labkit] keeping legacy keys for "${storageKey}": could not confirm the migrated document was written`,
|
|
126
|
-
);
|
|
124
|
+
/** Whether two stored values hold the same data. Stored values are
|
|
125
|
+
* structured-clone copies, so identity says nothing. */
|
|
126
|
+
export function sameStored(a: unknown, b: unknown): boolean {
|
|
127
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Delete `keys` and read each back, so an adapter whose delete silently did
|
|
131
|
+
* nothing is caught. `true` only once all of them are gone. */
|
|
132
|
+
export async function deleteConfirmed(storage: StorageAdapter, keys: string[]): Promise<boolean> {
|
|
133
|
+
try {
|
|
134
|
+
await Promise.all(keys.map((key) => storage.delete(key)));
|
|
135
|
+
const left = await Promise.all(keys.map((key) => storage.get(key)));
|
|
136
|
+
return left.every((v) => v === undefined);
|
|
137
|
+
} catch {
|
|
127
138
|
return false;
|
|
128
139
|
}
|
|
129
|
-
let allGone = true;
|
|
130
|
-
for (const bucket of LEGACY_BUCKETS) {
|
|
131
|
-
const key = labStorageKey(storageKey, bucket);
|
|
132
|
-
storage.delete?.(key);
|
|
133
|
-
if (storage.read(key) !== null) allGone = false;
|
|
134
|
-
}
|
|
135
|
-
return allGone;
|
|
136
140
|
}
|
|
137
141
|
|
|
138
|
-
/** Set `
|
|
142
|
+
/** Set `value` aside under the quarantine key, reading it back to confirm it
|
|
139
143
|
* landed. Returns `false` when it did not — a full disk is exactly when a
|
|
140
144
|
* document goes unreadable — so the caller can leave the original alone
|
|
141
145
|
* rather than overwrite the only copy of it. */
|
|
142
|
-
export function quarantineDocument(
|
|
146
|
+
export async function quarantineDocument(
|
|
143
147
|
storage: StorageAdapter,
|
|
144
148
|
storageKey: string,
|
|
145
|
-
|
|
146
|
-
): boolean {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
value: unknown,
|
|
150
|
+
): Promise<boolean> {
|
|
151
|
+
try {
|
|
152
|
+
await storage.set(quarantineKey(storageKey), value);
|
|
153
|
+
return sameStored(await storage.get(quarantineKey(storageKey)), value);
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/** Fill in whatever a document is missing or holds the wrong shape of, so a
|
|
@@ -202,5 +210,11 @@ export function migrateV2toV3(doc: Record<string, unknown>): Record<string, unkn
|
|
|
202
210
|
return { ...doc, undockedPanels: {}, version: 3 };
|
|
203
211
|
}
|
|
204
212
|
|
|
213
|
+
/** Version 3 to version 4: a lab is stored as records under one prefix
|
|
214
|
+
* instead of one document. The joined shape does not change. */
|
|
215
|
+
export function migrateV3toV4(doc: Record<string, unknown>): Record<string, unknown> {
|
|
216
|
+
return { ...doc, version: 4 };
|
|
217
|
+
}
|
|
218
|
+
|
|
205
219
|
/** Index `i` migrates a version-`i` document to version `i + 1`. */
|
|
206
|
-
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3];
|
|
220
|
+
export const MIGRATIONS: Migration[] = [migrateV0toV1, migrateV1toV2, migrateV2toV3, migrateV3toV4];
|
package/src/state/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export {
|
|
2
|
+
createIndexedDbAdapter,
|
|
2
3
|
createMemoryAdapter,
|
|
4
|
+
type IndexedDbAdapterOptions,
|
|
5
|
+
indexedDbAdapter,
|
|
3
6
|
localStorageAdapter,
|
|
4
7
|
noneAdapter,
|
|
5
8
|
sessionStorageAdapter,
|
|
@@ -26,6 +29,9 @@ export {
|
|
|
26
29
|
labStorageKey,
|
|
27
30
|
serializeTrials,
|
|
28
31
|
} from './helpers';
|
|
32
|
+
export { type OpenedLabStore, type OpenLabStoreOptions, openLabStore } from './openLabStore';
|
|
33
|
+
export { Persistence, type PersistenceProps } from './Persistence';
|
|
34
|
+
export type { RecordCache, RecordChange } from './records';
|
|
29
35
|
export {
|
|
30
36
|
SingletonExperimentProvider,
|
|
31
37
|
type SingletonExperimentProviderProps,
|
|
@@ -41,10 +47,12 @@ export type {
|
|
|
41
47
|
SavedSnapshot,
|
|
42
48
|
SerializedTrial,
|
|
43
49
|
StorageAdapter,
|
|
50
|
+
StorageChange,
|
|
44
51
|
TrialRecord,
|
|
45
52
|
TrialStateHandle,
|
|
46
53
|
UndoStack,
|
|
47
54
|
} from './types';
|
|
48
55
|
export type { UndockedPanel, UndockedPanels } from './undock';
|
|
49
56
|
export { dockPanel, panelKey, undockPanel } from './undock';
|
|
57
|
+
export { type PersistedStateOptions, usePersistedState } from './usePersistedState';
|
|
50
58
|
export { useTrialState } from './useTrialState';
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CURRENT_DOCUMENT_VERSION } from './document';
|
|
3
|
+
import {
|
|
4
|
+
documentOfRecords,
|
|
5
|
+
labPrefix,
|
|
6
|
+
parseRecordName,
|
|
7
|
+
recordsOfDocument,
|
|
8
|
+
trialRecord,
|
|
9
|
+
valueRecord,
|
|
10
|
+
} from './labRecords';
|
|
11
|
+
import type { LabDocument } from './types';
|
|
12
|
+
|
|
13
|
+
const VIEW = { zoom: 1, pan: { x: 0, y: 0 } };
|
|
14
|
+
|
|
15
|
+
describe('record names', () => {
|
|
16
|
+
it('encodes the storage key, so no lab prefix contains another', () => {
|
|
17
|
+
expect(labPrefix('drag-lab')).toBe('lk:drag-lab:');
|
|
18
|
+
expect(labPrefix('a:trial:x')).toBe('lk:a%3Atrial%3Ax:');
|
|
19
|
+
expect(labPrefix('a:trial:x').startsWith(labPrefix('a'))).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('round-trips ids holding the separator', () => {
|
|
23
|
+
expect(parseRecordName(trialRecord('odd:id'))).toEqual({ kind: 'trial', id: 'odd:id' });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('scopes a value to its trial or to the lab', () => {
|
|
27
|
+
expect(valueRecord('t:1', 'tab')).toBe('value:trial:t%3A1:tab');
|
|
28
|
+
expect(valueRecord(null, 'tab')).toBe('value:lab:tab');
|
|
29
|
+
expect(parseRecordName(valueRecord('t:1', 'a:b'))).toEqual({ kind: 'value' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('answers null for a name no lab writes', () => {
|
|
33
|
+
for (const name of [
|
|
34
|
+
'doc',
|
|
35
|
+
'meta:x',
|
|
36
|
+
'trial:',
|
|
37
|
+
'trial:a:b',
|
|
38
|
+
'value:lab',
|
|
39
|
+
'value:x:y',
|
|
40
|
+
'trial:%E0%A4%A',
|
|
41
|
+
]) {
|
|
42
|
+
expect(parseRecordName(name)).toBeNull();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('documents and records', () => {
|
|
48
|
+
const doc: LabDocument = {
|
|
49
|
+
version: CURRENT_DOCUMENT_VERSION,
|
|
50
|
+
trials: [
|
|
51
|
+
{ id: 'b', instrumentName: 'T', config: {}, state: { n: 2 }, view: VIEW },
|
|
52
|
+
{ id: 'a', instrumentName: 'T', config: {}, state: { n: 1 }, view: VIEW },
|
|
53
|
+
],
|
|
54
|
+
saves: [
|
|
55
|
+
{
|
|
56
|
+
id: 's2',
|
|
57
|
+
name: 'later',
|
|
58
|
+
trialId: 'a',
|
|
59
|
+
instrumentName: 'T',
|
|
60
|
+
config: {},
|
|
61
|
+
state: {},
|
|
62
|
+
savedAt: 2,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 's1',
|
|
66
|
+
name: 'earlier',
|
|
67
|
+
trialId: 'a',
|
|
68
|
+
instrumentName: 'T',
|
|
69
|
+
config: {},
|
|
70
|
+
state: {},
|
|
71
|
+
savedAt: 1,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
layout: { a: { h: 1 } },
|
|
75
|
+
undockedPanels: {},
|
|
76
|
+
mode: 'dark',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('writes meta last', () => {
|
|
80
|
+
const names = recordsOfDocument(doc).map(([name]) => name);
|
|
81
|
+
expect(names.at(-1)).toBe('meta');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('joins back into the document it came from, trials in stored order', () => {
|
|
85
|
+
const joined = documentOfRecords(recordsOfDocument(doc).reverse());
|
|
86
|
+
expect(joined?.doc).toEqual({ ...doc, saves: [doc.saves[1], doc.saves[0]] });
|
|
87
|
+
expect([...(joined?.orders ?? [])]).toEqual([
|
|
88
|
+
['b', 0],
|
|
89
|
+
['a', 1],
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('breaks an order tie by id', () => {
|
|
94
|
+
const joined = documentOfRecords([
|
|
95
|
+
['meta', { version: 4, mode: 'auto' }],
|
|
96
|
+
['trial:z', { instrumentName: 'T', order: 0 }],
|
|
97
|
+
['trial:m', { instrumentName: 'T', order: 0 }],
|
|
98
|
+
]);
|
|
99
|
+
expect((joined?.doc.trials as { id: string }[]).map((t) => t.id)).toEqual(['m', 'z']);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('is null without a meta record', () => {
|
|
103
|
+
expect(documentOfRecords([['trial:a', { instrumentName: 'T', order: 0 }]])).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { LabDocument, SavedSnapshot, SerializedTrial } from './types';
|
|
2
|
+
|
|
3
|
+
/** Every record of the lab stored under `storageKey` starts with this. The key
|
|
4
|
+
* is URI-encoded, so it holds no `:` and one lab's prefix can never reach into
|
|
5
|
+
* another lab's records. */
|
|
6
|
+
export function labPrefix(storageKey: string): string {
|
|
7
|
+
return `lk:${encodeURIComponent(storageKey)}:`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const META_RECORD = 'meta';
|
|
11
|
+
export const LAYOUT_RECORD = 'layout';
|
|
12
|
+
export const UNDOCK_RECORD = 'undock';
|
|
13
|
+
|
|
14
|
+
export function trialRecord(id: string): string {
|
|
15
|
+
return `trial:${encodeURIComponent(id)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveRecord(id: string): string {
|
|
19
|
+
return `save:${encodeURIComponent(id)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The prefix every value scoped to one trial shares. */
|
|
23
|
+
export function trialValuesPrefix(trialId: string): string {
|
|
24
|
+
return `value:trial:${encodeURIComponent(trialId)}:`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where a `usePersistedState` value lives: under its trial, or — with a null
|
|
28
|
+
* trial — under the lab. */
|
|
29
|
+
export function valueRecord(trialId: string | null, name: string): string {
|
|
30
|
+
const encoded = encodeURIComponent(name);
|
|
31
|
+
return trialId === null ? `value:lab:${encoded}` : `${trialValuesPrefix(trialId)}${encoded}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a record name addresses, or null for a name no lab writes. */
|
|
35
|
+
export type RecordName =
|
|
36
|
+
| { kind: 'meta' | 'layout' | 'undock' | 'value' }
|
|
37
|
+
| { kind: 'trial' | 'save'; id: string };
|
|
38
|
+
|
|
39
|
+
export function parseRecordName(name: string): RecordName | null {
|
|
40
|
+
const parts = name.split(':');
|
|
41
|
+
try {
|
|
42
|
+
switch (parts[0]) {
|
|
43
|
+
case 'meta':
|
|
44
|
+
case 'layout':
|
|
45
|
+
case 'undock':
|
|
46
|
+
return parts.length === 1 ? { kind: parts[0] } : null;
|
|
47
|
+
case 'trial':
|
|
48
|
+
case 'save':
|
|
49
|
+
return parts.length === 2 && parts[1]
|
|
50
|
+
? { kind: parts[0], id: decodeURIComponent(parts[1]) }
|
|
51
|
+
: null;
|
|
52
|
+
case 'value':
|
|
53
|
+
return (parts[1] === 'lab' && parts.length === 3) ||
|
|
54
|
+
(parts[1] === 'trial' && parts.length === 4)
|
|
55
|
+
? { kind: 'value' }
|
|
56
|
+
: null;
|
|
57
|
+
default:
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The fields of the meta record. */
|
|
66
|
+
export interface LabMeta {
|
|
67
|
+
version: number;
|
|
68
|
+
mode: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
72
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A document as the records a lab stores it in, `meta` last — so a write in
|
|
76
|
+
* this order that stops partway leaves no `meta`, and the next open folds the
|
|
77
|
+
* old storage again rather than trusting a partial set. */
|
|
78
|
+
export function recordsOfDocument(doc: LabDocument): [string, unknown][] {
|
|
79
|
+
return [
|
|
80
|
+
...doc.trials.map((t, order): [string, unknown] => [trialRecord(t.id), { ...t, order }]),
|
|
81
|
+
...doc.saves.map((s): [string, unknown] => [saveRecord(s.id), s]),
|
|
82
|
+
[LAYOUT_RECORD, doc.layout],
|
|
83
|
+
[UNDOCK_RECORD, doc.undockedPanels],
|
|
84
|
+
[META_RECORD, { version: doc.version, mode: doc.mode } satisfies LabMeta],
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Records joined back into an unmigrated document, with each trial's stored
|
|
89
|
+
* order. Null when there is no `meta` record — no lab has been stored here in
|
|
90
|
+
* this form. */
|
|
91
|
+
export function documentOfRecords(
|
|
92
|
+
entries: Iterable<[string, unknown]>,
|
|
93
|
+
): { doc: Record<string, unknown>; orders: Map<string, number> } | null {
|
|
94
|
+
let meta: unknown;
|
|
95
|
+
let layout: unknown;
|
|
96
|
+
let undock: unknown;
|
|
97
|
+
const trials: { trial: SerializedTrial; order: number }[] = [];
|
|
98
|
+
const saves: SavedSnapshot[] = [];
|
|
99
|
+
|
|
100
|
+
for (const [name, value] of entries) {
|
|
101
|
+
const parsed = parseRecordName(name);
|
|
102
|
+
if (!parsed) continue;
|
|
103
|
+
switch (parsed.kind) {
|
|
104
|
+
case 'meta':
|
|
105
|
+
meta = value;
|
|
106
|
+
break;
|
|
107
|
+
case 'layout':
|
|
108
|
+
layout = value;
|
|
109
|
+
break;
|
|
110
|
+
case 'undock':
|
|
111
|
+
undock = value;
|
|
112
|
+
break;
|
|
113
|
+
case 'trial':
|
|
114
|
+
if (isObject(value)) {
|
|
115
|
+
const { order, ...rest } = value;
|
|
116
|
+
trials.push({
|
|
117
|
+
trial: { ...rest, id: parsed.id } as SerializedTrial,
|
|
118
|
+
order: typeof order === 'number' && Number.isFinite(order) ? order : Infinity,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case 'save':
|
|
123
|
+
if (isObject(value)) saves.push({ ...value, id: parsed.id } as SavedSnapshot);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!isObject(meta)) return null;
|
|
128
|
+
|
|
129
|
+
trials.sort(compareTrialOrder);
|
|
130
|
+
const orders = new Map<string, number>();
|
|
131
|
+
let next = 0;
|
|
132
|
+
for (const { trial, order } of trials) {
|
|
133
|
+
const at = Number.isFinite(order) ? order : next;
|
|
134
|
+
orders.set(trial.id, at);
|
|
135
|
+
next = Math.max(next, at + 1);
|
|
136
|
+
}
|
|
137
|
+
saves.sort((a, b) => a.savedAt - b.savedAt);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
doc: {
|
|
141
|
+
version: meta.version,
|
|
142
|
+
mode: meta.mode,
|
|
143
|
+
trials: trials.map((t) => t.trial),
|
|
144
|
+
saves,
|
|
145
|
+
layout,
|
|
146
|
+
undockedPanels: undock,
|
|
147
|
+
},
|
|
148
|
+
orders,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Stored order first; two trials added at once in two tabs can share an
|
|
153
|
+
* order, so the id breaks the tie the same way everywhere. */
|
|
154
|
+
export function compareTrialOrder(
|
|
155
|
+
a: { trial: { id: string }; order: number },
|
|
156
|
+
b: { trial: { id: string }; order: number },
|
|
157
|
+
): number {
|
|
158
|
+
if (a.order !== b.order) return a.order < b.order ? -1 : 1;
|
|
159
|
+
return a.trial.id < b.trial.id ? -1 : a.trial.id > b.trial.id ? 1 : 0;
|
|
160
|
+
}
|