castle-web-cli 0.4.77 → 0.4.79

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 (53) hide show
  1. package/dist/agent-prompts.d.ts +4 -1
  2. package/dist/agent-prompts.js +28 -7
  3. package/dist/agent.d.ts +7 -2
  4. package/dist/agent.js +655 -51
  5. package/dist/ide.js +2 -0
  6. package/dist/native/loop.d.ts +2 -0
  7. package/dist/native/loop.js +698 -0
  8. package/dist/native/openrouter.d.ts +55 -0
  9. package/dist/native/openrouter.js +354 -0
  10. package/dist/native/playtest-browser.d.ts +34 -0
  11. package/dist/native/playtest-browser.js +354 -0
  12. package/dist/native/playtest-executor.d.ts +3 -0
  13. package/dist/native/playtest-executor.js +156 -0
  14. package/dist/native/playtest.d.ts +131 -0
  15. package/dist/native/playtest.js +314 -0
  16. package/dist/native/tools.d.ts +38 -0
  17. package/dist/native/tools.js +630 -0
  18. package/dist/native/types.d.ts +40 -0
  19. package/dist/native/types.js +41 -0
  20. package/dist/serve.js +12 -0
  21. package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
  22. package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
  23. package/dist/shell/index.html +2 -2
  24. package/kits/basic-2d/CLAUDE.md +29 -3
  25. package/kits/basic-2d/behaviors/Layout.jsx +10 -0
  26. package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
  27. package/kits/basic-2d/blueprints/cauldron.scene +22 -0
  28. package/kits/basic-2d/castle.json +5 -7
  29. package/kits/basic-2d/docs/pxart-format.md +83 -4
  30. package/kits/basic-2d/drawings/cauldron.pxart +113 -0
  31. package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
  32. package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
  33. package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
  34. package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
  35. package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
  36. package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
  37. package/kits/basic-2d/editors/editorHistory.js +95 -17
  38. package/kits/basic-2d/editors/inspectorSheet.js +5 -19
  39. package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
  40. package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
  41. package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
  42. package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
  43. package/kits/basic-2d/engine/blueprint.js +423 -0
  44. package/kits/basic-2d/engine/files.js +1 -1
  45. package/kits/basic-2d/engine/pxart.js +49 -2
  46. package/kits/basic-2d/engine/pxartSmooth.js +222 -0
  47. package/kits/basic-2d/engine/scene.js +29 -29
  48. package/kits/basic-2d/engine/ui.jsx +160 -21
  49. package/kits/basic-2d/engine/ui.module.css +263 -27
  50. package/kits/basic-2d/pnpm-workspace.yaml +3 -0
  51. package/kits/basic-2d/scenes/main.scene +3 -13
  52. package/package.json +2 -1
  53. package/kits/basic-2d/drawings/pig.pxart +0 -26
@@ -48,10 +48,19 @@ const SCALE_HANDLES = [
48
48
  { id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
49
49
  ];
50
50
 
51
+ // `sceneData` (raw, sparse) is the clone target for every write here --
52
+ // writes only ever add/replace the exact prop keys a gesture changed (x/y,
53
+ // rotation, width/height), preserving whatever sparse overrides already
54
+ // existed. `previewSceneData` (blueprint template merged with overrides) is
55
+ // the read side for every geometry computation (bounds, colliders, drag-start
56
+ // snapshots) -- an instance that inherits width/height from its blueprint has
57
+ // no `Layout.width` at all in `sceneData`, so reading raw here would produce
58
+ // NaN boxes. See engine/blueprint.js for the merge this mirrors.
51
59
  export function SelectionOverlay({
52
60
  canvasRef,
53
61
  editCameraRef,
54
62
  sceneData,
63
+ previewSceneData,
55
64
  selectedActorIds,
56
65
  snap,
57
66
  onArrange,
@@ -91,20 +100,20 @@ export function SelectionOverlay({
91
100
  setArrangeOpen(false);
92
101
  }, [selectionKey]);
93
102
 
94
- const frame = getSelectionFrame(sceneData, selectedActorIds, groupFrameRotation);
103
+ const frame = getSelectionFrame(previewSceneData, selectedActorIds, groupFrameRotation);
95
104
 
96
105
  const onMoveDown = usePointerDragHandle((event) => {
97
106
  const canvas = canvasRef.current;
98
107
  if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
99
108
  const startPoint = screenToCard(canvas, event.clientX, event.clientY);
100
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
109
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
101
110
  const state = { recorded: false };
102
111
  return {
103
112
  onMove: (moveEvent) => {
104
113
  const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
105
114
  const dx = snapDelta(point.x - startPoint.x, snap);
106
115
  const dy = snapDelta(point.y - startPoint.y, snap);
107
- const next = moveSelected(sceneData, selectedActorIds, starts, dx, dy);
116
+ const next = moveSelected(sceneData, previewSceneData, selectedActorIds, starts, dx, dy);
108
117
  applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
109
118
  },
110
119
  };
@@ -118,7 +127,7 @@ export function SelectionOverlay({
118
127
  let lastAngle = startAngle;
119
128
  let totalDelta = 0;
120
129
  const startFrameRotation = frame.rotation;
121
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
130
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
122
131
  const state = { recorded: false, lastDelta: 0 };
123
132
  return {
124
133
  onMove: (moveEvent) => {
@@ -138,7 +147,7 @@ export function SelectionOverlay({
138
147
  if (delta === state.lastDelta) return;
139
148
  state.lastDelta = delta;
140
149
  setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
141
- const next = rotateSelected(sceneData, selectedActorIds, starts, center, delta);
150
+ const next = rotateSelected(sceneData, previewSceneData, selectedActorIds, starts, center, delta);
142
151
  if (!state.recorded) {
143
152
  recordSnapshot();
144
153
  state.recorded = true;
@@ -155,7 +164,7 @@ export function SelectionOverlay({
155
164
  if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
156
165
  const startTransform = makeBoundsTransform(frame, frame.rotation);
157
166
  const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
158
- const starts = collectLayoutStarts(sceneData, selectedActorIds);
167
+ const starts = collectLayoutStarts(previewSceneData, selectedActorIds);
159
168
  const state = { recorded: false };
160
169
  return {
161
170
  onMove: (moveEvent) => {
@@ -169,7 +178,7 @@ export function SelectionOverlay({
169
178
  dy,
170
179
  moveEvent.shiftKey
171
180
  );
172
- const next = scaleSelected(sceneData, selectedActorIds, starts, startTransform, nextBounds);
181
+ const next = scaleSelected(sceneData, previewSceneData, selectedActorIds, starts, startTransform, nextBounds);
173
182
  applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
174
183
  },
175
184
  };
@@ -181,7 +190,7 @@ export function SelectionOverlay({
181
190
  }
182
191
 
183
192
  const geometry = getOverlayGeometry(frame, box, camera);
184
- const colliderFrames = getSelectedColliderFrames(sceneData, selectedActorIds);
193
+ const colliderFrames = getSelectedColliderFrames(previewSceneData, selectedActorIds);
185
194
 
186
195
  return (
187
196
  <div ref={rootRef} className={styles.selOverlayRoot}>
@@ -221,7 +230,7 @@ export function SelectionOverlay({
221
230
  transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
222
231
  transform: `rotate(${geometry.rotation}deg)`,
223
232
  }}>
224
- <SelectionChrome frame={frame} stemLength={geometry.stemLength} onScaleDown={onScaleDown} />
233
+ <SelectionChrome frame={frame} stemLength={geometry.stemLength} />
225
234
  </div>
226
235
  </div>
227
236
  </div>
@@ -230,12 +239,56 @@ export function SelectionOverlay({
230
239
  readable. The rotate handle orbits via its anchor; clone/delete flip
231
240
  above/below to stay clear of it. */}
232
241
  <div className={styles.selButtonLayer}>
242
+ {/* Dashed bounding box + center pivot render here (un-scaled px layer)
243
+ rather than the chrome layer, so their stroke width, dash pattern,
244
+ and pivot size stay constant with zoom. Their px dimensions are
245
+ projected from the box's screen extents, so the box still traces the
246
+ actual bounds. */}
247
+ <div
248
+ className={styles.selBox}
249
+ style={{
250
+ left: geometry.centerX,
251
+ top: geometry.centerY,
252
+ width: geometry.boxWidthPx,
253
+ height: geometry.boxHeightPx,
254
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
255
+ }}
256
+ />
257
+ <div className={styles.selPivot} style={{ left: geometry.centerX, top: geometry.centerY }} />
258
+ {/* Scale handles live here (un-scaled px layer), not in the chrome layer:
259
+ projecting them to screen px keeps them crisp and a constant size at
260
+ any zoom, instead of being rasterized small then stretched by the
261
+ layer's `scale()`. Each is rotated so edge handles hug the box side. */}
262
+ {geometry.scaleHandles.map((handle) => (
263
+ <div
264
+ key={handle.id}
265
+ role="button"
266
+ aria-label={`Scale ${handle.id}`}
267
+ title={`Scale ${handle.id}`}
268
+ className={cx(
269
+ styles.selScaleHandle,
270
+ handle.corner ? styles.selScaleCorner : styles.selScaleEdge
271
+ )}
272
+ data-handle={handle.id}
273
+ style={{
274
+ left: handle.x,
275
+ top: handle.y,
276
+ cursor: handle.cursor,
277
+ transform: `translate(-50%, -50%) rotate(${geometry.rotation}deg)`,
278
+ }}
279
+ onPointerDown={(event) => {
280
+ event.stopPropagation();
281
+ onScaleDown(event);
282
+ }}
283
+ />
284
+ ))}
233
285
  <Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
234
286
  <div className={styles.selBtnGroup}>
235
287
  <div className={styles.selArrangeWrap}>
236
288
  <OverlayButton
237
289
  label="Arrange"
238
290
  icon="layer-group"
291
+ disabled={!onArrange}
239
292
  onActivate={() => setArrangeOpen((open) => !open)}
240
293
  />
241
294
  {arrangeOpen ? (
@@ -261,8 +314,8 @@ export function SelectionOverlay({
261
314
  </div>
262
315
  ) : null}
263
316
  </div>
264
- <OverlayButton label="Clone" icon="clone" onActivate={onClone} />
265
- <OverlayButton label="Delete" icon="trash" onActivate={onDelete} />
317
+ <OverlayButton label="Clone" icon="clone" disabled={!onClone} onActivate={onClone} />
318
+ <OverlayButton label="Delete" icon="trash" disabled={!onDelete} onActivate={onDelete} />
266
319
  </div>
267
320
  </Floating>
268
321
  <Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
@@ -282,49 +335,21 @@ export function SelectionOverlay({
282
335
  // Chrome in card units: dashed bounding box, scale handles, connector stem, and
283
336
  // center pivot dot. The layer itself ignores pointer events; only the handles
284
337
  // opt back in so the canvas still receives ordinary selection gestures.
285
- function SelectionChrome({ frame, stemLength, onScaleDown }) {
338
+ function SelectionChrome({ frame, stemLength }) {
286
339
  const left = frame.x;
287
340
  const top = frame.y;
288
341
  const width = frame.width;
289
342
  const height = frame.height;
290
- const centerX = left + width / 2;
291
343
  const centerY = top + height / 2;
292
- const handlePosition = (handle) => ({
293
- left: centerX + (handle.x * width) / 2,
294
- top: centerY + (handle.y * height) / 2,
295
- cursor: handle.cursor,
296
- });
297
- return (
298
- <>
299
- {/* Connector stem from the right-center edge out to the rotate handle.
300
- Lives in the rotated chrome, so it swings with the actor. */}
301
- {stemLength > 0 ? (
302
- <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />
303
- ) : null}
304
- <div className={styles.selBox} style={{ left, top, width, height }} />
305
- {SCALE_HANDLES.map((handle) => (
306
- <div
307
- key={handle.id}
308
- role="button"
309
- aria-label={`Scale ${handle.id}`}
310
- title={`Scale ${handle.id}`}
311
- className={cx(
312
- styles.selScaleHandle,
313
- handle.corner ? styles.selScaleCorner : styles.selScaleEdge
314
- )}
315
- data-handle={handle.id}
316
- style={handlePosition(handle)}
317
- onPointerDown={(event) => {
318
- event.stopPropagation();
319
- onScaleDown(event);
320
- }}
321
- />
322
- ))}
323
- <div className={styles.selPivot} style={{ left: centerX, top: centerY }} />
324
- </>
325
- );
344
+ if (stemLength <= 0) return null;
345
+ // Connector stem from the right-center edge out to the rotate handle. Lives in
346
+ // the rotated chrome so it swings with the actor. (The box + pivot render in
347
+ // the un-scaled px layer so their stroke/size stay constant with zoom.)
348
+ return <div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />;
326
349
  }
327
350
 
351
+ // `sceneData` here is always the MERGED preview scene (see the note on
352
+ // `SelectionOverlay` above) -- every Layout read in this file is.
328
353
  function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
329
354
  if (!sceneData || !actorIds || actorIds.length === 0) return null;
330
355
  const wanted = new Set(actorIds);
@@ -370,9 +395,35 @@ function getOverlayGeometry(frame, box, camera) {
370
395
  rotateAnchor,
371
396
  cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
372
397
  stemLength: HANDLE_OFFSET + GAP_PX / sx,
398
+ boxWidthPx: frame.width * sx,
399
+ boxHeightPx: frame.height * sy,
400
+ scaleHandles: getScaleHandleAnchors({
401
+ centerX,
402
+ centerY,
403
+ halfWpx: (frame.width / 2) * sx,
404
+ halfHpx: (frame.height / 2) * sy,
405
+ rotation,
406
+ }),
373
407
  };
374
408
  }
375
409
 
410
+ // Project the 8 scale handles from the box's rotated corners/edges into screen
411
+ // px so they can render in the un-scaled button layer at a constant size.
412
+ function getScaleHandleAnchors({ centerX, centerY, halfWpx, halfHpx, rotation }) {
413
+ const rad = (rotation * Math.PI) / 180;
414
+ const cosR = Math.cos(rad);
415
+ const sinR = Math.sin(rad);
416
+ return SCALE_HANDLES.map((handle) => {
417
+ const lx = handle.x * halfWpx;
418
+ const ly = handle.y * halfHpx;
419
+ return {
420
+ ...handle,
421
+ x: centerX + lx * cosR - ly * sinR,
422
+ y: centerY + lx * sinR + ly * cosR,
423
+ };
424
+ });
425
+ }
426
+
376
427
  function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
377
428
  const rad = (rotation * Math.PI) / 180;
378
429
  const cosR = Math.cos(rad);
@@ -492,19 +543,22 @@ function Floating({ x, y, children }) {
492
543
  );
493
544
  }
494
545
 
495
- function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass }) {
546
+ function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass, disabled = false }) {
496
547
  return (
497
548
  <button
498
549
  type="button"
499
550
  aria-label={label}
500
551
  title={label}
552
+ disabled={disabled}
501
553
  className={cx(styles.selBtn, extraClass)}
502
554
  onPointerDown={(event) => {
503
555
  event.stopPropagation();
556
+ if (disabled) return;
504
557
  onPointerDown?.(event);
505
558
  }}
506
559
  onClick={(event) => {
507
560
  event.stopPropagation();
561
+ if (disabled) return;
508
562
  onActivate?.();
509
563
  }}>
510
564
  <Icon name={icon} />
@@ -577,29 +631,37 @@ function snapDelta(delta, snap) {
577
631
  return Math.round(delta / snap.gridSize) * snap.gridSize;
578
632
  }
579
633
 
580
- // Clone the scene and patch the Layout of every selected actor. `patch(layout,
581
- // start)` returns the props to merge into that actor's Layout, or null to leave
582
- // it untouched. Returns the original `sceneData` when nothing changed so callers
583
- // can skip a no-op commit.
584
- function updateSelectedLayouts(sceneData, actorIds, starts, patch) {
634
+ // Clone the RAW `sceneData` (the sparse write target) and patch the Layout
635
+ // override of every selected actor, but read each actor's CURRENT layout
636
+ // from `previewSceneData` (blueprint-merged) so `patch` sees real width/
637
+ // height/rotation even when an instance inherits them and has no Layout
638
+ // override of its own yet. `patch(layout, start)` returns the props to merge
639
+ // into that actor's Layout OVERRIDE (not the merged layout), or null to leave
640
+ // it untouched -- this is exactly the sparse-override write path (drag =
641
+ // x/y, always non-inherited; scale additionally promotes width/height to
642
+ // instance overrides). Returns the original `sceneData` when nothing changed
643
+ // so callers can skip a no-op commit.
644
+ function updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, patch) {
585
645
  const wanted = new Set(actorIds);
586
646
  const next = structuredClone(sceneData);
587
647
  let changed = false;
588
648
  for (const actor of next.actors) {
589
649
  if (!wanted.has(actor.id)) continue;
590
650
  const start = starts[actor.id];
591
- const layout = actor.components.Layout;
592
- if (!start || !layout) continue;
593
- const props = patch(layout, start);
651
+ const previewLayout = previewSceneData.actors.find((candidate) => candidate.id === actor.id)
652
+ ?.components?.Layout;
653
+ if (!start || !previewLayout) continue;
654
+ const props = patch(previewLayout, start);
594
655
  if (!props) continue;
595
- actor.components.Layout = { ...layout, ...props };
656
+ actor.components ??= {};
657
+ actor.components.Layout = { ...(actor.components.Layout ?? {}), ...props };
596
658
  changed = true;
597
659
  }
598
660
  return changed ? next : sceneData;
599
661
  }
600
662
 
601
- function moveSelected(sceneData, actorIds, starts, dx, dy) {
602
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
663
+ function moveSelected(sceneData, previewSceneData, actorIds, starts, dx, dy) {
664
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
603
665
  const x = Math.round(start.x + dx);
604
666
  const y = Math.round(start.y + dy);
605
667
  return layout.x === x && layout.y === y ? null : { x, y };
@@ -608,8 +670,8 @@ function moveSelected(sceneData, actorIds, starts, dx, dy) {
608
670
 
609
671
  // Common-pivot rotation: actor centers orbit the selection pivot while each
610
672
  // actor also spins by the same delta, matching a temporary group transform.
611
- function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
612
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
673
+ function rotateSelected(sceneData, previewSceneData, actorIds, starts, pivot, deltaDeg) {
674
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
613
675
  const center = {
614
676
  x: start.x + start.width / 2,
615
677
  y: start.y + start.height / 2,
@@ -623,11 +685,11 @@ function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
623
685
  });
624
686
  }
625
687
 
626
- function scaleSelected(sceneData, actorIds, starts, startTransform, nextLocalBounds) {
688
+ function scaleSelected(sceneData, previewSceneData, actorIds, starts, startTransform, nextLocalBounds) {
627
689
  const startBounds = startTransform.localBounds;
628
690
  const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
629
691
  const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
630
- return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
692
+ return updateSelectedLayouts(sceneData, previewSceneData, actorIds, starts, (layout, start) => {
631
693
  const startCenter = {
632
694
  x: start.x + start.width / 2,
633
695
  y: start.y + start.height / 2,
@@ -79,9 +79,17 @@ export function SingleEditor({ path, editor }) {
79
79
  [stashKey, selectedActorIds, multiSelectMode]
80
80
  );
81
81
  const { sprites } = collectAssets(files);
82
+ // Optimistic cross-file edit: fold the new text into live files state NOW
83
+ // (so merged previews update this frame) and debounce the real write; the
84
+ // fs echo of our own write is skipped while pending (shouldSkipPath above).
85
+ // Used for this editor's own file AND for blueprint-file edits made from a
86
+ // scene panel's blueprint inspector.
87
+ function onChangeFile(targetPath, nextText) {
88
+ setFiles((current) => ({ ...current, [targetPath]: nextText }));
89
+ schedule(targetPath, nextText);
90
+ }
82
91
  function onChange(nextText) {
83
- setFiles((current) => ({ ...current, [path]: nextText }));
84
- schedule(path, nextText);
92
+ onChangeFile(path, nextText);
85
93
  }
86
94
  const kind = editor || getFileKind(path);
87
95
  const text = files[path] ?? '';
@@ -94,6 +102,7 @@ export function SingleEditor({ path, editor }) {
94
102
  files={files}
95
103
  sprites={sprites}
96
104
  onChange={onChange}
105
+ onChangeFile={onChangeFile}
97
106
  selectedActorIds={selectedActorIds}
98
107
  onSelectActorIds={setSelectedActorIds}
99
108
  multiSelectMode={multiSelectMode}
@@ -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,25 +118,34 @@ 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();
66
138
  if (event.shiftKey) historyRef.current.redo();
67
139
  else historyRef.current.undo();
68
140
  }
69
- window.addEventListener('keydown', onKeyDown);
70
- return () => window.removeEventListener('keydown', onKeyDown);
141
+ // Capture phase, not bubble: this must win Cmd+Z/Cmd+Shift+Z before it can
142
+ // fall through to the browser's own undo, and before any other listener
143
+ // further down the tree (e.g. a widget that stops propagation on its own
144
+ // keydown) can swallow it first. This only matters once this iframe
145
+ // actually HAS keyboard focus (see EditorBody's pointer-down focus claim)
146
+ // -- capture order can't help if the event never reaches this window.
147
+ window.addEventListener('keydown', onKeyDown, true);
148
+ return () => window.removeEventListener('keydown', onKeyDown, true);
71
149
  }, []);
72
150
  }
73
151
 
@@ -1,23 +1,9 @@
1
- import { useEffect, useState } from 'react';
2
1
  import { styles, useMobileSheet } from '../engine/ui';
3
2
 
4
- // Inspector panel as a bottom sheet on compact viewports, docked on desktop.
5
- // Used by the pixel-art (PxArt) editor. `inspectorOpen` drives visibility;
6
- // tapping the grab handle toggles between the high and low snaps.
3
+ // Inspector panel as a drag-resizable bottom sheet on compact viewports, docked
4
+ // on desktop. Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
5
+ // visibility; the sheet hook owns the resize height and the settle-on-release
6
+ // behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
7
7
  export function useInspectorSheet(inspectorOpen) {
8
- const [snap, setSnap] = useState('high');
9
- useEffect(() => {
10
- if (inspectorOpen) setSnap('high');
11
- }, [inspectorOpen]);
12
- const effectiveSnap = inspectorOpen ? snap : 'hidden';
13
- return useMobileSheet({
14
- snap: effectiveSnap,
15
- baseClassName: styles.inspector,
16
- onTransition: (direction) => {
17
- if (!inspectorOpen) return;
18
- if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
19
- else if (direction === 'down') setSnap('low');
20
- else if (direction === 'up') setSnap('high');
21
- },
22
- });
8
+ return useMobileSheet({ open: inspectorOpen, baseClassName: styles.inspector });
23
9
  }
@@ -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}