@weasel-js/labkit 1.4.0-pre.0 → 1.4.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 +226 -55
- package/dist/_dts/{CanvasStackContext-DtAn_3O-.d.ts → CanvasStackContext-D6M0o8cQ.d.ts} +1 -1
- package/dist/_dts/{frac-Cp3NivlC.d.ts → frac-QS7XzvpG.d.ts} +11 -4
- package/dist/_dts/{index-BDVzvRzQ.d.ts → index-B2X21G1c.d.ts} +8 -2
- package/dist/_dts/{types-D6s4b7if.d.ts → types-C1Zw7s5P.d.ts} +1 -0
- package/dist/_dts/{types-DYMaEvM5.d.ts → types-ChVJJHvk.d.ts} +3 -0
- package/dist/_dts/{useTrialState-gmMvZqPc.d.ts → useTrialState-Cdm13fvl.d.ts} +2 -1
- package/dist/canvas/index.d.ts +75 -9
- package/dist/canvas/index.js +2 -2
- package/dist/chrome/index.d.ts +4 -4
- package/dist/chrome/index.js +5 -5
- package/dist/chunk-2YILQ7Y5.js +33 -0
- package/dist/chunk-2YILQ7Y5.js.map +1 -0
- package/dist/{chunk-3P5TTTA7.js → chunk-4AMN4MMB.js} +5 -5
- package/dist/{chunk-3P5TTTA7.js.map → chunk-4AMN4MMB.js.map} +1 -1
- package/dist/{chunk-ROQDRXKW.js → chunk-7VW3S44V.js} +621 -51
- package/dist/chunk-7VW3S44V.js.map +1 -0
- package/dist/{chunk-4EY67BKJ.js → chunk-B5VWJBWM.js} +12 -6
- package/dist/chunk-B5VWJBWM.js.map +1 -0
- package/dist/{chunk-HEORBLVT.js → chunk-L5WBGUVR.js} +1664 -1628
- package/dist/chunk-L5WBGUVR.js.map +1 -0
- package/dist/{chunk-XJ6N32QP.js → chunk-LJSIUFYD.js} +14 -30
- package/dist/chunk-LJSIUFYD.js.map +1 -0
- package/dist/{chunk-6JMU56CU.js → chunk-QR5V3AFG.js} +6 -4
- package/dist/chunk-QR5V3AFG.js.map +1 -0
- package/dist/{chunk-LITE5YFB.js → chunk-UD7TEHZT.js} +15 -15
- package/dist/chunk-UD7TEHZT.js.map +1 -0
- package/dist/{chunk-3ZELRRBV.js → chunk-VHFQKAUL.js} +86 -6
- package/dist/chunk-VHFQKAUL.js.map +1 -0
- package/dist/{chunk-YQJNT4XQ.js → chunk-W3KSUXXR.js} +16 -13
- package/dist/chunk-W3KSUXXR.js.map +1 -0
- package/dist/{chunk-JDDIZSLL.js → chunk-XCBCZEBO.js} +3 -3
- package/dist/{chunk-JDDIZSLL.js.map → chunk-XCBCZEBO.js.map} +1 -1
- package/dist/{chunk-EQIB2PGC.js → chunk-Y2HK44TG.js} +3 -3
- package/dist/{chunk-EQIB2PGC.js.map → chunk-Y2HK44TG.js.map} +1 -1
- package/dist/{chunk-5TCEVMPI.js → chunk-YQGQUBKA.js} +3 -3
- package/dist/{chunk-5TCEVMPI.js.map → chunk-YQGQUBKA.js.map} +1 -1
- package/dist/controls/index.d.ts +2 -2
- package/dist/controls/index.js +3 -3
- package/dist/dragdrop/index.d.ts +3 -3
- package/dist/dragdrop/index.js +2 -2
- package/dist/index.d.ts +57 -59
- package/dist/index.js +335 -240
- package/dist/index.js.map +1 -1
- package/dist/layers/index.d.ts +4 -4
- package/dist/layers/index.js +3 -3
- package/dist/loupe/index.d.ts +5 -5
- package/dist/loupe/index.js +2 -2
- package/dist/passthrough/weasel-canvas.js +1 -1
- package/dist/passthrough/weasel-ui.d.ts +336 -15
- package/dist/passthrough/weasel-ui.js +2 -2
- package/dist/primitives/index.js +4 -4
- package/dist/state/index.d.ts +3 -3
- package/dist/state/index.js +4 -2
- package/dist/state/index.js.map +1 -1
- package/dist/styles.css +64 -5
- package/dist/surface/index.d.ts +17 -2
- package/dist/surface/index.js +3 -2
- package/dist/ui/layers/index.js +2 -2
- package/dist/undo/index.d.ts +3 -3
- package/package.json +8 -8
- package/src/annotations/AnnotationOverlay.tsx +10 -6
- package/src/annotations/Annotations.less +6 -0
- package/src/annotations/Annotations.overlay.test.tsx +113 -0
- package/src/annotations/MarkList.tsx +3 -2
- package/src/annotations/store.test.ts +72 -0
- package/src/annotations/store.ts +30 -0
- package/src/annotations/types.ts +9 -2
- package/src/canvas/index.ts +11 -0
- package/src/config/builder.test.ts +5 -0
- package/src/config/builder.ts +6 -0
- package/src/config/types.ts +1 -0
- package/src/controls/ControlPanel.test.tsx +23 -0
- package/src/controls/ControlPanel.tsx +3 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +13 -9
- package/src/lab/Lab.surface.test.tsx +8 -3
- package/src/lab/LabHeader.test.tsx +12 -0
- package/src/lab/LabHeader.tsx +16 -6
- package/src/passthrough/weasel-ui.test.ts +9 -0
- package/src/passthrough/weasel-ui.ts +4 -0
- package/src/primitives/ZoomControl.tsx +2 -0
- package/src/state/store.ts +10 -0
- package/src/state/types.ts +3 -0
- package/src/surface/AGENTS.md +11 -1
- package/src/surface/index.ts +7 -1
- package/src/surface/useSurfaceTile.ts +20 -2
- package/src/surface/useTiledSurface.ts +4 -1
- package/src/theme/Interstellar.stories.tsx +4 -3
- package/src/theme/base.less +39 -1
- package/src/trial/Trial.less +27 -5
- package/src/trial/TrialBody.test.tsx +67 -0
- package/src/trial/TrialBody.tsx +155 -0
- package/src/trial/TrialChrome.tsx +18 -10
- package/src/trial/index.ts +2 -0
- package/dist/chunk-3ZELRRBV.js.map +0 -1
- package/dist/chunk-4EY67BKJ.js.map +0 -1
- package/dist/chunk-6JMU56CU.js.map +0 -1
- package/dist/chunk-HEORBLVT.js.map +0 -1
- package/dist/chunk-LITE5YFB.js.map +0 -1
- package/dist/chunk-ROQDRXKW.js.map +0 -1
- package/dist/chunk-XJ6N32QP.js.map +0 -1
- package/dist/chunk-YQJNT4XQ.js.map +0 -1
|
@@ -45,6 +45,7 @@ export function ZoomControl({
|
|
|
45
45
|
<Slider
|
|
46
46
|
className="lk-zoom__slider"
|
|
47
47
|
ariaLabel="Zoom"
|
|
48
|
+
density="slim"
|
|
48
49
|
min={toTrack(min)}
|
|
49
50
|
max={toTrack(max)}
|
|
50
51
|
// Detents at the octaves plus actual size, so 25/50/100/200/400% are
|
|
@@ -65,6 +66,7 @@ export function ZoomControl({
|
|
|
65
66
|
maxValue={max}
|
|
66
67
|
step={0.01}
|
|
67
68
|
hideSteppers
|
|
69
|
+
ghost
|
|
68
70
|
formatOptions={{ style: 'percent', maximumFractionDigits: 0 }}
|
|
69
71
|
onChange={(next) => {
|
|
70
72
|
if (!Number.isFinite(next)) return;
|
package/src/state/store.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface LabStoreActions {
|
|
|
34
34
|
updateTrialState: <TS>(id: string, next: TS | ((prev: TS) => TS)) => void;
|
|
35
35
|
updateTrialConfig: <TC>(id: string, key: keyof TC, value: TC[keyof TC]) => void;
|
|
36
36
|
updateTrialView: (id: string, view: unknown) => void;
|
|
37
|
+
updateTrialSidebarWidth: (id: string, width: number) => void;
|
|
37
38
|
updateTrialAnnotations: (id: string, doc: unknown) => void;
|
|
38
39
|
updateTrialUndoStack: (
|
|
39
40
|
id: string,
|
|
@@ -136,6 +137,15 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
|
|
|
136
137
|
scheduleFlush();
|
|
137
138
|
},
|
|
138
139
|
|
|
140
|
+
updateTrialSidebarWidth: (id, width) => {
|
|
141
|
+
set((s) => ({
|
|
142
|
+
trials: s.trials.map((w) =>
|
|
143
|
+
w.id === id && w.sidebarWidth !== width ? { ...w, sidebarWidth: width } : w,
|
|
144
|
+
),
|
|
145
|
+
}));
|
|
146
|
+
scheduleFlush();
|
|
147
|
+
},
|
|
148
|
+
|
|
139
149
|
updateTrialAnnotations: (id, doc) => {
|
|
140
150
|
set((s) => ({
|
|
141
151
|
trials: s.trials.map((w) =>
|
package/src/state/types.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface TrialRecord<TS = unknown, TC = unknown, TV = unknown> {
|
|
|
18
18
|
view: TV;
|
|
19
19
|
/** This trial's own tool slot. Undefined means it reads the lab's. */
|
|
20
20
|
activeToolId?: string | null;
|
|
21
|
+
/** The extent the trial's sidebar was last dragged to, in pixels. Undefined
|
|
22
|
+
* until someone moves the seam. */
|
|
23
|
+
sidebarWidth?: number;
|
|
21
24
|
/** The marks on this trial's annotation targets, as `AnnotationsApi.toJSON`
|
|
22
25
|
* wrote them. Opaque here, and absent for a trial whose instrument declares
|
|
23
26
|
* no `annotations` — or declares its own `storage`. Not in `state`, which
|
package/src/surface/AGENTS.md
CHANGED
|
@@ -17,7 +17,7 @@ than a few 3D tiles have to share one surface.
|
|
|
17
17
|
| `deviceRect.ts` | `toDeviceRect` — y-flip and device-grid snapping for a GL viewport |
|
|
18
18
|
| `useTiledSurface.ts` | ResizeObserver, dirty set, rAF coalescing, DPR |
|
|
19
19
|
| `SurfaceContext.ts` | Carries the handle down |
|
|
20
|
-
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
20
|
+
| `useSurfaceTile.ts` | `useSurfaceTile(id)`, `useTileId(id)`, `useSurface()`, `useSurfaceOptional()` |
|
|
21
21
|
|
|
22
22
|
## Shape of a consumer
|
|
23
23
|
|
|
@@ -40,6 +40,16 @@ const surface = useTiledSurface({
|
|
|
40
40
|
draw has to know where it is drawing relative to a surface that may have resized
|
|
41
41
|
under it.
|
|
42
42
|
|
|
43
|
+
## A tile id is scoped to its trial
|
|
44
|
+
|
|
45
|
+
The tile namespace is one lab-wide map, but an instrument names its regions once
|
|
46
|
+
and every trial of it declares those same names. `useSurfaceTile` registers under
|
|
47
|
+
`useTileId(id)` — `<trial>/<id>` inside a trial — and a frame's `rects` are keyed
|
|
48
|
+
the same way, so a host looking a tile up calls `useTileId` for the key. Unscoped,
|
|
49
|
+
the second trial to mount takes the first one's rect *and* its painter, and the
|
|
50
|
+
first is never told it moved again: it keeps the box it was measured at while it
|
|
51
|
+
was the only trial open.
|
|
52
|
+
|
|
43
53
|
## The unit is a rect, not a trial
|
|
44
54
|
|
|
45
55
|
`useSurfaceTile(id)` attaches to whatever element the surface should draw into. A
|
package/src/surface/index.ts
CHANGED
|
@@ -2,7 +2,13 @@ export { composeRects, rectsEqual } from './composeRects';
|
|
|
2
2
|
export { toDeviceRect } from './deviceRect';
|
|
3
3
|
export type { Box, Rect } from './rect';
|
|
4
4
|
export { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
useSurface,
|
|
7
|
+
useSurfaceCanvas,
|
|
8
|
+
useSurfaceOptional,
|
|
9
|
+
useSurfaceTile,
|
|
10
|
+
useTileId,
|
|
11
|
+
} from './useSurfaceTile';
|
|
6
12
|
export type {
|
|
7
13
|
SurfaceFrame,
|
|
8
14
|
SurfaceHandle,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useContext } from 'react';
|
|
2
|
+
import { TrialIdContext } from '../state/context';
|
|
2
3
|
import { SurfaceCanvasContext, SurfaceContext } from './SurfaceContext';
|
|
3
4
|
import type { SurfaceHandle } from './useTiledSurface';
|
|
4
5
|
|
|
@@ -20,19 +21,36 @@ export function useSurface(): SurfaceHandle {
|
|
|
20
21
|
return surface;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The key `id` is registered under, and so the key it comes back on in a
|
|
26
|
+
* frame's `rects`: `<trial>/<id>` inside a trial, and `id` alone outside one.
|
|
27
|
+
*
|
|
28
|
+
* A surface's tile namespace is flat and shared by the whole lab, but an
|
|
29
|
+
* instrument names its regions once and every trial of it declares those same
|
|
30
|
+
* names. Unscoped, the second trial to mount takes the first one's rect and its
|
|
31
|
+
* painter, and the first is never told it moved again.
|
|
32
|
+
*/
|
|
33
|
+
export function useTileId(id: string): string {
|
|
34
|
+
const trial = useContext(TrialIdContext);
|
|
35
|
+
return trial ? `${trial}/${id}` : id;
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
/**
|
|
24
39
|
* A ref callback that publishes this element's rect to the surface under `id`.
|
|
25
40
|
*
|
|
26
41
|
* Attach it to whichever element the surface should draw into — that is not
|
|
27
42
|
* necessarily the trial's own element, since a trial may hold a drawn pane beside
|
|
28
43
|
* an undrawn one, or none at all.
|
|
44
|
+
*
|
|
45
|
+
* The frame reports it under `useTileId(id)`, not under `id`.
|
|
29
46
|
*/
|
|
30
47
|
export function useSurfaceTile(id: string): (el: HTMLElement | null) => void {
|
|
31
48
|
const surface = useSurfaceOptional();
|
|
49
|
+
const tileId = useTileId(id);
|
|
32
50
|
return useCallback(
|
|
33
51
|
(el: HTMLElement | null) => {
|
|
34
|
-
surface?.registerTile(
|
|
52
|
+
surface?.registerTile(tileId, el);
|
|
35
53
|
},
|
|
36
|
-
[surface,
|
|
54
|
+
[surface, tileId],
|
|
37
55
|
);
|
|
38
56
|
}
|
|
@@ -5,7 +5,10 @@ import type { Box, Rect } from './rect';
|
|
|
5
5
|
|
|
6
6
|
/** What a surface owner is handed once per animation frame. `rects` carries every
|
|
7
7
|
* tile, not only the dirty ones: a scissored draw has to know where it is drawing
|
|
8
|
-
* relative to a surface that may have resized under it.
|
|
8
|
+
* relative to a surface that may have resized under it.
|
|
9
|
+
*
|
|
10
|
+
* Its keys are what `useTileId` returns — `<trial>/<id>` for a tile registered
|
|
11
|
+
* inside a trial — not the bare id the tile was named with. */
|
|
9
12
|
export interface SurfaceFrame {
|
|
10
13
|
dirty: ReadonlySet<string>;
|
|
11
14
|
rects: ReadonlyMap<string, Rect>;
|
|
@@ -91,11 +91,12 @@ function SwatchGrid({ tokens, title }: { tokens: readonly Swatch[]; title: strin
|
|
|
91
91
|
function NebulaPreview() {
|
|
92
92
|
return (
|
|
93
93
|
<div>
|
|
94
|
-
<h3 className="lk-theme-doc__heading">Cosmic backdrop
|
|
94
|
+
<h3 className="lk-theme-doc__heading">Cosmic backdrop</h3>
|
|
95
95
|
<div className="lk-theme-doc__nebula" style={{ background: 'var(--wzl-backdrop)' }} />
|
|
96
96
|
<p className="lk-theme-doc__note">
|
|
97
|
-
|
|
98
|
-
by passing <code>nebula={'{[colors]}'}</code> to the
|
|
97
|
+
<code>--wzl-backdrop</code> — four radial gradients (purple, pink, blue) over a dark void
|
|
98
|
+
base. Override per-Lab by passing <code>nebula={'{[colors]}'}</code> to the{' '}
|
|
99
|
+
<code><Lab></code> component.
|
|
99
100
|
</p>
|
|
100
101
|
</div>
|
|
101
102
|
);
|
package/src/theme/base.less
CHANGED
|
@@ -66,8 +66,46 @@
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Mirrors packages/ui/src/components/range.module.css so a consumer's bare
|
|
70
|
+
// range matches the kit's. A weasel-ui component's own module class outranks
|
|
71
|
+
// this, which is what keeps InlineRange's gradient track intact.
|
|
69
72
|
:where(input[type="range"]) {
|
|
70
|
-
|
|
73
|
+
appearance: none;
|
|
74
|
+
-webkit-appearance: none;
|
|
75
|
+
height: 12px;
|
|
76
|
+
background: transparent;
|
|
77
|
+
cursor: pointer;
|
|
78
|
+
|
|
79
|
+
&::-webkit-slider-runnable-track {
|
|
80
|
+
height: var(--wzl-slider-track-h);
|
|
81
|
+
border-radius: var(--wzl-radius-sm);
|
|
82
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
&::-moz-range-track {
|
|
86
|
+
height: var(--wzl-slider-track-h);
|
|
87
|
+
border-radius: var(--wzl-radius-sm);
|
|
88
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-track-tint), transparent);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
&::-webkit-slider-thumb {
|
|
92
|
+
appearance: none;
|
|
93
|
+
-webkit-appearance: none;
|
|
94
|
+
width: var(--wzl-slider-thumb-size);
|
|
95
|
+
height: var(--wzl-slider-thumb-size);
|
|
96
|
+
border: none;
|
|
97
|
+
border-radius: 50%;
|
|
98
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
|
|
99
|
+
margin-top: calc((var(--wzl-slider-track-h) - var(--wzl-slider-thumb-size)) / 2);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
&::-moz-range-thumb {
|
|
103
|
+
width: var(--wzl-slider-thumb-size);
|
|
104
|
+
height: var(--wzl-slider-thumb-size);
|
|
105
|
+
border: none;
|
|
106
|
+
border-radius: 50%;
|
|
107
|
+
background: color-mix(in srgb, var(--wzl-accent) var(--wzl-slider-thumb-tint), transparent);
|
|
108
|
+
}
|
|
71
109
|
}
|
|
72
110
|
}
|
|
73
111
|
|
package/src/trial/Trial.less
CHANGED
|
@@ -20,13 +20,19 @@
|
|
|
20
20
|
overflow: hidden;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The sidebar and the content well are a windease strip, so their extents
|
|
24
|
+
// come from the strategy and this pane only has to fill the rect it was
|
|
25
|
+
// given. `.windease-zone` supplies the containing block.
|
|
26
|
+
&__panes {
|
|
27
|
+
flex: 1;
|
|
28
|
+
min-width: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
&__sidebar {
|
|
24
|
-
flex-shrink: 0;
|
|
25
|
-
// Stated, not content-derived: an auto-width sidebar is as wide as its
|
|
26
|
-
// widest label, so one verbose config key sets the width of the lab.
|
|
27
|
-
width: var(--lk-trial-sidebar-w, 20rem);
|
|
28
32
|
display: flex;
|
|
29
33
|
flex-direction: column;
|
|
34
|
+
width: 100%;
|
|
35
|
+
height: 100%;
|
|
30
36
|
min-height: 0;
|
|
31
37
|
// Sections floor at their content height, so without this the last ones
|
|
32
38
|
// are cut rather than reachable.
|
|
@@ -40,7 +46,8 @@
|
|
|
40
46
|
&__content {
|
|
41
47
|
// The viewport region anchors against this well.
|
|
42
48
|
position: relative;
|
|
43
|
-
|
|
49
|
+
width: 100%;
|
|
50
|
+
height: 100%;
|
|
44
51
|
min-width: 0;
|
|
45
52
|
min-height: 0;
|
|
46
53
|
display: flex;
|
|
@@ -89,6 +96,21 @@
|
|
|
89
96
|
flex-shrink: 0;
|
|
90
97
|
}
|
|
91
98
|
|
|
99
|
+
// The strip's own seam. windease positions and sizes it; this is only what
|
|
100
|
+
// it looks like.
|
|
101
|
+
&__panes [data-affordance-kind='resize-x'] {
|
|
102
|
+
background: var(--wzl-border);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
&__panes .windease-affordance-hit:hover [data-affordance-kind='resize-x'],
|
|
106
|
+
&__panes .windease-affordance-hit:focus-visible [data-affordance-kind='resize-x'] {
|
|
107
|
+
background: var(--wzl-accent);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
&__panes .windease-affordance-hit:focus-visible {
|
|
111
|
+
outline: none;
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
&--unknown {
|
|
93
115
|
display: flex;
|
|
94
116
|
align-items: center;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { TrialBody } from './TrialBody';
|
|
4
|
+
|
|
5
|
+
const VIEWPORT = { w: 800, h: 600 };
|
|
6
|
+
|
|
7
|
+
function renderBody(props: Partial<Parameters<typeof TrialBody>[0]> = {}) {
|
|
8
|
+
return render(
|
|
9
|
+
<TrialBody viewport={VIEWPORT} sidebar={<p>sidebar body</p>} {...props}>
|
|
10
|
+
<p>instrument output</p>
|
|
11
|
+
</TrialBody>,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('TrialBody', () => {
|
|
16
|
+
it('renders both panes', () => {
|
|
17
|
+
renderBody();
|
|
18
|
+
expect(screen.getByText('sidebar body')).toBeInTheDocument();
|
|
19
|
+
expect(screen.getByText('instrument output')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('puts a labeled separator between the sidebar and the content', () => {
|
|
23
|
+
renderBody({ width: 320 });
|
|
24
|
+
const seam = screen.getByRole('separator');
|
|
25
|
+
expect(seam).toHaveAttribute('aria-orientation', 'horizontal');
|
|
26
|
+
expect(seam).toHaveAttribute('aria-valuenow', '320');
|
|
27
|
+
expect(seam).toHaveAccessibleName(/sidebar/i);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('reports the width a drag lands on', () => {
|
|
31
|
+
const onWidthChange = vi.fn();
|
|
32
|
+
renderBody({ width: 320, onWidthChange });
|
|
33
|
+
const seam = screen.getByRole('separator');
|
|
34
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
35
|
+
fireEvent.pointerMove(seam, { clientX: 380, clientY: 100 });
|
|
36
|
+
fireEvent.pointerUp(seam, { clientX: 380, clientY: 100 });
|
|
37
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(380);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('resizes from the keyboard', () => {
|
|
41
|
+
const onWidthChange = vi.fn();
|
|
42
|
+
renderBody({ width: 320, onWidthChange });
|
|
43
|
+
const seam = screen.getByRole('separator');
|
|
44
|
+
fireEvent.keyDown(seam, { key: 'ArrowRight' });
|
|
45
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(328);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('refuses to drag the sidebar past its stated bounds', () => {
|
|
49
|
+
const onWidthChange = vi.fn();
|
|
50
|
+
renderBody({ width: 320, minWidth: 200, maxWidth: 400, onWidthChange });
|
|
51
|
+
const seam = screen.getByRole('separator');
|
|
52
|
+
expect(seam).toHaveAttribute('aria-valuemin', '200');
|
|
53
|
+
expect(seam).toHaveAttribute('aria-valuemax', '400');
|
|
54
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
55
|
+
fireEvent.pointerMove(seam, { clientX: 900, clientY: 100 });
|
|
56
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(400);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('leaves the content pane a floor the seam cannot cross', () => {
|
|
60
|
+
const onWidthChange = vi.fn();
|
|
61
|
+
renderBody({ width: 320, maxWidth: 10_000, contentMinWidth: 300, onWidthChange });
|
|
62
|
+
const seam = screen.getByRole('separator');
|
|
63
|
+
fireEvent.pointerDown(seam, { clientX: 320, clientY: 100 });
|
|
64
|
+
fireEvent.pointerMove(seam, { clientX: 5_000, clientY: 100 });
|
|
65
|
+
expect(onWidthChange).toHaveBeenLastCalledWith(VIEWPORT.w - 300);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { asNodeId, createNode, Store, stripStrategy } from 'windease';
|
|
3
|
+
import { type ChromeMap, Container, Provider, StrategyRegistryProvider } from 'windease/react';
|
|
4
|
+
|
|
5
|
+
const ZONE_ID = asNodeId('lk-trial-body');
|
|
6
|
+
const SIDEBAR_ID = asNodeId('lk-trial-sidebar');
|
|
7
|
+
const CONTENT_ID = asNodeId('lk-trial-content');
|
|
8
|
+
const STRATEGIES = { strip: stripStrategy as never };
|
|
9
|
+
|
|
10
|
+
/** Props for `<TrialBody>`. */
|
|
11
|
+
export interface TrialBodyProps {
|
|
12
|
+
/** The sidebar region's sections. */
|
|
13
|
+
sidebar: ReactNode;
|
|
14
|
+
/** The instrument's own output. */
|
|
15
|
+
children: ReactNode;
|
|
16
|
+
/** Extra class on the content pane — `Trial` marks a canvas instrument's
|
|
17
|
+
* well flush. */
|
|
18
|
+
contentClassName?: string;
|
|
19
|
+
/** The sidebar's current extent in pixels. Omit for an unpersisted lab: the
|
|
20
|
+
* seam still works, its result just does not outlive the mount. */
|
|
21
|
+
width?: number;
|
|
22
|
+
/** Where the sidebar starts before anyone drags it. */
|
|
23
|
+
defaultWidth?: number;
|
|
24
|
+
minWidth?: number;
|
|
25
|
+
maxWidth?: number;
|
|
26
|
+
/** How little the instrument may be squeezed to. Reported as the seam's
|
|
27
|
+
* reachable maximum, so the drag stops there rather than clamping silently. */
|
|
28
|
+
contentMinWidth?: number;
|
|
29
|
+
/** Fires through the drag, not on release: the pane is already there. */
|
|
30
|
+
onWidthChange?: (width: number) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Fixed extent. Omit in an app — the strip measures its own box. Supply it
|
|
33
|
+
* where nothing measures, notably jsdom, or both panes lay out at zero.
|
|
34
|
+
*/
|
|
35
|
+
viewport?: { w: number; h: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A trial's sidebar and content as a two-pane strip with a draggable seam.
|
|
40
|
+
*
|
|
41
|
+
* The seam is `stripStrategy`'s own resize affordance, so it arrives as a
|
|
42
|
+
* `role="separator"` carrying the reachable range, operable by pointer and by
|
|
43
|
+
* arrows / Home / End, and clamped against the content pane's floor rather
|
|
44
|
+
* than against the sidebar's alone.
|
|
45
|
+
*/
|
|
46
|
+
export function TrialBody({
|
|
47
|
+
sidebar,
|
|
48
|
+
children,
|
|
49
|
+
contentClassName,
|
|
50
|
+
width,
|
|
51
|
+
defaultWidth = 320,
|
|
52
|
+
minWidth = 140,
|
|
53
|
+
maxWidth = 720,
|
|
54
|
+
contentMinWidth = 160,
|
|
55
|
+
onWidthChange,
|
|
56
|
+
viewport,
|
|
57
|
+
}: TrialBodyProps) {
|
|
58
|
+
// The cross-axis half of every size hint below is inert: a horizontal strip
|
|
59
|
+
// stretches its panes to the container's height and never reads `h`.
|
|
60
|
+
const storeRef = useRef<Store | null>(null);
|
|
61
|
+
if (storeRef.current === null) {
|
|
62
|
+
const store = new Store();
|
|
63
|
+
store.registerNode(
|
|
64
|
+
createNode({
|
|
65
|
+
kind: 'zone',
|
|
66
|
+
id: ZONE_ID,
|
|
67
|
+
container: { strategyId: 'strip', config: { axis: 'x', resizable: true } },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
store.registerNode(
|
|
71
|
+
createNode({
|
|
72
|
+
kind: 'sidebar',
|
|
73
|
+
id: SIDEBAR_ID,
|
|
74
|
+
parentId: ZONE_ID,
|
|
75
|
+
meta: { title: 'Sidebar' },
|
|
76
|
+
placement: { size: { w: width ?? defaultWidth } },
|
|
77
|
+
hints: { minSize: { w: minWidth, h: 0 }, maxSize: { w: maxWidth, h: 0 } },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
store.registerNode(
|
|
81
|
+
createNode({
|
|
82
|
+
kind: 'content',
|
|
83
|
+
id: CONTENT_ID,
|
|
84
|
+
parentId: ZONE_ID,
|
|
85
|
+
meta: { title: 'Content' },
|
|
86
|
+
hints: { minSize: { w: contentMinWidth, h: 0 } },
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
store.showNode(SIDEBAR_ID);
|
|
90
|
+
store.showNode(CONTENT_ID);
|
|
91
|
+
storeRef.current = store;
|
|
92
|
+
}
|
|
93
|
+
const store = storeRef.current;
|
|
94
|
+
|
|
95
|
+
// What the store last held, so the placement event can tell a drag from the
|
|
96
|
+
// echo of our own write and a controlled `width` does not fight the seam.
|
|
97
|
+
const widthRef = useRef(width ?? defaultWidth);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
store.setHints(SIDEBAR_ID, {
|
|
101
|
+
minSize: { w: minWidth, h: 0 },
|
|
102
|
+
maxSize: { w: maxWidth, h: 0 },
|
|
103
|
+
});
|
|
104
|
+
store.setHints(CONTENT_ID, { minSize: { w: contentMinWidth, h: 0 } });
|
|
105
|
+
}, [store, minWidth, maxWidth, contentMinWidth]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (width === undefined || width === widthRef.current) return;
|
|
109
|
+
widthRef.current = width;
|
|
110
|
+
store.patchPlacement(SIDEBAR_ID, { size: { w: width } });
|
|
111
|
+
}, [store, width]);
|
|
112
|
+
|
|
113
|
+
const onWidthChangeRef = useRef(onWidthChange);
|
|
114
|
+
onWidthChangeRef.current = onWidthChange;
|
|
115
|
+
useEffect(
|
|
116
|
+
() =>
|
|
117
|
+
store.events.on('node.placementChanged', () => {
|
|
118
|
+
const next = (store.getPlacement(SIDEBAR_ID)?.size as { w?: number } | undefined)?.w;
|
|
119
|
+
if (typeof next !== 'number' || next === widthRef.current) return;
|
|
120
|
+
widthRef.current = next;
|
|
121
|
+
onWidthChangeRef.current?.(next);
|
|
122
|
+
}),
|
|
123
|
+
[store],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const chrome = useMemo<ChromeMap>(
|
|
127
|
+
() => ({
|
|
128
|
+
sidebar: () => <div className="lk-trial__sidebar">{sidebar}</div>,
|
|
129
|
+
content: () => (
|
|
130
|
+
<div
|
|
131
|
+
className={
|
|
132
|
+
contentClassName ? `lk-trial__content ${contentClassName}` : 'lk-trial__content'
|
|
133
|
+
}
|
|
134
|
+
>
|
|
135
|
+
{children}
|
|
136
|
+
</div>
|
|
137
|
+
),
|
|
138
|
+
}),
|
|
139
|
+
[sidebar, children, contentClassName],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<Provider store={store}>
|
|
144
|
+
<StrategyRegistryProvider strategies={STRATEGIES}>
|
|
145
|
+
<Container
|
|
146
|
+
parentId={ZONE_ID}
|
|
147
|
+
chrome={chrome}
|
|
148
|
+
className="lk-trial__panes windease-zone"
|
|
149
|
+
affordances
|
|
150
|
+
viewport={viewport}
|
|
151
|
+
/>
|
|
152
|
+
</StrategyRegistryProvider>
|
|
153
|
+
</Provider>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -17,6 +17,7 @@ import { JobProgress } from '../primitives/JobProgress';
|
|
|
17
17
|
import { LabStoreContext } from '../state/context';
|
|
18
18
|
import type { TrialRecord } from '../state/types';
|
|
19
19
|
import { as2DView } from '../state/view';
|
|
20
|
+
import { TrialBody } from './TrialBody';
|
|
20
21
|
import { TrialTitleBar } from './TrialTitleBar';
|
|
21
22
|
import { UndockedSections } from './UndockedSections';
|
|
22
23
|
|
|
@@ -83,6 +84,7 @@ export function TrialChrome({
|
|
|
83
84
|
const storeCtx = useContext(LabStoreContext);
|
|
84
85
|
if (!storeCtx) throw new Error('[labkit] TrialChrome requires <LabStoreProvider>');
|
|
85
86
|
const updateTrialView = useStore(storeCtx.store, (s) => s.updateTrialView);
|
|
87
|
+
const updateTrialSidebarWidth = useStore(storeCtx.store, (s) => s.updateTrialSidebarWidth);
|
|
86
88
|
const updateTrialConfig = useStore(storeCtx.store, (s) => s.updateTrialConfig);
|
|
87
89
|
const updateTrialState = useStore(storeCtx.store, (s) => s.updateTrialState);
|
|
88
90
|
const undockPanelAction = useStore(storeCtx.store, (s) => s.undockPanel);
|
|
@@ -213,18 +215,24 @@ export function TrialChrome({
|
|
|
213
215
|
</div>
|
|
214
216
|
<div className="lk-trial__body">
|
|
215
217
|
<PaletteRegion contributions={inRegion('palette')} ctx={ctx} />
|
|
216
|
-
<
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
218
|
+
<TrialBody
|
|
219
|
+
width={record.sidebarWidth}
|
|
220
|
+
onWidthChange={(w) => updateTrialSidebarWidth(trialId, w)}
|
|
221
|
+
contentClassName={instrument.canvas ? 'lk-trial__content--flush' : undefined}
|
|
222
|
+
sidebar={
|
|
223
|
+
<>
|
|
224
|
+
<SidebarRegion contributions={inRegion('sidebar')} ctx={ctx} />
|
|
225
|
+
<UndockedSections
|
|
226
|
+
trialId={trialId}
|
|
227
|
+
sections={inRegion('sidebar').filter((c) => undockedIds.includes(c.id))}
|
|
228
|
+
onDock={ctx.dockPanel}
|
|
229
|
+
/>
|
|
230
|
+
</>
|
|
231
|
+
}
|
|
232
|
+
>
|
|
225
233
|
{children}
|
|
226
234
|
<ViewportRegion contributions={inRegion('viewport')} ctx={ctx} />
|
|
227
|
-
</
|
|
235
|
+
</TrialBody>
|
|
228
236
|
</div>
|
|
229
237
|
<div className="lk-trial__status">
|
|
230
238
|
{job ? <JobProgress job={job} /> : null}
|
package/src/trial/index.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type { TrialProps } from './Trial';
|
|
2
2
|
export { Trial } from './Trial';
|
|
3
|
+
export type { TrialBodyProps } from './TrialBody';
|
|
4
|
+
export { TrialBody } from './TrialBody';
|
|
3
5
|
export type { TrialChromeProps, UndoBindings } from './TrialChrome';
|
|
4
6
|
export { TrialChrome } from './TrialChrome';
|
|
5
7
|
export {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/canvas/useLayerScheduler.ts","../src/canvas/usePanZoom.ts","../src/canvas/CanvasStack.tsx"],"names":["useRef","useEffect"],"mappings":"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","file":"chunk-3ZELRRBV.js","sourcesContent":["import { useVisibleRaf } from '@weasel-js/core';\nimport { type RefObject, useEffect, useRef } from 'react';\nimport type { ViewTransform } from '../instrument/types';\nimport { DEFAULT_FRAME, type WorldFrame } from './worldSpec';\n\n/** One layer of a canvas stack: its id, whether it is currently shown, and how\n * it paints itself. */\nexport interface CanvasLayerDescriptor {\n id: string;\n visible: boolean;\n render: (ctx: CanvasRenderingContext2D, view: ViewTransform, frame: WorldFrame) => void;\n}\n\ninterface SchedulerOptions {\n layers: CanvasLayerDescriptor[];\n view: ViewTransform;\n /** The instrument's coordinate system, resolved against `size`. */\n frame?: WorldFrame;\n canvasRefs: RefObject<Map<string, HTMLCanvasElement>>;\n size: { width: number; height: number; dpr: number };\n /** The element the stack occupies. Given one, the scheduler also stops while\n * the stack sits outside the viewport. */\n host?: RefObject<Element | null>;\n}\n\nexport function useLayerScheduler({\n layers,\n view,\n frame = DEFAULT_FRAME,\n canvasRefs,\n size,\n host,\n}: SchedulerOptions): void {\n const dirty = useRef<Set<string>>(new Set());\n const lastRenderRef = useRef<Map<string, CanvasLayerDescriptor['render']>>(new Map());\n\n useEffect(() => {\n for (const layer of layers) {\n const prev = lastRenderRef.current.get(layer.id);\n if (prev !== layer.render) dirty.current.add(layer.id);\n lastRenderRef.current.set(layer.id, layer.render);\n }\n for (const id of [...lastRenderRef.current.keys()]) {\n if (!layers.find((l) => l.id === id)) lastRenderRef.current.delete(id);\n }\n }, [layers]);\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: re-mark dirty when view or size changes\n useEffect(() => {\n for (const layer of layers) dirty.current.add(layer.id);\n }, [view, frame, size, layers]);\n\n // Painting only what is dirty is not the same as doing nothing: a hidden tab\n // still commits React updates, and the effect above marks every layer dirty\n // on any view or size change. The gate is what actually stops the work.\n const frameLoop = useVisibleRaf(\n () => {\n if (dirty.current.size === 0) return;\n const map = canvasRefs.current;\n if (!map) {\n dirty.current.clear();\n return;\n }\n for (const layer of layers) {\n if (!dirty.current.has(layer.id)) continue;\n if (!layer.visible) continue;\n const canvas = map.get(layer.id);\n if (!canvas) continue;\n const ctx = canvas.getContext('2d');\n if (!ctx) continue;\n ctx.save();\n ctx.setTransform(size.dpr, 0, 0, size.dpr, 0, 0);\n ctx.clearRect(0, 0, size.width, size.height);\n layer.render(ctx, view, frame);\n ctx.restore();\n }\n dirty.current.clear();\n },\n { target: host },\n );\n\n useEffect(() => {\n if (dirty.current.size > 0) frameLoop.request();\n });\n}\n","import { type PointerEvent, useCallback, useRef, type WheelEvent } from 'react';\nimport type { ViewTransform } from '../instrument/types';\nimport { zoomAt } from './camera';\nimport { DEFAULT_FRAME, type WorldFrame } from './worldSpec';\n\nexport interface UsePanZoomOptions {\n view: ViewTransform;\n onViewChange: (v: ViewTransform) => void;\n /** The opening `view.zoom` always stays reachable, widening these past\n * whatever is passed here if it would otherwise exclude it. */\n minZoom?: number;\n maxZoom?: number;\n /** The instrument's coordinate system, resolved against the viewport. The\n * wheel anchors in this frame; omitting it anchors at the element's\n * top-left, which is what labkit did before a frame could be declared. */\n frame?: WorldFrame;\n}\n\nexport interface PanZoomHandlers {\n onWheel: (e: WheelEvent<HTMLElement>) => void;\n onPointerDown: (e: PointerEvent<HTMLElement>) => void;\n onPointerMove: (e: PointerEvent<HTMLElement>) => void;\n onPointerUp: (e: PointerEvent<HTMLElement>) => void;\n isDragging: () => boolean;\n}\n\ninterface DragState {\n pointerId: number;\n startScreenX: number;\n startScreenY: number;\n startPan: { x: number; y: number };\n moved: boolean;\n}\n\nconst DRAG_THRESHOLD = 3;\n\nfunction isPositiveFinite(n: number): boolean {\n return Number.isFinite(n) && n > 0;\n}\n\nexport function usePanZoom({\n view,\n onViewChange,\n minZoom = 0.1,\n maxZoom = 32,\n frame = DEFAULT_FRAME,\n}: UsePanZoomOptions): PanZoomHandlers {\n const dragRef = useRef<DragState | null>(null);\n const viewRef = useRef(view);\n viewRef.current = view;\n // Captured once, from the view the canvas opened at — not the current view,\n // which would let a prior zoom-out shrink the range and trap the opening\n // view unreachable behind it.\n const initialZoomRef = useRef(isPositiveFinite(view.zoom) ? view.zoom : null);\n\n const onWheel = useCallback(\n (e: WheelEvent<HTMLElement>) => {\n e.preventDefault();\n const rect = e.currentTarget.getBoundingClientRect();\n const initialZoom = initialZoomRef.current;\n onViewChange(\n zoomAt(\n viewRef.current,\n Math.exp(-e.deltaY * 0.001),\n { x: e.clientX - rect.left, y: e.clientY - rect.top },\n {\n frame,\n min: initialZoom == null ? minZoom : Math.min(minZoom, initialZoom),\n max: initialZoom == null ? maxZoom : Math.max(maxZoom, initialZoom),\n },\n ),\n );\n },\n [onViewChange, minZoom, maxZoom, frame],\n );\n\n const onPointerDown = useCallback((e: PointerEvent<HTMLElement>) => {\n if (e.button !== 0) return;\n e.currentTarget.setPointerCapture(e.pointerId);\n dragRef.current = {\n pointerId: e.pointerId,\n startScreenX: e.clientX,\n startScreenY: e.clientY,\n startPan: { ...viewRef.current.pan },\n moved: false,\n };\n }, []);\n\n const onPointerMove = useCallback(\n (e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n const dx = e.clientX - drag.startScreenX;\n const dy = e.clientY - drag.startScreenY;\n if (!drag.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;\n drag.moved = true;\n onViewChange({\n zoom: viewRef.current.zoom,\n pan: { x: drag.startPan.x + dx, y: drag.startPan.y + dy },\n });\n },\n [onViewChange],\n );\n\n const onPointerUp = useCallback((e: PointerEvent<HTMLElement>) => {\n const drag = dragRef.current;\n if (!drag || drag.pointerId !== e.pointerId) return;\n if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n e.currentTarget.releasePointerCapture(e.pointerId);\n }\n dragRef.current = null;\n }, []);\n\n const isDragging = useCallback(() => dragRef.current?.moved === true, []);\n\n return { onWheel, onPointerDown, onPointerMove, onPointerUp, isDragging };\n}\n","import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';\nimport type { Point, ViewTransform } from '../instrument/types';\nimport { CanvasStackContext } from './CanvasStackContext';\nimport { screenToWorld } from './canvasCoords';\nimport { type CanvasLayerDescriptor, useLayerScheduler } from './useLayerScheduler';\nimport { usePanZoom } from './usePanZoom';\nimport { resolveFrame, type ViewportSize, type WorldSpec } from './worldSpec';\n\n/** Props for `<CanvasStack>`. */\nexport interface CanvasStackProps {\n layers: CanvasLayerDescriptor[];\n view: ViewTransform;\n onViewChange: (v: ViewTransform) => void;\n /** The instrument's coordinate system. Omitted, world (0,0) sits at the\n * element's top-left with y running down. */\n worldSpec?: WorldSpec;\n minZoom?: number;\n maxZoom?: number;\n width?: number | string;\n height?: number | string;\n className?: string;\n /** Fired whenever the stack is measured, so a consumer can place a view that\n * only makes sense in terms of the viewport. */\n onResize?: (size: ViewportSize) => void;\n onHitTest?: (worldPos: Point) => void;\n children?: ReactNode;\n}\n\n/** Stacks one `<canvas>` per layer and drives them from a shared view, so a\n * layer that changes rarely is not redrawn with one that changes every frame.\n * Handles sizing, device pixel ratio, and pan/zoom. */\nexport function CanvasStack({\n layers,\n view,\n onViewChange,\n worldSpec,\n minZoom,\n maxZoom,\n width = '100%',\n height = '100%',\n className,\n onResize,\n onHitTest,\n children,\n}: CanvasStackProps) {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const canvasMap = useRef<Map<string, HTMLCanvasElement>>(new Map());\n const [size, setSize] = useState({ width: 0, height: 0, dpr: 1 });\n\n const setCanvasRef = (id: string, el: HTMLCanvasElement | null): void => {\n if (el) canvasMap.current.set(id, el);\n else canvasMap.current.delete(id);\n };\n\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n const update = () => {\n const rect = el.getBoundingClientRect();\n const dpr = window.devicePixelRatio ?? 1;\n setSize((prev) => {\n if (prev.width === rect.width && prev.height === rect.height && prev.dpr === dpr) {\n return prev;\n }\n return { width: rect.width, height: rect.height, dpr };\n });\n };\n update();\n if (typeof ResizeObserver === 'undefined') return;\n const ro = new ResizeObserver(update);\n ro.observe(el);\n return () => ro.disconnect();\n }, []);\n\n const onResizeRef = useRef(onResize);\n onResizeRef.current = onResize;\n useEffect(() => {\n if (size.width === 0 && size.height === 0) return;\n onResizeRef.current?.({ width: size.width, height: size.height });\n }, [size.width, size.height]);\n\n const frame = useMemo(() => resolveFrame(worldSpec, size), [worldSpec, size]);\n\n const handlers = usePanZoom({ view, onViewChange, minZoom, maxZoom, frame });\n useLayerScheduler({ layers, view, frame, canvasRefs: canvasMap, size, host: containerRef });\n\n const ctxValue = useMemo(\n () => ({\n view,\n frame,\n surface: { element: containerRef, size, canvases: canvasMap, layers },\n }),\n [view, frame, size, layers],\n );\n\n const containerStyle: CSSProperties = { width, height };\n const canvasPx = {\n width: Math.max(0, Math.round(size.width * size.dpr)),\n height: Math.max(0, Math.round(size.height * size.dpr)),\n };\n const canvasCss: CSSProperties = {\n width: size.width,\n height: size.height,\n };\n\n const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n const wasDragging = handlers.isDragging();\n handlers.onPointerUp(e);\n if (!wasDragging && onHitTest && containerRef.current) {\n const rect = containerRef.current.getBoundingClientRect();\n const screen = { x: e.clientX - rect.left, y: e.clientY - rect.top };\n onHitTest(screenToWorld(screen, view, frame));\n }\n };\n\n return (\n <CanvasStackContext.Provider value={ctxValue}>\n <div\n ref={containerRef}\n className={className ? `lk-canvas-stack ${className}` : 'lk-canvas-stack'}\n style={containerStyle}\n onWheel={handlers.onWheel}\n onPointerDown={handlers.onPointerDown}\n onPointerMove={handlers.onPointerMove}\n onPointerUp={handlePointerUp}\n >\n {layers.map((layer) => (\n <canvas\n key={layer.id}\n ref={(el) => setCanvasRef(layer.id, el)}\n className=\"lk-canvas-stack__canvas\"\n width={canvasPx.width}\n height={canvasPx.height}\n style={{ ...canvasCss, display: layer.visible ? 'block' : 'none' }}\n />\n ))}\n <div className=\"lk-canvas-stack__overlay\">{children}</div>\n </div>\n </CanvasStackContext.Provider>\n );\n}\n"]}
|