castle-web-cli 0.4.76 → 0.4.78

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.
@@ -4,8 +4,8 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Castle Editor</title>
7
- <script type="module" crossorigin src="/__castle/ide/assets/index-DNWEQd4R.js"></script>
8
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-DuKq-Grp.css">
7
+ <script type="module" crossorigin src="/__castle/ide/assets/index-yGdKhgfZ.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-WE24qX3d.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { frameCount, renderSpriteFrame } from '../engine/pxart';
3
+ import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
3
4
  import { Panel, SelectField } from '../engine/ui';
4
5
  import { AutoFields } from '../engine/autoInspector';
5
6
  import { parseTint, tintImageData } from './tint';
@@ -7,7 +8,10 @@ import { parseTint, tintImageData } from './tint';
7
8
  // Runtime behavior for the pixel-art Sprite format (.pxart). It looks up the
8
9
  // parsed Sprite from the scene's sprite map, composites the current animation
9
10
  // frame into a cached offscreen canvas (tinted), and blits it into the actor's
10
- // Layout box with smoothing disabled.
11
+ // Layout box with smoothing disabled — unless the FILE itself opts into a
12
+ // `cornerRadius` (a property of the .pxart asset, not a Sprite prop) greater
13
+ // than 0, in which case the cached canvas holds a corner-rounded vector
14
+ // render instead and gets blitted with smoothing on.
11
15
  //
12
16
  // Animation plays in `update` and is stored on `actor.runtime.spriteAnim` (the
13
17
  // behavior instance is recreated every frame, but the actor object persists in
@@ -58,7 +62,11 @@ export class Sprite {
58
62
  const frameIndex = Math.min(Math.max(actor.runtime?.spriteAnim?.frameIndex ?? 0, 0), total - 1);
59
63
  const canvas = getSpriteCanvas(sprite, frameIndex, this.props.tint);
60
64
  const prevSmoothing = ctx.imageSmoothingEnabled;
61
- ctx.imageSmoothingEnabled = false;
65
+ // File-level `cornerRadius > 0` sprites are pre-rendered as supersampled,
66
+ // corner-rounded vector fills (see engine/pxartSmooth.js); blit those with
67
+ // smoothing on so downscaling to the Layout box stays antialiased. Plain
68
+ // pixel sprites (`cornerRadius === 0`) keep nearest-neighbor blitting.
69
+ ctx.imageSmoothingEnabled = sprite.cornerRadius > 0;
62
70
  ctx.drawImage(canvas, layout.x, layout.y, layout.width, layout.height);
63
71
  ctx.imageSmoothingEnabled = prevSmoothing;
64
72
  }
@@ -100,7 +108,11 @@ function getSpriteCanvas(sprite, frameIndex, tint) {
100
108
  if (cached) return cached;
101
109
 
102
110
  const canvas = document.createElement('canvas');
103
- renderSpriteFrame(sprite, frameIndex, canvas);
111
+ if (sprite.cornerRadius > 0) {
112
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { cornerRadius: sprite.cornerRadius });
113
+ } else {
114
+ renderSpriteFrame(sprite, frameIndex, canvas);
115
+ }
104
116
  const tintRgba = parseTint(tint);
105
117
  if (tintRgba) {
106
118
  const octx = canvas.getContext('2d');
@@ -282,7 +282,85 @@ case).
282
282
 
283
283
  ---
284
284
 
285
- ## 10. Reserved / out of scope
285
+ ## 10. Corner rounding
286
+
287
+ ```json
288
+ "cornerRadius": 0.25
289
+ ```
290
+
291
+ - A top-level **number**: a corner-rounding radius, in native-pixel units.
292
+ `0` (the default) renders sharp/nearest-neighbor, same as before this field
293
+ existed. Any value `> 0` renders rounded, by that amount — see "Rendering"
294
+ below. **File-level** — a property of the sprite asset itself, not a
295
+ per-actor/per-placement override. There is no equivalent `Sprite` behavior
296
+ prop.
297
+ - **A value, not a flag**, deliberately: the amount of rounding is itself
298
+ part of the portable file format, not a fixed code-side constant every
299
+ smooth sprite would otherwise be stuck with. `MAX_CORNER_RADIUS` (0.5, in
300
+ `engine/pxart.js`) is the ceiling — two corner cuts on the same
301
+ 1-native-pixel edge must not overlap — and any value is clamped to
302
+ `[0, MAX_CORNER_RADIUS]` on read. The `PxArtEditor` UI curates this down to
303
+ a 3-way segmented control (`0` / `¼` / `½`, see `CornerRadiusBar` in
304
+ `editors/pixelInspector.jsx`) so picking a value never means hunting a
305
+ slider, but the format itself isn't limited to those three — any in-range
306
+ number is valid, e.g. from hand-edited JSON or a future finer-grained UI.
307
+ - **`0` is the default** and is never written to disk: `serializeFull` and
308
+ `serializeCompact` both OMIT the field when it's `0`, so existing (and
309
+ newly authored, unsmoothed) files stay byte-identical to their
310
+ pre-`cornerRadius`-field shape.
311
+ - **Present in BOTH on-disk forms.** `parseCompact` reads a top-level
312
+ `cornerRadius` alongside `palette`/`grid`; `parseFull`'s native-full branch
313
+ reads it alongside `resolution`/`layers`; `upgradeCompactToFull` carries a
314
+ compact file's `cornerRadius` through into the upgraded Sprite. This means
315
+ the kit's save path — which picks compact vs. full by a serialize →
316
+ re-parse round-trip (`serializeModel` in `editors/pxArtEditorModel.js`, see
317
+ [§9](#9-compact-shorthand)) — never drops the field: a single-layer/frame
318
+ rounded sprite still serializes compact, with `cornerRadius: 0.25` alongside
319
+ `palette`/`grid`.
320
+ - **Parser tolerance:** any non-finite-number value (missing field, `null`, a
321
+ typo) defaults to `0`. Numbers are clamped to `[0, MAX_CORNER_RADIUS]`.
322
+ **Legacy back-compat:** this field used to be called `render` — first a
323
+ `"pixel"` | `"smooth"` string enum, then (briefly) a bare numeric radius
324
+ under that same key. Both migrate on read: a legacy `render: "smooth"`
325
+ becomes a fixed radius (`0.25`); a legacy numeric `render` value is read
326
+ as-is; anything else (including the original `render: "pixel"`) defaults to
327
+ `0`. This keeps old files rendering rounded rather than silently reverting
328
+ to sharp. Detection of compact vs. full form ([§1](#1-form-discriminator))
329
+ is unaffected — neither key is ever used as a structural signal.
330
+ - **Rendering.** `cornerRadius === 0` renders as before: 1px/cell,
331
+ nearest-neighbor (`renderSpriteFrame`, `imageSmoothingEnabled = false`).
332
+ `cornerRadius > 0` renders through `engine/pxartSmooth.js`'s
333
+ `renderSmoothSpriteFrame`, using a LOCAL per-pixel kernel — the same shape
334
+ of algorithm as Animal Crossing's actual smoothing (xBRZ-style template
335
+ matching), not a global vectorization pass: it composites the frame
336
+ normally (so layer visibility/opacity/blend keep working), then for each
337
+ pixel independently, classifies each of its 4 corners against only the 3
338
+ pixels touching that corner (its two edge-adjacent neighbors and their
339
+ shared diagonal neighbor). A corner rounds only when it's a genuine convex
340
+ corner of that pixel's own color region — a diagonal touch between two
341
+ same-colored pixels is deliberately left sharp so it doesn't get visually
342
+ pinched off, and a corner already matched by an adjacent same-color
343
+ neighbor is left for that neighbor's own (independent) classification to
344
+ handle. Because the kernel never looks past a pixel's immediate
345
+ neighborhood, there's no notion of a "run" or "line" to (mis)detect across
346
+ a whole shape — every pixel supersamples into its own fixed block of the
347
+ output canvas (by default 8x the sprite's native resolution), so rounding
348
+ a corner is always a same-block recoloring, never a shape that could leave
349
+ a gap against its neighbors. This suits small pixel-art sprites, not
350
+ general raster upscaling.
351
+ - `behaviors/Sprite.jsx` blits a `cornerRadius > 0` sprite's cached canvas
352
+ with `imageSmoothingEnabled = true` (vs. `false` for `cornerRadius === 0`),
353
+ passing the sprite's own `cornerRadius` value through as
354
+ `renderSmoothSpriteFrame`'s `cornerRadius` option; the offscreen canvas
355
+ cache itself is unaffected (still one WeakMap entry per sprite object —
356
+ smoothing is a property of the sprite, not an extra cache dimension). The
357
+ `PxArtEditor` artboard previews rounded sprites live (a display-resolution
358
+ canvas behind the pixel-grid editing surface) while keeping normal
359
+ pixel-grid editing/selection interactions on the grid underneath.
360
+
361
+ ---
362
+
363
+ ## 11. Reserved / out of scope
286
364
 
287
365
  Named so the format can grow without a breaking change, but **not built**:
288
366
  tilemap/tileset layers (a future `kind`), slices, layer groups, vector/avatar
@@ -1,19 +1,24 @@
1
1
  import React from 'react';
2
2
  import { Lifecycle } from 'castle-web-sdk';
3
- import { initialFiles, parseJsonFile } from '../engine/files';
3
+ import { parseJsonFile } from '../engine/files';
4
+ import { useLiveDeckFiles } from '../engine/liveReload';
4
5
  import { collectAssets } from '../engine/assets';
5
6
  import { ScenePlayer } from '../engine/ScenePlayer';
6
7
  import { behaviorClasses } from './behaviorRegistry';
7
8
  // Play-mode entry point. Intentionally thin: it locates the start scene and
8
9
  // hands it to the engine's `ScenePlayer`, which owns the runtime and input.
9
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).
10
13
  export function PlayOnly() {
11
- const sceneText = initialFiles['scenes/main.scene'] ?? '';
14
+ const { files, dataVersion } = useLiveDeckFiles();
15
+ const sceneText = files['scenes/main.scene'] ?? '';
12
16
  const { value: sceneData } = parseJsonFile('scenes/main.scene', sceneText);
13
17
  if (!sceneData) return null;
14
- const { sprites } = collectAssets(initialFiles);
18
+ const { sprites } = collectAssets(files);
15
19
  return (
16
20
  <ScenePlayer
21
+ key={dataVersion}
17
22
  sceneData={sceneData}
18
23
  sprites={sprites}
19
24
  behaviorClasses={behaviorClasses}
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
+ import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
3
4
  import { basename } from '../engine/files';
4
5
  import { EditorBody, styles } from '../engine/ui';
5
6
  import { eventToCell } from './pixelCanvas';
@@ -8,6 +9,7 @@ import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEd
8
9
  import {
9
10
  BRUSH_SIZES,
10
11
  CanvasSizeBar,
12
+ CornerRadiusBar,
11
13
  PaintStrip,
12
14
  PIXEL_TOOLS,
13
15
  PixelToolStrip,
@@ -53,6 +55,7 @@ import {
53
55
  setCellOffset,
54
56
  setDefaultDuration,
55
57
  setDefaultTag,
58
+ setCornerRadius,
56
59
  setFrameDuration,
57
60
  setupSpriteCanvas,
58
61
  unlinkCell,
@@ -85,6 +88,7 @@ const TOOL_CURSORS = {
85
88
 
86
89
  export function PxArtEditor({ path, text, onChange, ...chrome }) {
87
90
  const canvasRef = useRef(null);
91
+ const smoothRef = useRef(null);
88
92
  const overlayRef = useRef(null);
89
93
  const strokeRef = useRef(null);
90
94
  // The serialized text this editor last wrote itself. Lets us tell our own
@@ -116,7 +120,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
116
120
  eraseSize,
117
121
  setEraseSize,
118
122
  } = toolState;
119
- const { history, headerShell } = usePixelEditorShell(text, onChange);
123
+ const { history, headerShell } = usePixelEditorShell(text, onChange, path);
120
124
  const canvasWrapRef = useRef(null);
121
125
  const canvasSizeBarRef = useRef(null);
122
126
  const colorButtonRef = useRef(null);
@@ -143,12 +147,20 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
143
147
  eraseSize,
144
148
  activeKey,
145
149
  });
146
- useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
147
150
  const { canvasStyle } = useArtboardFit({
148
151
  wrapRef: canvasWrapRef,
149
152
  sizeBarRef: canvasSizeBarRef,
150
153
  resolution: sprite?.resolution ?? { width: 16, height: 16 },
151
154
  });
155
+ // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
156
+ // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
157
+ // dockview panel drag). Feeding it in here is what keeps the supersampled
158
+ // canvas's resolution in sync with that display size: without it, this
159
+ // effect has no way to know the wrap resized (nothing about `text`,
160
+ // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
161
+ // it would keep rendering at whatever scale was last measured.
162
+ useSmoothPreviewRender(smoothRef, text, sprite, previewIndex, canvasStyle?.width);
163
+ useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
152
164
  const isCompactLayout = useEditorCompactLayout(editorBodyRef);
153
165
  useEffect(() => {
154
166
  if (!isCompactLayout) setPaletteOpen(false);
@@ -272,6 +284,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
272
284
  deletePixels: deleteMarqueePixels,
273
285
  };
274
286
  function startTool(event) {
287
+ // Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
288
+ // handler (bound on this iframe's window) instead of falling through to the
289
+ // browser. Safari in particular won't keep keyboard focus in a subframe
290
+ // after clicking a non-focusable canvas, so we focus it explicitly. Done
291
+ // before the early-return so even a click outside a cell claims focus.
292
+ event.currentTarget.focus({ preventScroll: true });
275
293
  const point = eventToCell(event, width, height);
276
294
  if (!point) return;
277
295
  event.currentTarget.setPointerCapture(event.pointerId);
@@ -433,15 +451,22 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
433
451
  </div>
434
452
  <div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
435
453
  <div className={styles.artboardStack}>
436
- <div ref={canvasSizeBarRef}>
437
- <CanvasSizeBar
438
- width={width}
439
- height={height}
440
- onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
441
- />
454
+ <div ref={canvasSizeBarRef} className={styles.canvasTopStack}>
455
+ <div className={styles.canvasTopBar}>
456
+ <CanvasSizeBar
457
+ width={width}
458
+ height={height}
459
+ onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
460
+ />
461
+ <CornerRadiusBar
462
+ radius={sprite.cornerRadius}
463
+ onChange={(radius) => history.commit(serializeModel(setCornerRadius(sprite, radius)))}
464
+ />
465
+ </div>
442
466
  </div>
443
467
  <PixelArtboard
444
468
  canvasRef={canvasRef}
469
+ smoothRef={smoothRef}
445
470
  overlayRef={overlayRef}
446
471
  style={{ ...canvasStyle, cursor }}
447
472
  handlers={{
@@ -592,6 +617,34 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
592
617
  ]);
593
618
  }
594
619
 
620
+ // Render the "smooth" preview into its own display-resolution canvas, behind
621
+ // the interactive artboard (see .drawingSmooth). Left empty when
622
+ // `cornerRadius` is 0 (sharp/pixel) — the interactive canvas's own unrounded
623
+ // frame draw is what shows then. Runs off `text`/`previewIndex` rather than
624
+ // the `sprite` object (a fresh object every render) so it only redraws on an
625
+ // actual content/frame/radius change, not on every hover-driven re-render of
626
+ // the tool preview — PLUS `displayWidth` (the artboard's current CSS width
627
+ // from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
628
+ // triggers a re-render at the new supersample scale, not just a content
629
+ // change. The supersample factor auto-fits to the displayed size (capped
630
+ // between 4x and 16x).
631
+ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
632
+ const cornerRadius = sprite?.cornerRadius ?? 0;
633
+ useEffect(() => {
634
+ const canvas = smoothRef.current;
635
+ if (!canvas) return;
636
+ if (!sprite || cornerRadius <= 0) {
637
+ canvas.width = 0;
638
+ canvas.height = 0;
639
+ return;
640
+ }
641
+ const cssWidth = canvas.clientWidth || sprite.resolution.width;
642
+ const dpr = window.devicePixelRatio || 1;
643
+ const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
644
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
645
+ }, [text, frameIndex, smoothRef, cornerRadius, displayWidth]);
646
+ }
647
+
595
648
  // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
596
649
  function useToolShortcuts(ctx) {
597
650
  const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
@@ -614,7 +667,13 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
614
667
  }
615
668
  }
616
669
  ctx.globalAlpha = 1;
617
- ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
670
+ // "Smooth" sprites are drawn by the display-resolution .drawingSmooth canvas
671
+ // BEHIND this one (see useSmoothPreviewRender); leaving this canvas
672
+ // transparent here lets that show through while pointer math, the marquee,
673
+ // and the tool-preview ghost below all keep working in native grid units.
674
+ if (sprite.cornerRadius <= 0) {
675
+ ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
676
+ }
618
677
  if (preview) drawToolPreview(ctx, canvas, preview);
619
678
  }
620
679
 
@@ -84,8 +84,11 @@ export function SceneEditor({
84
84
  const selectedActorIdsRef = useRef(selectedActorIds);
85
85
  selectedActorIdsRef.current = selectedActorIds;
86
86
  const [isPlaying, setIsPlaying] = useState(false);
87
- const history = useEditHistory(text, onChange);
88
- useUndoRedoShortcuts(history);
87
+ const history = useEditHistory(text, onChange, path);
88
+ // Disabled during play: the header undo/redo buttons are already disabled
89
+ // then, but without this the keyboard shortcut could still rewrite the
90
+ // scene file out from under the running play-mode runtime.
91
+ useUndoRedoShortcuts(history, !isPlaying);
89
92
  const showMulti = selectedActorIds.length > 1 || multiSelectMode;
90
93
  const inspectorSheet = useSelectionInspectorSheet(true);
91
94
  const { value: sceneData, error } = parseJsonFile(path, text);
@@ -206,13 +209,18 @@ export function SceneEditor({
206
209
  <canvas
207
210
  ref={canvasRef}
208
211
  className={cx(styles.stageCanvas, !isPlaying && styles.stageCanvasEdit)}
209
- onPointerDown={(event) =>
210
- isPlaying
211
- ? playPointer.onPointerDown(event)
212
- : panGesture.isSpacePanning()
213
- ? panGesture.onPointerDown(event)
214
- : gesture.onPointerDown(event)
215
- }
212
+ // Focusable so pointer-down pulls keyboard focus into this
213
+ // editor iframe and Cmd+Z reaches our undo handler instead of
214
+ // the browser's (see PxArtEditor startTool for the full
215
+ // rationale -- Safari won't keep focus on a clicked
216
+ // non-focusable canvas).
217
+ tabIndex={-1}
218
+ onPointerDown={(event) => {
219
+ event.currentTarget.focus({ preventScroll: true });
220
+ if (isPlaying) return playPointer.onPointerDown(event);
221
+ if (panGesture.isSpacePanning()) return panGesture.onPointerDown(event);
222
+ return gesture.onPointerDown(event);
223
+ }}
216
224
  onPointerMove={(event) =>
217
225
  isPlaying
218
226
  ? playPointer.onPointerMove(event)
@@ -660,12 +668,17 @@ function makeActorId(existingIds) {
660
668
  return `actor-${Date.now()}`;
661
669
  }
662
670
  function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorIds }) {
663
- function commitScene(next) {
664
- commit(formatJson(next));
671
+ function commitScene(next, options) {
672
+ commit(formatJson(next), options);
665
673
  }
666
674
  return {
675
+ // Keyed by the changed field so consecutive edits to the SAME field (e.g.
676
+ // dragging a ColorField or clicking a NumberField stepper) coalesce into
677
+ // one undo entry, while edits to a different field stay separate steps.
667
678
  updateComponent: (actorId, behaviorName, nextProps) =>
668
- commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps)),
679
+ commitScene(setActorComponent(sceneData, actorId, behaviorName, nextProps), {
680
+ coalesceKey: componentCoalesceKey(actorId, behaviorName, nextProps),
681
+ }),
669
682
  addBehavior: (actorId, behaviorName) => {
670
683
  const Behavior = findBehaviorClass(behaviorName);
671
684
  if (!Behavior) return;
@@ -693,6 +706,11 @@ function makeSceneActions({ sceneData, commit, selectedActorIds, onSelectActorId
693
706
  },
694
707
  };
695
708
  }
709
+ // setActorComponent merges nextProps into the actor's existing component, so
710
+ // its keys ARE the fields this call actually changed.
711
+ function componentCoalesceKey(actorId, behaviorName, nextProps) {
712
+ return `${actorId}:${behaviorName}:${Object.keys(nextProps).sort().join(',')}`;
713
+ }
696
714
  function setSceneSettings(sceneData, nextScene) {
697
715
  return {
698
716
  ...sceneData,
@@ -5,8 +5,9 @@
5
5
  // browsing and the code/text editor are now builtin shell panels.
6
6
 
7
7
  import React, { useEffect, useRef, useState } from 'react';
8
- import { onBeforeRestart, writeFile } from 'castle-web-sdk';
9
- import { getFileKind, initialFiles } from '../engine/files';
8
+ import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
9
+ import { getFileKind } from '../engine/files';
10
+ import { useLiveDeckFiles } from '../engine/liveReload';
10
11
  import { collectAssets } from '../engine/assets';
11
12
  import { MainEditor, styles } from '../engine/ui';
12
13
  import { PxArtEditor } from './PxArtEditor';
@@ -36,10 +37,12 @@ function useFileSaver() {
36
37
  versions.current[path] = version;
37
38
  pending.current[path] = text;
38
39
  if (timers.current[path]) window.clearTimeout(timers.current[path]);
40
+ // Short-ish debounce: writes now drive the live play/panel updates, so a
41
+ // drag should reflect soon after the user pauses.
39
42
  timers.current[path] = window.setTimeout(() => {
40
43
  delete timers.current[path];
41
44
  void commit(path, text, version);
42
- }, 1500);
45
+ }, 800);
43
46
  }
44
47
  useEffect(
45
48
  () =>
@@ -54,14 +57,27 @@ function useFileSaver() {
54
57
  }),
55
58
  []
56
59
  );
57
- return schedule;
60
+ function hasPending(path) {
61
+ return pending.current[path] !== undefined;
62
+ }
63
+ return { schedule, hasPending };
58
64
  }
59
65
 
60
66
  export function SingleEditor({ path, editor }) {
61
- const [files, setFiles] = useState(initialFiles);
62
- const [selectedActorIds, setSelectedActorIds] = useState([]);
63
- const [multiSelectMode, setMultiSelectMode] = useState(false);
64
- const schedule = useFileSaver();
67
+ // Selection survives a code-change reload: stashed via the SDK's save-state
68
+ // hook right before the reload, picked back up here on boot.
69
+ const stashKey = `single-editor:${path}`;
70
+ const [stash] = useState(() => takeReloadState(stashKey));
71
+ const { schedule, hasPending } = useFileSaver();
72
+ // Live deck files; our own in-flight (debounced, unsaved) edits win over the
73
+ // fs echo of a stale write, so a drag in progress isn't clobbered.
74
+ const { files, setFiles } = useLiveDeckFiles({ shouldSkipPath: hasPending });
75
+ const [selectedActorIds, setSelectedActorIds] = useState(stash?.selectedActorIds ?? []);
76
+ const [multiSelectMode, setMultiSelectMode] = useState(stash?.multiSelectMode ?? false);
77
+ useEffect(
78
+ () => onSaveReloadState(stashKey, () => ({ selectedActorIds, multiSelectMode })),
79
+ [stashKey, selectedActorIds, multiSelectMode]
80
+ );
65
81
  const { sprites } = collectAssets(files);
66
82
  function onChange(nextText) {
67
83
  setFiles((current) => ({ ...current, [path]: nextText }));
@@ -1,47 +1,116 @@
1
1
  import { useEffect, useRef, useState } from 'react';
2
2
  const HISTORY_LIMIT = 50;
3
+ // Time window (ms) within which consecutive commits sharing the same
4
+ // coalesceKey collapse into a single undo entry (e.g. dragging a color
5
+ // picker or repeatedly clicking a number stepper). Sliding: each coalesced
6
+ // commit refreshes the window.
7
+ const COALESCE_WINDOW_MS = 800;
8
+
9
+ function historyStorageKey(path) {
10
+ return `castle-edit-history:${path}`;
11
+ }
12
+ // Best-effort sessionStorage read/write so undo/redo survives the editor
13
+ // iframe reloading (e.g. after `npm run restart`). Quota errors or disabled
14
+ // storage (private browsing, etc.) degrade silently to in-memory-only history.
15
+ function loadStoredHistory(path) {
16
+ if (!path) return null;
17
+ try {
18
+ const raw = sessionStorage.getItem(historyStorageKey(path));
19
+ if (!raw) return null;
20
+ const parsed = JSON.parse(raw);
21
+ if (!parsed || !Array.isArray(parsed.undo) || !Array.isArray(parsed.redo)) return null;
22
+ return {
23
+ undo: parsed.undo.slice(-HISTORY_LIMIT),
24
+ redo: parsed.redo.slice(0, HISTORY_LIMIT),
25
+ };
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+ function saveStoredHistory(path, history) {
31
+ if (!path) return;
32
+ try {
33
+ sessionStorage.setItem(historyStorageKey(path), JSON.stringify(history));
34
+ } catch {
35
+ // Storage full or unavailable -- history still works in-memory this session.
36
+ }
37
+ }
38
+ // Drop entries at the end of `stack` equal to `text`: snapshots recorded at
39
+ // gesture start (recordSnapshot) that the gesture never actually changed.
40
+ function trimTrailingNoOps(stack, text) {
41
+ let end = stack.length;
42
+ while (end > 0 && stack[end - 1] === text) end--;
43
+ return end === stack.length ? stack : stack.slice(0, end);
44
+ }
45
+ // Same idea for the redo stack, which is read from the front.
46
+ function trimLeadingNoOps(stack, text) {
47
+ let start = 0;
48
+ while (start < stack.length && stack[start] === text) start++;
49
+ return start === 0 ? stack : stack.slice(start);
50
+ }
3
51
  // Text-undo/redo for file-backed editors. `text` is the canonical current
4
52
  // value; `onChange` writes the new value back. The hook owns the undo/redo
5
- // stacks; it never mutates `text` directly.
6
- export function useEditHistory(text, onChange) {
7
- const [history, setHistory] = useState({ undo: [], redo: [] });
53
+ // stacks; it never mutates `text` directly. `path`, when given, persists the
54
+ // stacks to sessionStorage keyed by file path.
55
+ export function useEditHistory(text, onChange, path) {
56
+ const [history, setHistory] = useState(() => loadStoredHistory(path) ?? { undo: [], redo: [] });
8
57
  // Mirror the latest stacks so undo/redo can read them synchronously in the
9
58
  // event handler. onChange writes the PARENT's state, so it must never run
10
59
  // inside a setHistory updater -- updaters execute in React's render phase,
11
60
  // which would update the parent while this editor renders (setState-in-render).
12
61
  const historyRef = useRef(history);
13
62
  historyRef.current = history;
14
- function commit(nextText) {
63
+ useEffect(() => {
64
+ saveStoredHistory(path, history);
65
+ }, [path, history]);
66
+ // { key, time } of the most recent coalescable commit. Undo/redo/
67
+ // recordSnapshot null this out so a later commit never coalesces across them.
68
+ const coalesceRef = useRef({ key: null, time: 0 });
69
+ function commit(nextText, { coalesceKey } = {}) {
15
70
  if (nextText === text) return;
16
- setHistory((current) => ({
17
- undo: [...current.undo, text].slice(-HISTORY_LIMIT),
18
- redo: [],
19
- }));
71
+ const now = Date.now();
72
+ const last = coalesceRef.current;
73
+ const coalescing =
74
+ coalesceKey != null && last.key === coalesceKey && now - last.time <= COALESCE_WINDOW_MS;
75
+ coalesceRef.current = { key: coalesceKey ?? null, time: now };
76
+ if (coalescing) {
77
+ setHistory((current) => ({ ...current, redo: [] }));
78
+ } else {
79
+ setHistory((current) => ({
80
+ undo: [...current.undo, text].slice(-HISTORY_LIMIT),
81
+ redo: [],
82
+ }));
83
+ }
20
84
  onChange(nextText);
21
85
  }
22
86
  function recordSnapshot() {
87
+ coalesceRef.current = { key: null, time: 0 };
23
88
  setHistory((current) => ({
24
89
  undo: [...current.undo, text].slice(-HISTORY_LIMIT),
25
90
  redo: [],
26
91
  }));
27
92
  }
28
93
  function undo() {
94
+ coalesceRef.current = { key: null, time: 0 };
29
95
  const current = historyRef.current;
30
- const previous = current.undo.at(-1);
96
+ const stack = trimTrailingNoOps(current.undo, text);
97
+ const previous = stack.at(-1);
31
98
  if (previous === undefined) return;
32
99
  setHistory({
33
- undo: current.undo.slice(0, -1),
100
+ undo: stack.slice(0, -1),
34
101
  redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
35
102
  });
36
103
  onChange(previous);
37
104
  }
38
105
  function redo() {
106
+ coalesceRef.current = { key: null, time: 0 };
39
107
  const current = historyRef.current;
40
- const next = current.redo[0];
108
+ const stack = trimLeadingNoOps(current.redo, text);
109
+ const next = stack[0];
41
110
  if (next === undefined) return;
42
111
  setHistory({
43
112
  undo: [...current.undo, text].slice(-HISTORY_LIMIT),
44
- redo: current.redo.slice(1),
113
+ redo: stack.slice(1),
45
114
  });
46
115
  onChange(next);
47
116
  }
@@ -49,17 +118,20 @@ export function useEditHistory(text, onChange) {
49
118
  commit,
50
119
  undo,
51
120
  redo,
52
- canUndo: history.undo.length > 0,
53
- canRedo: history.redo.length > 0,
121
+ canUndo: trimTrailingNoOps(history.undo, text).length > 0,
122
+ canRedo: trimLeadingNoOps(history.redo, text).length > 0,
54
123
  recordSnapshot,
55
124
  };
56
125
  }
57
126
 
58
- export function useUndoRedoShortcuts(history) {
127
+ export function useUndoRedoShortcuts(history, enabled = true) {
59
128
  const historyRef = useRef(history);
60
129
  historyRef.current = history;
130
+ const enabledRef = useRef(enabled);
131
+ enabledRef.current = enabled;
61
132
  useEffect(() => {
62
133
  function onKeyDown(event) {
134
+ if (!enabledRef.current) return;
63
135
  if (event.defaultPrevented || isEditableTarget(event.target)) return;
64
136
  if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
65
137
  event.preventDefault();
@@ -5,8 +5,8 @@ import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
5
5
  // Shared shell state for the pixel editors: text undo/redo history and undo/redo
6
6
  // keyboard shortcuts. Returns the `history` controller and `headerShell` for the
7
7
  // header (undo/redo only — paint controls live in the artboard layout).
8
- export function usePixelEditorShell(text, onChange) {
9
- const history = useEditHistory(text, onChange);
8
+ export function usePixelEditorShell(text, onChange, path) {
9
+ const history = useEditHistory(text, onChange, path);
10
10
  useUndoRedoShortcuts(history);
11
11
  const headerShell = { history };
12
12
  return { history, headerShell };
@@ -36,18 +36,31 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
36
36
  }
37
37
 
38
38
  // The native-resolution canvas inside its artboard frame. `style` sets the fitted
39
- // display size; `handlers` are the pointer callbacks the editor wires for tools.
40
- // `overlayRef` is a sibling canvas at display resolution used for crisp overlays
41
- // (marching-ants selection) that would otherwise render sub-pixel on the upscaled
42
- // native canvas.
43
- export function PixelArtboard({ canvasRef, overlayRef, style, handlers }) {
39
+ // display size, and is applied ONLY to the frame: all three canvases inside are
40
+ // absolutely positioned with `width/height: 100%`, so they fill the frame's
41
+ // content box identically. (Passing `style` to a canvas too would size it to the
42
+ // frame's BORDER box — the frame is border-box with a 1px border — leaving the
43
+ // canvas ~2px larger than its siblings and anchored top-left, which shifts the
44
+ // smooth-preview vs. pixel render out of alignment.) `handlers` are the pointer
45
+ // callbacks the editor wires for tools. `overlayRef` is a sibling canvas at
46
+ // display resolution used for crisp overlays (marching-ants selection) that would
47
+ // otherwise render sub-pixel on the upscaled native canvas. `smoothRef` is a
48
+ // sibling canvas BEHIND the main one, holding the corner-rounded render for
49
+ // "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
50
+ // above it can stay at native resolution — pointer math and tool-preview overlays
51
+ // are unaffected by the render mode.
52
+ export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers }) {
44
53
  return (
45
54
  <div className={styles.drawingArtboard}>
46
55
  <div className={styles.drawingArtboardFrame} style={style}>
56
+ <canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
47
57
  <canvas
48
58
  ref={canvasRef}
49
59
  className={styles.drawingCanvas}
50
- style={style}
60
+ // Focusable so a pointer-down can pull keyboard focus into this editor
61
+ // iframe (see startTool). Without it, Safari often leaves keyboard focus
62
+ // on the top document and Cmd+Z falls through to the browser's own undo.
63
+ tabIndex={-1}
51
64
  onPointerDown={handlers.onPointerDown}
52
65
  onPointerMove={handlers.onPointerMove}
53
66
  onPointerUp={handlers.onPointerUp}