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.
- package/dist/agent-prompts.d.ts +4 -1
- package/dist/agent-prompts.js +28 -7
- package/dist/agent.d.ts +7 -2
- package/dist/agent.js +655 -51
- package/dist/ide.js +2 -0
- package/dist/native/loop.d.ts +2 -0
- package/dist/native/loop.js +698 -0
- package/dist/native/openrouter.d.ts +55 -0
- package/dist/native/openrouter.js +354 -0
- package/dist/native/playtest-browser.d.ts +34 -0
- package/dist/native/playtest-browser.js +354 -0
- package/dist/native/playtest-executor.d.ts +3 -0
- package/dist/native/playtest-executor.js +156 -0
- package/dist/native/playtest.d.ts +131 -0
- package/dist/native/playtest.js +314 -0
- package/dist/native/tools.d.ts +38 -0
- package/dist/native/tools.js +630 -0
- package/dist/native/types.d.ts +40 -0
- package/dist/native/types.js +41 -0
- package/dist/serve.js +12 -0
- package/dist/shell/assets/{index-CvHiGhAV.js → index-CNT3KxJb.js} +37 -37
- package/dist/shell/assets/{index-QteLRDnK.css → index-RZrw5gQ2.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/kits/basic-2d/CLAUDE.md +29 -3
- package/kits/basic-2d/behaviors/Layout.jsx +10 -0
- package/kits/basic-2d/behaviors/Sprite.jsx +16 -4
- package/kits/basic-2d/blueprints/cauldron.scene +22 -0
- package/kits/basic-2d/castle.json +5 -7
- package/kits/basic-2d/docs/pxart-format.md +83 -4
- package/kits/basic-2d/drawings/cauldron.pxart +113 -0
- package/kits/basic-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +1 -0
- package/kits/basic-2d/editors/PxArtEditor.jsx +68 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +424 -418
- package/kits/basic-2d/editors/SelectionOverlay.jsx +125 -63
- package/kits/basic-2d/editors/SingleEditor.jsx +11 -2
- package/kits/basic-2d/editors/editorHistory.js +95 -17
- package/kits/basic-2d/editors/inspectorSheet.js +5 -19
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +21 -8
- package/kits/basic-2d/editors/pixelInspector.jsx +39 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +15 -1
- package/kits/basic-2d/engine/ScenePlayer.jsx +2 -2
- package/kits/basic-2d/engine/blueprint.js +423 -0
- package/kits/basic-2d/engine/files.js +1 -1
- package/kits/basic-2d/engine/pxart.js +49 -2
- package/kits/basic-2d/engine/pxartSmooth.js +222 -0
- package/kits/basic-2d/engine/scene.js +29 -29
- package/kits/basic-2d/engine/ui.jsx +160 -21
- package/kits/basic-2d/engine/ui.module.css +263 -27
- package/kits/basic-2d/pnpm-workspace.yaml +3 -0
- package/kits/basic-2d/scenes/main.scene +3 -13
- package/package.json +2 -1
- 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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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}
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
96
|
+
const stack = trimTrailingNoOps(current.undo, text);
|
|
97
|
+
const previous = stack.at(-1);
|
|
31
98
|
if (previous === undefined) return;
|
|
32
99
|
setHistory({
|
|
33
|
-
undo:
|
|
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
|
|
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:
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
5
|
-
// Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
|
|
6
|
-
//
|
|
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
|
-
|
|
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
|
|
40
|
-
//
|
|
41
|
-
// (
|
|
42
|
-
//
|
|
43
|
-
|
|
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
|
-
|
|
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}
|