@weasel-js/labkit 0.1.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 +72 -0
- package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
- package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
- package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
- package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
- package/dist/canvas/index.d.ts +34 -0
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -0
- package/dist/chunk-2QNYYL3V.js +86 -0
- package/dist/chunk-2QNYYL3V.js.map +1 -0
- package/dist/chunk-54IQ2DX7.js +222 -0
- package/dist/chunk-54IQ2DX7.js.map +1 -0
- package/dist/chunk-6FO7G5BB.js +6095 -0
- package/dist/chunk-6FO7G5BB.js.map +1 -0
- package/dist/chunk-7BKDG73Z.js +54 -0
- package/dist/chunk-7BKDG73Z.js.map +1 -0
- package/dist/chunk-B5ZUYRKN.js +23330 -0
- package/dist/chunk-B5ZUYRKN.js.map +1 -0
- package/dist/chunk-HXZHVU4G.js +126 -0
- package/dist/chunk-HXZHVU4G.js.map +1 -0
- package/dist/chunk-PWC7AQZM.js +8 -0
- package/dist/chunk-PWC7AQZM.js.map +1 -0
- package/dist/chunk-SD3HDXR7.js +161 -0
- package/dist/chunk-SD3HDXR7.js.map +1 -0
- package/dist/chunk-SSMHPMDV.js +350 -0
- package/dist/chunk-SSMHPMDV.js.map +1 -0
- package/dist/chunk-VLAHRJOC.js +201 -0
- package/dist/chunk-VLAHRJOC.js.map +1 -0
- package/dist/controls/index.d.ts +13 -0
- package/dist/controls/index.js +3 -0
- package/dist/controls/index.js.map +1 -0
- package/dist/dragdrop/index.d.ts +3 -0
- package/dist/dragdrop/index.js +3 -0
- package/dist/dragdrop/index.js.map +1 -0
- package/dist/fonts/oswald-latin-variable.woff2 +0 -0
- package/dist/index.d.ts +410 -0
- package/dist/index.js +1425 -0
- package/dist/index.js.map +1 -0
- package/dist/layers/index.d.ts +17 -0
- package/dist/layers/index.js +3 -0
- package/dist/layers/index.js.map +1 -0
- package/dist/passthrough/weasel-canvas.d.ts +258 -0
- package/dist/passthrough/weasel-canvas.js +3 -0
- package/dist/passthrough/weasel-canvas.js.map +1 -0
- package/dist/passthrough/weasel-ui.d.ts +2826 -0
- package/dist/passthrough/weasel-ui.js +4 -0
- package/dist/passthrough/weasel-ui.js.map +1 -0
- package/dist/primitives/index.d.ts +57 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/state/index.d.ts +33 -0
- package/dist/state/index.js +34 -0
- package/dist/state/index.js.map +1 -0
- package/dist/styles.css +1224 -0
- package/dist/theme-interstellar.css +25 -0
- package/dist/theme-light.css +13 -0
- package/dist/ui/layers/index.d.ts +42 -0
- package/dist/ui/layers/index.js +5 -0
- package/dist/ui/layers/index.js.map +1 -0
- package/dist/undo/index.d.ts +30 -0
- package/dist/undo/index.js +3 -0
- package/dist/undo/index.js.map +1 -0
- package/package.json +99 -0
- package/src/canvas/AGENTS.md +72 -0
- package/src/canvas/CanvasStack.less +22 -0
- package/src/canvas/CanvasStack.stories.tsx +63 -0
- package/src/canvas/CanvasStack.test.tsx +82 -0
- package/src/canvas/CanvasStack.tsx +108 -0
- package/src/canvas/CanvasStackContext.ts +8 -0
- package/src/canvas/canvasCoords.test.ts +30 -0
- package/src/canvas/canvasCoords.ts +15 -0
- package/src/canvas/index.ts +6 -0
- package/src/canvas/useLayerScheduler.ts +72 -0
- package/src/canvas/usePanZoom.ts +98 -0
- package/src/controls/ControlPanel.less +61 -0
- package/src/controls/ControlPanel.stories.tsx +77 -0
- package/src/controls/ControlPanel.test.tsx +149 -0
- package/src/controls/ControlPanel.tsx +249 -0
- package/src/controls/index.ts +12 -0
- package/src/controls/types.ts +61 -0
- package/src/dragdrop/DragDropRuntime.tsx +135 -0
- package/src/dragdrop/DragGhost.less +17 -0
- package/src/dragdrop/DragGhost.tsx +24 -0
- package/src/dragdrop/Palette.less +34 -0
- package/src/dragdrop/Palette.tsx +27 -0
- package/src/dragdrop/dragDrop.test.tsx +145 -0
- package/src/dragdrop/index.ts +1 -0
- package/src/fonts/oswald-latin-variable.woff2 +0 -0
- package/src/index.ts +70 -0
- package/src/instrument/SineWave.smoke.test.tsx +94 -0
- package/src/instrument/capabilityDetector.test.ts +64 -0
- package/src/instrument/capabilityDetector.ts +17 -0
- package/src/instrument/defineInstrument.test.ts +27 -0
- package/src/instrument/defineInstrument.ts +5 -0
- package/src/instrument/index.ts +19 -0
- package/src/instrument/types.ts +65 -0
- package/src/instrument/validateConfigSchema.test.ts +125 -0
- package/src/instrument/validateConfigSchema.ts +65 -0
- package/src/lab/Lab.less +3 -0
- package/src/lab/Lab.stories.tsx +51 -0
- package/src/lab/Lab.test.tsx +126 -0
- package/src/lab/Lab.tsx +196 -0
- package/src/lab/LabContext.test.ts +11 -0
- package/src/lab/LabContext.ts +28 -0
- package/src/lab/LabShell.less +45 -0
- package/src/lab/LabShell.stories.tsx +39 -0
- package/src/lab/LabShell.test.tsx +59 -0
- package/src/lab/LabShell.tsx +33 -0
- package/src/lab/WorkspaceGrid.less +8 -0
- package/src/lab/WorkspaceGrid.stories.tsx +61 -0
- package/src/lab/WorkspaceGrid.test.tsx +40 -0
- package/src/lab/WorkspaceGrid.tsx +20 -0
- package/src/lab/gridDims.test.ts +35 -0
- package/src/lab/gridDims.ts +11 -0
- package/src/lab/index.ts +10 -0
- package/src/layers/AGENTS.md +65 -0
- package/src/layers/LayerList.less +53 -0
- package/src/layers/LayerList.test.tsx +49 -0
- package/src/layers/LayerList.tsx +105 -0
- package/src/layers/index.ts +3 -0
- package/src/passthrough/weasel-canvas.test.ts +30 -0
- package/src/passthrough/weasel-canvas.ts +23 -0
- package/src/passthrough/weasel-ui.test.ts +79 -0
- package/src/passthrough/weasel-ui.ts +150 -0
- package/src/primitives/FpsMeter.less +14 -0
- package/src/primitives/FpsMeter.stories.tsx +14 -0
- package/src/primitives/FpsMeter.test.tsx +38 -0
- package/src/primitives/FpsMeter.tsx +36 -0
- package/src/primitives/ScaleIndicator.less +25 -0
- package/src/primitives/ScaleIndicator.stories.tsx +16 -0
- package/src/primitives/ScaleIndicator.test.tsx +46 -0
- package/src/primitives/ScaleIndicator.tsx +48 -0
- package/src/primitives/Sidebar.less +57 -0
- package/src/primitives/Sidebar.stories.tsx +38 -0
- package/src/primitives/Sidebar.test.tsx +74 -0
- package/src/primitives/Sidebar.tsx +32 -0
- package/src/primitives/StatusBar.less +21 -0
- package/src/primitives/StatusBar.stories.tsx +24 -0
- package/src/primitives/StatusBar.test.tsx +25 -0
- package/src/primitives/StatusBar.tsx +18 -0
- package/src/primitives/Toolbar.less +43 -0
- package/src/primitives/Toolbar.stories.tsx +36 -0
- package/src/primitives/Toolbar.test.tsx +65 -0
- package/src/primitives/Toolbar.tsx +44 -0
- package/src/primitives/fpsAverage.test.ts +21 -0
- package/src/primitives/fpsAverage.ts +5 -0
- package/src/primitives/index.ts +9 -0
- package/src/state/SingletonExperiment.test.tsx +101 -0
- package/src/state/SingletonExperiment.tsx +52 -0
- package/src/state/adapters.test.ts +94 -0
- package/src/state/adapters.ts +102 -0
- package/src/state/context.tsx +42 -0
- package/src/state/helpers.test.ts +89 -0
- package/src/state/helpers.ts +61 -0
- package/src/state/index.ts +40 -0
- package/src/state/store.test.ts +243 -0
- package/src/state/store.ts +225 -0
- package/src/state/types.ts +53 -0
- package/src/state/useExperimentState.test.tsx +102 -0
- package/src/state/useExperimentState.ts +28 -0
- package/src/styles.less +20 -0
- package/src/test-setup.ts +17 -0
- package/src/theme/Interstellar.stories.less +92 -0
- package/src/theme/Interstellar.stories.tsx +243 -0
- package/src/theme/base.less +122 -0
- package/src/theme/interstellar.less +34 -0
- package/src/theme/light.less +14 -0
- package/src/theme/tokens.less +85 -0
- package/src/ui/format.ts +19 -0
- package/src/ui/layers/LayerStack.less +143 -0
- package/src/ui/layers/LayerStack.stories.tsx +45 -0
- package/src/ui/layers/LayerStack.test.tsx +170 -0
- package/src/ui/layers/LayerStack.tsx +215 -0
- package/src/ui/layers/index.ts +1 -0
- package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
- package/src/ui/properties/ColorRow.stories.tsx +106 -0
- package/src/ui/properties/CurveField.less +48 -0
- package/src/ui/properties/CurveField.stories.tsx +20 -0
- package/src/ui/properties/CurveField.test.tsx +127 -0
- package/src/ui/properties/CurveField.tsx +181 -0
- package/src/ui/properties/EffectCard.tsx +312 -0
- package/src/ui/properties/Gallery.stories.tsx +178 -0
- package/src/ui/properties/NumberRow.stories.tsx +65 -0
- package/src/ui/properties/PropertyGroup.less +50 -0
- package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
- package/src/ui/properties/PropertyGroup.test.tsx +33 -0
- package/src/ui/properties/PropertyGroup.tsx +44 -0
- package/src/ui/properties/PropertyList.stories.tsx +83 -0
- package/src/ui/properties/PropertyPanel.less +592 -0
- package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
- package/src/ui/properties/PropertyPanel.test.tsx +193 -0
- package/src/ui/properties/PropertyPanel.tsx +441 -0
- package/src/ui/properties/PropertyRow.stories.tsx +53 -0
- package/src/ui/properties/SelectRow.stories.tsx +58 -0
- package/src/ui/properties/SliderRow.stories.tsx +74 -0
- package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
- package/src/ui/properties/TextRow.stories.tsx +62 -0
- package/src/ui/properties/ToggleRow.stories.tsx +57 -0
- package/src/ui/properties/index.ts +39 -0
- package/src/ui/properties/storyLayouts.tsx +36 -0
- package/src/undo/eventBus.test.ts +49 -0
- package/src/undo/eventBus.ts +32 -0
- package/src/undo/index.ts +5 -0
- package/src/undo/undoStack.test.ts +70 -0
- package/src/undo/undoStack.ts +38 -0
- package/src/workspace/DefaultSidebar.tsx +26 -0
- package/src/workspace/DefaultStatusBar.tsx +15 -0
- package/src/workspace/DefaultToolbar.tsx +88 -0
- package/src/workspace/Workspace.less +58 -0
- package/src/workspace/Workspace.stories.tsx +78 -0
- package/src/workspace/Workspace.test.tsx +186 -0
- package/src/workspace/Workspace.tsx +255 -0
- package/src/workspace/WorkspaceChrome.tsx +150 -0
- package/src/workspace/index.ts +24 -0
- package/src/workspace/slotTypes.ts +44 -0
- package/src/workspace/workspaceOps.test.ts +142 -0
- package/src/workspace/workspaceOps.ts +78 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import { type ControlPoint, CurveEditor, dlog } from '../../passthrough/weasel-ui';
|
|
3
|
+
|
|
4
|
+
export type CurveMark =
|
|
5
|
+
| { kind: 'band'; x: [number, number]; color?: string }
|
|
6
|
+
| { kind: 'line'; x: number; color?: string };
|
|
7
|
+
|
|
8
|
+
export interface CurveFieldProps {
|
|
9
|
+
/** Flat [x0, y0, x1, y1, …] — matches how curve-as-array configs
|
|
10
|
+
* serialize in JSON snapshots. */
|
|
11
|
+
values: number[];
|
|
12
|
+
min: number;
|
|
13
|
+
max: number;
|
|
14
|
+
step: number;
|
|
15
|
+
/** Plot width in CSS px. Caller sizes (typically uses a ResizeObserver). */
|
|
16
|
+
width: number;
|
|
17
|
+
/** Plot height in CSS px. Default 110. */
|
|
18
|
+
height?: number;
|
|
19
|
+
/** Flat default curve. When provided, a Reset button restores it. */
|
|
20
|
+
defaults?: readonly number[];
|
|
21
|
+
onChange: (next: number[]) => void;
|
|
22
|
+
/** Optional vertical marks overlaid on the plot (e.g. landmarks). */
|
|
23
|
+
marks?: readonly CurveMark[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A function-domain (y = f(x), x ∈ [0,1]) curve editor with per-stop
|
|
28
|
+
* numeric readouts and flip buttons. Wraps weasel-ui's
|
|
29
|
+
* `CurveEditor`; consumers wanting the raw editor (2D paths,
|
|
30
|
+
* custom anchors) should import it from `@weasel-js/labkit/weasel-ui`.
|
|
31
|
+
*/
|
|
32
|
+
export function CurveField({
|
|
33
|
+
values,
|
|
34
|
+
min,
|
|
35
|
+
max,
|
|
36
|
+
step,
|
|
37
|
+
width,
|
|
38
|
+
height = 110,
|
|
39
|
+
defaults,
|
|
40
|
+
onChange,
|
|
41
|
+
marks,
|
|
42
|
+
}: CurveFieldProps) {
|
|
43
|
+
const points: ControlPoint[] = useMemo(() => {
|
|
44
|
+
const out: ControlPoint[] = [];
|
|
45
|
+
for (let i = 0; i + 1 < values.length; i += 2) out.push({ x: values[i]!, y: values[i + 1]! });
|
|
46
|
+
return out;
|
|
47
|
+
}, [values]);
|
|
48
|
+
|
|
49
|
+
const handleChange = useCallback(
|
|
50
|
+
(next: ControlPoint[]) => {
|
|
51
|
+
const flat: number[] = new Array(next.length * 2);
|
|
52
|
+
for (let i = 0; i < next.length; i++) {
|
|
53
|
+
const ySnap = Math.round(next[i]!.y / step) * step;
|
|
54
|
+
const y = Math.max(min, Math.min(max, ySnap));
|
|
55
|
+
flat[i * 2] = next[i]!.x;
|
|
56
|
+
flat[i * 2 + 1] = y;
|
|
57
|
+
}
|
|
58
|
+
dlog('curve-field', 'handleChange', {
|
|
59
|
+
nLen: next.length,
|
|
60
|
+
flatLen: flat.length,
|
|
61
|
+
sample: flat.slice(0, 4),
|
|
62
|
+
});
|
|
63
|
+
onChange(flat);
|
|
64
|
+
},
|
|
65
|
+
[onChange, min, max, step],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const handleFlip = useCallback(() => {
|
|
69
|
+
const flipped: Array<{ x: number; y: number }> = [];
|
|
70
|
+
for (let i = 0; i + 1 < values.length; i += 2) {
|
|
71
|
+
flipped.push({ x: 1 - values[i]!, y: values[i + 1]! });
|
|
72
|
+
}
|
|
73
|
+
flipped.sort((a, b) => a.x - b.x);
|
|
74
|
+
const out: number[] = [];
|
|
75
|
+
for (const p of flipped) out.push(p.x, p.y);
|
|
76
|
+
onChange(out);
|
|
77
|
+
}, [values, onChange]);
|
|
78
|
+
|
|
79
|
+
const handleFlipVertical = useCallback(() => {
|
|
80
|
+
const mid = (min + max) / 2;
|
|
81
|
+
const flipped: number[] = new Array(values.length);
|
|
82
|
+
for (let i = 0; i + 1 < values.length; i += 2) {
|
|
83
|
+
flipped[i] = values[i]!;
|
|
84
|
+
flipped[i + 1] = 2 * mid - values[i + 1]!;
|
|
85
|
+
}
|
|
86
|
+
onChange(flipped);
|
|
87
|
+
}, [values, min, max, onChange]);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="lk-curve-field">
|
|
91
|
+
<div className="lk-curve-field__plot">
|
|
92
|
+
<CurveEditor
|
|
93
|
+
value={points}
|
|
94
|
+
onChange={handleChange}
|
|
95
|
+
domain="1d"
|
|
96
|
+
constrain="function"
|
|
97
|
+
xRange={[0, 1]}
|
|
98
|
+
yRange={[min, max]}
|
|
99
|
+
width={width}
|
|
100
|
+
height={height}
|
|
101
|
+
endpoints="pinned-x"
|
|
102
|
+
addPointMode="click-curve"
|
|
103
|
+
minPoints={2}
|
|
104
|
+
grid={{}}
|
|
105
|
+
history={false}
|
|
106
|
+
/>
|
|
107
|
+
{marks && marks.length > 0 && (
|
|
108
|
+
<svg
|
|
109
|
+
className="lk-curve-field__marks"
|
|
110
|
+
width={width}
|
|
111
|
+
height={height}
|
|
112
|
+
viewBox={`0 0 ${width} ${height}`}
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
>
|
|
115
|
+
{marks.map((m) => {
|
|
116
|
+
const color = m.color ?? '#ffcc00';
|
|
117
|
+
if (m.kind === 'band') {
|
|
118
|
+
const x0 = Math.max(0, Math.min(1, m.x[0])) * width;
|
|
119
|
+
const x1 = Math.max(0, Math.min(1, m.x[1])) * width;
|
|
120
|
+
const left = Math.min(x0, x1);
|
|
121
|
+
const w = Math.abs(x1 - x0);
|
|
122
|
+
return (
|
|
123
|
+
<g key={`band-${m.x[0]}-${m.x[1]}-${color}`}>
|
|
124
|
+
<rect x={left} y={0} width={w} height={height} fill={color} fillOpacity={0.4} />
|
|
125
|
+
<line
|
|
126
|
+
x1={left}
|
|
127
|
+
y1={0}
|
|
128
|
+
x2={left}
|
|
129
|
+
y2={height}
|
|
130
|
+
stroke={color}
|
|
131
|
+
strokeOpacity={0.85}
|
|
132
|
+
strokeWidth={1}
|
|
133
|
+
/>
|
|
134
|
+
<line
|
|
135
|
+
x1={left + w}
|
|
136
|
+
y1={0}
|
|
137
|
+
x2={left + w}
|
|
138
|
+
y2={height}
|
|
139
|
+
stroke={color}
|
|
140
|
+
strokeOpacity={0.85}
|
|
141
|
+
strokeWidth={1}
|
|
142
|
+
/>
|
|
143
|
+
</g>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const x = Math.max(0, Math.min(1, m.x)) * width;
|
|
147
|
+
return (
|
|
148
|
+
<line
|
|
149
|
+
key={`mark-${m.x}-${color}`}
|
|
150
|
+
x1={x}
|
|
151
|
+
y1={0}
|
|
152
|
+
x2={x}
|
|
153
|
+
y2={height}
|
|
154
|
+
stroke={color}
|
|
155
|
+
strokeWidth={1}
|
|
156
|
+
/>
|
|
157
|
+
);
|
|
158
|
+
})}
|
|
159
|
+
</svg>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
<div className="lk-curve-field__actions">
|
|
163
|
+
<button type="button" className="lk-curve-field__action" onClick={handleFlip}>
|
|
164
|
+
Flip horizontally
|
|
165
|
+
</button>
|
|
166
|
+
<button type="button" className="lk-curve-field__action" onClick={handleFlipVertical}>
|
|
167
|
+
Flip vertically
|
|
168
|
+
</button>
|
|
169
|
+
{defaults && (
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className="lk-curve-field__action"
|
|
173
|
+
onClick={() => onChange([...defaults])}
|
|
174
|
+
>
|
|
175
|
+
Reset
|
|
176
|
+
</button>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// ── Subpanel ─────────────────────────────────────────────────────────
|
|
4
|
+
// Headered group of controls inside a wider panel/list. The header is a
|
|
5
|
+
// label flanked by a horizontal rule — no background, padding, or border;
|
|
6
|
+
// purely a typographic divider. Ported from speech-balloons styles.css:277-313.
|
|
7
|
+
|
|
8
|
+
export interface SubpanelProps {
|
|
9
|
+
title: ReactNode;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Subpanel({ title, children, className }: SubpanelProps) {
|
|
15
|
+
const cls = className ? `lk-subpanel ${className}` : 'lk-subpanel';
|
|
16
|
+
return (
|
|
17
|
+
<div className={cls}>
|
|
18
|
+
<h4 className="lk-subpanel__title">
|
|
19
|
+
<span>{title}</span>
|
|
20
|
+
<hr />
|
|
21
|
+
</h4>
|
|
22
|
+
{children}
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── EffectCard ───────────────────────────────────────────────────────
|
|
28
|
+
// Draggable, collapsible card for stacked effect editors. Optional --lk-panel-accent
|
|
29
|
+
// per-instance recolors the title bar, border-left, and (via re-binding --lk-accent
|
|
30
|
+
// inside the card) every descendant control that reads from the accent token.
|
|
31
|
+
|
|
32
|
+
export interface EffectCardProps {
|
|
33
|
+
/** Card title rendered in the title bar (e.g. effect kind label or a select). */
|
|
34
|
+
title: ReactNode;
|
|
35
|
+
/** Optional badge/chip displayed before the remove button (e.g. primary value summary). */
|
|
36
|
+
primary?: ReactNode;
|
|
37
|
+
/** Optional accent color in any CSS color form. Sets --lk-panel-accent on the card. */
|
|
38
|
+
accent?: string;
|
|
39
|
+
/** Optional ordinal badge that doubles as the drag handle (used by tail cards in SB). */
|
|
40
|
+
index?: number;
|
|
41
|
+
expanded?: boolean;
|
|
42
|
+
onToggleExpanded?: () => void;
|
|
43
|
+
onRemove?: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Drag-handle wiring. When provided, an inline handle (⋮⋮ icon or `index`)
|
|
46
|
+
* activates HTML5 drag on press. Consumer owns the drag/drop coordination —
|
|
47
|
+
* see EffectCardList for the batteries-included wrapper.
|
|
48
|
+
*/
|
|
49
|
+
draggable?: boolean;
|
|
50
|
+
onPressHandle?: () => void;
|
|
51
|
+
onReleaseHandle?: () => void;
|
|
52
|
+
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void;
|
|
53
|
+
onDragEnd?: (e: React.DragEvent<HTMLDivElement>) => void;
|
|
54
|
+
onDragOver?: (e: React.DragEvent<HTMLDivElement>) => void;
|
|
55
|
+
onDragLeave?: (e: React.DragEvent<HTMLDivElement>) => void;
|
|
56
|
+
onDrop?: (e: React.DragEvent<HTMLDivElement>) => void;
|
|
57
|
+
dragging?: boolean;
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
className?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function EffectCard({
|
|
63
|
+
title,
|
|
64
|
+
primary,
|
|
65
|
+
accent,
|
|
66
|
+
index,
|
|
67
|
+
expanded = true,
|
|
68
|
+
onToggleExpanded,
|
|
69
|
+
onRemove,
|
|
70
|
+
draggable,
|
|
71
|
+
onPressHandle,
|
|
72
|
+
onReleaseHandle,
|
|
73
|
+
onDragStart,
|
|
74
|
+
onDragEnd,
|
|
75
|
+
onDragOver,
|
|
76
|
+
onDragLeave,
|
|
77
|
+
onDrop,
|
|
78
|
+
dragging,
|
|
79
|
+
children,
|
|
80
|
+
className,
|
|
81
|
+
}: EffectCardProps) {
|
|
82
|
+
const stateClass = `${expanded ? 'is-expanded' : 'is-collapsed'}${dragging ? ' is-dragging' : ''}${accent ? ' has-accent' : ''}`;
|
|
83
|
+
const cls = `lk-effect-card ${stateClass}${className ? ` ${className}` : ''}`;
|
|
84
|
+
// Per-instance dynamic color → CSS custom property requires inline style.
|
|
85
|
+
// The card's CSS re-binds --lk-accent to --lk-panel-accent so descendants tint.
|
|
86
|
+
const style = accent ? ({ '--lk-panel-accent': accent } as CSSProperties) : undefined;
|
|
87
|
+
|
|
88
|
+
// When a toggle handler is supplied the header acts as a disclosure button:
|
|
89
|
+
// role + tab stop + Enter/Space. Bundled together so the interactive props are
|
|
90
|
+
// applied as a set (no half-interactive element when onToggleExpanded is absent).
|
|
91
|
+
const headProps = onToggleExpanded
|
|
92
|
+
? {
|
|
93
|
+
role: 'button' as const,
|
|
94
|
+
tabIndex: 0,
|
|
95
|
+
onClick: onToggleExpanded,
|
|
96
|
+
onKeyDown: (e: React.KeyboardEvent) => {
|
|
97
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
onToggleExpanded();
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
: {};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: native HTML5 drag-and-drop container — drag events are not click/keyboard interactions and have no semantic element; the in-card handle and header carry the a11y affordances.
|
|
107
|
+
<div
|
|
108
|
+
className={cls}
|
|
109
|
+
style={style}
|
|
110
|
+
draggable={draggable}
|
|
111
|
+
onDragStart={onDragStart}
|
|
112
|
+
onDragEnd={onDragEnd}
|
|
113
|
+
onDragOver={onDragOver}
|
|
114
|
+
onDragLeave={onDragLeave}
|
|
115
|
+
onDrop={onDrop}
|
|
116
|
+
>
|
|
117
|
+
<div className="lk-effect-card__head" {...headProps}>
|
|
118
|
+
{index != null ? (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="lk-effect-card__index-badge lk-effect-card__handle"
|
|
122
|
+
title="Drag to reorder · click to toggle"
|
|
123
|
+
aria-label={`Item ${index + 1} — drag to reorder, click to toggle`}
|
|
124
|
+
onMouseDown={onPressHandle}
|
|
125
|
+
onMouseUp={onReleaseHandle}
|
|
126
|
+
onClick={(e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
onToggleExpanded?.();
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{index + 1}
|
|
132
|
+
</button>
|
|
133
|
+
) : (
|
|
134
|
+
<span
|
|
135
|
+
className="lk-effect-card__handle"
|
|
136
|
+
aria-hidden="true"
|
|
137
|
+
title="Drag to reorder"
|
|
138
|
+
onMouseDown={onPressHandle}
|
|
139
|
+
onMouseUp={onReleaseHandle}
|
|
140
|
+
>
|
|
141
|
+
<DragHandleIcon />
|
|
142
|
+
</span>
|
|
143
|
+
)}
|
|
144
|
+
<span className="lk-effect-card__title">{title}</span>
|
|
145
|
+
{primary != null && <span className="lk-effect-card__primary">{primary}</span>}
|
|
146
|
+
{onRemove && (
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="lk-effect-card__remove"
|
|
150
|
+
onClick={(e) => {
|
|
151
|
+
e.stopPropagation();
|
|
152
|
+
onRemove();
|
|
153
|
+
}}
|
|
154
|
+
title="Remove"
|
|
155
|
+
aria-label="Remove"
|
|
156
|
+
>
|
|
157
|
+
✕
|
|
158
|
+
</button>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{expanded && <div className="lk-effect-card__body">{children}</div>}
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function DragHandleIcon() {
|
|
167
|
+
return (
|
|
168
|
+
<svg width="10" height="14" viewBox="0 0 10 14" aria-hidden="true">
|
|
169
|
+
<circle cx="3" cy="3" r="1.3" fill="currentColor" />
|
|
170
|
+
<circle cx="7" cy="3" r="1.3" fill="currentColor" />
|
|
171
|
+
<circle cx="3" cy="7" r="1.3" fill="currentColor" />
|
|
172
|
+
<circle cx="7" cy="7" r="1.3" fill="currentColor" />
|
|
173
|
+
<circle cx="3" cy="11" r="1.3" fill="currentColor" />
|
|
174
|
+
<circle cx="7" cy="11" r="1.3" fill="currentColor" />
|
|
175
|
+
</svg>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── EffectCardList ───────────────────────────────────────────────────
|
|
180
|
+
// Batteries-included list wrapper that coordinates HTML5 drag-and-drop across
|
|
181
|
+
// a list of EffectCards. Consumer supplies items + a renderItem callback;
|
|
182
|
+
// onReorder fires with (sourceId, targetId, position) on drop. Mirrors the
|
|
183
|
+
// LayerStack pattern from speech-balloons Lab.tsx:630-707.
|
|
184
|
+
|
|
185
|
+
export interface EffectCardListItem {
|
|
186
|
+
id: string | number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface EffectCardListProps<T extends EffectCardListItem> {
|
|
190
|
+
items: ReadonlyArray<T>;
|
|
191
|
+
renderItem: (
|
|
192
|
+
item: T,
|
|
193
|
+
helpers: {
|
|
194
|
+
isExpanded: boolean;
|
|
195
|
+
isDragging: boolean;
|
|
196
|
+
toggleExpanded: () => void;
|
|
197
|
+
cardProps: Pick<
|
|
198
|
+
EffectCardProps,
|
|
199
|
+
| 'expanded'
|
|
200
|
+
| 'dragging'
|
|
201
|
+
| 'draggable'
|
|
202
|
+
| 'onToggleExpanded'
|
|
203
|
+
| 'onPressHandle'
|
|
204
|
+
| 'onReleaseHandle'
|
|
205
|
+
| 'onDragStart'
|
|
206
|
+
| 'onDragEnd'
|
|
207
|
+
| 'onDragOver'
|
|
208
|
+
| 'onDragLeave'
|
|
209
|
+
| 'onDrop'
|
|
210
|
+
>;
|
|
211
|
+
},
|
|
212
|
+
) => ReactNode;
|
|
213
|
+
onReorder: (sourceId: T['id'], targetId: T['id'], position: 'before' | 'after') => void;
|
|
214
|
+
/** Optional rendered when the list is empty. */
|
|
215
|
+
empty?: ReactNode;
|
|
216
|
+
/** IDs to render expanded on first mount. Defaults to all collapsed. */
|
|
217
|
+
defaultExpandedIds?: ReadonlyArray<T['id']>;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function EffectCardList<T extends EffectCardListItem>({
|
|
221
|
+
items,
|
|
222
|
+
renderItem,
|
|
223
|
+
onReorder,
|
|
224
|
+
empty,
|
|
225
|
+
defaultExpandedIds,
|
|
226
|
+
}: EffectCardListProps<T>) {
|
|
227
|
+
const [expanded, setExpanded] = useState<Set<T['id']>>(() => new Set(defaultExpandedIds ?? []));
|
|
228
|
+
const [draggingId, setDraggingId] = useState<T['id'] | null>(null);
|
|
229
|
+
const [pressedId, setPressedId] = useState<T['id'] | null>(null);
|
|
230
|
+
const [dropHint, setDropHint] = useState<{ id: T['id']; position: 'before' | 'after' } | null>(
|
|
231
|
+
null,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (items.length === 0) {
|
|
235
|
+
return <>{empty ?? <div className="lk-effect-card-list__empty">No items.</div>}</>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<div className="lk-effect-card-list">
|
|
240
|
+
{items.map((item) => {
|
|
241
|
+
const isExpanded = expanded.has(item.id);
|
|
242
|
+
const isDragging = draggingId === item.id;
|
|
243
|
+
const isPressed = pressedId === item.id;
|
|
244
|
+
const showHintBefore =
|
|
245
|
+
dropHint && dropHint.id === item.id && dropHint.position === 'before';
|
|
246
|
+
const showHintAfter = dropHint && dropHint.id === item.id && dropHint.position === 'after';
|
|
247
|
+
|
|
248
|
+
const toggleExpanded = () =>
|
|
249
|
+
setExpanded((prev) => {
|
|
250
|
+
const next = new Set(prev);
|
|
251
|
+
if (next.has(item.id)) next.delete(item.id);
|
|
252
|
+
else next.add(item.id);
|
|
253
|
+
return next;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div key={String(item.id)} className="lk-effect-card-list__wrap">
|
|
258
|
+
{showHintBefore && <div className="lk-effect-card-list__drop-hint" />}
|
|
259
|
+
{renderItem(item, {
|
|
260
|
+
isExpanded,
|
|
261
|
+
isDragging,
|
|
262
|
+
toggleExpanded,
|
|
263
|
+
cardProps: {
|
|
264
|
+
expanded: isExpanded,
|
|
265
|
+
dragging: isDragging,
|
|
266
|
+
draggable: isPressed,
|
|
267
|
+
onToggleExpanded: toggleExpanded,
|
|
268
|
+
onPressHandle: () => setPressedId(item.id),
|
|
269
|
+
onReleaseHandle: () => setPressedId(null),
|
|
270
|
+
onDragStart: (e) => {
|
|
271
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
272
|
+
e.dataTransfer.setData('text/plain', String(item.id));
|
|
273
|
+
setDraggingId(item.id);
|
|
274
|
+
},
|
|
275
|
+
onDragEnd: () => {
|
|
276
|
+
setDraggingId(null);
|
|
277
|
+
setPressedId(null);
|
|
278
|
+
setDropHint(null);
|
|
279
|
+
},
|
|
280
|
+
onDragOver: (e) => {
|
|
281
|
+
if (draggingId === null || draggingId === item.id) return;
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
e.dataTransfer.dropEffect = 'move';
|
|
284
|
+
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
285
|
+
const position: 'before' | 'after' =
|
|
286
|
+
e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
|
287
|
+
setDropHint({ id: item.id, position });
|
|
288
|
+
},
|
|
289
|
+
onDragLeave: () => {
|
|
290
|
+
setDropHint((h) => (h && h.id === item.id ? null : h));
|
|
291
|
+
},
|
|
292
|
+
onDrop: (e) => {
|
|
293
|
+
e.preventDefault();
|
|
294
|
+
if (draggingId === null || draggingId === item.id) {
|
|
295
|
+
setDropHint(null);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
299
|
+
const position: 'before' | 'after' =
|
|
300
|
+
e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
|
|
301
|
+
onReorder(draggingId, item.id, position);
|
|
302
|
+
setDropHint(null);
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
})}
|
|
306
|
+
{showHintAfter && <div className="lk-effect-card-list__drop-hint" />}
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
})}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
CheckboxRow,
|
|
5
|
+
ColorRow,
|
|
6
|
+
NumberRow,
|
|
7
|
+
PropertyList,
|
|
8
|
+
PropertyPanel,
|
|
9
|
+
PropertyRow,
|
|
10
|
+
SelectRow,
|
|
11
|
+
SliderRow,
|
|
12
|
+
TextRow,
|
|
13
|
+
ToggleRow,
|
|
14
|
+
} from './PropertyPanel';
|
|
15
|
+
|
|
16
|
+
// Titled "Gallery" so it sorts above PropertyPanel/PropertyList/Rows in the
|
|
17
|
+
// sidebar alphabetically (G < P < R). Acts as the visual root of the section.
|
|
18
|
+
const meta: Meta = {
|
|
19
|
+
title: 'labkit/UI/Properties/Gallery',
|
|
20
|
+
};
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj;
|
|
23
|
+
|
|
24
|
+
function BlockRows() {
|
|
25
|
+
const [opacity, setOpacity] = useState(0.65);
|
|
26
|
+
const [count, setCount] = useState(8);
|
|
27
|
+
const [name, setName] = useState('Untitled');
|
|
28
|
+
const [mode, setMode] = useState<'fill' | 'stroke' | 'both'>('both');
|
|
29
|
+
const [align, setAlign] = useState<'left' | 'center' | 'right'>('center');
|
|
30
|
+
return (
|
|
31
|
+
<PropertyPanel title="Block layout (default)">
|
|
32
|
+
<PropertyList>
|
|
33
|
+
<SliderRow
|
|
34
|
+
label="Opacity"
|
|
35
|
+
value={opacity}
|
|
36
|
+
min={0}
|
|
37
|
+
max={1}
|
|
38
|
+
step={0.01}
|
|
39
|
+
onChange={setOpacity}
|
|
40
|
+
format={(v) => v.toFixed(2)}
|
|
41
|
+
/>
|
|
42
|
+
<NumberRow label="Count" value={count} onChange={setCount} min={0} max={100} />
|
|
43
|
+
<TextRow label="Name" value={name} onChange={setName} />
|
|
44
|
+
<SelectRow
|
|
45
|
+
label="Mode"
|
|
46
|
+
value={mode}
|
|
47
|
+
onChange={setMode}
|
|
48
|
+
options={[
|
|
49
|
+
{ value: 'fill', label: 'Fill only' },
|
|
50
|
+
{ value: 'stroke', label: 'Stroke only' },
|
|
51
|
+
{ value: 'both', label: 'Fill + stroke' },
|
|
52
|
+
]}
|
|
53
|
+
/>
|
|
54
|
+
<ToggleRow
|
|
55
|
+
label="Align"
|
|
56
|
+
value={align}
|
|
57
|
+
onChange={setAlign}
|
|
58
|
+
options={[
|
|
59
|
+
{ value: 'left', label: 'L' },
|
|
60
|
+
{ value: 'center', label: 'C' },
|
|
61
|
+
{ value: 'right', label: 'R' },
|
|
62
|
+
]}
|
|
63
|
+
/>
|
|
64
|
+
</PropertyList>
|
|
65
|
+
</PropertyPanel>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function InlineRows() {
|
|
70
|
+
const [opacity, setOpacity] = useState(0.5);
|
|
71
|
+
const [count, setCount] = useState(8);
|
|
72
|
+
const [name, setName] = useState('Untitled');
|
|
73
|
+
const [mode, setMode] = useState<'fill' | 'stroke' | 'both'>('both');
|
|
74
|
+
const [align, setAlign] = useState<'left' | 'center' | 'right'>('center');
|
|
75
|
+
return (
|
|
76
|
+
<PropertyPanel title="Inline layout">
|
|
77
|
+
<PropertyList>
|
|
78
|
+
<SliderRow
|
|
79
|
+
label="Opacity"
|
|
80
|
+
layout="inline"
|
|
81
|
+
value={opacity}
|
|
82
|
+
min={0}
|
|
83
|
+
max={1}
|
|
84
|
+
step={0.01}
|
|
85
|
+
onChange={setOpacity}
|
|
86
|
+
format={(v) => v.toFixed(2)}
|
|
87
|
+
/>
|
|
88
|
+
<NumberRow
|
|
89
|
+
label="Count"
|
|
90
|
+
layout="inline"
|
|
91
|
+
value={count}
|
|
92
|
+
onChange={setCount}
|
|
93
|
+
min={0}
|
|
94
|
+
max={100}
|
|
95
|
+
/>
|
|
96
|
+
<TextRow label="Name" layout="inline" value={name} onChange={setName} />
|
|
97
|
+
<SelectRow
|
|
98
|
+
label="Mode"
|
|
99
|
+
layout="inline"
|
|
100
|
+
value={mode}
|
|
101
|
+
onChange={setMode}
|
|
102
|
+
options={[
|
|
103
|
+
{ value: 'fill', label: 'Fill only' },
|
|
104
|
+
{ value: 'stroke', label: 'Stroke only' },
|
|
105
|
+
{ value: 'both', label: 'Fill + stroke' },
|
|
106
|
+
]}
|
|
107
|
+
/>
|
|
108
|
+
<ToggleRow
|
|
109
|
+
label="Align"
|
|
110
|
+
layout="inline"
|
|
111
|
+
value={align}
|
|
112
|
+
onChange={setAlign}
|
|
113
|
+
options={[
|
|
114
|
+
{ value: 'left', label: 'L' },
|
|
115
|
+
{ value: 'center', label: 'C' },
|
|
116
|
+
{ value: 'right', label: 'R' },
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
</PropertyList>
|
|
120
|
+
</PropertyPanel>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function IntrinsicRows() {
|
|
125
|
+
const [fill, setFill] = useState('#b08adb');
|
|
126
|
+
const [stroke, setStroke] = useState('#1a1428');
|
|
127
|
+
const [highlight, setHighlight] = useState('#7fb069');
|
|
128
|
+
const [shadow, setShadow] = useState('#000000');
|
|
129
|
+
const [visible, setVisible] = useState(true);
|
|
130
|
+
const [snap, setSnap] = useState(false);
|
|
131
|
+
return (
|
|
132
|
+
<PropertyPanel title="Intrinsic-layout variants">
|
|
133
|
+
<PropertyList>
|
|
134
|
+
<ColorRow label="Fill" value={fill} onChange={setFill} />
|
|
135
|
+
<ColorRow label="Stroke" value={stroke} onChange={setStroke} />
|
|
136
|
+
<ColorRow label="Highlight" value={highlight} onChange={setHighlight} />
|
|
137
|
+
<ColorRow label="Shadow" value={shadow} onChange={setShadow} />
|
|
138
|
+
<CheckboxRow label="Visible" value={visible} onChange={setVisible} />
|
|
139
|
+
<CheckboxRow label="Snap to grid" value={snap} onChange={setSnap} />
|
|
140
|
+
</PropertyList>
|
|
141
|
+
</PropertyPanel>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function CustomRows() {
|
|
146
|
+
return (
|
|
147
|
+
<PropertyPanel title="Custom (PropertyRow)">
|
|
148
|
+
<PropertyList>
|
|
149
|
+
<PropertyRow label="Tempo" readout="120 bpm">
|
|
150
|
+
<button type="button">Tap to set</button>
|
|
151
|
+
</PropertyRow>
|
|
152
|
+
<PropertyRow label="Free-form" layout="inline">
|
|
153
|
+
<code style={{ background: 'rgba(0,0,0,0.35)', padding: '2px 6px', borderRadius: 3 }}>
|
|
154
|
+
arbitrary children
|
|
155
|
+
</code>
|
|
156
|
+
</PropertyRow>
|
|
157
|
+
</PropertyList>
|
|
158
|
+
</PropertyPanel>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const All: Story = {
|
|
163
|
+
render: () => (
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
display: 'grid',
|
|
167
|
+
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
|
168
|
+
gap: 16,
|
|
169
|
+
maxWidth: 1100,
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<BlockRows />
|
|
173
|
+
<InlineRows />
|
|
174
|
+
<IntrinsicRows />
|
|
175
|
+
<CustomRows />
|
|
176
|
+
</div>
|
|
177
|
+
),
|
|
178
|
+
};
|