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.
Files changed (35) hide show
  1. package/dist/shell/assets/{index-BJLaUTJE.js → index-BFCG4tLs.js} +3 -3
  2. package/dist/shell/assets/{index-DonnH--m.css → index-DSIr52Kl.css} +1 -1
  3. package/dist/shell/index.html +2 -2
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/PxArtEditor.jsx +155 -17
  9. package/kits/basic-2d/editors/SceneEditor.jsx +103 -19
  10. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  11. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  12. package/kits/basic-2d/editors/useArtboardFit.js +4 -1
  13. package/kits/basic-2d/engine/ScenePlayer.jsx +14 -1
  14. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  15. package/kits/basic-2d/engine/collider.js +60 -6
  16. package/kits/basic-2d/engine/scene.js +28 -2
  17. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  18. package/kits/basic-2d/engine/ui.jsx +9 -0
  19. package/kits/basic-2d/main.jsx +3 -2
  20. package/kits/physics-2d/behaviors/Collider.jsx +20 -6
  21. package/kits/physics-2d/editors/PxArtEditor.jsx +155 -17
  22. package/kits/physics-2d/editors/SceneEditor.jsx +111 -45
  23. package/kits/physics-2d/editors/useArtboardFit.js +4 -1
  24. package/kits/physics-2d/engine/ScenePlayer.jsx +14 -1
  25. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  26. package/kits/physics-2d/engine/collider.js +6 -2
  27. package/kits/physics-2d/engine/scene.js +12 -13
  28. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  29. package/kits/physics-2d/engine/ui.jsx +7 -0
  30. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +4 -2
  31. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +8 -2
  32. package/kits/physics-2d/physics/controls.js +14 -0
  33. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  34. package/kits/physics-2d/systems/physics.js +8 -0
  35. 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
- kind: 'solid',
11
- mode: 'auto',
12
- width: 50,
13
- height: 50,
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 rect = getColliderRect(actor, scene.sprites);
26
- if (!rect) return;
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 = this.props.kind === 'pickup' ? '#ffe17a' : '#8db7ff';
100
+ ctx.strokeStyle = isSensor ? '#ffe17a' : '#8db7ff';
29
101
  ctx.lineWidth = 2;
30
- ctx.strokeRect(rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2);
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="Kind"
39
- value={component.kind}
40
- onChange={(kind) => setComponent({ kind: kind })}
41
- options={['solid', 'pickup']}
42
- {...overrideProps(override, 'kind')}
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={['kind', 'mode']}
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. Intentionally thin: it locates the start scene and
9
- // hands it to the engine's `ScenePlayer`, which owns the runtime and input.
10
- // Deck/game logic belongs in `scenes/` and `behaviors/`, not here.
11
- // Files are live: scene/drawing edits re-key the player against fresh data;
12
- // code changes reload this context (see engine/liveReload.js).
13
- export function PlayOnly() {
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 sceneText = files['scenes/main.scene'] ?? '';
16
- const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
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
- <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}