@weasel-js/labkit 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/_dts/{CanvasStackContext-kTEZvEgE.d.ts → CanvasStackContext-CqkxkFJW.d.ts} +1 -1
- package/dist/_dts/ToggleBar.d-DbHoWKIA.d.ts +116 -0
- package/dist/_dts/{frac-z7ker2Vx.d.ts → frac-8R6UmlvN.d.ts} +253 -31
- package/dist/_dts/index-B8uu2ba7.d.ts +237 -0
- package/dist/_dts/{index-Blv9uQzu.d.ts → index-retnAut7.d.ts} +18 -10
- package/dist/_dts/{types-B9_zrHmb.d.ts → types-Ca2LCnPq.d.ts} +65 -2
- package/dist/_dts/usePanZoom-nS798rOF.d.ts +160 -0
- package/dist/_dts/useTrialState-jcx_TdU6.d.ts +212 -0
- package/dist/canvas/index.d.ts +17 -128
- package/dist/canvas/index.js +4 -2
- package/dist/chrome/index.d.ts +4 -92
- package/dist/chrome/index.js +9 -5
- package/dist/chunk-67SJLMC7.js +704 -0
- package/dist/chunk-67SJLMC7.js.map +1 -0
- package/dist/chunk-6ZDGOZQV.js +310 -0
- package/dist/chunk-6ZDGOZQV.js.map +1 -0
- package/dist/{chunk-MOM3GOVY.js → chunk-EKIICY6X.js} +111 -25
- package/dist/chunk-EKIICY6X.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-SMHP6XZ4.js → chunk-KJALCDWE.js} +9 -7
- package/dist/chunk-KJALCDWE.js.map +1 -0
- package/dist/{chunk-BKFVHKJH.js → chunk-MUKOW3TC.js} +3 -3
- package/dist/{chunk-BKFVHKJH.js.map → chunk-MUKOW3TC.js.map} +1 -1
- package/dist/chunk-NDRYLVOW.js +15 -0
- package/dist/chunk-NDRYLVOW.js.map +1 -0
- package/dist/{chunk-64ZCN3DA.js → chunk-NS64DXMN.js} +226 -159
- package/dist/chunk-NS64DXMN.js.map +1 -0
- package/dist/{chunk-I6JCVE24.js → chunk-PGETSEDK.js} +135 -13
- package/dist/chunk-PGETSEDK.js.map +1 -0
- package/dist/{chunk-FQMJUVHJ.js → chunk-QLSV2N3G.js} +32 -37
- package/dist/chunk-QLSV2N3G.js.map +1 -0
- package/dist/chunk-RWNDDKOH.js +682 -0
- package/dist/chunk-RWNDDKOH.js.map +1 -0
- package/dist/chunk-S2SHZRD7.js +7681 -0
- package/dist/chunk-S2SHZRD7.js.map +1 -0
- package/dist/{chunk-U3IYHIAE.js → chunk-TQBBYTAF.js} +19 -82
- package/dist/chunk-TQBBYTAF.js.map +1 -0
- package/dist/chunk-UU3NJO6N.js +72 -0
- package/dist/chunk-UU3NJO6N.js.map +1 -0
- package/dist/chunk-WIG6XHJ7.js +35 -0
- package/dist/chunk-WIG6XHJ7.js.map +1 -0
- package/dist/{chunk-TJ7QY3OC.js → chunk-ZPH5WTWF.js} +21 -15
- package/dist/chunk-ZPH5WTWF.js.map +1 -0
- package/dist/config/index.d.ts +232 -0
- package/dist/config/index.js +6 -0
- package/dist/config/index.js.map +1 -0
- package/dist/controls/index.d.ts +2 -3
- package/dist/controls/index.js +3 -2
- package/dist/dragdrop/index.d.ts +2 -4
- package/dist/index.d.ts +110 -381
- package/dist/index.js +691 -817
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +3 -5
- package/dist/layers/index.js +2 -2
- package/dist/loupe/index.d.ts +6 -8
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-ui.d.ts +164 -122
- package/dist/passthrough/weasel-ui.js +1 -1
- package/dist/primitives/index.d.ts +53 -6
- package/dist/primitives/index.js +6 -4
- package/dist/state/index.d.ts +12 -7
- package/dist/state/index.js +16 -13
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +84 -50
- 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 +2 -4
- package/package.json +15 -8
- package/src/annotations/AnnotationOverlay.tsx +53 -5
- package/src/annotations/Annotations.overlay.test.tsx +85 -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/toolMap.ts +3 -3
- package/src/annotations/types.ts +6 -3
- package/src/canvas/CameraWheelContext.ts +12 -0
- 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.ts +10 -5
- package/src/chrome/ChromeRegions.stories.tsx +0 -3
- package/src/chrome/LabChrome.tsx +114 -0
- package/src/chrome/builtins.tsx +20 -31
- package/src/chrome/index.ts +15 -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 +21 -9
- package/src/chrome/regions/SidebarRegion.tsx +25 -13
- package/src/chrome/regions/StatusRegion.tsx +15 -8
- package/src/chrome/regions/ToolbarRegion.tsx +30 -16
- package/src/chrome/types.ts +47 -20
- package/src/config/builder.test.ts +4 -0
- package/src/config/builder.ts +16 -10
- 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 +13 -1
- package/src/config/nodeClasses.test.ts +18 -0
- package/src/config/types.ts +3 -1
- package/src/controls/ControlPanel.test.tsx +16 -0
- package/src/controls/ControlPanel.tsx +5 -2
- package/src/fake-indexeddb-auto.d.ts +5 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +38 -5
- package/src/instrument/serializers.test.ts +22 -0
- package/src/instrument/serializers.ts +11 -0
- package/src/instrument/types.ts +25 -1
- package/src/lab/Lab.less +4 -13
- package/src/lab/Lab.persist.test.tsx +147 -0
- package/src/lab/Lab.stories.tsx +0 -2
- package/src/lab/Lab.surface.test.tsx +66 -3
- package/src/lab/Lab.test.tsx +91 -17
- package/src/lab/Lab.tsx +337 -124
- package/src/lab/LabFit.stories.less +18 -0
- package/src/lab/LabFit.stories.tsx +219 -0
- package/src/lab/LabFullChrome.stories.tsx +0 -3
- package/src/lab/LabHeader.test.tsx +40 -9
- package/src/lab/LabHeader.tsx +7 -13
- package/src/lab/LabPalette.tsx +13 -26
- package/src/lab/LabShell.less +49 -12
- package/src/lab/LabSwitcher.less +8 -1
- package/src/lab/Workspace.surface.test.tsx +2 -1
- package/src/lab/fitCheck.test.ts +57 -0
- package/src/lab/fitCheck.ts +125 -0
- package/src/loupe/AGENTS.md +1 -1
- package/src/loupe/Loupe.less +1 -1
- package/src/loupe/canvasLens.ts +1 -1
- package/src/loupe/types.ts +1 -1
- package/src/loupe/useLoupe.test.tsx +1 -1
- package/src/loupe/useLoupe.ts +1 -1
- package/src/passthrough/weasel-ui.ts +9 -0
- package/src/primitives/FloatingPanel.stories.tsx +13 -1
- package/src/primitives/FloatingPanel.test.tsx +47 -16
- package/src/primitives/FloatingPanel.tsx +11 -25
- package/src/primitives/Split.less +14 -0
- package/src/primitives/Split.test.tsx +108 -0
- package/src/primitives/Split.tsx +159 -0
- package/src/primitives/ZoomControl.tsx +5 -2
- package/src/primitives/index.ts +2 -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 +104 -635
- package/src/state/store.ts +116 -157
- package/src/state/toolSlot.test.ts +1 -1
- package/src/state/types.ts +39 -15
- 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 +2 -3
- package/src/state/view.test.ts +49 -15
- package/src/state/view.ts +18 -1
- package/src/styles.less +2 -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 +2 -1
- package/src/surface/useSurfaceTile.ts +6 -5
- package/src/surface/useTiledSurface.test.tsx +175 -0
- package/src/surface/useTiledSurface.ts +54 -5
- package/src/theme/Interstellar.stories.tsx +7 -7
- package/src/theme/interstellar.test.ts +1 -1
- package/src/theme/interstellar.tokens.json +2 -2
- package/src/tools/labTool.ts +11 -0
- package/src/trial/Trial.annotations.persist.test.tsx +100 -58
- package/src/trial/Trial.annotations.test.tsx +47 -4
- package/src/trial/Trial.job.test.tsx +3 -3
- package/src/trial/Trial.less +1 -26
- package/src/trial/Trial.stories.tsx +1 -2
- package/src/trial/Trial.test.tsx +28 -4
- package/src/trial/Trial.trialId.test.tsx +23 -1
- package/src/trial/Trial.tsx +118 -38
- package/src/trial/TrialBody.tsx +17 -141
- package/src/trial/TrialChrome.tsx +7 -4
- package/src/trial/trialOps.ts +5 -3
- 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-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.map +0 -1
- package/dist/chunk-W3ECWC2K.js +0 -7335
- package/dist/chunk-W3ECWC2K.js.map +0 -1
|
@@ -1,94 +1,146 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'fake-indexeddb/auto';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { describeAdapterContract } from './adapterContract';
|
|
2
4
|
import {
|
|
5
|
+
createIndexedDbAdapter,
|
|
3
6
|
createMemoryAdapter,
|
|
7
|
+
defaultStorage,
|
|
8
|
+
indexedDbAdapter,
|
|
4
9
|
localStorageAdapter,
|
|
5
10
|
noneAdapter,
|
|
11
|
+
resetDefaultStorage,
|
|
6
12
|
sessionStorageAdapter,
|
|
7
13
|
urlHashAdapter,
|
|
8
14
|
} from './adapters';
|
|
15
|
+
import { decodeUrlHash, encodeUrlHash } from './helpers';
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
describeAdapterContract('createMemoryAdapter', () => {
|
|
18
|
+
const backing = new Map<string, unknown>();
|
|
19
|
+
const peer = createMemoryAdapter(backing);
|
|
20
|
+
return { adapter: createMemoryAdapter(backing), binary: true, foreign: peer };
|
|
21
|
+
});
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
let database = 0;
|
|
24
|
+
describeAdapterContract('createIndexedDbAdapter', () => {
|
|
25
|
+
const name = `contract-${database++}`;
|
|
26
|
+
return {
|
|
27
|
+
adapter: createIndexedDbAdapter({ database: name }),
|
|
28
|
+
binary: true,
|
|
29
|
+
foreign: createIndexedDbAdapter({ database: name }),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
17
32
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
33
|
+
/** jsdom has one window, so no `storage` event ever arrives from another tab.
|
|
34
|
+
* This writes the key and dispatches the event another tab's write would —
|
|
35
|
+
* a stand-in for the browser, not a test of it. */
|
|
36
|
+
function foreignWebStorage(area: Storage) {
|
|
37
|
+
const fire = (key: string, newValue: string | null): void => {
|
|
38
|
+
window.dispatchEvent(new StorageEvent('storage', { key, newValue, storageArea: area }));
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
set: (key: string, value: unknown) => {
|
|
42
|
+
const raw = JSON.stringify(value);
|
|
43
|
+
area.setItem(key, raw);
|
|
44
|
+
fire(key, raw);
|
|
45
|
+
},
|
|
46
|
+
delete: (key: string) => {
|
|
47
|
+
area.removeItem(key);
|
|
48
|
+
fire(key, null);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
21
52
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
53
|
+
describeAdapterContract('localStorageAdapter', () => {
|
|
54
|
+
localStorage.clear();
|
|
55
|
+
return { adapter: localStorageAdapter, binary: false, foreign: foreignWebStorage(localStorage) };
|
|
56
|
+
});
|
|
27
57
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
33
|
-
expect(() => localStorageAdapter.write('k', 'v')).not.toThrow();
|
|
34
|
-
expect(warn).toHaveBeenCalled();
|
|
35
|
-
warn.mockRestore();
|
|
36
|
-
});
|
|
58
|
+
describeAdapterContract('sessionStorageAdapter', () => {
|
|
59
|
+
sessionStorage.clear();
|
|
60
|
+
return { adapter: sessionStorageAdapter, binary: false };
|
|
37
61
|
});
|
|
38
62
|
|
|
39
|
-
|
|
40
|
-
|
|
63
|
+
function foreignHash() {
|
|
64
|
+
const edit = (change: (map: Record<string, string>) => void): void => {
|
|
65
|
+
const raw = decodeUrlHash(window.location.hash.replace(/^#/, ''));
|
|
66
|
+
const map = raw ? (JSON.parse(raw) as Record<string, string>) : {};
|
|
67
|
+
change(map);
|
|
68
|
+
window.location.hash = encodeUrlHash(JSON.stringify(map));
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
set: (key: string, value: unknown) =>
|
|
72
|
+
edit((map) => {
|
|
73
|
+
map[key] = JSON.stringify(value);
|
|
74
|
+
}),
|
|
75
|
+
delete: (key: string) =>
|
|
76
|
+
edit((map) => {
|
|
77
|
+
delete map[key];
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
41
81
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
82
|
+
describeAdapterContract('urlHashAdapter', () => {
|
|
83
|
+
window.history.replaceState(null, '', '#');
|
|
84
|
+
return { adapter: urlHashAdapter, binary: false, foreign: foreignHash() };
|
|
46
85
|
});
|
|
47
86
|
|
|
48
|
-
describe('
|
|
49
|
-
beforeEach(() =>
|
|
50
|
-
window.location.hash = '';
|
|
51
|
-
});
|
|
87
|
+
describe('localStorageAdapter', () => {
|
|
88
|
+
beforeEach(() => localStorage.clear());
|
|
52
89
|
|
|
53
|
-
it('
|
|
54
|
-
|
|
55
|
-
|
|
90
|
+
it('reads a value stored before records existed, raw or JSON', async () => {
|
|
91
|
+
localStorage.setItem('lk:old:theme', 'dark');
|
|
92
|
+
localStorage.setItem('lk:old:doc', '{"version":3}');
|
|
93
|
+
expect(await localStorageAdapter.get('lk:old:theme')).toBe('dark');
|
|
94
|
+
expect(await localStorageAdapter.get('lk:old:doc')).toEqual({ version: 3 });
|
|
56
95
|
});
|
|
57
96
|
|
|
58
|
-
it('
|
|
59
|
-
|
|
97
|
+
it('rejects a write that does not land', async () => {
|
|
98
|
+
vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => {
|
|
99
|
+
throw new DOMException('QuotaExceeded', 'QuotaExceededError');
|
|
100
|
+
});
|
|
101
|
+
await expect(localStorageAdapter.set('k', 'v')).rejects.toThrow('QuotaExceeded');
|
|
60
102
|
});
|
|
61
103
|
|
|
62
|
-
it('
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
104
|
+
it('warns once per key when JSON would change the value, and stores what JSON keeps', async () => {
|
|
105
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
106
|
+
await localStorageAdapter.set('lk:w:a', { at: new Set([1]) });
|
|
107
|
+
await localStorageAdapter.set('lk:w:a', { at: new Set([2]) });
|
|
108
|
+
await localStorageAdapter.set('lk:w:b', { fine: [1, 'x', null], skipped: undefined });
|
|
109
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(await localStorageAdapter.get('lk:w:a')).toEqual({ at: {} });
|
|
111
|
+
warn.mockRestore();
|
|
67
112
|
});
|
|
68
113
|
});
|
|
69
114
|
|
|
70
|
-
describe('
|
|
71
|
-
it('
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(b.read('k')).toBeNull();
|
|
115
|
+
describe('noneAdapter', () => {
|
|
116
|
+
it('stores nothing', async () => {
|
|
117
|
+
await noneAdapter.set('k', 'v');
|
|
118
|
+
expect(await noneAdapter.get('k')).toBeUndefined();
|
|
119
|
+
expect(await noneAdapter.list('')).toEqual([]);
|
|
76
120
|
});
|
|
121
|
+
});
|
|
77
122
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
expect(m.read('k')).toBeNull();
|
|
123
|
+
describe('defaultStorage', () => {
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
resetDefaultStorage();
|
|
126
|
+
vi.unstubAllGlobals();
|
|
83
127
|
});
|
|
84
|
-
});
|
|
85
128
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
expect(noneAdapter.read('anything')).toBeNull();
|
|
129
|
+
it('is IndexedDB where it opens', async () => {
|
|
130
|
+
expect(await defaultStorage()).toBe(indexedDbAdapter);
|
|
89
131
|
});
|
|
90
132
|
|
|
91
|
-
it('
|
|
92
|
-
|
|
133
|
+
it('falls back to localStorage, with a warning, where it will not', async () => {
|
|
134
|
+
vi.stubGlobal('indexedDB', undefined);
|
|
135
|
+
// The shared adapter may already hold an open database from the case above.
|
|
136
|
+
vi.resetModules();
|
|
137
|
+
const fresh = await import('./adapters');
|
|
138
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
139
|
+
expect(await fresh.defaultStorage()).toBe(fresh.localStorageAdapter);
|
|
140
|
+
expect(warn).toHaveBeenCalledWith(
|
|
141
|
+
expect.stringContaining('IndexedDB would not open'),
|
|
142
|
+
expect.anything(),
|
|
143
|
+
);
|
|
144
|
+
warn.mockRestore();
|
|
93
145
|
});
|
|
94
146
|
});
|
package/src/state/adapters.ts
CHANGED
|
@@ -1,56 +1,104 @@
|
|
|
1
1
|
import { decodeUrlHash, encodeUrlHash } from './helpers';
|
|
2
|
-
import type { StorageAdapter } from './types';
|
|
3
|
-
|
|
4
|
-
/** Persist to `localStorage` — state survives a reload and a new tab. Falls
|
|
5
|
-
* back to no-ops when storage is unavailable. */
|
|
6
|
-
export const localStorageAdapter: StorageAdapter = {
|
|
7
|
-
read: (key) => {
|
|
8
|
-
try {
|
|
9
|
-
return localStorage.getItem(key);
|
|
10
|
-
} catch {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
},
|
|
14
|
-
write: (key, value) => {
|
|
15
|
-
try {
|
|
16
|
-
localStorage.setItem(key, value);
|
|
17
|
-
} catch (e) {
|
|
18
|
-
console.warn('[labkit] localStorage write failed:', e);
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
delete: (key) => {
|
|
22
|
-
try {
|
|
23
|
-
localStorage.removeItem(key);
|
|
24
|
-
} catch {
|
|
25
|
-
/* ignore */
|
|
26
|
-
}
|
|
27
|
-
},
|
|
28
|
-
};
|
|
2
|
+
import type { StorageAdapter, StorageChange } from './types';
|
|
29
3
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
4
|
+
/** JSON-backed substrates held raw strings before records existed — a theme
|
|
5
|
+
* bucket stored `dark`, not `"dark"` — so an unparseable value comes back as
|
|
6
|
+
* the string it is. */
|
|
7
|
+
function parse(raw: string): unknown {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
} catch {
|
|
11
|
+
return raw;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether `JSON.stringify` would keep `value` exactly. An object key holding
|
|
16
|
+
* `undefined` counts as kept: JSON drops the key, and reading back an absent
|
|
17
|
+
* key yields the same `undefined`. */
|
|
18
|
+
function isJsonSafe(value: unknown, seen: Set<object> = new Set()): boolean {
|
|
19
|
+
if (value === null) return true;
|
|
20
|
+
switch (typeof value) {
|
|
21
|
+
case 'string':
|
|
22
|
+
case 'boolean':
|
|
23
|
+
return true;
|
|
24
|
+
case 'number':
|
|
25
|
+
return Number.isFinite(value);
|
|
26
|
+
case 'object': {
|
|
27
|
+
if (seen.has(value)) return false;
|
|
28
|
+
seen.add(value);
|
|
29
|
+
const proto = Object.getPrototypeOf(value);
|
|
30
|
+
const ok = Array.isArray(value)
|
|
31
|
+
? value.every((v) => isJsonSafe(v, seen))
|
|
32
|
+
: (proto === Object.prototype || proto === null) &&
|
|
33
|
+
Object.values(value).every((v) => v === undefined || isJsonSafe(v, seen));
|
|
34
|
+
seen.delete(value);
|
|
35
|
+
return ok;
|
|
51
36
|
}
|
|
52
|
-
|
|
53
|
-
|
|
37
|
+
default:
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function webStorageAdapter(area: () => Storage, name: string, crossTab: boolean): StorageAdapter {
|
|
43
|
+
const warned = new Set<string>();
|
|
44
|
+
const adapter: StorageAdapter = {
|
|
45
|
+
get: async (key) => {
|
|
46
|
+
const raw = area().getItem(key);
|
|
47
|
+
return raw === null ? undefined : parse(raw);
|
|
48
|
+
},
|
|
49
|
+
list: async (prefix) => {
|
|
50
|
+
const store = area();
|
|
51
|
+
const out: [string, unknown][] = [];
|
|
52
|
+
for (let i = 0; i < store.length; i++) {
|
|
53
|
+
const key = store.key(i);
|
|
54
|
+
if (key === null || !key.startsWith(prefix)) continue;
|
|
55
|
+
const raw = store.getItem(key);
|
|
56
|
+
if (raw !== null) out.push([key, parse(raw)]);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
},
|
|
60
|
+
set: async (key, value) => {
|
|
61
|
+
if (!warned.has(key) && !isJsonSafe(value)) {
|
|
62
|
+
warned.add(key);
|
|
63
|
+
console.warn(
|
|
64
|
+
`[labkit] "${key}" holds a value JSON cannot keep exactly; ${name} stores what JSON keeps. Use IndexedDB for binary or non-JSON state.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
area().setItem(key, JSON.stringify(value));
|
|
68
|
+
},
|
|
69
|
+
delete: async (key) => {
|
|
70
|
+
area().removeItem(key);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
if (crossTab) {
|
|
74
|
+
adapter.subscribe = (prefix, on) => {
|
|
75
|
+
if (typeof window === 'undefined') return () => {};
|
|
76
|
+
const handler = (e: StorageEvent): void => {
|
|
77
|
+
if (e.key === null || !e.key.startsWith(prefix)) return;
|
|
78
|
+
if (e.storageArea !== area()) return;
|
|
79
|
+
on([[e.key, e.newValue === null ? undefined : parse(e.newValue)]]);
|
|
80
|
+
};
|
|
81
|
+
window.addEventListener('storage', handler);
|
|
82
|
+
return () => window.removeEventListener('storage', handler);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return adapter;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Persist to `localStorage`, one JSON value per record. Survives a reload and
|
|
89
|
+
* reaches other tabs through the `storage` event. */
|
|
90
|
+
export const localStorageAdapter: StorageAdapter = webStorageAdapter(
|
|
91
|
+
() => localStorage,
|
|
92
|
+
'localStorage',
|
|
93
|
+
true,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** Persist to `sessionStorage` — survives a reload but not a new tab. */
|
|
97
|
+
export const sessionStorageAdapter: StorageAdapter = webStorageAdapter(
|
|
98
|
+
() => sessionStorage,
|
|
99
|
+
'sessionStorage',
|
|
100
|
+
false,
|
|
101
|
+
);
|
|
54
102
|
|
|
55
103
|
const URL_HASH_GUARD = typeof window !== 'undefined';
|
|
56
104
|
|
|
@@ -65,46 +113,257 @@ function readHashMap(): Record<string, string> {
|
|
|
65
113
|
}
|
|
66
114
|
}
|
|
67
115
|
|
|
116
|
+
/** What each live subscription last saw, refreshed on this page's own writes
|
|
117
|
+
* so `hashchange` reports only somebody else's. */
|
|
118
|
+
const hashSubscribers = new Set<{ last: Record<string, string> }>();
|
|
119
|
+
|
|
68
120
|
function writeHashMap(map: Record<string, string>): void {
|
|
69
121
|
if (!URL_HASH_GUARD) return;
|
|
70
122
|
const encoded = encodeUrlHash(JSON.stringify(map));
|
|
71
123
|
window.history.replaceState(null, '', `#${encoded}`);
|
|
124
|
+
for (const sub of hashSubscribers) sub.last = { ...map };
|
|
72
125
|
}
|
|
73
126
|
|
|
74
|
-
/** Persist into the URL fragment, so the page's link carries its state and
|
|
75
|
-
*
|
|
127
|
+
/** Persist into the URL fragment, so the page's link carries its state and can
|
|
128
|
+
* be shared or bookmarked. Changes to the fragment made elsewhere — a pasted
|
|
129
|
+
* link, back and forward — arrive through `hashchange`. */
|
|
76
130
|
export const urlHashAdapter: StorageAdapter = {
|
|
77
|
-
|
|
78
|
-
|
|
131
|
+
get: async (key) => {
|
|
132
|
+
const raw = readHashMap()[key];
|
|
133
|
+
return raw === undefined ? undefined : parse(raw);
|
|
134
|
+
},
|
|
135
|
+
list: async (prefix) =>
|
|
136
|
+
Object.entries(readHashMap())
|
|
137
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
138
|
+
.map(([key, raw]): [string, unknown] => [key, parse(raw)]),
|
|
139
|
+
set: async (key, value) => {
|
|
79
140
|
const map = readHashMap();
|
|
80
|
-
map[key] = value;
|
|
141
|
+
map[key] = JSON.stringify(value);
|
|
81
142
|
writeHashMap(map);
|
|
82
143
|
},
|
|
83
|
-
delete: (key) => {
|
|
144
|
+
delete: async (key) => {
|
|
84
145
|
const map = readHashMap();
|
|
85
146
|
delete map[key];
|
|
86
147
|
writeHashMap(map);
|
|
87
148
|
},
|
|
149
|
+
subscribe: (prefix, on) => {
|
|
150
|
+
if (!URL_HASH_GUARD) return () => {};
|
|
151
|
+
const sub = { last: readHashMap() };
|
|
152
|
+
hashSubscribers.add(sub);
|
|
153
|
+
const handler = (): void => {
|
|
154
|
+
const next = readHashMap();
|
|
155
|
+
const changes: StorageChange[] = [];
|
|
156
|
+
for (const key of new Set([...Object.keys(sub.last), ...Object.keys(next)])) {
|
|
157
|
+
if (!key.startsWith(prefix) || sub.last[key] === next[key]) continue;
|
|
158
|
+
const raw = next[key];
|
|
159
|
+
changes.push([key, raw === undefined ? undefined : parse(raw)]);
|
|
160
|
+
}
|
|
161
|
+
sub.last = next;
|
|
162
|
+
if (changes.length > 0) on(changes);
|
|
163
|
+
};
|
|
164
|
+
window.addEventListener('hashchange', handler);
|
|
165
|
+
return () => {
|
|
166
|
+
hashSubscribers.delete(sub);
|
|
167
|
+
window.removeEventListener('hashchange', handler);
|
|
168
|
+
};
|
|
169
|
+
},
|
|
88
170
|
};
|
|
89
171
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
172
|
+
interface MemorySubscriber {
|
|
173
|
+
owner: StorageAdapter;
|
|
174
|
+
prefix: string;
|
|
175
|
+
on: (changes: StorageChange[]) => void;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const memorySubscribers = new WeakMap<Map<string, unknown>, Set<MemorySubscriber>>();
|
|
179
|
+
|
|
180
|
+
/** An in-memory store, discarded on reload. Values are copied in and out, as a
|
|
181
|
+
* real substrate would. Adapters built over one shared `backing` hear each
|
|
182
|
+
* other's writes — two tabs, for a test. */
|
|
183
|
+
export function createMemoryAdapter(backing: Map<string, unknown> = new Map()): StorageAdapter {
|
|
184
|
+
let subscribers = memorySubscribers.get(backing);
|
|
185
|
+
if (!subscribers) {
|
|
186
|
+
subscribers = new Set();
|
|
187
|
+
memorySubscribers.set(backing, subscribers);
|
|
188
|
+
}
|
|
189
|
+
const peers = subscribers;
|
|
190
|
+
const notify = (key: string, value: unknown): void => {
|
|
191
|
+
for (const sub of peers) {
|
|
192
|
+
if (sub.owner === adapter || !key.startsWith(sub.prefix)) continue;
|
|
193
|
+
const copy = value === undefined ? undefined : structuredClone(value);
|
|
194
|
+
queueMicrotask(() => sub.on([[key, copy]]));
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const adapter: StorageAdapter = {
|
|
198
|
+
get: async (key) => (backing.has(key) ? structuredClone(backing.get(key)) : undefined),
|
|
199
|
+
list: async (prefix) =>
|
|
200
|
+
[...backing]
|
|
201
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
202
|
+
.map(([key, value]): [string, unknown] => [key, structuredClone(value)]),
|
|
203
|
+
set: async (key, value) => {
|
|
204
|
+
const copy = structuredClone(value);
|
|
205
|
+
backing.set(key, copy);
|
|
206
|
+
notify(key, copy);
|
|
207
|
+
},
|
|
208
|
+
delete: async (key) => {
|
|
209
|
+
backing.delete(key);
|
|
210
|
+
notify(key, undefined);
|
|
98
211
|
},
|
|
99
|
-
|
|
100
|
-
|
|
212
|
+
subscribe: (prefix, on) => {
|
|
213
|
+
const sub = { owner: adapter, prefix, on };
|
|
214
|
+
peers.add(sub);
|
|
215
|
+
return () => {
|
|
216
|
+
peers.delete(sub);
|
|
217
|
+
};
|
|
101
218
|
},
|
|
102
219
|
};
|
|
220
|
+
return adapter;
|
|
103
221
|
}
|
|
104
222
|
|
|
105
223
|
/** Persists nothing and reads back nothing. */
|
|
106
224
|
export const noneAdapter: StorageAdapter = {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
225
|
+
get: async () => undefined,
|
|
226
|
+
list: async () => [],
|
|
227
|
+
set: async () => {},
|
|
228
|
+
delete: async () => {},
|
|
110
229
|
};
|
|
230
|
+
|
|
231
|
+
/** Options for `createIndexedDbAdapter`. */
|
|
232
|
+
export interface IndexedDbAdapterOptions {
|
|
233
|
+
/** Default `'labkit'`. */
|
|
234
|
+
database?: string;
|
|
235
|
+
/** Default `'records'`. One object store per database: a store added to a
|
|
236
|
+
* database that already exists is not created. */
|
|
237
|
+
store?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
interface NodeChannel extends BroadcastChannel {
|
|
241
|
+
unref?: () => void;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Persist to IndexedDB, which stores structured-clone values natively — typed
|
|
245
|
+
* arrays, `Map`s, `Blob`s — and holds far more than localStorage. Other tabs
|
|
246
|
+
* hear writes through a `BroadcastChannel`. The database opens on first use. */
|
|
247
|
+
export function createIndexedDbAdapter({
|
|
248
|
+
database = 'labkit',
|
|
249
|
+
store = 'records',
|
|
250
|
+
}: IndexedDbAdapterOptions = {}): StorageAdapter {
|
|
251
|
+
let opened: Promise<IDBDatabase> | null = null;
|
|
252
|
+
const open = (): Promise<IDBDatabase> => {
|
|
253
|
+
opened ??= new Promise((resolve, reject) => {
|
|
254
|
+
if (typeof indexedDB === 'undefined') {
|
|
255
|
+
reject(new Error('[labkit] IndexedDB is not available here'));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const request = indexedDB.open(database, 1);
|
|
259
|
+
request.onupgradeneeded = () => {
|
|
260
|
+
if (!request.result.objectStoreNames.contains(store)) {
|
|
261
|
+
request.result.createObjectStore(store);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
request.onsuccess = () => {
|
|
265
|
+
const db = request.result;
|
|
266
|
+
if (!db.objectStoreNames.contains(store)) {
|
|
267
|
+
reject(new Error(`[labkit] IndexedDB database "${database}" has no store "${store}"`));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
resolve(db);
|
|
271
|
+
};
|
|
272
|
+
request.onerror = () => reject(request.error);
|
|
273
|
+
request.onblocked = () => reject(new Error(`[labkit] IndexedDB "${database}" is blocked`));
|
|
274
|
+
});
|
|
275
|
+
return opened;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const run = <T>(mode: IDBTransactionMode, body: (s: IDBObjectStore) => () => T): Promise<T> =>
|
|
279
|
+
open().then(
|
|
280
|
+
(db) =>
|
|
281
|
+
new Promise<T>((resolve, reject) => {
|
|
282
|
+
const tx = db.transaction(store, mode);
|
|
283
|
+
const result = body(tx.objectStore(store));
|
|
284
|
+
tx.oncomplete = () => resolve(result());
|
|
285
|
+
tx.onerror = () => reject(tx.error);
|
|
286
|
+
tx.onabort = () => reject(tx.error);
|
|
287
|
+
}),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
let channel: NodeChannel | null = null;
|
|
291
|
+
const getChannel = (): NodeChannel | null => {
|
|
292
|
+
if (typeof BroadcastChannel === 'undefined') return null;
|
|
293
|
+
if (!channel) {
|
|
294
|
+
channel = new BroadcastChannel(`labkit:${database}:${store}`) as NodeChannel;
|
|
295
|
+
// Node keeps its event loop alive for an open channel; a browser has no unref.
|
|
296
|
+
channel.unref?.();
|
|
297
|
+
}
|
|
298
|
+
return channel;
|
|
299
|
+
};
|
|
300
|
+
const announce = (changes: StorageChange[]): void => {
|
|
301
|
+
getChannel()?.postMessage(changes);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const adapter: StorageAdapter = {
|
|
305
|
+
get: (key) =>
|
|
306
|
+
run('readonly', (s) => {
|
|
307
|
+
const request = s.get(key);
|
|
308
|
+
return () => request.result as unknown;
|
|
309
|
+
}),
|
|
310
|
+
list: (prefix) =>
|
|
311
|
+
run('readonly', (s) => {
|
|
312
|
+
const range = IDBKeyRange.bound(prefix, `${prefix}`);
|
|
313
|
+
const keys = s.getAllKeys(range);
|
|
314
|
+
const values = s.getAll(range);
|
|
315
|
+
return () =>
|
|
316
|
+
keys.result.map((key, i): [string, unknown] => [String(key), values.result[i]]);
|
|
317
|
+
}),
|
|
318
|
+
set: async (key, value) => {
|
|
319
|
+
await run('readwrite', (s) => {
|
|
320
|
+
s.put(value, key);
|
|
321
|
+
return () => undefined;
|
|
322
|
+
});
|
|
323
|
+
announce([[key, value]]);
|
|
324
|
+
},
|
|
325
|
+
delete: async (key) => {
|
|
326
|
+
await run('readwrite', (s) => {
|
|
327
|
+
s.delete(key);
|
|
328
|
+
return () => undefined;
|
|
329
|
+
});
|
|
330
|
+
announce([[key, undefined]]);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
if (typeof BroadcastChannel !== 'undefined') {
|
|
334
|
+
adapter.subscribe = (prefix, on) => {
|
|
335
|
+
const ch = getChannel();
|
|
336
|
+
if (!ch) return () => {};
|
|
337
|
+
const handler = (e: MessageEvent<StorageChange[]>): void => {
|
|
338
|
+
const mine = e.data.filter(([key]) => key.startsWith(prefix));
|
|
339
|
+
if (mine.length > 0) on(mine);
|
|
340
|
+
};
|
|
341
|
+
ch.addEventListener('message', handler);
|
|
342
|
+
return () => ch.removeEventListener('message', handler);
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return adapter;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** IndexedDB under the default database and store. */
|
|
349
|
+
export const indexedDbAdapter: StorageAdapter = createIndexedDbAdapter();
|
|
350
|
+
|
|
351
|
+
let resolvedDefault: Promise<StorageAdapter> | null = null;
|
|
352
|
+
|
|
353
|
+
/** What a lab given only a `storageKey` persists to: IndexedDB, or
|
|
354
|
+
* localStorage — with a warning — where IndexedDB will not open. */
|
|
355
|
+
export function defaultStorage(): Promise<StorageAdapter> {
|
|
356
|
+
resolvedDefault ??= indexedDbAdapter.list('').then(
|
|
357
|
+
() => indexedDbAdapter,
|
|
358
|
+
(error) => {
|
|
359
|
+
console.warn('[labkit] IndexedDB would not open; persisting to localStorage instead', error);
|
|
360
|
+
return localStorageAdapter;
|
|
361
|
+
},
|
|
362
|
+
);
|
|
363
|
+
return resolvedDefault;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Forget which default was chosen. Tests only. */
|
|
367
|
+
export function resetDefaultStorage(): void {
|
|
368
|
+
resolvedDefault = null;
|
|
369
|
+
}
|