@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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createMemoryAdapter } from './adapters';
|
|
4
|
+
import { labPrefix } from './labRecords';
|
|
5
|
+
import { Persistence } from './Persistence';
|
|
6
|
+
import { SingletonExperimentProvider } from './SingletonExperiment';
|
|
7
|
+
import { type PersistedStateOptions, usePersistedState } from './usePersistedState';
|
|
8
|
+
|
|
9
|
+
function Tab({
|
|
10
|
+
name = 'tab',
|
|
11
|
+
label = 'tab',
|
|
12
|
+
options,
|
|
13
|
+
}: {
|
|
14
|
+
name?: string | null;
|
|
15
|
+
label?: string;
|
|
16
|
+
options?: PersistedStateOptions;
|
|
17
|
+
}) {
|
|
18
|
+
const [value, setValue] = usePersistedState(name, 'shape', options);
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<output aria-label={label}>{value}</output>
|
|
22
|
+
<button type="button" onClick={() => setValue('color')}>
|
|
23
|
+
{`set ${label}`}
|
|
24
|
+
</button>
|
|
25
|
+
<button type="button" onClick={() => setValue((prev) => `${prev}+`)}>
|
|
26
|
+
{`append ${label}`}
|
|
27
|
+
</button>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const P = labPrefix('k');
|
|
33
|
+
const shown = (label = 'tab') => screen.getByLabelText(label).textContent;
|
|
34
|
+
|
|
35
|
+
function persisted(backing: Map<string, unknown>, children: React.ReactNode) {
|
|
36
|
+
return (
|
|
37
|
+
<Persistence storageKey="k" storage={createMemoryAdapter(backing)} fallback={<p>loading</p>}>
|
|
38
|
+
{children}
|
|
39
|
+
</Persistence>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('usePersistedState', () => {
|
|
44
|
+
it('is plain useState with no provider above it', () => {
|
|
45
|
+
render(<Tab />);
|
|
46
|
+
act(() => screen.getByText('set tab').click());
|
|
47
|
+
expect(shown()).toBe('color');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is plain useState, writing nothing, for a null name', async () => {
|
|
51
|
+
const backing = new Map<string, unknown>();
|
|
52
|
+
const view = render(persisted(backing, <Tab name={null} />));
|
|
53
|
+
await screen.findByLabelText('tab');
|
|
54
|
+
act(() => screen.getByText('set tab').click());
|
|
55
|
+
expect(shown()).toBe('color');
|
|
56
|
+
view.unmount();
|
|
57
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
58
|
+
expect(backing.size).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps its value across a remount, lab-scoped outside a trial', async () => {
|
|
62
|
+
const backing = new Map<string, unknown>();
|
|
63
|
+
const first = render(persisted(backing, <Tab />));
|
|
64
|
+
expect(screen.getByText('loading')).toBeInTheDocument();
|
|
65
|
+
await screen.findByLabelText('tab');
|
|
66
|
+
expect(shown()).toBe('shape');
|
|
67
|
+
act(() => screen.getByText('set tab').click());
|
|
68
|
+
act(() => screen.getByText('append tab').click());
|
|
69
|
+
expect(shown()).toBe('color+');
|
|
70
|
+
first.unmount();
|
|
71
|
+
await waitFor(() => expect(backing.get(`${P}value:lab:tab`)).toBe('color+'));
|
|
72
|
+
|
|
73
|
+
render(persisted(backing, <Tab />));
|
|
74
|
+
await screen.findByLabelText('tab');
|
|
75
|
+
expect(shown()).toBe('color+');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('scopes to the trial it is in, unless asked for the lab', async () => {
|
|
79
|
+
const backing = new Map<string, unknown>();
|
|
80
|
+
render(
|
|
81
|
+
<SingletonExperimentProvider
|
|
82
|
+
id="t:1"
|
|
83
|
+
initialConfig={{}}
|
|
84
|
+
initialState={{}}
|
|
85
|
+
storageKey="k"
|
|
86
|
+
storage={createMemoryAdapter(backing)}
|
|
87
|
+
>
|
|
88
|
+
<Tab label="own" />
|
|
89
|
+
<Tab label="shared" name="shared" options={{ scope: 'lab' }} />
|
|
90
|
+
</SingletonExperimentProvider>,
|
|
91
|
+
);
|
|
92
|
+
await screen.findByLabelText('own');
|
|
93
|
+
act(() => screen.getByText('set own').click());
|
|
94
|
+
act(() => screen.getByText('set shared').click());
|
|
95
|
+
await waitFor(() => expect(backing.get(`${P}value:trial:t%3A1:tab`)).toBe('color'));
|
|
96
|
+
await waitFor(() => expect(backing.get(`${P}value:lab:shared`)).toBe('color'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps two components with one name in step', async () => {
|
|
100
|
+
const backing = new Map<string, unknown>();
|
|
101
|
+
render(
|
|
102
|
+
persisted(
|
|
103
|
+
backing,
|
|
104
|
+
<>
|
|
105
|
+
<Tab label="a" />
|
|
106
|
+
<Tab label="b" />
|
|
107
|
+
</>,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
await screen.findByLabelText('a');
|
|
111
|
+
act(() => screen.getByText('set a').click());
|
|
112
|
+
expect(shown('b')).toBe('color');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("re-renders with another tab's write", async () => {
|
|
116
|
+
const backing = new Map<string, unknown>();
|
|
117
|
+
render(persisted(backing, <Tab />));
|
|
118
|
+
await screen.findByLabelText('tab');
|
|
119
|
+
await act(async () => {
|
|
120
|
+
await createMemoryAdapter(backing).set(`${P}value:lab:tab`, 'from elsewhere');
|
|
121
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
122
|
+
});
|
|
123
|
+
expect(shown()).toBe('from elsewhere');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Dispatch,
|
|
3
|
+
type SetStateAction,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useState,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { TrialIdContext } from './context';
|
|
10
|
+
import { valueRecord } from './labRecords';
|
|
11
|
+
import { PersistenceContext } from './Persistence';
|
|
12
|
+
|
|
13
|
+
/** Options for `usePersistedState`. */
|
|
14
|
+
export interface PersistedStateOptions {
|
|
15
|
+
/** `'trial'` — the default inside a trial — keeps a value per trial, and
|
|
16
|
+
* closing the trial deletes it. `'lab'` shares one value across the lab. */
|
|
17
|
+
scope?: 'trial' | 'lab';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `useState` whose value survives a reload: a widget's open tab, a panel's
|
|
22
|
+
* position. Inside a `<Lab>`, `SingletonExperimentProvider` or `<Persistence>`
|
|
23
|
+
* the value is a record, loaded before first render and re-rendered when
|
|
24
|
+
* another tab changes it. With no provider above it, or a null `name`, it is
|
|
25
|
+
* plain `useState` — so a component can offer optional persistence and still
|
|
26
|
+
* mount anywhere.
|
|
27
|
+
*
|
|
28
|
+
* Values must be structured-clone values; the JSON-backed adapters warn about
|
|
29
|
+
* one JSON would change. Snapshots do not include them.
|
|
30
|
+
*/
|
|
31
|
+
export function usePersistedState<T>(
|
|
32
|
+
name: string | null | undefined,
|
|
33
|
+
initial: T | (() => T),
|
|
34
|
+
options: PersistedStateOptions = {},
|
|
35
|
+
): [T, Dispatch<SetStateAction<T>>] {
|
|
36
|
+
const records = useContext(PersistenceContext);
|
|
37
|
+
const trialId = useContext(TrialIdContext);
|
|
38
|
+
const [local, setLocal] = useState(initial);
|
|
39
|
+
|
|
40
|
+
const record =
|
|
41
|
+
records && name != null ? valueRecord(options.scope === 'lab' ? null : trialId, name) : null;
|
|
42
|
+
|
|
43
|
+
const subscribe = useCallback(
|
|
44
|
+
(notify: () => void) =>
|
|
45
|
+
records && record
|
|
46
|
+
? records.subscribe((changes) => {
|
|
47
|
+
if (changes.some((c) => c.name === record)) notify();
|
|
48
|
+
})
|
|
49
|
+
: () => {},
|
|
50
|
+
[records, record],
|
|
51
|
+
);
|
|
52
|
+
const read = (): T =>
|
|
53
|
+
records && record && records.has(record) ? (records.get(record) as T) : local;
|
|
54
|
+
const persisted = useSyncExternalStore(subscribe, read, read);
|
|
55
|
+
|
|
56
|
+
const setPersisted = useCallback<Dispatch<SetStateAction<T>>>(
|
|
57
|
+
(next) => {
|
|
58
|
+
if (!records || !record) return;
|
|
59
|
+
const prev = records.has(record) ? (records.get(record) as T) : local;
|
|
60
|
+
records.set(record, typeof next === 'function' ? (next as (p: T) => T)(prev) : next);
|
|
61
|
+
},
|
|
62
|
+
[records, record, local],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return records && record ? [persisted, setPersisted] : [local, setLocal];
|
|
66
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { act, renderHook } from '@testing-library/react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import {
|
|
4
|
+
import { auto } from '../config/auto';
|
|
5
|
+
import { f } from '../config/builder';
|
|
6
|
+
import { defineInstrument } from '../instrument/defineInstrument';
|
|
7
|
+
import type { Instrument } from '../instrument/types';
|
|
5
8
|
import { LabStoreProvider, TrialIdProvider } from './context';
|
|
6
9
|
import { createLabStore } from './store';
|
|
7
10
|
import { useTrialState } from './useTrialState';
|
|
@@ -10,7 +13,7 @@ type TestState = { count: number };
|
|
|
10
13
|
type TestConfig = { step: number };
|
|
11
14
|
|
|
12
15
|
function makeWrapper(trialId: string) {
|
|
13
|
-
const store = createLabStore(
|
|
16
|
+
const store = createLabStore();
|
|
14
17
|
store.getState().addTrial({
|
|
15
18
|
id: trialId,
|
|
16
19
|
instrumentName: 'Counter',
|
|
@@ -59,7 +62,7 @@ describe('useTrialState', () => {
|
|
|
59
62
|
});
|
|
60
63
|
|
|
61
64
|
it('sibling trial does not re-render on unrelated trial changes', () => {
|
|
62
|
-
const store = createLabStore(
|
|
65
|
+
const store = createLabStore();
|
|
63
66
|
store.getState().addTrial({
|
|
64
67
|
id: 'w1',
|
|
65
68
|
instrumentName: 'T',
|
|
@@ -100,3 +103,84 @@ describe('useTrialState', () => {
|
|
|
100
103
|
expect(() => renderHook(() => useTrialState())).toThrow('[labkit]');
|
|
101
104
|
});
|
|
102
105
|
});
|
|
106
|
+
|
|
107
|
+
const autoInstrument = defineInstrument({
|
|
108
|
+
name: 'Auto',
|
|
109
|
+
config: f.schema({
|
|
110
|
+
width: f.number(432),
|
|
111
|
+
gap: f.number(12).auto((c) => (c.width as number) / 24),
|
|
112
|
+
}),
|
|
113
|
+
initialState: () => ({ count: 0 }),
|
|
114
|
+
render: () => null,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
function makeAutoWrapper(trialId: string) {
|
|
118
|
+
const store = createLabStore({ instruments: [autoInstrument as Instrument] });
|
|
119
|
+
store.getState().addTrial({
|
|
120
|
+
id: trialId,
|
|
121
|
+
instrumentName: 'Auto',
|
|
122
|
+
config: autoInstrument.defaultConfig(),
|
|
123
|
+
state: { count: 0 },
|
|
124
|
+
view: { zoom: 1, pan: { x: 0, y: 0 } },
|
|
125
|
+
});
|
|
126
|
+
return {
|
|
127
|
+
store,
|
|
128
|
+
wrapper: ({ children }: { children: ReactNode }) => (
|
|
129
|
+
<LabStoreProvider store={store}>
|
|
130
|
+
<TrialIdProvider trialId={trialId}>{children}</TrialIdProvider>
|
|
131
|
+
</LabStoreProvider>
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe('auto paths', () => {
|
|
137
|
+
it('hands the instrument the resolved config and the raw one separately', () => {
|
|
138
|
+
const { store, wrapper } = makeAutoWrapper('w1');
|
|
139
|
+
const { result } = renderHook(
|
|
140
|
+
() => useTrialState<{ count: number }, { width: number; gap: number }>(),
|
|
141
|
+
{ wrapper },
|
|
142
|
+
);
|
|
143
|
+
act(() => store.getState().updateTrialConfig('w1', 'gap', auto));
|
|
144
|
+
expect(result.current.config.gap).toBe(18);
|
|
145
|
+
expect(result.current.raw.gap).toBe(12);
|
|
146
|
+
expect([...result.current.auto]).toEqual(['gap']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('leaves a pinned config untouched', () => {
|
|
150
|
+
const { wrapper } = makeAutoWrapper('w1');
|
|
151
|
+
const { result } = renderHook(
|
|
152
|
+
() => useTrialState<{ count: number }, { width: number; gap: number }>(),
|
|
153
|
+
{ wrapper },
|
|
154
|
+
);
|
|
155
|
+
expect(result.current.config.gap).toBe(12);
|
|
156
|
+
expect(result.current.config).toBe(result.current.raw);
|
|
157
|
+
expect([...result.current.auto]).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('records a path written as auto instead of storing the sentinel', () => {
|
|
161
|
+
const { store } = makeAutoWrapper('w1');
|
|
162
|
+
store.getState().updateTrialConfig('w1', 'gap', 24);
|
|
163
|
+
store.getState().updateTrialConfig('w1', 'gap', auto);
|
|
164
|
+
const rec = store.getState().trials[0];
|
|
165
|
+
expect(rec?.auto).toEqual(['gap']);
|
|
166
|
+
// The last pinned value survives, so un-pinning is lossless.
|
|
167
|
+
expect(rec?.config).toEqual({ width: 432, gap: 24 });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('pins again when a real value is written to an auto path', () => {
|
|
171
|
+
const { store } = makeAutoWrapper('w1');
|
|
172
|
+
store.getState().updateTrialConfig('w1', 'gap', auto);
|
|
173
|
+
store.getState().updateTrialConfig('w1', 'gap', 30);
|
|
174
|
+
const rec = store.getState().trials[0];
|
|
175
|
+
expect(rec?.auto).toBeUndefined();
|
|
176
|
+
expect((rec?.config as { gap: number }).gap).toBe(30);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('does not allocate a new record when the path is already auto', () => {
|
|
180
|
+
const { store } = makeAutoWrapper('w1');
|
|
181
|
+
store.getState().updateTrialConfig('w1', 'gap', auto);
|
|
182
|
+
const first = store.getState().trials[0];
|
|
183
|
+
store.getState().updateTrialConfig('w1', 'gap', auto);
|
|
184
|
+
expect(store.getState().trials[0]).toBe(first);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useContext } from 'react';
|
|
2
2
|
import { useStore } from 'zustand/react';
|
|
3
|
+
import { useResolvedConfig } from '../config/useResolvedConfig';
|
|
3
4
|
import { LabStoreContext, TrialIdContext } from './context';
|
|
4
5
|
import type { TrialStateHandle } from './types';
|
|
5
6
|
|
|
@@ -19,10 +20,17 @@ export function useTrialState<TS = unknown, TC = unknown>(): TrialStateHandle<TS
|
|
|
19
20
|
|
|
20
21
|
const updateTrialState = useStore(ctx.store, (s) => s.updateTrialState);
|
|
21
22
|
const updateTrialConfig = useStore(ctx.store, (s) => s.updateTrialConfig);
|
|
23
|
+
// The store's own copy, not `useConfigSchema`: that reads `LabContext`, and
|
|
24
|
+
// this hook only has the store's.
|
|
25
|
+
const instrumentHooks = useStore(ctx.store, (s) => s.instrumentHooks);
|
|
26
|
+
const schema = instrumentHooks().configSchemas[record.instrumentName];
|
|
27
|
+
const { config, auto } = useResolvedConfig<TC>(schema, record.config as TC, record.auto);
|
|
22
28
|
|
|
23
29
|
return {
|
|
24
30
|
state: record.state as TS,
|
|
25
|
-
config
|
|
31
|
+
config,
|
|
32
|
+
raw: record.config as TC,
|
|
33
|
+
auto,
|
|
26
34
|
setState: (next) => updateTrialState(trialId, next as Parameters<typeof updateTrialState>[1]),
|
|
27
35
|
setConfig: (path, value) => updateTrialConfig(trialId, path, value),
|
|
28
36
|
};
|
package/src/state/view.test.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ZOOM_FLOOR } from '@weasel-js/core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { createMemoryAdapter } from './adapters';
|
|
4
|
+
import { openLabStore } from './openLabStore';
|
|
3
5
|
import { createLabStore } from './store';
|
|
4
|
-
import { as2DView, DEFAULT_VIEW } from './view';
|
|
6
|
+
import { as2DView, DEFAULT_VIEW, normalize2DView, withZoom } from './view';
|
|
5
7
|
|
|
6
8
|
interface OrbitView {
|
|
7
9
|
yaw: number;
|
|
@@ -41,7 +43,7 @@ describe('as2DView', () => {
|
|
|
41
43
|
|
|
42
44
|
describe('a trial view labkit does not interpret', () => {
|
|
43
45
|
it('stores and returns a view shape that is not zoom/pan', () => {
|
|
44
|
-
const store = createLabStore(
|
|
46
|
+
const store = createLabStore();
|
|
45
47
|
store.getState().addTrial({
|
|
46
48
|
id: 'w1',
|
|
47
49
|
instrumentName: 'gem',
|
|
@@ -55,28 +57,32 @@ describe('a trial view labkit does not interpret', () => {
|
|
|
55
57
|
expect(store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
56
58
|
});
|
|
57
59
|
|
|
58
|
-
it('round-trips that view through persistence', () => {
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
it('round-trips that view through persistence', async () => {
|
|
61
|
+
const backing = new Map<string, unknown>();
|
|
62
|
+
const seed = await openLabStore({
|
|
63
|
+
storageKey: 'view-b',
|
|
64
|
+
storage: createMemoryAdapter(backing),
|
|
65
|
+
});
|
|
66
|
+
seed.store.getState().addTrial({
|
|
63
67
|
id: 'w1',
|
|
64
68
|
instrumentName: 'gem',
|
|
65
69
|
config: {},
|
|
66
70
|
state: {},
|
|
67
71
|
view: orbit,
|
|
68
72
|
});
|
|
69
|
-
|
|
70
|
-
vi.useRealTimers();
|
|
73
|
+
await seed.close();
|
|
71
74
|
|
|
72
|
-
const hydrated =
|
|
73
|
-
|
|
75
|
+
const hydrated = await openLabStore({
|
|
76
|
+
storageKey: 'view-b',
|
|
77
|
+
storage: createMemoryAdapter(backing),
|
|
78
|
+
});
|
|
79
|
+
expect(hydrated.store.getState().trials[0]?.view as OrbitView).toEqual(orbit);
|
|
74
80
|
});
|
|
75
81
|
});
|
|
76
82
|
|
|
77
83
|
describe('an unchanged write costs no new record', () => {
|
|
78
84
|
it('returns the same trial object when setState returns its input', () => {
|
|
79
|
-
const store = createLabStore(
|
|
85
|
+
const store = createLabStore();
|
|
80
86
|
store.getState().addTrial({
|
|
81
87
|
id: 'w1',
|
|
82
88
|
instrumentName: 'gem',
|
|
@@ -94,7 +100,7 @@ describe('an unchanged write costs no new record', () => {
|
|
|
94
100
|
});
|
|
95
101
|
|
|
96
102
|
it('still replaces the record when the state actually changes', () => {
|
|
97
|
-
const store = createLabStore(
|
|
103
|
+
const store = createLabStore();
|
|
98
104
|
store.getState().addTrial({
|
|
99
105
|
id: 'w1',
|
|
100
106
|
instrumentName: 'gem',
|
|
@@ -110,7 +116,7 @@ describe('an unchanged write costs no new record', () => {
|
|
|
110
116
|
});
|
|
111
117
|
|
|
112
118
|
it('returns the same trial object when the view is written unchanged', () => {
|
|
113
|
-
const store = createLabStore(
|
|
119
|
+
const store = createLabStore();
|
|
114
120
|
store.getState().addTrial({
|
|
115
121
|
id: 'w1',
|
|
116
122
|
instrumentName: 'gem',
|
|
@@ -125,3 +131,31 @@ describe('an unchanged write costs no new record', () => {
|
|
|
125
131
|
expect(store.getState().trials[0]).toBe(before);
|
|
126
132
|
});
|
|
127
133
|
});
|
|
134
|
+
|
|
135
|
+
describe('2D view zoom invariant', () => {
|
|
136
|
+
it('reads a stored zoom of 0 back as the floor', () => {
|
|
137
|
+
expect(as2DView({ zoom: 0, pan: { x: 1, y: 2 } })).toEqual({
|
|
138
|
+
zoom: ZOOM_FLOOR,
|
|
139
|
+
pan: { x: 1, y: 2 },
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('normalizes a NaN zoom and a non-finite pan', () => {
|
|
144
|
+
expect(normalize2DView({ zoom: Number.NaN, pan: { x: Infinity, y: 3 } })).toEqual({
|
|
145
|
+
zoom: ZOOM_FLOOR,
|
|
146
|
+
pan: { x: 0, y: 3 },
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('returns a valid view unchanged', () => {
|
|
151
|
+
const v = { zoom: 2, pan: { x: 1, y: 1 } };
|
|
152
|
+
expect(normalize2DView(v)).toBe(v);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('writes a new zoom through the same rule', () => {
|
|
156
|
+
expect(withZoom({ zoom: 2, pan: { x: 4, y: 5 } }, 0)).toEqual({
|
|
157
|
+
zoom: ZOOM_FLOOR,
|
|
158
|
+
pan: { x: 4, y: 5 },
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/state/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeZoom } from '@weasel-js/core';
|
|
1
2
|
import type { ViewTransform } from '../instrument/types';
|
|
2
3
|
|
|
3
4
|
/** The 2D view labkit has always shipped, and what a trial gets when it names no
|
|
@@ -14,5 +15,21 @@ export function as2DView(view: unknown): ViewTransform2D | null {
|
|
|
14
15
|
if (typeof v.zoom !== 'number') return null;
|
|
15
16
|
if (typeof v.pan !== 'object' || v.pan === null) return null;
|
|
16
17
|
if (typeof v.pan.x !== 'number' || typeof v.pan.y !== 'number') return null;
|
|
17
|
-
return { zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } };
|
|
18
|
+
return normalize2DView({ zoom: v.zoom, pan: { x: v.pan.x, y: v.pan.y } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `view` inside the invariant every view holds: a zoom that is not a finite
|
|
22
|
+
* number of at least `ZOOM_FLOOR` becomes the floor, and a non-finite pan
|
|
23
|
+
* becomes 0. Returns `view` itself when it already holds. */
|
|
24
|
+
export function normalize2DView(view: ViewTransform2D): ViewTransform2D {
|
|
25
|
+
const zoom = normalizeZoom(view.zoom);
|
|
26
|
+
const x = Number.isFinite(view.pan.x) ? view.pan.x : 0;
|
|
27
|
+
const y = Number.isFinite(view.pan.y) ? view.pan.y : 0;
|
|
28
|
+
if (zoom === view.zoom && x === view.pan.x && y === view.pan.y) return view;
|
|
29
|
+
return { zoom, pan: { x, y } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `view` at a new zoom, through the same rule. */
|
|
33
|
+
export function withZoom(view: ViewTransform2D, zoom: number): ViewTransform2D {
|
|
34
|
+
return normalize2DView({ zoom, pan: view.pan });
|
|
18
35
|
}
|
package/src/styles.less
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
@import './lab/LabSwitcher.less';
|
|
6
6
|
@import './primitives/Toolbar.less';
|
|
7
7
|
@import './primitives/Sidebar.less';
|
|
8
|
+
@import './primitives/Split.less';
|
|
8
9
|
@import './primitives/ZoomControl.less';
|
|
9
10
|
@import './primitives/StatusBar.less';
|
|
10
11
|
@import './primitives/JobProgress.less';
|
|
@@ -18,8 +19,10 @@
|
|
|
18
19
|
@import './chrome/regions/PaletteRegion.less';
|
|
19
20
|
@import './controls/ControlPanel.less';
|
|
20
21
|
@import './canvas/CanvasStack.less';
|
|
22
|
+
@import './canvas/Stage.less';
|
|
21
23
|
@import './loupe/Loupe.less';
|
|
22
24
|
@import './layers/LayerList.less';
|
|
23
25
|
@import './annotations/Annotations.less';
|
|
24
26
|
@import './dragdrop/Palette.less';
|
|
25
27
|
@import './dragdrop/DragGhost.less';
|
|
28
|
+
@import './specimen/Specimen.less';
|
package/src/surface/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ than a few 3D tiles have to share one surface.
|
|
|
15
15
|
| `rect.ts` | The `Rect` and `Box` types |
|
|
16
16
|
| `composeRects.ts` | Tile boxes into surface-relative rects; both are viewport-relative, so it is a subtraction |
|
|
17
17
|
| `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
|
|
18
|
-
| `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
|
|
18
|
+
| `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR, re-tile clears |
|
|
19
19
|
| `SurfaceContext.ts` | Carries the handle down |
|
|
20
20
|
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
21
21
|
|
|
@@ -38,7 +38,9 @@ const surface = useTiledSurface({
|
|
|
38
38
|
|
|
39
39
|
`onFrame` carries **every** tile's rect, not only the dirty ones — a scissored
|
|
40
40
|
draw has to know where it is drawing relative to a surface that may have resized
|
|
41
|
-
under it.
|
|
41
|
+
under it. It runs before this frame's painters, and what it invalidates paints
|
|
42
|
+
in the same frame: a buffer the owner just resized is blank, and every tile owes
|
|
43
|
+
a repaint.
|
|
42
44
|
|
|
43
45
|
## A tile id is scoped to its trial
|
|
44
46
|
|
|
@@ -61,14 +63,44 @@ one contributes a single rect, and a trial with nothing to draw contributes none
|
|
|
61
63
|
- **`preserveDrawingBuffer` is the consumer's job and is usually required.** A
|
|
62
64
|
partial redraw touches one tile; without it the default framebuffer's contents
|
|
63
65
|
are undefined after the page composites, and every other tile goes black.
|
|
64
|
-
- **Gutters lie outside every scissor
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
- **Gutters lie outside every scissor**, so a tile that moves leaves its old
|
|
67
|
+
picture where nothing will paint over it — a cloned trial strands the whole
|
|
68
|
+
previous layout in the gap. Register a `registerClear(id, fn)` beside the
|
|
69
|
+
painter: every registered clear runs, before any painter, on a frame where
|
|
70
|
+
`frame.retiled` is true. A tenant must not clear from its own painter, because
|
|
71
|
+
tenants paint in sequence and the second would wipe the first.
|
|
72
|
+
- **The owner cannot clear for you.** labkit owns the canvas, never the context,
|
|
73
|
+
and `canvas.width = <its own value>` resizes nothing, so it clears nothing —
|
|
74
|
+
the DOM trick that resets a 2D canvas is a no-op here. Only the context that
|
|
75
|
+
drew the pixels can erase them, which is why the clear is a tenant's to
|
|
76
|
+
register.
|
|
77
|
+
- **There are two buffers, and the default is the wrong one for an opaque
|
|
78
|
+
tenant.** `useSurfaceCanvas()` gives you the buffer *over* the trial DOM,
|
|
79
|
+
which is what a mark annotating an instrument wants and what every tenant
|
|
80
|
+
predating the split asked for. A renderer that fills its tile buries anything
|
|
81
|
+
the pane draws, so it wants `useSurfaceCanvas('under')` and a transparent
|
|
82
|
+
pane. Both buffers share one set of tile rects — rects are measured against
|
|
83
|
+
the surface container either way — so nothing else about a tenant changes.
|
|
84
|
+
- **A tile that moves without resizing** is handled twice over, and it needs
|
|
85
|
+
both. `Workspace` invalidates rects off the grid's own `node.placementChanged`,
|
|
86
|
+
which catches the move being *ordered*; the surface then keeps measuring until
|
|
87
|
+
two measurements agree, which catches where it *lands*. Without the second, a
|
|
88
|
+
panel whose size settles while its position is still animating leaves
|
|
89
|
+
`ResizeObserver` with nothing to report, and the tile paints for the rest of
|
|
90
|
+
its life at the spot it was caught mid-flight. A host that moves something the
|
|
91
|
+
grid does not know about still calls `invalidateRects()` itself.
|
|
69
92
|
|
|
70
93
|
## Testing
|
|
71
94
|
|
|
72
95
|
No WebGL in the suite. The arithmetic is pure and tested directly; the hook is
|
|
73
96
|
tested with `getBoundingClientRect` stubbed per element, because jsdom measures
|
|
74
97
|
everything as zero.
|
|
98
|
+
|
|
99
|
+
Which layer a tenant landed on is not testable here at all — jsdom resolves no
|
|
100
|
+
`z-index`, so `Lab.surface.test.tsx` asserts source order and says in the test
|
|
101
|
+
that it is a proxy. A browser settles it, and the probe has to be a **child of
|
|
102
|
+
the tile's own element**: one appended to `<body>` is outside the stacking
|
|
103
|
+
context the buffers sit in, so it paints over everything and reports success
|
|
104
|
+
whichever buffer the tenant chose. Measured against the 3D lab with a magenta
|
|
105
|
+
div inside `.td-pane` — on `'under'` the div survives, on `'over'` it reads back
|
|
106
|
+
as the viewport's own background.
|
|
@@ -4,6 +4,29 @@ import type { SurfaceHandle } from './useTiledSurface';
|
|
|
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
6
|
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Which of the two stacked buffers a tenant paints into.
|
|
9
|
+
*
|
|
10
|
+
* `'over'` sits above the trial DOM: right for marks that annotate an
|
|
11
|
+
* instrument, which is what the surface was built for and so the default.
|
|
12
|
+
* `'under'` sits below it, which is what an opaque renderer needs — a tile that
|
|
13
|
+
* fills its pane hides anything the pane's own DOM draws, so a 3D viewport on
|
|
14
|
+
* the over-buffer has to paint its own chrome into the backend rather than
|
|
15
|
+
* putting a label or a button in the pane.
|
|
16
|
+
*
|
|
17
|
+
* Both exist for the lifetime of the surface and share one set of tile rects,
|
|
18
|
+
* so a lab can hold an annotated 2D trial and an opaque 3D one at once.
|
|
19
|
+
*/
|
|
20
|
+
export type SurfaceLayer = 'over' | 'under';
|
|
21
|
+
|
|
22
|
+
/** The two buffers a surface owner publishes. A member is null until the
|
|
23
|
+
* owner's canvas mounts, and for an owner that keeps its surface unpainted. */
|
|
24
|
+
export interface SurfaceCanvases {
|
|
25
|
+
over: HTMLCanvasElement | null;
|
|
26
|
+
under: HTMLCanvasElement | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NO_CANVASES: SurfaceCanvases = { over: null, under: null };
|
|
30
|
+
|
|
31
|
+
/** The buffers the surface's tiles paint into. */
|
|
32
|
+
export const SurfaceCanvasContext = createContext<SurfaceCanvases>(NO_CANVASES);
|
package/src/surface/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { composeRects, rectsEqual } from './composeRects';
|
|
2
2
|
export { toDeviceRect } from './deviceRect';
|
|
3
3
|
export type { Box, Rect } from './rect';
|
|
4
|
+
export type { SurfaceCanvases, SurfaceLayer } from './SurfaceContext';
|
|
4
5
|
export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
5
6
|
export {
|
|
6
7
|
useSurface,
|
|
@@ -10,6 +11,7 @@ export {
|
|
|
10
11
|
useTileId,
|
|
11
12
|
} from './useSurfaceTile';
|
|
12
13
|
export type {
|
|
14
|
+
SurfaceClear,
|
|
13
15
|
SurfaceFrame,
|
|
14
16
|
SurfaceHandle,
|
|
15
17
|
TilePainter,
|
|
@@ -10,6 +10,7 @@ function fakeHandle(): SurfaceHandle {
|
|
|
10
10
|
invalidateAll: vi.fn(),
|
|
11
11
|
invalidateRects: vi.fn(),
|
|
12
12
|
registerTile: vi.fn(),
|
|
13
|
+
registerClear: vi.fn(() => () => {}),
|
|
13
14
|
registerPainter: vi.fn(() => () => {}),
|
|
14
15
|
containerRef: vi.fn(),
|
|
15
16
|
getContainer: vi.fn(() => null),
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useCallback, useContext } from 'react';
|
|
2
2
|
import { TrialIdContext } from '../state/context';
|
|
3
|
-
import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
3
|
+
import { SurfaceCanvasContext, SurfaceContext, type SurfaceLayer } from './SurfaceContext';
|
|
4
4
|
import type { SurfaceHandle } from './useTiledSurface';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* hands to `paintInto`.
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/** One of the surface's two shared buffers, or null where nothing paints one.
|
|
7
|
+
* What a tile hands to `paintInto`. See {@link SurfaceLayer} for which to ask
|
|
8
|
+
* for; the default is the one over the trial DOM. */
|
|
9
|
+
export function useSurfaceCanvas(layer: SurfaceLayer = 'over'): HTMLCanvasElement | null {
|
|
10
|
+
return useContext(SurfaceCanvasContext)[layer];
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/** The surface above, or null. Use this where a surface is genuinely optional. */
|