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.
- package/dist/shell/assets/{index-BJLaUTJE.js → index-BFCG4tLs.js} +3 -3
- package/dist/shell/assets/{index-DonnH--m.css → index-DSIr52Kl.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/basic-2d/editors/SceneEditor.jsx +93 -42
- package/kits/basic-2d/editors/useArtboardFit.js +4 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/basic-2d/engine/ui.jsx +7 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/physics-2d/editors/SceneEditor.jsx +93 -42
- package/kits/physics-2d/editors/useArtboardFit.js +4 -1
- package/kits/physics-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/physics-2d/engine/ui.jsx +7 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +4 -2
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +8 -2
- package/kits/physics-2d/physics/controls.js +14 -0
- package/package.json +1 -1
|
@@ -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
|
-
{
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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={{
|
|
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
|
-
<
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
{
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 (
|
|
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
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|