@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.
Files changed (217) hide show
  1. package/README.md +72 -0
  2. package/dist/_dts/fitViewToBounds-rWrBI4a6.d.ts +870 -0
  3. package/dist/_dts/index-C6Yze7sQ.d.ts +93 -0
  4. package/dist/_dts/types-Si4Fw-1F.d.ts +48 -0
  5. package/dist/_dts/useExperimentState-eSvpwV_P.d.ts +102 -0
  6. package/dist/canvas/index.d.ts +34 -0
  7. package/dist/canvas/index.js +4 -0
  8. package/dist/canvas/index.js.map +1 -0
  9. package/dist/chunk-2QNYYL3V.js +86 -0
  10. package/dist/chunk-2QNYYL3V.js.map +1 -0
  11. package/dist/chunk-54IQ2DX7.js +222 -0
  12. package/dist/chunk-54IQ2DX7.js.map +1 -0
  13. package/dist/chunk-6FO7G5BB.js +6095 -0
  14. package/dist/chunk-6FO7G5BB.js.map +1 -0
  15. package/dist/chunk-7BKDG73Z.js +54 -0
  16. package/dist/chunk-7BKDG73Z.js.map +1 -0
  17. package/dist/chunk-B5ZUYRKN.js +23330 -0
  18. package/dist/chunk-B5ZUYRKN.js.map +1 -0
  19. package/dist/chunk-HXZHVU4G.js +126 -0
  20. package/dist/chunk-HXZHVU4G.js.map +1 -0
  21. package/dist/chunk-PWC7AQZM.js +8 -0
  22. package/dist/chunk-PWC7AQZM.js.map +1 -0
  23. package/dist/chunk-SD3HDXR7.js +161 -0
  24. package/dist/chunk-SD3HDXR7.js.map +1 -0
  25. package/dist/chunk-SSMHPMDV.js +350 -0
  26. package/dist/chunk-SSMHPMDV.js.map +1 -0
  27. package/dist/chunk-VLAHRJOC.js +201 -0
  28. package/dist/chunk-VLAHRJOC.js.map +1 -0
  29. package/dist/controls/index.d.ts +13 -0
  30. package/dist/controls/index.js +3 -0
  31. package/dist/controls/index.js.map +1 -0
  32. package/dist/dragdrop/index.d.ts +3 -0
  33. package/dist/dragdrop/index.js +3 -0
  34. package/dist/dragdrop/index.js.map +1 -0
  35. package/dist/fonts/oswald-latin-variable.woff2 +0 -0
  36. package/dist/index.d.ts +410 -0
  37. package/dist/index.js +1425 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/layers/index.d.ts +17 -0
  40. package/dist/layers/index.js +3 -0
  41. package/dist/layers/index.js.map +1 -0
  42. package/dist/passthrough/weasel-canvas.d.ts +258 -0
  43. package/dist/passthrough/weasel-canvas.js +3 -0
  44. package/dist/passthrough/weasel-canvas.js.map +1 -0
  45. package/dist/passthrough/weasel-ui.d.ts +2826 -0
  46. package/dist/passthrough/weasel-ui.js +4 -0
  47. package/dist/passthrough/weasel-ui.js.map +1 -0
  48. package/dist/primitives/index.d.ts +57 -0
  49. package/dist/primitives/index.js +4 -0
  50. package/dist/primitives/index.js.map +1 -0
  51. package/dist/state/index.d.ts +33 -0
  52. package/dist/state/index.js +34 -0
  53. package/dist/state/index.js.map +1 -0
  54. package/dist/styles.css +1224 -0
  55. package/dist/theme-interstellar.css +25 -0
  56. package/dist/theme-light.css +13 -0
  57. package/dist/ui/layers/index.d.ts +42 -0
  58. package/dist/ui/layers/index.js +5 -0
  59. package/dist/ui/layers/index.js.map +1 -0
  60. package/dist/undo/index.d.ts +30 -0
  61. package/dist/undo/index.js +3 -0
  62. package/dist/undo/index.js.map +1 -0
  63. package/package.json +99 -0
  64. package/src/canvas/AGENTS.md +72 -0
  65. package/src/canvas/CanvasStack.less +22 -0
  66. package/src/canvas/CanvasStack.stories.tsx +63 -0
  67. package/src/canvas/CanvasStack.test.tsx +82 -0
  68. package/src/canvas/CanvasStack.tsx +108 -0
  69. package/src/canvas/CanvasStackContext.ts +8 -0
  70. package/src/canvas/canvasCoords.test.ts +30 -0
  71. package/src/canvas/canvasCoords.ts +15 -0
  72. package/src/canvas/index.ts +6 -0
  73. package/src/canvas/useLayerScheduler.ts +72 -0
  74. package/src/canvas/usePanZoom.ts +98 -0
  75. package/src/controls/ControlPanel.less +61 -0
  76. package/src/controls/ControlPanel.stories.tsx +77 -0
  77. package/src/controls/ControlPanel.test.tsx +149 -0
  78. package/src/controls/ControlPanel.tsx +249 -0
  79. package/src/controls/index.ts +12 -0
  80. package/src/controls/types.ts +61 -0
  81. package/src/dragdrop/DragDropRuntime.tsx +135 -0
  82. package/src/dragdrop/DragGhost.less +17 -0
  83. package/src/dragdrop/DragGhost.tsx +24 -0
  84. package/src/dragdrop/Palette.less +34 -0
  85. package/src/dragdrop/Palette.tsx +27 -0
  86. package/src/dragdrop/dragDrop.test.tsx +145 -0
  87. package/src/dragdrop/index.ts +1 -0
  88. package/src/fonts/oswald-latin-variable.woff2 +0 -0
  89. package/src/index.ts +70 -0
  90. package/src/instrument/SineWave.smoke.test.tsx +94 -0
  91. package/src/instrument/capabilityDetector.test.ts +64 -0
  92. package/src/instrument/capabilityDetector.ts +17 -0
  93. package/src/instrument/defineInstrument.test.ts +27 -0
  94. package/src/instrument/defineInstrument.ts +5 -0
  95. package/src/instrument/index.ts +19 -0
  96. package/src/instrument/types.ts +65 -0
  97. package/src/instrument/validateConfigSchema.test.ts +125 -0
  98. package/src/instrument/validateConfigSchema.ts +65 -0
  99. package/src/lab/Lab.less +3 -0
  100. package/src/lab/Lab.stories.tsx +51 -0
  101. package/src/lab/Lab.test.tsx +126 -0
  102. package/src/lab/Lab.tsx +196 -0
  103. package/src/lab/LabContext.test.ts +11 -0
  104. package/src/lab/LabContext.ts +28 -0
  105. package/src/lab/LabShell.less +45 -0
  106. package/src/lab/LabShell.stories.tsx +39 -0
  107. package/src/lab/LabShell.test.tsx +59 -0
  108. package/src/lab/LabShell.tsx +33 -0
  109. package/src/lab/WorkspaceGrid.less +8 -0
  110. package/src/lab/WorkspaceGrid.stories.tsx +61 -0
  111. package/src/lab/WorkspaceGrid.test.tsx +40 -0
  112. package/src/lab/WorkspaceGrid.tsx +20 -0
  113. package/src/lab/gridDims.test.ts +35 -0
  114. package/src/lab/gridDims.ts +11 -0
  115. package/src/lab/index.ts +10 -0
  116. package/src/layers/AGENTS.md +65 -0
  117. package/src/layers/LayerList.less +53 -0
  118. package/src/layers/LayerList.test.tsx +49 -0
  119. package/src/layers/LayerList.tsx +105 -0
  120. package/src/layers/index.ts +3 -0
  121. package/src/passthrough/weasel-canvas.test.ts +30 -0
  122. package/src/passthrough/weasel-canvas.ts +23 -0
  123. package/src/passthrough/weasel-ui.test.ts +79 -0
  124. package/src/passthrough/weasel-ui.ts +150 -0
  125. package/src/primitives/FpsMeter.less +14 -0
  126. package/src/primitives/FpsMeter.stories.tsx +14 -0
  127. package/src/primitives/FpsMeter.test.tsx +38 -0
  128. package/src/primitives/FpsMeter.tsx +36 -0
  129. package/src/primitives/ScaleIndicator.less +25 -0
  130. package/src/primitives/ScaleIndicator.stories.tsx +16 -0
  131. package/src/primitives/ScaleIndicator.test.tsx +46 -0
  132. package/src/primitives/ScaleIndicator.tsx +48 -0
  133. package/src/primitives/Sidebar.less +57 -0
  134. package/src/primitives/Sidebar.stories.tsx +38 -0
  135. package/src/primitives/Sidebar.test.tsx +74 -0
  136. package/src/primitives/Sidebar.tsx +32 -0
  137. package/src/primitives/StatusBar.less +21 -0
  138. package/src/primitives/StatusBar.stories.tsx +24 -0
  139. package/src/primitives/StatusBar.test.tsx +25 -0
  140. package/src/primitives/StatusBar.tsx +18 -0
  141. package/src/primitives/Toolbar.less +43 -0
  142. package/src/primitives/Toolbar.stories.tsx +36 -0
  143. package/src/primitives/Toolbar.test.tsx +65 -0
  144. package/src/primitives/Toolbar.tsx +44 -0
  145. package/src/primitives/fpsAverage.test.ts +21 -0
  146. package/src/primitives/fpsAverage.ts +5 -0
  147. package/src/primitives/index.ts +9 -0
  148. package/src/state/SingletonExperiment.test.tsx +101 -0
  149. package/src/state/SingletonExperiment.tsx +52 -0
  150. package/src/state/adapters.test.ts +94 -0
  151. package/src/state/adapters.ts +102 -0
  152. package/src/state/context.tsx +42 -0
  153. package/src/state/helpers.test.ts +89 -0
  154. package/src/state/helpers.ts +61 -0
  155. package/src/state/index.ts +40 -0
  156. package/src/state/store.test.ts +243 -0
  157. package/src/state/store.ts +225 -0
  158. package/src/state/types.ts +53 -0
  159. package/src/state/useExperimentState.test.tsx +102 -0
  160. package/src/state/useExperimentState.ts +28 -0
  161. package/src/styles.less +20 -0
  162. package/src/test-setup.ts +17 -0
  163. package/src/theme/Interstellar.stories.less +92 -0
  164. package/src/theme/Interstellar.stories.tsx +243 -0
  165. package/src/theme/base.less +122 -0
  166. package/src/theme/interstellar.less +34 -0
  167. package/src/theme/light.less +14 -0
  168. package/src/theme/tokens.less +85 -0
  169. package/src/ui/format.ts +19 -0
  170. package/src/ui/layers/LayerStack.less +143 -0
  171. package/src/ui/layers/LayerStack.stories.tsx +45 -0
  172. package/src/ui/layers/LayerStack.test.tsx +170 -0
  173. package/src/ui/layers/LayerStack.tsx +215 -0
  174. package/src/ui/layers/index.ts +1 -0
  175. package/src/ui/properties/CheckboxRow.stories.tsx +27 -0
  176. package/src/ui/properties/ColorRow.stories.tsx +106 -0
  177. package/src/ui/properties/CurveField.less +48 -0
  178. package/src/ui/properties/CurveField.stories.tsx +20 -0
  179. package/src/ui/properties/CurveField.test.tsx +127 -0
  180. package/src/ui/properties/CurveField.tsx +181 -0
  181. package/src/ui/properties/EffectCard.tsx +312 -0
  182. package/src/ui/properties/Gallery.stories.tsx +178 -0
  183. package/src/ui/properties/NumberRow.stories.tsx +65 -0
  184. package/src/ui/properties/PropertyGroup.less +50 -0
  185. package/src/ui/properties/PropertyGroup.stories.tsx +34 -0
  186. package/src/ui/properties/PropertyGroup.test.tsx +33 -0
  187. package/src/ui/properties/PropertyGroup.tsx +44 -0
  188. package/src/ui/properties/PropertyList.stories.tsx +83 -0
  189. package/src/ui/properties/PropertyPanel.less +592 -0
  190. package/src/ui/properties/PropertyPanel.stories.tsx +191 -0
  191. package/src/ui/properties/PropertyPanel.test.tsx +193 -0
  192. package/src/ui/properties/PropertyPanel.tsx +441 -0
  193. package/src/ui/properties/PropertyRow.stories.tsx +53 -0
  194. package/src/ui/properties/SelectRow.stories.tsx +58 -0
  195. package/src/ui/properties/SliderRow.stories.tsx +74 -0
  196. package/src/ui/properties/SpeechBalloonPanels.stories.tsx +396 -0
  197. package/src/ui/properties/TextRow.stories.tsx +62 -0
  198. package/src/ui/properties/ToggleRow.stories.tsx +57 -0
  199. package/src/ui/properties/index.ts +39 -0
  200. package/src/ui/properties/storyLayouts.tsx +36 -0
  201. package/src/undo/eventBus.test.ts +49 -0
  202. package/src/undo/eventBus.ts +32 -0
  203. package/src/undo/index.ts +5 -0
  204. package/src/undo/undoStack.test.ts +70 -0
  205. package/src/undo/undoStack.ts +38 -0
  206. package/src/workspace/DefaultSidebar.tsx +26 -0
  207. package/src/workspace/DefaultStatusBar.tsx +15 -0
  208. package/src/workspace/DefaultToolbar.tsx +88 -0
  209. package/src/workspace/Workspace.less +58 -0
  210. package/src/workspace/Workspace.stories.tsx +78 -0
  211. package/src/workspace/Workspace.test.tsx +186 -0
  212. package/src/workspace/Workspace.tsx +255 -0
  213. package/src/workspace/WorkspaceChrome.tsx +150 -0
  214. package/src/workspace/index.ts +24 -0
  215. package/src/workspace/slotTypes.ts +44 -0
  216. package/src/workspace/workspaceOps.test.ts +142 -0
  217. package/src/workspace/workspaceOps.ts +78 -0
@@ -0,0 +1,249 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type {
3
+ CheckboxField,
4
+ ColorField,
5
+ ConfigField,
6
+ NumberField,
7
+ SelectField,
8
+ SliderField,
9
+ TextField,
10
+ } from './types';
11
+
12
+ export interface ControlPanelProps<TC extends Record<string, unknown>> {
13
+ fields: ConfigField[];
14
+ config: TC;
15
+ setConfig: (key: keyof TC, value: unknown) => void;
16
+ className?: string;
17
+ }
18
+
19
+ export function ControlPanel<TC extends Record<string, unknown>>({
20
+ fields,
21
+ config,
22
+ setConfig,
23
+ className,
24
+ }: ControlPanelProps<TC>) {
25
+ return (
26
+ <div className={className ? `lk-control-panel ${className}` : 'lk-control-panel'}>
27
+ {fields.map((field) => (
28
+ <ControlRow key={field.key} field={field} config={config} setConfig={setConfig} />
29
+ ))}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ interface ControlRowProps<TC extends Record<string, unknown>> {
35
+ field: ConfigField;
36
+ config: TC;
37
+ setConfig: (key: keyof TC, value: unknown) => void;
38
+ }
39
+
40
+ function ControlRow<TC extends Record<string, unknown>>({
41
+ field,
42
+ config,
43
+ setConfig,
44
+ }: ControlRowProps<TC>) {
45
+ switch (field.type) {
46
+ case 'slider':
47
+ return <SliderRow field={field} config={config} setConfig={setConfig} />;
48
+ case 'checkbox':
49
+ return <CheckboxRow field={field} config={config} setConfig={setConfig} />;
50
+ case 'select':
51
+ return <SelectRow field={field} config={config} setConfig={setConfig} />;
52
+ case 'number':
53
+ return <NumberRow field={field} config={config} setConfig={setConfig} />;
54
+ case 'text':
55
+ return <TextRow field={field} config={config} setConfig={setConfig} />;
56
+ case 'color':
57
+ return <ColorRow field={field} config={config} setConfig={setConfig} />;
58
+ default:
59
+ return null;
60
+ }
61
+ }
62
+
63
+ type RowProps<F extends ConfigField, TC extends Record<string, unknown>> = {
64
+ field: F;
65
+ config: TC;
66
+ setConfig: (key: keyof TC, value: unknown) => void;
67
+ };
68
+
69
+ function SliderRow<TC extends Record<string, unknown>>({
70
+ field,
71
+ config,
72
+ setConfig,
73
+ }: RowProps<SliderField, TC>) {
74
+ const value = (config[field.key] as number | undefined) ?? field.default;
75
+ return (
76
+ <div className="lk-control-row">
77
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
78
+ {field.label}
79
+ </label>
80
+ <div className="lk-control-slider-row">
81
+ <input
82
+ id={`lk-ctrl-${field.key}`}
83
+ className="lk-control-input lk-control-input--range"
84
+ type="range"
85
+ min={field.min}
86
+ max={field.max}
87
+ step={field.step ?? 1}
88
+ value={value}
89
+ onChange={(e) => setConfig(field.key as keyof TC, Number(e.target.value))}
90
+ />
91
+ <span className="lk-control-slider-value">{value}</span>
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function CheckboxRow<TC extends Record<string, unknown>>({
98
+ field,
99
+ config,
100
+ setConfig,
101
+ }: RowProps<CheckboxField, TC>) {
102
+ const value = (config[field.key] as boolean | undefined) ?? field.default;
103
+ return (
104
+ <div className="lk-control-row lk-control-row--inline">
105
+ <input
106
+ id={`lk-ctrl-${field.key}`}
107
+ className="lk-control-input lk-control-input--checkbox"
108
+ type="checkbox"
109
+ checked={value}
110
+ onChange={(e) => setConfig(field.key as keyof TC, e.target.checked)}
111
+ />
112
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
113
+ {field.label}
114
+ </label>
115
+ </div>
116
+ );
117
+ }
118
+
119
+ function SelectRow<TC extends Record<string, unknown>>({
120
+ field,
121
+ config,
122
+ setConfig,
123
+ }: RowProps<SelectField, TC>) {
124
+ const value = (config[field.key] as string | undefined) ?? field.default;
125
+ return (
126
+ <div className="lk-control-row">
127
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
128
+ {field.label}
129
+ </label>
130
+ <select
131
+ id={`lk-ctrl-${field.key}`}
132
+ className="lk-control-input"
133
+ value={value}
134
+ onChange={(e) => setConfig(field.key as keyof TC, e.target.value)}
135
+ >
136
+ {field.options.map((opt) => (
137
+ <option key={opt.value} value={opt.value}>
138
+ {opt.label}
139
+ </option>
140
+ ))}
141
+ </select>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ function NumberRow<TC extends Record<string, unknown>>({
147
+ field,
148
+ config,
149
+ setConfig,
150
+ }: RowProps<NumberField, TC>) {
151
+ const value = (config[field.key] as number | undefined) ?? field.default;
152
+ return (
153
+ <div className="lk-control-row">
154
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
155
+ {field.label}
156
+ </label>
157
+ <input
158
+ id={`lk-ctrl-${field.key}`}
159
+ className="lk-control-input"
160
+ type="number"
161
+ value={value}
162
+ min={field.min}
163
+ max={field.max}
164
+ step={field.step}
165
+ onChange={(e) => setConfig(field.key as keyof TC, Number(e.target.value))}
166
+ onBlur={(e) => {
167
+ const n = Number(e.target.value);
168
+ if (!Number.isFinite(n)) return;
169
+ let clamped = n;
170
+ if (field.min !== undefined && clamped < field.min) clamped = field.min;
171
+ if (field.max !== undefined && clamped > field.max) clamped = field.max;
172
+ if (clamped !== n) setConfig(field.key as keyof TC, clamped);
173
+ }}
174
+ />
175
+ </div>
176
+ );
177
+ }
178
+
179
+ function TextRow<TC extends Record<string, unknown>>({
180
+ field,
181
+ config,
182
+ setConfig,
183
+ }: RowProps<TextField, TC>) {
184
+ const debounceMs = field.debounceMs ?? 150;
185
+ const initial = (config[field.key] as string | undefined) ?? field.default;
186
+ const [local, setLocal] = useState(initial);
187
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
188
+ const lastExternal = useRef(initial);
189
+
190
+ useEffect(() => {
191
+ const ext = (config[field.key] as string | undefined) ?? field.default;
192
+ if (ext !== lastExternal.current) {
193
+ lastExternal.current = ext;
194
+ setLocal(ext);
195
+ }
196
+ }, [config, field.key, field.default]);
197
+
198
+ return (
199
+ <div className="lk-control-row">
200
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
201
+ {field.label}
202
+ </label>
203
+ <input
204
+ id={`lk-ctrl-${field.key}`}
205
+ className="lk-control-input"
206
+ type="text"
207
+ value={local}
208
+ placeholder={field.placeholder}
209
+ maxLength={field.maxLength}
210
+ onChange={(e) => {
211
+ const next = e.target.value;
212
+ setLocal(next);
213
+ if (timer.current) clearTimeout(timer.current);
214
+ if (debounceMs === 0) {
215
+ lastExternal.current = next;
216
+ setConfig(field.key as keyof TC, next);
217
+ } else {
218
+ timer.current = setTimeout(() => {
219
+ lastExternal.current = next;
220
+ setConfig(field.key as keyof TC, next);
221
+ }, debounceMs);
222
+ }
223
+ }}
224
+ />
225
+ </div>
226
+ );
227
+ }
228
+
229
+ function ColorRow<TC extends Record<string, unknown>>({
230
+ field,
231
+ config,
232
+ setConfig,
233
+ }: RowProps<ColorField, TC>) {
234
+ const value = (config[field.key] as string | undefined) ?? field.default;
235
+ return (
236
+ <div className="lk-control-row lk-control-row--inline">
237
+ <label className="lk-control-label" htmlFor={`lk-ctrl-${field.key}`}>
238
+ {field.label}
239
+ </label>
240
+ <input
241
+ id={`lk-ctrl-${field.key}`}
242
+ className="lk-control-input lk-control-input--color"
243
+ type="color"
244
+ value={value}
245
+ onChange={(e) => setConfig(field.key as keyof TC, e.target.value)}
246
+ />
247
+ </div>
248
+ );
249
+ }
@@ -0,0 +1,12 @@
1
+ export { ControlPanel } from './ControlPanel';
2
+ export type {
3
+ CheckboxField,
4
+ ColorField,
5
+ ConfigField,
6
+ ConfigFieldType,
7
+ NumberField,
8
+ SelectField,
9
+ SelectOption,
10
+ SliderField,
11
+ TextField,
12
+ } from './types';
@@ -0,0 +1,61 @@
1
+ export type ConfigFieldType = 'slider' | 'checkbox' | 'select' | 'number' | 'text' | 'color';
2
+
3
+ export interface ConfigFieldBase {
4
+ key: string;
5
+ label: string;
6
+ type: ConfigFieldType;
7
+ }
8
+
9
+ export interface SliderField extends ConfigFieldBase {
10
+ type: 'slider';
11
+ default: number;
12
+ min: number;
13
+ max: number;
14
+ step?: number;
15
+ }
16
+
17
+ export interface CheckboxField extends ConfigFieldBase {
18
+ type: 'checkbox';
19
+ default: boolean;
20
+ }
21
+
22
+ export interface SelectOption {
23
+ value: string;
24
+ label: string;
25
+ }
26
+
27
+ export interface SelectField extends ConfigFieldBase {
28
+ type: 'select';
29
+ default: string;
30
+ options: SelectOption[];
31
+ }
32
+
33
+ export interface NumberField extends ConfigFieldBase {
34
+ type: 'number';
35
+ default: number;
36
+ min?: number;
37
+ max?: number;
38
+ step?: number;
39
+ }
40
+
41
+ export interface TextField extends ConfigFieldBase {
42
+ type: 'text';
43
+ default: string;
44
+ placeholder?: string;
45
+ maxLength?: number;
46
+ /** Milliseconds to debounce live setConfig calls. Default 150 ms. Set to 0 to disable. */
47
+ debounceMs?: number;
48
+ }
49
+
50
+ export interface ColorField extends ConfigFieldBase {
51
+ type: 'color';
52
+ default: string;
53
+ }
54
+
55
+ export type ConfigField =
56
+ | SliderField
57
+ | CheckboxField
58
+ | SelectField
59
+ | NumberField
60
+ | TextField
61
+ | ColorField;
@@ -0,0 +1,135 @@
1
+ import { type RefObject, useCallback, useEffect, useRef, useState } from 'react';
2
+ import { screenToWorld } from '../canvas/canvasCoords';
3
+ import type {
4
+ DragDropCapability,
5
+ DragFeedback,
6
+ PaletteItem,
7
+ Point,
8
+ ViewTransform,
9
+ } from '../instrument/types';
10
+ import { DragGhost } from './DragGhost';
11
+
12
+ export interface DragState {
13
+ item: PaletteItem;
14
+ screenPos: Point;
15
+ feedback: DragFeedback | null;
16
+ }
17
+
18
+ export interface UseDragDropArgs<TS, TC> {
19
+ capability: DragDropCapability<TS, TC>;
20
+ canvasContainerRef: RefObject<HTMLElement | null>;
21
+ view: ViewTransform;
22
+ state: TS;
23
+ config: TC;
24
+ setState: (next: TS | ((prev: TS) => TS)) => void;
25
+ emit: (event: string) => void;
26
+ }
27
+
28
+ export interface UseDragDropResult {
29
+ drag: DragState | null;
30
+ startDrag: (item: PaletteItem, originScreenPos: Point) => void;
31
+ }
32
+
33
+ export function useDragDrop<TS, TC>({
34
+ capability,
35
+ canvasContainerRef,
36
+ view,
37
+ state,
38
+ config,
39
+ setState,
40
+ emit,
41
+ }: UseDragDropArgs<TS, TC>): UseDragDropResult {
42
+ const [drag, setDrag] = useState<DragState | null>(null);
43
+ const dragRef = useRef<DragState | null>(null);
44
+ dragRef.current = drag;
45
+ const rafThrottle = useRef<number | null>(null);
46
+
47
+ const isOverCanvas = useCallback(
48
+ (screenPos: Point): boolean => {
49
+ const el = canvasContainerRef.current;
50
+ if (!el) return false;
51
+ const r = el.getBoundingClientRect();
52
+ return (
53
+ screenPos.x >= r.left &&
54
+ screenPos.x <= r.right &&
55
+ screenPos.y >= r.top &&
56
+ screenPos.y <= r.bottom
57
+ );
58
+ },
59
+ [canvasContainerRef],
60
+ );
61
+
62
+ const screenToWorldFromContainer = useCallback(
63
+ (screenPos: Point): Point | null => {
64
+ const el = canvasContainerRef.current;
65
+ if (!el) return null;
66
+ const r = el.getBoundingClientRect();
67
+ return screenToWorld({ x: screenPos.x - r.left, y: screenPos.y - r.top }, view);
68
+ },
69
+ [canvasContainerRef, view],
70
+ );
71
+
72
+ useEffect(() => {
73
+ if (!drag) return;
74
+ const handleMove = (e: PointerEvent) => {
75
+ const screenPos = { x: e.clientX, y: e.clientY };
76
+ const current = dragRef.current;
77
+ if (!current) return;
78
+ if (rafThrottle.current !== null) {
79
+ dragRef.current = { ...current, screenPos };
80
+ setDrag(dragRef.current);
81
+ return;
82
+ }
83
+ rafThrottle.current = requestAnimationFrame(() => {
84
+ rafThrottle.current = null;
85
+ const active = dragRef.current;
86
+ if (!active) return;
87
+ let feedback: DragFeedback | null = null;
88
+ if (capability.onDragOver && isOverCanvas(screenPos)) {
89
+ const world = screenToWorldFromContainer(screenPos);
90
+ if (world) feedback = capability.onDragOver(world, active.item, state, config);
91
+ }
92
+ setDrag({ ...active, screenPos, feedback });
93
+ });
94
+ };
95
+ const handleUp = (e: PointerEvent) => {
96
+ const screenPos = { x: e.clientX, y: e.clientY };
97
+ const active = dragRef.current;
98
+ if (!active) return;
99
+ if (rafThrottle.current !== null) {
100
+ cancelAnimationFrame(rafThrottle.current);
101
+ rafThrottle.current = null;
102
+ }
103
+ if (isOverCanvas(screenPos)) {
104
+ const world = screenToWorldFromContainer(screenPos);
105
+ if (world) {
106
+ const nextState = capability.onDrop(world, active.item, state, config);
107
+ setState(nextState);
108
+ emit('canvas.itemAdded');
109
+ }
110
+ }
111
+ setDrag(null);
112
+ };
113
+ window.addEventListener('pointermove', handleMove);
114
+ window.addEventListener('pointerup', handleUp);
115
+ return () => {
116
+ window.removeEventListener('pointermove', handleMove);
117
+ window.removeEventListener('pointerup', handleUp);
118
+ };
119
+ }, [drag, capability, state, config, setState, emit, isOverCanvas, screenToWorldFromContainer]);
120
+
121
+ const startDrag = useCallback((item: PaletteItem, originScreenPos: Point) => {
122
+ setDrag({ item, screenPos: originScreenPos, feedback: null });
123
+ }, []);
124
+
125
+ return { drag, startDrag };
126
+ }
127
+
128
+ export interface DragOverlayProps {
129
+ drag: DragState | null;
130
+ }
131
+
132
+ export function DragOverlay({ drag }: DragOverlayProps) {
133
+ if (!drag) return null;
134
+ return <DragGhost item={drag.item} screenPos={drag.screenPos} />;
135
+ }
@@ -0,0 +1,17 @@
1
+ .lk-drag-ghost {
2
+ position: fixed;
3
+ pointer-events: none;
4
+ z-index: 9999;
5
+ padding: 4px 8px;
6
+ background: var(--lk-color-surface, #fff);
7
+ border: 1px solid var(--lk-color-border, #999);
8
+ border-radius: 4px;
9
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
10
+ font: inherit;
11
+ color: inherit;
12
+ opacity: 0.9;
13
+ }
14
+
15
+ .lk-drag-ghost__label {
16
+ white-space: nowrap;
17
+ }
@@ -0,0 +1,24 @@
1
+ import { type ReactPortal, useEffect, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { PaletteItem, Point } from '../instrument/types';
4
+
5
+ export interface DragGhostProps {
6
+ item: PaletteItem;
7
+ screenPos: Point;
8
+ }
9
+
10
+ export function DragGhost({ item, screenPos }: DragGhostProps): ReactPortal | null {
11
+ const [mounted, setMounted] = useState(false);
12
+ useEffect(() => setMounted(true), []);
13
+ if (!mounted || typeof document === 'undefined') return null;
14
+ const style = {
15
+ left: `${screenPos.x + 12}px`,
16
+ top: `${screenPos.y + 12}px`,
17
+ };
18
+ return createPortal(
19
+ <div className="lk-drag-ghost" style={style}>
20
+ <span className="lk-drag-ghost__label">{item.label}</span>
21
+ </div>,
22
+ document.body,
23
+ );
24
+ }
@@ -0,0 +1,34 @@
1
+ .lk-palette {
2
+ display: grid;
3
+ grid-template-columns: repeat(2, 1fr);
4
+ gap: var(--lk-spacing-sm, 8px);
5
+ padding: var(--lk-spacing-sm, 8px);
6
+ }
7
+
8
+ .lk-palette__item {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ min-height: 48px;
13
+ padding: var(--lk-spacing-sm, 8px);
14
+ background: var(--lk-color-surface, #fff);
15
+ border: 1px solid var(--lk-color-border, #d0d0d0);
16
+ border-radius: 4px;
17
+ cursor: grab;
18
+ font: inherit;
19
+ color: inherit;
20
+ user-select: none;
21
+ touch-action: none;
22
+
23
+ &:hover {
24
+ background: var(--lk-color-surface-hover, #f4f4f4);
25
+ }
26
+
27
+ &:active {
28
+ cursor: grabbing;
29
+ }
30
+ }
31
+
32
+ .lk-palette__label {
33
+ pointer-events: none;
34
+ }
@@ -0,0 +1,27 @@
1
+ import type { PaletteItem, Point } from '../instrument/types';
2
+
3
+ export interface PaletteProps {
4
+ items: PaletteItem[];
5
+ onDragStart: (item: PaletteItem, originScreenPos: Point) => void;
6
+ className?: string;
7
+ }
8
+
9
+ export function Palette({ items, onDragStart, className }: PaletteProps) {
10
+ return (
11
+ <div className={className ? `lk-palette ${className}` : 'lk-palette'}>
12
+ {items.map((item) => (
13
+ <button
14
+ type="button"
15
+ key={item.id}
16
+ className="lk-palette__item"
17
+ onPointerDown={(e) => {
18
+ e.preventDefault();
19
+ onDragStart(item, { x: e.clientX, y: e.clientY });
20
+ }}
21
+ >
22
+ <span className="lk-palette__label">{item.label}</span>
23
+ </button>
24
+ ))}
25
+ </div>
26
+ );
27
+ }