castle-web-cli 0.4.85 → 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/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/basic-2d/editors/SceneEditor.jsx +103 -19
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/editors/useArtboardFit.js +4 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +9 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/behaviors/Collider.jsx +20 -6
- package/kits/physics-2d/editors/PxArtEditor.jsx +155 -17
- package/kits/physics-2d/editors/SceneEditor.jsx +111 -45
- package/kits/physics-2d/editors/useArtboardFit.js +4 -1
- package/kits/physics-2d/engine/ScenePlayer.jsx +14 -1
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/collider.js +6 -2
- package/kits/physics-2d/engine/scene.js +12 -13
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- 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/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/package.json +1 -1
|
@@ -1,58 +1,204 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Panel, SelectField } from '../engine/ui';
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Icon, NumberField, Panel, SelectField } from '../engine/ui';
|
|
3
3
|
import { AutoFields, overrideProps } from '../engine/autoInspector';
|
|
4
|
-
import { getColliderRect, intersects } from '../engine/collider';
|
|
4
|
+
import { computeAutoFit, getColliderRect, getColliderShape, intersects } from '../engine/collider';
|
|
5
|
+
import { extensionDefaultProps } from '../engine/behaviorExtensions';
|
|
6
|
+
|
|
7
|
+
// Collapsible "Dimensions" section. The header row's label lines up exactly with
|
|
8
|
+
// the field labels above/below (both start at the panel body's 16px left pad);
|
|
9
|
+
// the open/closed caret floats in the left gutter without shifting the label.
|
|
10
|
+
// `margin-bottom: 12px` matches a field row's `padding-bottom`, so the gap to
|
|
11
|
+
// the next entry is the same whether the section is open or closed.
|
|
12
|
+
const dimHeaderRowStyle = {
|
|
13
|
+
position: 'relative',
|
|
14
|
+
display: 'flex',
|
|
15
|
+
alignItems: 'center',
|
|
16
|
+
gap: 10,
|
|
17
|
+
minHeight: 28,
|
|
18
|
+
margin: '0 0 12px',
|
|
19
|
+
};
|
|
20
|
+
const dimToggleStyle = {
|
|
21
|
+
display: 'inline-flex',
|
|
22
|
+
alignItems: 'center',
|
|
23
|
+
background: 'none',
|
|
24
|
+
border: 'none',
|
|
25
|
+
padding: 0,
|
|
26
|
+
margin: 0,
|
|
27
|
+
color: 'var(--castle-inspector-text)',
|
|
28
|
+
fontFamily: 'inherit',
|
|
29
|
+
fontSize: 14,
|
|
30
|
+
cursor: 'pointer',
|
|
31
|
+
};
|
|
32
|
+
// The caret sits in the panel's left gutter (label column starts at x=16).
|
|
33
|
+
const dimCaretStyle = {
|
|
34
|
+
position: 'absolute',
|
|
35
|
+
left: -14,
|
|
36
|
+
top: '50%',
|
|
37
|
+
transform: 'translateY(-50%)',
|
|
38
|
+
display: 'inline-flex',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
fontSize: 11,
|
|
41
|
+
opacity: 0.65,
|
|
42
|
+
};
|
|
43
|
+
const autoFitLinkStyle = {
|
|
44
|
+
background: 'none',
|
|
45
|
+
border: 'none',
|
|
46
|
+
padding: 0,
|
|
47
|
+
color: '#4aa3ff',
|
|
48
|
+
fontFamily: 'inherit',
|
|
49
|
+
fontSize: 13,
|
|
50
|
+
cursor: 'pointer',
|
|
51
|
+
};
|
|
52
|
+
const dimBodyStyle = { paddingLeft: 14 };
|
|
5
53
|
|
|
6
54
|
export class Collider {
|
|
7
55
|
static behaviorName = 'Collider';
|
|
8
56
|
|
|
9
57
|
static defaultProps = {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
58
|
+
shape: 'box',
|
|
59
|
+
// width/height/radius are intentionally NOT defaulted: an unset size means
|
|
60
|
+
// "match the actor's Layout box" (see engine/collider.js manualRect and the
|
|
61
|
+
// effective values in the inspector). Defaulting them to a fixed number made
|
|
62
|
+
// every collider shrink to that number instead of tracking the actor.
|
|
63
|
+
radius: 0,
|
|
14
64
|
offsetX: 0,
|
|
15
65
|
offsetY: 0,
|
|
66
|
+
// `isTrigger` marks a sensor: it still reports overlaps (draws yellow, reads
|
|
67
|
+
// as a pass-through zone for pickup/goal logic) but does not block on its
|
|
68
|
+
// own -- collisions are data you act on. Kit modules can register MORE
|
|
69
|
+
// Collider fields from outside this file (the physics module adds
|
|
70
|
+
// bounciness/friction material); see engine/behaviorExtensions.js. That's
|
|
71
|
+
// why this file is byte-identical across kits -- the physics-only fields
|
|
72
|
+
// live in physics/extensions/, not here.
|
|
73
|
+
isTrigger: false,
|
|
16
74
|
debug: false,
|
|
75
|
+
...extensionDefaultProps('Collider'),
|
|
17
76
|
};
|
|
18
77
|
|
|
19
78
|
constructor(props) {
|
|
20
79
|
this.props = props;
|
|
21
80
|
}
|
|
22
81
|
|
|
82
|
+
// Seed props when the collider is first added to an actor: snap it to the
|
|
83
|
+
// sprite's opaque-pixel bounds (what "Auto-fit to sprite" computes) so a new
|
|
84
|
+
// collider frames the art rather than the whole Layout box. Falls back to the
|
|
85
|
+
// unset (Layout-box) size when there's nothing to fit to -- no sprite, tile
|
|
86
|
+
// mode, or transparent art. The editor's addBehavior calls this (see
|
|
87
|
+
// editors/SceneEditor.jsx initialBehaviorProps).
|
|
88
|
+
static initialProps(actor, ctx) {
|
|
89
|
+
const fit = computeAutoFit(actor, ctx?.sprites);
|
|
90
|
+
return fit ? { ...Collider.defaultProps, ...fit } : { ...Collider.defaultProps };
|
|
91
|
+
}
|
|
92
|
+
|
|
23
93
|
draw(actor, scene, ctx, options) {
|
|
24
94
|
if (!options.showDebugColliders && !this.props.debug) return;
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
95
|
+
const geom = getColliderShape(actor, scene.sprites);
|
|
96
|
+
if (!geom) return;
|
|
97
|
+
// A sensor (isTrigger) reads as a pass-through zone; a solid as a wall.
|
|
98
|
+
const isSensor = Boolean(this.props.isTrigger) || this.props.kind === 'pickup';
|
|
27
99
|
ctx.save();
|
|
28
|
-
ctx.strokeStyle =
|
|
100
|
+
ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
|
|
29
101
|
ctx.lineWidth = 2;
|
|
30
|
-
|
|
102
|
+
if (geom.shape === 'circle') {
|
|
103
|
+
ctx.beginPath();
|
|
104
|
+
ctx.arc(geom.cx, geom.cy, geom.radius, 0, Math.PI * 2);
|
|
105
|
+
ctx.stroke();
|
|
106
|
+
} else {
|
|
107
|
+
ctx.strokeRect(geom.x + 1, geom.y + 1, geom.width - 2, geom.height - 2);
|
|
108
|
+
}
|
|
31
109
|
ctx.restore();
|
|
32
110
|
}
|
|
33
111
|
|
|
34
|
-
static Inspector({ component, setComponent, override }) {
|
|
112
|
+
static Inspector({ actor, component, sprites, setComponent, override }) {
|
|
113
|
+
const [dimOpen, setDimOpen] = useState(true);
|
|
114
|
+
const layout = actor?.components?.Layout ?? {};
|
|
115
|
+
const shape = component.shape ?? Collider.defaultProps.shape;
|
|
116
|
+
// Unset width/height/radius track the actor's Layout box; surface that
|
|
117
|
+
// effective value so a field never reads blank while the collider is sized.
|
|
118
|
+
const width = component.width ?? layout.width ?? 50;
|
|
119
|
+
const height = component.height ?? layout.height ?? 50;
|
|
120
|
+
const effectiveRadius = component.radius > 0 ? component.radius : Math.min(width, height) / 2;
|
|
121
|
+
|
|
122
|
+
// "Auto-fit to sprite": the explicit dims/offset that match the sprite's
|
|
123
|
+
// opaque-pixel bounds. Only offered when the collider isn't already fitted.
|
|
124
|
+
const autoFit = computeAutoFit(actor, sprites);
|
|
125
|
+
let autoFitPatch = null;
|
|
126
|
+
if (autoFit) {
|
|
127
|
+
autoFitPatch =
|
|
128
|
+
shape === 'circle'
|
|
129
|
+
? { radius: Math.round(Math.min(autoFit.width, autoFit.height) / 2), offsetX: autoFit.offsetX, offsetY: autoFit.offsetY }
|
|
130
|
+
: { width: autoFit.width, height: autoFit.height, offsetX: autoFit.offsetX, offsetY: autoFit.offsetY };
|
|
131
|
+
}
|
|
132
|
+
const currentValue = (key) => {
|
|
133
|
+
if (key === 'radius') return effectiveRadius;
|
|
134
|
+
if (key === 'width') return width;
|
|
135
|
+
if (key === 'height') return height;
|
|
136
|
+
return component[key] ?? 0;
|
|
137
|
+
};
|
|
138
|
+
const alreadyFitted =
|
|
139
|
+
autoFitPatch && Object.entries(autoFitPatch).every(([key, value]) => Math.abs(currentValue(key) - value) < 0.6);
|
|
140
|
+
|
|
35
141
|
return (
|
|
36
142
|
<Panel title="Collider" overridden={override?.anyOverridden()}>
|
|
37
143
|
<SelectField
|
|
38
|
-
label="
|
|
39
|
-
value={component.
|
|
40
|
-
onChange={(
|
|
41
|
-
options={['
|
|
42
|
-
{...overrideProps(override, '
|
|
43
|
-
/>
|
|
44
|
-
<SelectField
|
|
45
|
-
label="Mode"
|
|
46
|
-
value={component.mode}
|
|
47
|
-
onChange={(mode) => setComponent({ mode: mode })}
|
|
48
|
-
options={['auto', 'manual']}
|
|
49
|
-
{...overrideProps(override, 'mode')}
|
|
144
|
+
label="Shape"
|
|
145
|
+
value={component.shape}
|
|
146
|
+
onChange={(value) => setComponent({ shape: value })}
|
|
147
|
+
options={['box', 'circle']}
|
|
148
|
+
{...overrideProps(override, 'shape')}
|
|
50
149
|
/>
|
|
150
|
+
<div style={dimHeaderRowStyle}>
|
|
151
|
+
<span style={dimCaretStyle}>
|
|
152
|
+
<Icon name={dimOpen ? 'chevron-down' : 'chevron-right'} />
|
|
153
|
+
</span>
|
|
154
|
+
<button type="button" onClick={() => setDimOpen((open) => !open)} style={dimToggleStyle}>
|
|
155
|
+
Dimensions
|
|
156
|
+
</button>
|
|
157
|
+
{autoFitPatch && !alreadyFitted ? (
|
|
158
|
+
<button type="button" onClick={() => setComponent(autoFitPatch)} style={autoFitLinkStyle}>
|
|
159
|
+
Auto-fit to sprite
|
|
160
|
+
</button>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
{dimOpen ? (
|
|
164
|
+
<div style={dimBodyStyle}>
|
|
165
|
+
{shape === 'circle' ? (
|
|
166
|
+
<NumberField
|
|
167
|
+
label="Radius"
|
|
168
|
+
value={effectiveRadius}
|
|
169
|
+
onChange={(value) => setComponent({ radius: value })}
|
|
170
|
+
{...overrideProps(override, 'radius')}
|
|
171
|
+
/>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<NumberField
|
|
175
|
+
label="Width"
|
|
176
|
+
value={width}
|
|
177
|
+
onChange={(value) => setComponent({ width: value })}
|
|
178
|
+
{...overrideProps(override, 'width')}
|
|
179
|
+
/>
|
|
180
|
+
<NumberField
|
|
181
|
+
label="Height"
|
|
182
|
+
value={height}
|
|
183
|
+
onChange={(value) => setComponent({ height: value })}
|
|
184
|
+
{...overrideProps(override, 'height')}
|
|
185
|
+
/>
|
|
186
|
+
</>
|
|
187
|
+
)}
|
|
188
|
+
<AutoFields
|
|
189
|
+
defaultProps={Collider.defaultProps}
|
|
190
|
+
component={component}
|
|
191
|
+
setComponent={setComponent}
|
|
192
|
+
only={['offsetX', 'offsetY']}
|
|
193
|
+
override={override}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
) : null}
|
|
51
197
|
<AutoFields
|
|
52
198
|
defaultProps={Collider.defaultProps}
|
|
53
199
|
component={component}
|
|
54
200
|
setComponent={setComponent}
|
|
55
|
-
exclude={['
|
|
201
|
+
exclude={['shape', 'width', 'height', 'radius', 'offsetX', 'offsetY']}
|
|
56
202
|
override={override}
|
|
57
203
|
/>
|
|
58
204
|
</Panel>
|
|
@@ -64,4 +210,4 @@ export class Collider {
|
|
|
64
210
|
// scene (e.g. editors/SelectionOverlay.jsx, which passes the merged preview
|
|
65
211
|
// actors plus the sprites map). See engine/collider.js for the single
|
|
66
212
|
// implementation.
|
|
67
|
-
export { getColliderRect, intersects };
|
|
213
|
+
export { getColliderRect, getColliderShape, intersects };
|
|
@@ -8,6 +8,9 @@ export class Layout {
|
|
|
8
8
|
rotation: 0,
|
|
9
9
|
width: 50,
|
|
10
10
|
height: 50,
|
|
11
|
+
// Show the Layout box (the invisible frame the sprite fills and the collider
|
|
12
|
+
// derives from) as a dashed outline, in editor and play. Per-actor toggle.
|
|
13
|
+
debug: false,
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
// Position/rotation/z never inherit from a blueprint: they're always written
|
|
@@ -16,6 +19,13 @@ export class Layout {
|
|
|
16
19
|
// affects newly placed instances, never moves ones already placed. Because
|
|
17
20
|
// they're always instance-local, they're also never surfaced as blueprint
|
|
18
21
|
// "overrides" in the inspector. width/height default to inherited (absent).
|
|
22
|
+
//
|
|
23
|
+
// Size is width/height in card units -- the same space you compose in. A
|
|
24
|
+
// user-facing scaleX/scaleY (relative to the sprite's native pixels) was
|
|
25
|
+
// tried and reverted: it only pays off under pixel-perfect (integer scales),
|
|
26
|
+
// and in today's card-units model it just adds a conversion + couples Layout
|
|
27
|
+
// to the Sprite. Revisit scale fields when pixel-perfect mode lands. See
|
|
28
|
+
// ~/castle/cauldron-rendering-scale-model.md.
|
|
19
29
|
static propertyMeta = {
|
|
20
30
|
x: { inherit: false },
|
|
21
31
|
y: { inherit: false },
|
|
@@ -26,4 +36,18 @@ export class Layout {
|
|
|
26
36
|
constructor(props) {
|
|
27
37
|
this.props = props;
|
|
28
38
|
}
|
|
39
|
+
|
|
40
|
+
// Outline the Layout box when `debug` is on. Drawn in the actor's own rotated
|
|
41
|
+
// frame (so it rotates with the actor), with a distinct dashed lavender style
|
|
42
|
+
// so it reads separately from the solid collider debug and the selection box.
|
|
43
|
+
draw(actor, scene, ctx) {
|
|
44
|
+
if (!this.props.debug) return;
|
|
45
|
+
const { x, y, width, height } = this.props;
|
|
46
|
+
ctx.save();
|
|
47
|
+
ctx.strokeStyle = 'rgba(190, 178, 255, 0.75)';
|
|
48
|
+
ctx.setLineDash([4, 3]);
|
|
49
|
+
ctx.lineWidth = 1.5;
|
|
50
|
+
ctx.strokeRect(x + 0.5, y + 0.5, width - 1, height - 1);
|
|
51
|
+
ctx.restore();
|
|
52
|
+
}
|
|
29
53
|
}
|
|
@@ -5,20 +5,22 @@ import { useLiveDeckFiles } from '../engine/liveReload';
|
|
|
5
5
|
import { collectAssets } from '../engine/assets';
|
|
6
6
|
import { ScenePlayer } from '../engine/ScenePlayer';
|
|
7
7
|
import { behaviorClasses } from './behaviorRegistry';
|
|
8
|
-
// Play-mode entry point.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
8
|
+
// Play-mode entry point. Plays the scene named by `?scene=<path>` (the shell's
|
|
9
|
+
// Play panel sets this), defaulting to `scenes/main.scene`. It renders ONLY the
|
|
10
|
+
// game — the scene picker lives in the Play panel chrome (the shell), not here,
|
|
11
|
+
// so nothing floats over the game surface and steals input. Files are live:
|
|
12
|
+
// scene/drawing edits re-key the player against fresh data.
|
|
13
|
+
const DEFAULT_SCENE = 'scenes/main.scene';
|
|
14
|
+
|
|
15
|
+
export function PlayOnly({ initialScene } = {}) {
|
|
14
16
|
const { files, dataVersion } = useLiveDeckFiles();
|
|
15
|
-
const
|
|
16
|
-
const { value: sceneData } = parseJsonFile('
|
|
17
|
+
const scenePath = initialScene && files[initialScene] !== undefined ? initialScene : DEFAULT_SCENE;
|
|
18
|
+
const { value: sceneData } = parseJsonFile(scenePath, files[scenePath] ?? '');
|
|
17
19
|
if (!sceneData) return null;
|
|
18
20
|
const { sprites } = collectAssets(files);
|
|
19
21
|
return (
|
|
20
22
|
<ScenePlayer
|
|
21
|
-
key={dataVersion}
|
|
23
|
+
key={`${scenePath}:${dataVersion}`}
|
|
22
24
|
sceneData={sceneData}
|
|
23
25
|
sprites={sprites}
|
|
24
26
|
files={files}
|
|
@@ -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}
|