castle-web-cli 0.4.71 → 0.4.73

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 (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BY21Og40.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -0,0 +1,808 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { getColliderRect } from '../behaviors/Collider';
3
+ import { cardSize, screenToCard } from '../engine/scene';
4
+ import { cx, Icon, styles, useElementSize } from '../engine/ui';
5
+
6
+ // On-canvas selection toolbar ("selection handles"). Mounts inside `.stageCard`
7
+ // as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
8
+ //
9
+ // - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
10
+ // layer can be `scale()`d onto it -- the chrome (dashed box, corner dots,
11
+ // center pivot) lives in card units inside that scaled layer and is
12
+ // translated by -camera so it tracks the edit camera exactly like the canvas
13
+ // draw path (`ctx.translate(-camera.x, -camera.y)`).
14
+ // - The floating buttons live in a separate, un-scaled px layer. Their
15
+ // positions are projected from world card units into screen px, so they stay
16
+ // crisp and fixed-size with fixed px gaps regardless of the responsive card
17
+ // scale (the alternative -- buttons inside the scaled layer -- would distort
18
+ // them and make gaps scale).
19
+ //
20
+ // Clone/Delete reuse the existing duplicate/remove scene actions; Move and
21
+ // Rotate are pointer drags that translate / rotate every selected actor and
22
+ // commit one history entry per gesture.
23
+
24
+ const HANDLE_OFFSET = 8; // card units between the real bounds and transform handles
25
+ const GAP_PX = 30; // px from a box edge to a floating button's center
26
+ const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
27
+ const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
28
+ const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
29
+ const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
30
+ // Move/grab handle is intentionally hidden for now (its drag logic + helpers
31
+ // stay wired below so it can be re-enabled later); flip this to render it.
32
+ const SHOW_MOVE_HANDLE = false;
33
+ const ARRANGE_OPTIONS = [
34
+ { label: 'Bring to Front', action: 'front' },
35
+ { label: 'Bring Forward', action: 'forward' },
36
+ { label: 'Send Backward', action: 'backward' },
37
+ { label: 'Send to Back', action: 'back' },
38
+ ];
39
+
40
+ const SCALE_HANDLES = [
41
+ { id: 'nw', x: -1, y: -1, cursor: 'nwse-resize', corner: true },
42
+ { id: 'n', x: 0, y: -1, cursor: 'ns-resize' },
43
+ { id: 'ne', x: 1, y: -1, cursor: 'nesw-resize', corner: true },
44
+ { id: 'e', x: 1, y: 0, cursor: 'ew-resize' },
45
+ { id: 'se', x: 1, y: 1, cursor: 'nwse-resize', corner: true },
46
+ { id: 's', x: 0, y: 1, cursor: 'ns-resize' },
47
+ { id: 'sw', x: -1, y: 1, cursor: 'nesw-resize', corner: true },
48
+ { id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
49
+ ];
50
+
51
+ export function SelectionOverlay({
52
+ canvasRef,
53
+ editCameraRef,
54
+ sceneData,
55
+ selectedActorIds,
56
+ snap,
57
+ onArrange,
58
+ onClone,
59
+ onDelete,
60
+ applyScene,
61
+ recordSnapshot,
62
+ }) {
63
+ const rootRef = useRef(null);
64
+ // Track the canvas-sized overlay box so the card-unit layer scales onto it.
65
+ const box = useElementSize(rootRef);
66
+ const [camera, setCamera] = useState({ x: 0, y: 0 });
67
+ const [groupFrameRotation, setGroupFrameRotation] = useState(null);
68
+ const [arrangeOpen, setArrangeOpen] = useState(false);
69
+ const selectionKey = [...selectedActorIds].sort().join('|');
70
+
71
+ // Follow the edit camera on a RAF tick so the toolbar pans with the canvas.
72
+ // setState is skipped when the camera is unchanged, so this idles cheaply.
73
+ useEffect(() => {
74
+ let raf = 0;
75
+ const tick = () => {
76
+ const cam = editCameraRef.current ?? { x: 0, y: 0 };
77
+ setCamera((prev) => (prev.x === cam.x && prev.y === cam.y ? prev : { x: cam.x, y: cam.y }));
78
+ raf = requestAnimationFrame(tick);
79
+ };
80
+ raf = requestAnimationFrame(tick);
81
+ return () => cancelAnimationFrame(raf);
82
+ }, [editCameraRef]);
83
+
84
+ useEffect(() => {
85
+ setGroupFrameRotation(null);
86
+ setArrangeOpen(false);
87
+ }, [selectionKey]);
88
+
89
+ const frame = getSelectionFrame(sceneData, selectedActorIds, groupFrameRotation);
90
+
91
+ const onMoveDown = usePointerDragHandle((event) => {
92
+ const canvas = canvasRef.current;
93
+ if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
94
+ const startPoint = screenToCard(canvas, event.clientX, event.clientY);
95
+ const starts = collectLayoutStarts(sceneData, selectedActorIds);
96
+ const state = { recorded: false };
97
+ return {
98
+ onMove: (moveEvent) => {
99
+ const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
100
+ const dx = snapDelta(point.x - startPoint.x, snap);
101
+ const dy = snapDelta(point.y - startPoint.y, snap);
102
+ const next = moveSelected(sceneData, selectedActorIds, starts, dx, dy);
103
+ applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
104
+ },
105
+ };
106
+ });
107
+
108
+ const onRotateDown = usePointerDragHandle((event) => {
109
+ const canvas = canvasRef.current;
110
+ if (!canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
111
+ const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
112
+ const startAngle = pointerAngle(canvas, editCameraRef, event, center);
113
+ let lastAngle = startAngle;
114
+ let totalDelta = 0;
115
+ const startFrameRotation = frame.rotation;
116
+ const starts = collectLayoutStarts(sceneData, selectedActorIds);
117
+ const state = { recorded: false, lastDelta: 0 };
118
+ return {
119
+ onMove: (moveEvent) => {
120
+ const angle = pointerAngle(canvas, editCameraRef, moveEvent, center);
121
+ totalDelta += shortestAngleDelta(lastAngle, angle);
122
+ lastAngle = angle;
123
+ const delta = snapRotationDelta(totalDelta, snap?.enabled ?? true);
124
+ // Key the commit on the snapped delta itself, not on a diff against the
125
+ // pointer-down snapshot. `onMove` closes over the `sceneData` captured at
126
+ // drag-start, where the actor still sits at its original angle, so when
127
+ // the magnet pulls `delta` back to 0 `rotateSelected` returns that
128
+ // unchanged snapshot. `applyDragResult`'s `next === sceneData` guard
129
+ // would treat that as a no-op and skip it, stranding the actor on the
130
+ // last grid step -- the reason returning to the original angle felt
131
+ // impossible. Re-applying whenever the delta changes lets delta 0
132
+ // actually restore the start angle.
133
+ if (delta === state.lastDelta) return;
134
+ state.lastDelta = delta;
135
+ setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
136
+ const next = rotateSelected(sceneData, selectedActorIds, starts, center, delta);
137
+ if (!state.recorded) {
138
+ recordSnapshot();
139
+ state.recorded = true;
140
+ }
141
+ applyScene(next);
142
+ },
143
+ };
144
+ });
145
+
146
+ const onScaleDown = usePointerDragHandle((event) => {
147
+ const handleId = event.currentTarget.dataset.handle;
148
+ const handle = SCALE_HANDLES.find((candidate) => candidate.id === handleId);
149
+ const canvas = canvasRef.current;
150
+ if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
151
+ const startTransform = makeBoundsTransform(frame, frame.rotation);
152
+ const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
153
+ const starts = collectLayoutStarts(sceneData, selectedActorIds);
154
+ const state = { recorded: false };
155
+ return {
156
+ onMove: (moveEvent) => {
157
+ const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
158
+ const dx = snapDelta(point.x - startPointer.x, snap);
159
+ const dy = snapDelta(point.y - startPointer.y, snap);
160
+ const nextBounds = scaleBoundsFromHandle(
161
+ startTransform.localBounds,
162
+ handle,
163
+ dx,
164
+ dy,
165
+ moveEvent.shiftKey
166
+ );
167
+ const next = scaleSelected(sceneData, selectedActorIds, starts, startTransform, nextBounds);
168
+ applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
169
+ },
170
+ };
171
+ });
172
+
173
+ if (!frame || box.width === 0 || box.height === 0) {
174
+ // Keep the root mounted so the ResizeObserver always has a node to measure.
175
+ return <div ref={rootRef} className={styles.selOverlayRoot} aria-hidden="true" />;
176
+ }
177
+
178
+ const geometry = getOverlayGeometry(frame, box, camera);
179
+ const colliderFrames = getSelectedColliderFrames(sceneData, selectedActorIds);
180
+
181
+ return (
182
+ <div ref={rootRef} className={styles.selOverlayRoot}>
183
+ <div
184
+ className={styles.selChromeLayer}
185
+ style={{
186
+ width: cardSize.width,
187
+ height: cardSize.height,
188
+ transform: `scale(${geometry.sx}, ${geometry.sy})`,
189
+ }}>
190
+ <div
191
+ className={styles.selChromeWorld}
192
+ style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
193
+ {colliderFrames.map((collider) => (
194
+ <div
195
+ key={collider.actorId}
196
+ className={cx(
197
+ styles.selColliderBox,
198
+ collider.kind === 'pickup' && styles.selColliderPickup
199
+ )}
200
+ style={{
201
+ left: collider.x,
202
+ top: collider.y,
203
+ width: collider.width,
204
+ height: collider.height,
205
+ transformOrigin: `${collider.originX}px ${collider.originY}px`,
206
+ transform: `rotate(${collider.rotation}deg)`,
207
+ }}
208
+ />
209
+ ))}
210
+ {/* Rotate about the actor center, in card units, exactly like the
211
+ canvas. Sits between the scale + camera-translate layers and the
212
+ card-unit chrome. */}
213
+ <div
214
+ className={styles.selChromeRotate}
215
+ style={{
216
+ transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
217
+ transform: `rotate(${geometry.rotation}deg)`,
218
+ }}>
219
+ <SelectionChrome frame={frame} stemLength={geometry.stemLength} onScaleDown={onScaleDown} />
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Buttons stay screen-upright (never rotated) so their glyphs are always
225
+ readable. The rotate handle orbits via its anchor; clone/delete flip
226
+ above/below to stay clear of it. */}
227
+ <div className={styles.selButtonLayer}>
228
+ <Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
229
+ <div className={styles.selBtnGroup}>
230
+ <div className={styles.selArrangeWrap}>
231
+ <OverlayButton
232
+ label="Arrange"
233
+ icon="layer-group"
234
+ onActivate={() => setArrangeOpen((open) => !open)}
235
+ />
236
+ {arrangeOpen ? (
237
+ <div
238
+ className={styles.selArrangeMenu}
239
+ role="menu"
240
+ aria-label="Arrange"
241
+ onPointerDown={(event) => event.stopPropagation()}
242
+ onClick={(event) => event.stopPropagation()}>
243
+ {ARRANGE_OPTIONS.map((option) => (
244
+ <button
245
+ key={option.action}
246
+ type="button"
247
+ role="menuitem"
248
+ className={styles.selArrangeItem}
249
+ onClick={() => {
250
+ onArrange?.(option.action);
251
+ setArrangeOpen(false);
252
+ }}>
253
+ {option.label}
254
+ </button>
255
+ ))}
256
+ </div>
257
+ ) : null}
258
+ </div>
259
+ <OverlayButton label="Clone" icon="clone" onActivate={onClone} />
260
+ <OverlayButton label="Delete" icon="trash" onActivate={onDelete} />
261
+ </div>
262
+ </Floating>
263
+ <Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
264
+ <OverlayButton label="Rotate" icon="rotate" extraClass={styles.selBtnRotate} onPointerDown={onRotateDown} />
265
+ </Floating>
266
+ {/* Move/grab handle hidden for now; drag wiring (onMoveDown) preserved. */}
267
+ {SHOW_MOVE_HANDLE && (
268
+ <Floating x={geometry.centerX} y={geometry.centerY + geometry.halfH + GAP_PX}>
269
+ <OverlayButton label="Move" icon="move" extraClass={styles.selBtnGrab} onPointerDown={onMoveDown} />
270
+ </Floating>
271
+ )}
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ // Chrome in card units: dashed bounding box, scale handles, connector stem, and
278
+ // center pivot dot. The layer itself ignores pointer events; only the handles
279
+ // opt back in so the canvas still receives ordinary selection gestures.
280
+ function SelectionChrome({ frame, stemLength, onScaleDown }) {
281
+ const left = frame.x;
282
+ const top = frame.y;
283
+ const width = frame.width;
284
+ const height = frame.height;
285
+ const centerX = left + width / 2;
286
+ const centerY = top + height / 2;
287
+ const handlePosition = (handle) => ({
288
+ left: centerX + (handle.x * width) / 2,
289
+ top: centerY + (handle.y * height) / 2,
290
+ cursor: handle.cursor,
291
+ });
292
+ return (
293
+ <>
294
+ {/* Connector stem from the right-center edge out to the rotate handle.
295
+ Lives in the rotated chrome, so it swings with the actor. */}
296
+ {stemLength > 0 ? (
297
+ <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />
298
+ ) : null}
299
+ <div className={styles.selBox} style={{ left, top, width, height }} />
300
+ {SCALE_HANDLES.map((handle) => (
301
+ <div
302
+ key={handle.id}
303
+ role="button"
304
+ aria-label={`Scale ${handle.id}`}
305
+ title={`Scale ${handle.id}`}
306
+ className={cx(
307
+ styles.selScaleHandle,
308
+ handle.corner ? styles.selScaleCorner : styles.selScaleEdge
309
+ )}
310
+ data-handle={handle.id}
311
+ style={handlePosition(handle)}
312
+ onPointerDown={(event) => {
313
+ event.stopPropagation();
314
+ onScaleDown(event);
315
+ }}
316
+ />
317
+ ))}
318
+ <div className={styles.selPivot} style={{ left: centerX, top: centerY }} />
319
+ </>
320
+ );
321
+ }
322
+
323
+ function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
324
+ if (!sceneData || !actorIds || actorIds.length === 0) return null;
325
+ const wanted = new Set(actorIds);
326
+ const layouts = sceneData.actors
327
+ .filter((actor) => wanted.has(actor.id))
328
+ .map((actor) => actor.components.Layout)
329
+ .filter(Boolean);
330
+ if (layouts.length === 0) return null;
331
+ const sharedRotation = getSharedRotation(layouts);
332
+ const rotation = sharedRotation ?? (layouts.length > 1 ? preferredRotation : null) ?? 0;
333
+ return getOrientedLayoutBounds(layouts, rotation);
334
+ }
335
+
336
+ function getOverlayGeometry(frame, box, camera) {
337
+ const sx = box.width / cardSize.width;
338
+ const sy = box.height / cardSize.height;
339
+ const camX = Math.round(camera.x);
340
+ const camY = Math.round(camera.y);
341
+ const rotation = frame.rotation;
342
+ const centerCardX = frame.x + frame.width / 2;
343
+ const centerCardY = frame.y + frame.height / 2;
344
+ const centerX = (centerCardX - camX) * sx;
345
+ const centerY = (centerCardY - camY) * sy;
346
+ const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
347
+ const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
348
+ const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
349
+ return {
350
+ sx,
351
+ sy,
352
+ camX,
353
+ camY,
354
+ rotation,
355
+ centerCardX,
356
+ centerCardY,
357
+ centerX,
358
+ centerY,
359
+ halfH,
360
+ rotateAnchor,
361
+ cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
362
+ stemLength: HANDLE_OFFSET + GAP_PX / sx,
363
+ };
364
+ }
365
+
366
+ function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
367
+ const rad = (rotation * Math.PI) / 180;
368
+ const cosR = Math.cos(rad);
369
+ const sinR = Math.sin(rad);
370
+ const lx = halfW + GAP_PX;
371
+ return {
372
+ x: centerX + lx * cosR,
373
+ y: centerY + lx * sinR,
374
+ };
375
+ }
376
+
377
+ function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
378
+ const topY = centerY - halfH - ACTION_GAP_PX;
379
+ const bottomY = centerY + halfH + ACTION_GAP_PX;
380
+ const overlapsTop =
381
+ Math.abs(rotateAnchor.x - centerX) < GAP_PX * 1.9 &&
382
+ Math.abs(rotateAnchor.y - topY) < GAP_PX * 1.4;
383
+ return {
384
+ x: centerX,
385
+ y: overlapsTop ? bottomY : topY,
386
+ };
387
+ }
388
+
389
+ function getSelectedColliderFrames(sceneData, actorIds) {
390
+ if (!sceneData || !actorIds || actorIds.length === 0) return [];
391
+ const wanted = new Set(actorIds);
392
+ return sceneData.actors
393
+ .filter((actor) => wanted.has(actor.id))
394
+ .map((actor) => {
395
+ const layout = actor.components.Layout;
396
+ const collider = actor.components.Collider;
397
+ const rect = getColliderRect(actor);
398
+ if (!layout || !collider || !rect) return null;
399
+ return {
400
+ actorId: actor.id,
401
+ kind: collider.kind,
402
+ x: rect.x,
403
+ y: rect.y,
404
+ width: rect.width,
405
+ height: rect.height,
406
+ rotation: layout.rotation ?? 0,
407
+ originX: layout.x + layout.width / 2 - rect.x,
408
+ originY: layout.y + layout.height / 2 - rect.y,
409
+ };
410
+ })
411
+ .filter(Boolean);
412
+ }
413
+
414
+ function getSharedRotation(layouts) {
415
+ const first = normalizeAngle(layouts[0].rotation ?? 0);
416
+ return layouts.every((layout) => normalizeAngle(layout.rotation ?? 0) === first) ? first : null;
417
+ }
418
+
419
+ function getOrientedLayoutBounds(layouts, rotation) {
420
+ let minX = Infinity;
421
+ let minY = Infinity;
422
+ let maxX = -Infinity;
423
+ let maxY = -Infinity;
424
+ for (const layout of layouts) {
425
+ for (const corner of getLayoutWorldCorners(layout)) {
426
+ const local = rotateAroundOrigin(corner, -rotation);
427
+ minX = Math.min(minX, local.x);
428
+ minY = Math.min(minY, local.y);
429
+ maxX = Math.max(maxX, local.x);
430
+ maxY = Math.max(maxY, local.y);
431
+ }
432
+ }
433
+ const width = maxX - minX;
434
+ const height = maxY - minY;
435
+ const center = rotateAroundOrigin({ x: minX + width / 2, y: minY + height / 2 }, rotation);
436
+ return {
437
+ x: center.x - width / 2,
438
+ y: center.y - height / 2,
439
+ width,
440
+ height,
441
+ rotation,
442
+ };
443
+ }
444
+
445
+ function getLayoutWorldCorners(layout) {
446
+ const center = {
447
+ x: layout.x + layout.width / 2,
448
+ y: layout.y + layout.height / 2,
449
+ };
450
+ const halfW = layout.width / 2;
451
+ const halfH = layout.height / 2;
452
+ const rotation = layout.rotation ?? 0;
453
+ return [
454
+ { x: -halfW, y: -halfH },
455
+ { x: halfW, y: -halfH },
456
+ { x: halfW, y: halfH },
457
+ { x: -halfW, y: halfH },
458
+ ].map((corner) => {
459
+ const rotated = rotateAroundOrigin(corner, rotation);
460
+ return {
461
+ x: center.x + rotated.x,
462
+ y: center.y + rotated.y,
463
+ };
464
+ });
465
+ }
466
+
467
+ function rotateAroundOrigin(point, deg) {
468
+ const rad = (deg * Math.PI) / 180;
469
+ const cos = Math.cos(rad);
470
+ const sin = Math.sin(rad);
471
+ return {
472
+ x: point.x * cos - point.y * sin,
473
+ y: point.x * sin + point.y * cos,
474
+ };
475
+ }
476
+
477
+ function Floating({ x, y, children }) {
478
+ return (
479
+ <div className={styles.selBtnWrap} style={{ left: x, top: y }}>
480
+ {children}
481
+ </div>
482
+ );
483
+ }
484
+
485
+ function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass }) {
486
+ return (
487
+ <button
488
+ type="button"
489
+ aria-label={label}
490
+ title={label}
491
+ className={cx(styles.selBtn, extraClass)}
492
+ onPointerDown={(event) => {
493
+ event.stopPropagation();
494
+ onPointerDown?.(event);
495
+ }}
496
+ onClick={(event) => {
497
+ event.stopPropagation();
498
+ onActivate?.();
499
+ }}>
500
+ <Icon name={icon} />
501
+ </button>
502
+ );
503
+ }
504
+
505
+ // Shared pointer-drag scaffolding. `begin(downEvent)` runs on pointerdown and
506
+ // returns `{ onMove }` (or null to ignore). Window listeners + pointer capture
507
+ // are wired here so the move/rotate gestures only describe their own math.
508
+ function usePointerDragHandle(begin) {
509
+ const beginRef = useRef(begin);
510
+ beginRef.current = begin;
511
+ return useCallback((event) => {
512
+ const handlers = beginRef.current(event);
513
+ if (!handlers) return;
514
+ event.preventDefault();
515
+ try {
516
+ event.currentTarget.setPointerCapture(event.pointerId);
517
+ } catch {
518
+ // Pointer capture may be unavailable; window listeners still drive it.
519
+ }
520
+ const pointerId = event.pointerId;
521
+ const move = (moveEvent) => {
522
+ if (moveEvent.pointerId !== pointerId) return;
523
+ handlers.onMove(moveEvent);
524
+ };
525
+ const end = (endEvent) => {
526
+ if (endEvent.pointerId !== pointerId) return;
527
+ window.removeEventListener('pointermove', move);
528
+ window.removeEventListener('pointerup', end);
529
+ window.removeEventListener('pointercancel', end);
530
+ };
531
+ window.addEventListener('pointermove', move);
532
+ window.addEventListener('pointerup', end);
533
+ window.addEventListener('pointercancel', end);
534
+ }, []);
535
+ }
536
+
537
+ function applyDragResult(next, sceneData, state, recordSnapshot, applyScene) {
538
+ if (next === sceneData) return;
539
+ if (!state.recorded) {
540
+ recordSnapshot();
541
+ state.recorded = true;
542
+ }
543
+ applyScene(next);
544
+ }
545
+
546
+ function collectLayoutStarts(sceneData, actorIds) {
547
+ const starts = {};
548
+ const wanted = new Set(actorIds);
549
+ for (const actor of sceneData.actors) {
550
+ if (!wanted.has(actor.id)) continue;
551
+ const layout = actor.components.Layout;
552
+ if (layout) {
553
+ starts[actor.id] = {
554
+ x: layout.x,
555
+ y: layout.y,
556
+ width: layout.width,
557
+ height: layout.height,
558
+ rotation: layout.rotation ?? 0,
559
+ };
560
+ }
561
+ }
562
+ return starts;
563
+ }
564
+
565
+ function snapDelta(delta, snap) {
566
+ if (!snap || !snap.enabled) return delta;
567
+ return Math.round(delta / snap.gridSize) * snap.gridSize;
568
+ }
569
+
570
+ // Clone the scene and patch the Layout of every selected actor. `patch(layout,
571
+ // start)` returns the props to merge into that actor's Layout, or null to leave
572
+ // it untouched. Returns the original `sceneData` when nothing changed so callers
573
+ // can skip a no-op commit.
574
+ function updateSelectedLayouts(sceneData, actorIds, starts, patch) {
575
+ const wanted = new Set(actorIds);
576
+ const next = structuredClone(sceneData);
577
+ let changed = false;
578
+ for (const actor of next.actors) {
579
+ if (!wanted.has(actor.id)) continue;
580
+ const start = starts[actor.id];
581
+ const layout = actor.components.Layout;
582
+ if (!start || !layout) continue;
583
+ const props = patch(layout, start);
584
+ if (!props) continue;
585
+ actor.components.Layout = { ...layout, ...props };
586
+ changed = true;
587
+ }
588
+ return changed ? next : sceneData;
589
+ }
590
+
591
+ function moveSelected(sceneData, actorIds, starts, dx, dy) {
592
+ return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
593
+ const x = Math.round(start.x + dx);
594
+ const y = Math.round(start.y + dy);
595
+ return layout.x === x && layout.y === y ? null : { x, y };
596
+ });
597
+ }
598
+
599
+ // Common-pivot rotation: actor centers orbit the selection pivot while each
600
+ // actor also spins by the same delta, matching a temporary group transform.
601
+ function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
602
+ return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
603
+ const center = {
604
+ x: start.x + start.width / 2,
605
+ y: start.y + start.height / 2,
606
+ };
607
+ const nextCenter = rotatePoint(center, pivot, deltaDeg);
608
+ const x = Math.round(nextCenter.x - start.width / 2);
609
+ const y = Math.round(nextCenter.y - start.height / 2);
610
+ const rotation = normalizeAngle((start.rotation ?? 0) + deltaDeg);
611
+ if (layout.x === x && layout.y === y && (layout.rotation ?? 0) === rotation) return null;
612
+ return { x, y, rotation };
613
+ });
614
+ }
615
+
616
+ function scaleSelected(sceneData, actorIds, starts, startTransform, nextLocalBounds) {
617
+ const startBounds = startTransform.localBounds;
618
+ const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
619
+ const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
620
+ return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
621
+ const startCenter = {
622
+ x: start.x + start.width / 2,
623
+ y: start.y + start.height / 2,
624
+ };
625
+ const localCenter = worldToLocal(startCenter, startTransform);
626
+ const nextLocalCenter = {
627
+ x: nextLocalBounds.left + (localCenter.x - startBounds.left) * scaleX,
628
+ y: nextLocalBounds.top + (localCenter.y - startBounds.top) * scaleY,
629
+ };
630
+ const nextCenter = localToWorld(nextLocalCenter, startTransform);
631
+ const width = Math.max(MIN_LAYOUT_SIZE, Math.round(start.width * scaleX));
632
+ const height = Math.max(MIN_LAYOUT_SIZE, Math.round(start.height * scaleY));
633
+ const x = Math.round(nextCenter.x - width / 2);
634
+ const y = Math.round(nextCenter.y - height / 2);
635
+ if (layout.x === x && layout.y === y && layout.width === width && layout.height === height) {
636
+ return null;
637
+ }
638
+ return { x, y, width, height };
639
+ });
640
+ }
641
+
642
+ function scaleBoundsFromHandle(bounds, handle, dx, dy, uniform = false) {
643
+ if (uniform) return uniformScaleBoundsFromHandle(bounds, handle, dx, dy);
644
+ let left = bounds.left;
645
+ let right = bounds.right;
646
+ let top = bounds.top;
647
+ let bottom = bounds.bottom;
648
+ if (handle.x < 0) left = clampMax(bounds.left + dx, right - MIN_LAYOUT_SIZE);
649
+ else if (handle.x > 0) right = clampMin(bounds.right + dx, left + MIN_LAYOUT_SIZE);
650
+ if (handle.y < 0) top = clampMax(bounds.top + dy, bottom - MIN_LAYOUT_SIZE);
651
+ else if (handle.y > 0) bottom = clampMin(bounds.bottom + dy, top + MIN_LAYOUT_SIZE);
652
+ return {
653
+ left,
654
+ right,
655
+ top,
656
+ bottom,
657
+ width: right - left,
658
+ height: bottom - top,
659
+ };
660
+ }
661
+
662
+ function uniformScaleBoundsFromHandle(bounds, handle, dx, dy) {
663
+ const raw = scaleBoundsFromHandle(bounds, handle, dx, dy);
664
+ const scale = uniformScaleForHandle(bounds, handle, raw);
665
+ return boundsFromUniformScale(bounds, handle, scale);
666
+ }
667
+
668
+ function uniformScaleForHandle(bounds, handle, raw) {
669
+ const scaleX = raw.width / bounds.width;
670
+ const scaleY = raw.height / bounds.height;
671
+ const minScale = Math.max(MIN_LAYOUT_SIZE / bounds.width, MIN_LAYOUT_SIZE / bounds.height);
672
+ if (handle.x === 0) return Math.max(scaleY, minScale);
673
+ if (handle.y === 0) return Math.max(scaleX, minScale);
674
+ const xDelta = Math.abs(scaleX - 1);
675
+ const yDelta = Math.abs(scaleY - 1);
676
+ return Math.max(xDelta >= yDelta ? scaleX : scaleY, minScale);
677
+ }
678
+
679
+ function boundsFromUniformScale(bounds, handle, scale) {
680
+ const width = bounds.width * scale;
681
+ const height = bounds.height * scale;
682
+ const centerX = (bounds.left + bounds.right) / 2;
683
+ const centerY = (bounds.top + bounds.bottom) / 2;
684
+ let left = centerX - width / 2;
685
+ let right = centerX + width / 2;
686
+ let top = centerY - height / 2;
687
+ let bottom = centerY + height / 2;
688
+ if (handle.x < 0) left = bounds.right - width;
689
+ else if (handle.x > 0) right = bounds.left + width;
690
+ if (handle.x !== 0) {
691
+ if (handle.x < 0) right = bounds.right;
692
+ else left = bounds.left;
693
+ }
694
+ if (handle.y < 0) top = bounds.bottom - height;
695
+ else if (handle.y > 0) bottom = bounds.top + height;
696
+ if (handle.y !== 0) {
697
+ if (handle.y < 0) bottom = bounds.bottom;
698
+ else top = bounds.top;
699
+ }
700
+ return {
701
+ left,
702
+ right,
703
+ top,
704
+ bottom,
705
+ width: right - left,
706
+ height: bottom - top,
707
+ };
708
+ }
709
+
710
+ function makeBoundsTransform(bounds, rotation) {
711
+ const pivot = {
712
+ x: bounds.x + bounds.width / 2,
713
+ y: bounds.y + bounds.height / 2,
714
+ };
715
+ const left = -bounds.width / 2;
716
+ const right = bounds.width / 2;
717
+ const top = -bounds.height / 2;
718
+ const bottom = bounds.height / 2;
719
+ return {
720
+ pivot,
721
+ rotation,
722
+ localBounds: {
723
+ left,
724
+ right,
725
+ top,
726
+ bottom,
727
+ width: bounds.width,
728
+ height: bounds.height,
729
+ },
730
+ };
731
+ }
732
+
733
+ function pointerLocal(canvas, editCameraRef, event, transform) {
734
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
735
+ const cam = editCameraRef.current ?? { x: 0, y: 0 };
736
+ return worldToLocal({ x: raw.x + cam.x, y: raw.y + cam.y }, transform);
737
+ }
738
+
739
+ function worldToLocal(point, transform) {
740
+ const dx = point.x - transform.pivot.x;
741
+ const dy = point.y - transform.pivot.y;
742
+ const rad = (-transform.rotation * Math.PI) / 180;
743
+ const cos = Math.cos(rad);
744
+ const sin = Math.sin(rad);
745
+ return {
746
+ x: dx * cos - dy * sin,
747
+ y: dx * sin + dy * cos,
748
+ };
749
+ }
750
+
751
+ function localToWorld(point, transform) {
752
+ const rad = (transform.rotation * Math.PI) / 180;
753
+ const cos = Math.cos(rad);
754
+ const sin = Math.sin(rad);
755
+ return {
756
+ x: transform.pivot.x + point.x * cos - point.y * sin,
757
+ y: transform.pivot.y + point.x * sin + point.y * cos,
758
+ };
759
+ }
760
+
761
+ function rotatePoint(point, pivot, deltaDeg) {
762
+ const rad = (deltaDeg * Math.PI) / 180;
763
+ const cos = Math.cos(rad);
764
+ const sin = Math.sin(rad);
765
+ const dx = point.x - pivot.x;
766
+ const dy = point.y - pivot.y;
767
+ return {
768
+ x: pivot.x + dx * cos - dy * sin,
769
+ y: pivot.y + dx * sin + dy * cos,
770
+ };
771
+ }
772
+
773
+ function clampMin(value, min) {
774
+ return value < min ? min : value;
775
+ }
776
+
777
+ function clampMax(value, max) {
778
+ return value > max ? max : value;
779
+ }
780
+
781
+ function normalizeAngle(deg) {
782
+ const wrapped = ((deg % 360) + 360) % 360;
783
+ return Math.round(wrapped * 100) / 100;
784
+ }
785
+
786
+ function shortestAngleDelta(from, to) {
787
+ return ((((to - from) % 360) + 540) % 360) - 180;
788
+ }
789
+
790
+ // Snap a raw rotation delta (degrees from the angle at drag-start) to a value.
791
+ // The original (pre-drag) angle lives at delta 0, so a magnet band there lets
792
+ // the actor always return to exactly where it began -- independent of the
793
+ // 15-degree grid, which would otherwise skip an off-grid original. Outside the
794
+ // magnet, snap to the grid when enabled, else rotate freely (whole degrees).
795
+ function snapRotationDelta(delta, snapEnabled) {
796
+ if (Math.abs(delta) <= ROTATE_MAGNET) return 0;
797
+ if (!snapEnabled) return Math.round(delta);
798
+ return Math.round(delta / ROTATE_STEP) * ROTATE_STEP;
799
+ }
800
+
801
+ // Angle (degrees) of the pointer around `center`, in world card units.
802
+ function pointerAngle(canvas, editCameraRef, event, center) {
803
+ const raw = screenToCard(canvas, event.clientX, event.clientY);
804
+ const cam = editCameraRef.current ?? { x: 0, y: 0 };
805
+ const wx = raw.x + cam.x;
806
+ const wy = raw.y + cam.y;
807
+ return (Math.atan2(wy - center.y, wx - center.x) * 180) / Math.PI;
808
+ }