castle-web-cli 0.4.86 → 0.4.87

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.
@@ -58,6 +58,17 @@ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
58
58
  const LONG_PRESS_MS = 500;
59
59
  const DRAG_THRESHOLD = 4;
60
60
  const DEFAULT_GRID_SIZE = 25;
61
+ // Floating zoom/pan control cluster, tucked into the bottom-right corner of the
62
+ // stage workspace (matches the art editor's placement).
63
+ const ZOOM_CONTROLS_STYLE = {
64
+ position: 'absolute',
65
+ right: 8,
66
+ bottom: 8,
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ gap: 4,
70
+ zIndex: 5,
71
+ };
61
72
  const EDIT_VIEWPORT = {
62
73
  width: cardSize.width * 3,
63
74
  height: cardSize.height * 3,
@@ -468,35 +479,22 @@ export function SceneEditor({
468
479
  <div className={styles.sceneWorkspace}>
469
480
  <div className={styles.sceneTools}>
470
481
  {isBlueprintFile ? null : (
471
- <>
472
- {/* Open (or focus) a Play panel in the shell for this scene.
473
- Lives here in the visible toolbar because the scene editor's
474
- header only renders in the combined editor, not the shell. */}
475
- <IconButton
476
- icon="external"
477
- label="Open play preview"
478
- onClick={(event) => {
479
- event.currentTarget.blur();
480
- window.parent.postMessage({ type: 'castle-open-playtest', scene: path }, '*');
481
- }}
482
- />
483
- <BlueprintLibrary
484
- files={files}
485
- sprites={sprites}
486
- canvasRef={canvasRef}
487
- editCameraRef={editCameraRef}
488
- isPlaying={isPlaying}
489
- selectedBlueprintPath={selectedBlueprintPath}
490
- instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
491
- onSelectBlueprint={onSelectBlueprint}
492
- onAddActor={onAddActor}
493
- dragPlace={dragPlace}
494
- onDeleteBlueprint={onDeleteBlueprint}
495
- />
496
- </>
482
+ <BlueprintLibrary
483
+ files={files}
484
+ sprites={sprites}
485
+ canvasRef={canvasRef}
486
+ editCameraRef={editCameraRef}
487
+ isPlaying={isPlaying}
488
+ selectedBlueprintPath={selectedBlueprintPath}
489
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
490
+ onSelectBlueprint={onSelectBlueprint}
491
+ onAddActor={onAddActor}
492
+ dragPlace={dragPlace}
493
+ onDeleteBlueprint={onDeleteBlueprint}
494
+ />
497
495
  )}
498
496
  </div>
499
- <div className={styles.stageWrap} style={stageWrapStyle}>
497
+ <div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
500
498
  <div className={styles.stageCard} data-castle-card>
501
499
  <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
502
500
  <canvas
@@ -511,7 +509,12 @@ export function SceneEditor({
511
509
  onPointerDown={(event) => {
512
510
  event.currentTarget.focus({ preventScroll: true });
513
511
  if (isPlaying) return playPointer.onPointerDown(event);
514
- if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
512
+ if (
513
+ panGesture.isSpacePanning() ||
514
+ event.button === 1 ||
515
+ (event.button === 0 && event.metaKey)
516
+ )
517
+ return panGesture.onPointerDown(event);
515
518
  return gesture.onPointerDown(event);
516
519
  }}
517
520
  onPointerMove={(event) =>
@@ -555,6 +558,25 @@ export function SceneEditor({
555
558
  />
556
559
  )}
557
560
  </div>
561
+ {!isPlaying && (
562
+ <div style={ZOOM_CONTROLS_STYLE}>
563
+ <IconButton
564
+ icon="zoom-in"
565
+ label="Zoom in"
566
+ onClick={() => panGesture.zoomAtCenter(1.25)}
567
+ />
568
+ <IconButton
569
+ icon="zoom-out"
570
+ label="Zoom out"
571
+ onClick={() => panGesture.zoomAtCenter(0.8)}
572
+ />
573
+ <IconButton
574
+ icon="fit"
575
+ label="Reset view"
576
+ onClick={() => panGesture.resetView()}
577
+ />
578
+ </div>
579
+ )}
558
580
  </div>
559
581
  <aside {...inspectorSheet.rootProps}>
560
582
  <div {...inspectorSheet.grabProps}>
@@ -1036,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1036
1058
  const onWheel = (event) => {
1037
1059
  if (isPlayingRef.current) return;
1038
1060
  event.preventDefault();
1039
- // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1040
- // in Chromium; route those to zoom and leave plain scroll as pan.
1041
- if (event.ctrlKey) {
1042
- if (gestureActive) return;
1043
- zoomByFactorAtPointer(
1044
- event.clientX,
1045
- event.clientY,
1046
- Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1047
- );
1048
- } else {
1049
- panByScreenDelta(event.deltaX, event.deltaY);
1050
- }
1061
+ // Safari fires ctrl+wheel alongside its own gesture* pinch events; skip the
1062
+ // wheel while a pinch is mid-flight so zoom isn't applied twice.
1063
+ if (gestureActive) return;
1064
+ // The wheel zooms at the cursor (standard editor affordance). Trackpad
1065
+ // pinch and Ctrl+wheel arrive here too (Chromium sets ctrlKey) and map to
1066
+ // the same zoom. Clamp per-event delta so a chunky mouse-wheel notch
1067
+ // (deltaY ~100) is a sane step (≤1.65x) while trackpad deltas stay smooth.
1068
+ const clampedDeltaY = Math.max(-50, Math.min(50, event.deltaY));
1069
+ zoomByFactorAtPointer(
1070
+ event.clientX,
1071
+ event.clientY,
1072
+ Math.exp(-clampedDeltaY * ZOOM_WHEEL_SPEED)
1073
+ );
1051
1074
  };
1052
1075
  const onGestureStart = (event) => {
1053
1076
  if (isPlayingRef.current) return;
@@ -1088,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1088
1111
  };
1089
1112
  }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1090
1113
  const onPointerDown = useCallback((event) => {
1091
- if (isPlaying || !spaceDownRef.current) return;
1114
+ if (isPlaying) return;
1115
+ // Pan when Space is held (existing modifier), on a middle-button drag, or on
1116
+ // a ⌘+left-drag (metaKey) — matching the art editor. preventDefault also
1117
+ // suppresses the browser's middle-click autoscroll.
1118
+ const middle = event.button === 1;
1119
+ const cmdDrag = event.button === 0 && event.metaKey;
1120
+ if (!spaceDownRef.current && !middle && !cmdDrag) return;
1092
1121
  event.preventDefault();
1093
1122
  event.currentTarget.setPointerCapture(event.pointerId);
1094
1123
  dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
@@ -1117,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1117
1146
  }, []);
1118
1147
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1119
1148
  const isActive = useCallback(() => Boolean(dragRef.current), []);
1120
- return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
1149
+ // Button-driven zoom about the canvas center, and a reset-to-fit that restores
1150
+ // the initial camera (zoom 1 = the card exactly fills the viewport).
1151
+ const zoomAtCenter = useCallback(
1152
+ (factor) => {
1153
+ const canvas = canvasRef.current;
1154
+ if (!canvas) return;
1155
+ const rect = canvas.getBoundingClientRect();
1156
+ zoomByFactorAtPointer(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
1157
+ },
1158
+ [canvasRef, zoomByFactorAtPointer]
1159
+ );
1160
+ const resetView = useCallback(() => {
1161
+ editCameraRef.current = { x: 0, y: 0, zoom: 1 };
1162
+ }, [editCameraRef]);
1163
+ return {
1164
+ onPointerDown,
1165
+ onPointerMove,
1166
+ onPointerUp,
1167
+ isSpacePanning,
1168
+ isActive,
1169
+ zoomAtCenter,
1170
+ resetView,
1171
+ };
1121
1172
  }
1122
1173
  function handleShiftPointerDown(drag, actor, current) {
1123
1174
  if (actor) {
@@ -98,5 +98,8 @@ export function useArtboardFit({ wrapRef, sizeBarRef, resolution }) {
98
98
  ? { width: `${displaySize.width}px`, height: `${displaySize.height}px` }
99
99
  : undefined;
100
100
 
101
- return { canvasStyle, refit: fit };
101
+ // `displaySize` (numeric px, or null) is the fit baseline the zoom/pan layer
102
+ // multiplies against; `canvasStyle` is the same value pre-formatted for the
103
+ // unzoomed render path.
104
+ return { canvasStyle, displaySize, refit: fit };
102
105
  }
@@ -93,7 +93,20 @@ export function ScenePlayer({ sceneData, sprites, files, behaviorClasses, onFirs
93
93
  <canvas
94
94
  ref={canvasRef}
95
95
  tabIndex={0}
96
- style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
96
+ style={{
97
+ width: '100%',
98
+ height: '100%',
99
+ display: 'block',
100
+ outline: 'none',
101
+ // Claim touches for the game: without this, a mobile WebView
102
+ // (castle-client) treats a drag as a scroll/pan gesture and fires
103
+ // `pointercancel` mid-drag, so a held Draggable/Slingshot loses its
104
+ // grip. Also suppress long-press text selection / callout.
105
+ touchAction: 'none',
106
+ userSelect: 'none',
107
+ WebkitUserSelect: 'none',
108
+ WebkitTouchCallout: 'none',
109
+ }}
97
110
  />
98
111
  <SceneUI getRuntime={getRuntime} />
99
112
  {error ? <PlayErrorOverlay error={error} onRestart={onRestart} /> : null}
@@ -13,6 +13,7 @@ import {
13
13
  faCode,
14
14
  faCodeBranch,
15
15
  faEraser,
16
+ faExpand,
16
17
  faExternalLinkAlt,
17
18
  faEyeDropper,
18
19
  faFile,
@@ -27,6 +28,8 @@ import {
27
28
  faPlay,
28
29
  faPlus,
29
30
  faRedo,
31
+ faSearchMinus,
32
+ faSearchPlus,
30
33
  faShapes,
31
34
  faSlash,
32
35
  faVectorSquare,
@@ -180,6 +183,7 @@ const icons = {
180
183
  // FA5 has no faCodeFork (that's the FA6 name); faCodeBranch is the fork glyph.
181
184
  'code-fork': faCodeBranch,
182
185
  eraser: faEraser,
186
+ expand: faExpand,
183
187
  external: faExternalLinkAlt,
184
188
  eyedropper: faEyeDropper,
185
189
  fill: faFillDrip,
@@ -199,6 +203,9 @@ const icons = {
199
203
  plus: faPlus,
200
204
  redo: faRedo,
201
205
  rotate: faSyncAlt,
206
+ 'zoom-in': faSearchPlus,
207
+ 'zoom-out': faSearchMinus,
208
+ fit: faExpand,
202
209
  shapes: faShapes,
203
210
  slash: faSlash,
204
211
  square: faSquare,
@@ -1,8 +1,8 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
2
  import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
3
  import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
4
4
  import { basename } from '../engine/files';
5
- import { EditorBody, styles } from '../engine/ui';
5
+ import { EditorBody, IconButton, styles } from '../engine/ui';
6
6
  import { eventToCell } from './pixelCanvas';
7
7
  import { forEachDab } from './pixelGeometry';
8
8
  import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
@@ -74,6 +74,24 @@ import tl from './pxArtTimeline.module.css';
74
74
  const ONION_WARM = '#ff7a3c';
75
75
  const ONION_COOL = '#3ca0ff';
76
76
 
77
+ // Artboard zoom bounds and wheel sensitivity. zoom 1 = fit (the artboard fills
78
+ // its region); zooming in scrolls the surrounding viewport to pan.
79
+ const MIN_ART_ZOOM = 1;
80
+ const MAX_ART_ZOOM = 12;
81
+ const ART_ZOOM_WHEEL_SPEED = 0.01;
82
+ // Floating zoom/pan control cluster, tucked into the true bottom-right corner of
83
+ // the artboard region (over the palette column's empty lower area — the palette
84
+ // keeps its swatches top-aligned).
85
+ const PXART_ZOOM_CONTROLS_STYLE = {
86
+ position: 'absolute',
87
+ right: 8,
88
+ bottom: 8,
89
+ display: 'flex',
90
+ flexDirection: 'column',
91
+ gap: 4,
92
+ zIndex: 5,
93
+ };
94
+
77
95
  // Native CSS cursors over the artboard, keyed by the active tool. The transient
78
96
  // eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
79
97
  // listed fall back to the default cursor.
@@ -147,11 +165,109 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
147
165
  eraseSize,
148
166
  activeKey,
149
167
  });
150
- const { canvasStyle } = useArtboardFit({
168
+ const { canvasStyle, displaySize } = useArtboardFit({
151
169
  wrapRef: canvasWrapRef,
152
170
  sizeBarRef: canvasSizeBarRef,
153
171
  resolution: sprite?.resolution ?? { width: 16, height: 16 },
154
172
  });
173
+ // Artboard zoom + pan. `zoom` scales the canvas above its fit size; the
174
+ // surrounding viewport (overflow: auto) scrolls to pan. Middle-drag pans and
175
+ // the wheel zooms at the cursor (wired below); the button cluster drives the
176
+ // same via `applyZoom`.
177
+ const viewportRef = useRef(null);
178
+ const panRef = useRef(null);
179
+ const [zoom, setZoom] = useState(1);
180
+ const zoomRef = useRef(zoom);
181
+ zoomRef.current = zoom;
182
+ // Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
183
+ // anchorY) client coords — defaulting to the viewport center. Scroll is fixed
184
+ // up on the next frame, once the resized canvas has laid out.
185
+ const applyZoom = useCallback((nextRaw, anchorX, anchorY) => {
186
+ const vp = viewportRef.current;
187
+ const z0 = zoomRef.current;
188
+ const z1 = Math.max(MIN_ART_ZOOM, Math.min(MAX_ART_ZOOM, nextRaw));
189
+ if (!vp || z1 === z0) return;
190
+ const rect = vp.getBoundingClientRect();
191
+ const ax = (Number.isFinite(anchorX) ? anchorX : rect.left + rect.width / 2) - rect.left;
192
+ const ay = (Number.isFinite(anchorY) ? anchorY : rect.top + rect.height / 2) - rect.top;
193
+ const contentX = vp.scrollLeft + ax;
194
+ const contentY = vp.scrollTop + ay;
195
+ const ratio = z1 / z0;
196
+ setZoom(z1);
197
+ requestAnimationFrame(() => {
198
+ vp.scrollLeft = contentX * ratio - ax;
199
+ vp.scrollTop = contentY * ratio - ay;
200
+ });
201
+ }, []);
202
+ const resetZoom = useCallback(() => {
203
+ setZoom(1);
204
+ const vp = viewportRef.current;
205
+ if (vp) requestAnimationFrame(() => {
206
+ vp.scrollLeft = 0;
207
+ vp.scrollTop = 0;
208
+ });
209
+ }, []);
210
+ // Wheel zoom as a NATIVE non-passive listener so preventDefault stops the
211
+ // page/browser zoom underneath. Delta is clamped so a chunky mouse notch is a
212
+ // sane step while trackpad deltas stay smooth.
213
+ useEffect(() => {
214
+ const vp = viewportRef.current;
215
+ if (!vp) return undefined;
216
+ const onWheel = (event) => {
217
+ event.preventDefault();
218
+ const clamped = Math.max(-50, Math.min(50, event.deltaY));
219
+ applyZoom(zoomRef.current * Math.exp(-clamped * ART_ZOOM_WHEEL_SPEED), event.clientX, event.clientY);
220
+ };
221
+ vp.addEventListener('wheel', onWheel, { passive: false });
222
+ return () => vp.removeEventListener('wheel', onWheel, { passive: false });
223
+ }, [applyZoom]);
224
+ // Middle-button drag pans by scrolling the viewport. Intercept in the capture
225
+ // phase so the press never reaches the canvas's draw handler (startTool).
226
+ const onViewportPointerDownCapture = (event) => {
227
+ // Pan on a middle-button drag or a ⌘+left-drag (metaKey). Both are caught in
228
+ // the capture phase so the press never reaches the canvas's draw handler.
229
+ const startsPan = event.button === 1 || (event.button === 0 && event.metaKey);
230
+ if (!startsPan) return;
231
+ event.preventDefault();
232
+ event.stopPropagation();
233
+ const vp = viewportRef.current;
234
+ vp.setPointerCapture(event.pointerId);
235
+ panRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
236
+ };
237
+ const onViewportPointerMove = (event) => {
238
+ const pan = panRef.current;
239
+ if (!pan || pan.id !== event.pointerId) return;
240
+ const vp = viewportRef.current;
241
+ vp.scrollLeft -= event.clientX - pan.x;
242
+ vp.scrollTop -= event.clientY - pan.y;
243
+ pan.x = event.clientX;
244
+ pan.y = event.clientY;
245
+ };
246
+ const onViewportPointerUp = (event) => {
247
+ const pan = panRef.current;
248
+ if (!pan || pan.id !== event.pointerId) return;
249
+ try {
250
+ viewportRef.current?.releasePointerCapture(event.pointerId);
251
+ } catch {
252
+ // pointer capture may already be gone
253
+ }
254
+ panRef.current = null;
255
+ };
256
+ // Viewport is sized to the fit baseline; the canvas inside scales by zoom, so
257
+ // zoom > 1 overflows and the viewport scrolls (= pan).
258
+ const artboardViewportStyle = displaySize
259
+ ? {
260
+ width: displaySize.width,
261
+ height: displaySize.height,
262
+ maxWidth: '100%',
263
+ maxHeight: '100%',
264
+ overflow: 'auto',
265
+ touchAction: 'none',
266
+ }
267
+ : { overflow: 'auto' };
268
+ const artboardCanvasStyle = displaySize
269
+ ? { width: displaySize.width * zoom, height: displaySize.height * zoom }
270
+ : canvasStyle;
155
271
  // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
156
272
  // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
157
273
  // dockview panel drag). Feeding it in here is what keeps the supersampled
@@ -437,7 +553,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
437
553
  <PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
438
554
  <div className={styles.drawingEditor}>
439
555
  <div className={tl.editorLeft}>
440
- <div className={tl.artboardRegion}>
556
+ <div className={tl.artboardRegion} style={{ position: 'relative' }}>
441
557
  <div className={styles.artboardToolColumn}>
442
558
  <PixelToolStrip
443
559
  tools={PIXEL_TOOLS}
@@ -464,19 +580,28 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
464
580
  />
465
581
  </div>
466
582
  </div>
467
- <PixelArtboard
468
- canvasRef={canvasRef}
469
- smoothRef={smoothRef}
470
- overlayRef={overlayRef}
471
- style={{ ...canvasStyle, cursor }}
472
- handlers={{
473
- onPointerDown: startTool,
474
- onPointerMove: handlePointerMove,
475
- onPointerUp: finishTool,
476
- onPointerCancel: finishTool,
477
- onPointerLeave: clearHover,
478
- }}
479
- />
583
+ <div
584
+ ref={viewportRef}
585
+ style={artboardViewportStyle}
586
+ onPointerDownCapture={onViewportPointerDownCapture}
587
+ onPointerMove={onViewportPointerMove}
588
+ onPointerUp={onViewportPointerUp}
589
+ onPointerCancel={onViewportPointerUp}
590
+ >
591
+ <PixelArtboard
592
+ canvasRef={canvasRef}
593
+ smoothRef={smoothRef}
594
+ overlayRef={overlayRef}
595
+ style={{ ...artboardCanvasStyle, cursor }}
596
+ handlers={{
597
+ onPointerDown: startTool,
598
+ onPointerMove: handlePointerMove,
599
+ onPointerUp: finishTool,
600
+ onPointerCancel: finishTool,
601
+ onPointerLeave: clearHover,
602
+ }}
603
+ />
604
+ </div>
480
605
  </div>
481
606
  </div>
482
607
  <div className={styles.paintColumn}>
@@ -494,6 +619,19 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
494
619
  selectActions={selectActions}
495
620
  />
496
621
  </div>
622
+ <div style={PXART_ZOOM_CONTROLS_STYLE}>
623
+ <IconButton
624
+ icon="zoom-in"
625
+ label="Zoom in"
626
+ onClick={() => applyZoom(zoomRef.current * 1.25)}
627
+ />
628
+ <IconButton
629
+ icon="zoom-out"
630
+ label="Zoom out"
631
+ onClick={() => applyZoom(zoomRef.current * 0.8)}
632
+ />
633
+ <IconButton icon="fit" label="Fit" onClick={resetZoom} />
634
+ </div>
497
635
  </div>
498
636
  <PxArtTimeline
499
637
  sprite={sprite}
@@ -58,6 +58,17 @@ const BLUEPRINT_TEMPLATE_ACTOR_ID = '__template__';
58
58
  const LONG_PRESS_MS = 500;
59
59
  const DRAG_THRESHOLD = 4;
60
60
  const DEFAULT_GRID_SIZE = 25;
61
+ // Floating zoom/pan control cluster, tucked into the bottom-right corner of the
62
+ // stage workspace (matches the art editor's placement).
63
+ const ZOOM_CONTROLS_STYLE = {
64
+ position: 'absolute',
65
+ right: 8,
66
+ bottom: 8,
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ gap: 4,
70
+ zIndex: 5,
71
+ };
61
72
  const EDIT_VIEWPORT = {
62
73
  width: cardSize.width * 3,
63
74
  height: cardSize.height * 3,
@@ -468,35 +479,22 @@ export function SceneEditor({
468
479
  <div className={styles.sceneWorkspace}>
469
480
  <div className={styles.sceneTools}>
470
481
  {isBlueprintFile ? null : (
471
- <>
472
- {/* Open (or focus) a Play panel in the shell for this scene.
473
- Lives here in the visible toolbar because the scene editor's
474
- header only renders in the combined editor, not the shell. */}
475
- <IconButton
476
- icon="external"
477
- label="Open play preview"
478
- onClick={(event) => {
479
- event.currentTarget.blur();
480
- window.parent.postMessage({ type: 'castle-open-playtest', scene: path }, '*');
481
- }}
482
- />
483
- <BlueprintLibrary
484
- files={files}
485
- sprites={sprites}
486
- canvasRef={canvasRef}
487
- editCameraRef={editCameraRef}
488
- isPlaying={isPlaying}
489
- selectedBlueprintPath={selectedBlueprintPath}
490
- instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
491
- onSelectBlueprint={onSelectBlueprint}
492
- onAddActor={onAddActor}
493
- dragPlace={dragPlace}
494
- onDeleteBlueprint={onDeleteBlueprint}
495
- />
496
- </>
482
+ <BlueprintLibrary
483
+ files={files}
484
+ sprites={sprites}
485
+ canvasRef={canvasRef}
486
+ editCameraRef={editCameraRef}
487
+ isPlaying={isPlaying}
488
+ selectedBlueprintPath={selectedBlueprintPath}
489
+ instanceBlueprintPath={rawSelectedActor?.blueprint ?? null}
490
+ onSelectBlueprint={onSelectBlueprint}
491
+ onAddActor={onAddActor}
492
+ dragPlace={dragPlace}
493
+ onDeleteBlueprint={onDeleteBlueprint}
494
+ />
497
495
  )}
498
496
  </div>
499
- <div className={styles.stageWrap} style={stageWrapStyle}>
497
+ <div className={styles.stageWrap} style={{ ...stageWrapStyle, position: 'relative' }}>
500
498
  <div className={styles.stageCard} data-castle-card>
501
499
  <div className={cx(styles.stageCardClip, !isPlaying && styles.stageCardEditSurface)}>
502
500
  <canvas
@@ -511,7 +509,12 @@ export function SceneEditor({
511
509
  onPointerDown={(event) => {
512
510
  event.currentTarget.focus({ preventScroll: true });
513
511
  if (isPlaying) return playPointer.onPointerDown(event);
514
- if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
512
+ if (
513
+ panGesture.isSpacePanning() ||
514
+ event.button === 1 ||
515
+ (event.button === 0 && event.metaKey)
516
+ )
517
+ return panGesture.onPointerDown(event);
515
518
  return gesture.onPointerDown(event);
516
519
  }}
517
520
  onPointerMove={(event) =>
@@ -555,6 +558,25 @@ export function SceneEditor({
555
558
  />
556
559
  )}
557
560
  </div>
561
+ {!isPlaying && (
562
+ <div style={ZOOM_CONTROLS_STYLE}>
563
+ <IconButton
564
+ icon="zoom-in"
565
+ label="Zoom in"
566
+ onClick={() => panGesture.zoomAtCenter(1.25)}
567
+ />
568
+ <IconButton
569
+ icon="zoom-out"
570
+ label="Zoom out"
571
+ onClick={() => panGesture.zoomAtCenter(0.8)}
572
+ />
573
+ <IconButton
574
+ icon="fit"
575
+ label="Reset view"
576
+ onClick={() => panGesture.resetView()}
577
+ />
578
+ </div>
579
+ )}
558
580
  </div>
559
581
  <aside {...inspectorSheet.rootProps}>
560
582
  <div {...inspectorSheet.grabProps}>
@@ -1036,18 +1058,19 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1036
1058
  const onWheel = (event) => {
1037
1059
  if (isPlayingRef.current) return;
1038
1060
  event.preventDefault();
1039
- // Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
1040
- // in Chromium; route those to zoom and leave plain scroll as pan.
1041
- if (event.ctrlKey) {
1042
- if (gestureActive) return;
1043
- zoomByFactorAtPointer(
1044
- event.clientX,
1045
- event.clientY,
1046
- Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
1047
- );
1048
- } else {
1049
- panByScreenDelta(event.deltaX, event.deltaY);
1050
- }
1061
+ // Safari fires ctrl+wheel alongside its own gesture* pinch events; skip the
1062
+ // wheel while a pinch is mid-flight so zoom isn't applied twice.
1063
+ if (gestureActive) return;
1064
+ // The wheel zooms at the cursor (standard editor affordance). Trackpad
1065
+ // pinch and Ctrl+wheel arrive here too (Chromium sets ctrlKey) and map to
1066
+ // the same zoom. Clamp per-event delta so a chunky mouse-wheel notch
1067
+ // (deltaY ~100) is a sane step (≤1.65x) while trackpad deltas stay smooth.
1068
+ const clampedDeltaY = Math.max(-50, Math.min(50, event.deltaY));
1069
+ zoomByFactorAtPointer(
1070
+ event.clientX,
1071
+ event.clientY,
1072
+ Math.exp(-clampedDeltaY * ZOOM_WHEEL_SPEED)
1073
+ );
1051
1074
  };
1052
1075
  const onGestureStart = (event) => {
1053
1076
  if (isPlayingRef.current) return;
@@ -1088,7 +1111,13 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1088
1111
  };
1089
1112
  }, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
1090
1113
  const onPointerDown = useCallback((event) => {
1091
- if (isPlaying || !spaceDownRef.current) return;
1114
+ if (isPlaying) return;
1115
+ // Pan when Space is held (existing modifier), on a middle-button drag, or on
1116
+ // a ⌘+left-drag (metaKey) — matching the art editor. preventDefault also
1117
+ // suppresses the browser's middle-click autoscroll.
1118
+ const middle = event.button === 1;
1119
+ const cmdDrag = event.button === 0 && event.metaKey;
1120
+ if (!spaceDownRef.current && !middle && !cmdDrag) return;
1092
1121
  event.preventDefault();
1093
1122
  event.currentTarget.setPointerCapture(event.pointerId);
1094
1123
  dragRef.current = { id: event.pointerId, lastX: event.clientX, lastY: event.clientY };
@@ -1117,7 +1146,29 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
1117
1146
  }, []);
1118
1147
  const isSpacePanning = useCallback(() => spaceDownRef.current, []);
1119
1148
  const isActive = useCallback(() => Boolean(dragRef.current), []);
1120
- return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
1149
+ // Button-driven zoom about the canvas center, and a reset-to-fit that restores
1150
+ // the initial camera (zoom 1 = the card exactly fills the viewport).
1151
+ const zoomAtCenter = useCallback(
1152
+ (factor) => {
1153
+ const canvas = canvasRef.current;
1154
+ if (!canvas) return;
1155
+ const rect = canvas.getBoundingClientRect();
1156
+ zoomByFactorAtPointer(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
1157
+ },
1158
+ [canvasRef, zoomByFactorAtPointer]
1159
+ );
1160
+ const resetView = useCallback(() => {
1161
+ editCameraRef.current = { x: 0, y: 0, zoom: 1 };
1162
+ }, [editCameraRef]);
1163
+ return {
1164
+ onPointerDown,
1165
+ onPointerMove,
1166
+ onPointerUp,
1167
+ isSpacePanning,
1168
+ isActive,
1169
+ zoomAtCenter,
1170
+ resetView,
1171
+ };
1121
1172
  }
1122
1173
  function handleShiftPointerDown(drag, actor, current) {
1123
1174
  if (actor) {
@@ -98,5 +98,8 @@ export function useArtboardFit({ wrapRef, sizeBarRef, resolution }) {
98
98
  ? { width: `${displaySize.width}px`, height: `${displaySize.height}px` }
99
99
  : undefined;
100
100
 
101
- return { canvasStyle, refit: fit };
101
+ // `displaySize` (numeric px, or null) is the fit baseline the zoom/pan layer
102
+ // multiplies against; `canvasStyle` is the same value pre-formatted for the
103
+ // unzoomed render path.
104
+ return { canvasStyle, displaySize, refit: fit };
102
105
  }