castle-web-cli 0.4.71 → 0.4.73

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 (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BY21Og40.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -0,0 +1,662 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
+ import { basename } from '../engine/files';
4
+ import { EditorBody, styles } from '../engine/ui';
5
+ import { eventToCell } from './pixelCanvas';
6
+ import { forEachDab } from './pixelGeometry';
7
+ import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
8
+ import {
9
+ BRUSH_SIZES,
10
+ CanvasSizeBar,
11
+ PaintStrip,
12
+ PIXEL_TOOLS,
13
+ PixelToolStrip,
14
+ PxArtInspectorDock,
15
+ usePixelToolState,
16
+ } from './pixelInspector';
17
+ import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
18
+ import {
19
+ EDITOR_KEYS,
20
+ EDITOR_PALETTE,
21
+ fillRegion,
22
+ paintLine,
23
+ sampleKey,
24
+ swapKey,
25
+ } from './pxArtTools';
26
+ import {
27
+ addFrame,
28
+ addLayer,
29
+ addTag,
30
+ cellOffsetAt,
31
+ cellsAt,
32
+ clampSelection,
33
+ deleteFrame,
34
+ deleteLayer,
35
+ deleteTag,
36
+ deriveSprite,
37
+ duplicateFrame,
38
+ duplicateLayer,
39
+ linkCell,
40
+ moveFrame,
41
+ moveLayer,
42
+ patchLayer,
43
+ patchTag,
44
+ resizeSprite,
45
+ serializeModel,
46
+ setCellOffset,
47
+ setDefaultDuration,
48
+ setDefaultTag,
49
+ setFrameDuration,
50
+ setupSpriteCanvas,
51
+ unlinkCell,
52
+ withCellsAt,
53
+ } from './pxArtEditorModel';
54
+ import { frameDurations, playbackOrder, resolveActiveTag, usePreviewPlayback } from './pxArtPlayback';
55
+ import { PxArtTimeline } from './pxArtTimeline';
56
+ import tl from './pxArtTimeline.module.css';
57
+
58
+ // Aseprite-style editor for the full .pxart sprite format: a full layers x frames
59
+ // timeline below a compositing artboard. The artboard composites the active
60
+ // frame's visible layers (renderSpriteFrame) so the artist sees the whole image
61
+ // while painting targets the active layer's cell, over a FIXED Endesga-64
62
+ // palette and power-of-two resolutions. Sprites that lose nothing in the compact
63
+ // form serialize to compact; anything richer serializes to the full form.
64
+ const ONION_WARM = '#ff7a3c';
65
+ const ONION_COOL = '#3ca0ff';
66
+
67
+ // Native CSS cursors over the artboard, keyed by the active tool. The transient
68
+ // eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
69
+ // listed fall back to the default cursor.
70
+ const TOOL_CURSORS = {
71
+ brush: 'cell',
72
+ erase: 'cell',
73
+ fill: 'crosshair',
74
+ 'move-all': 'move',
75
+ };
76
+
77
+ export function PxArtEditor({ path, text, onChange, ...chrome }) {
78
+ const canvasRef = useRef(null);
79
+ const strokeRef = useRef(null);
80
+ const [activeKey, setActiveKey] = useState(EDITOR_KEYS[1]);
81
+ const [rawSelection, setRawSelection] = useState({ layerIndex: 0, frameIndex: 0 });
82
+ const [playing, setPlaying] = useState(false);
83
+ const [onion, setOnion] = useState({ enabled: false, range: 1 });
84
+ const [hoverCell, setHoverCell] = useState(null);
85
+ const toolState = usePixelToolState();
86
+ const {
87
+ tool,
88
+ setTool,
89
+ picking,
90
+ setPicking,
91
+ eraseFillMode,
92
+ fillSwapMode,
93
+ brushSize,
94
+ setBrushSize,
95
+ eraseSize,
96
+ setEraseSize,
97
+ } = toolState;
98
+ const { history, headerShell } = usePixelEditorShell(text, onChange);
99
+ const canvasWrapRef = useRef(null);
100
+ const canvasSizeBarRef = useRef(null);
101
+ const colorButtonRef = useRef(null);
102
+ const editorBodyRef = useRef(null);
103
+ const [paletteOpen, setPaletteOpen] = useState(false);
104
+
105
+ const sprite = deriveSprite(text);
106
+ const selection = sprite
107
+ ? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
108
+ : rawSelection;
109
+
110
+ const activeTag = sprite ? resolveActiveTag(sprite, sprite.defaultTag) : null;
111
+ const order = sprite ? playbackOrder(sprite, activeTag) : [0];
112
+ const durations = sprite ? frameDurations(sprite, order) : [100];
113
+ const repeat = activeTag ? activeTag.repeat : 0;
114
+ const previewIndex = usePreviewPlayback({ order, durations, repeat }, playing, selection.frameIndex);
115
+
116
+ useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, {
117
+ selection,
118
+ hoverCell,
119
+ tool,
120
+ picking,
121
+ brushSize,
122
+ eraseSize,
123
+ activeKey,
124
+ });
125
+ useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
126
+ const { canvasStyle } = useArtboardFit({
127
+ wrapRef: canvasWrapRef,
128
+ sizeBarRef: canvasSizeBarRef,
129
+ resolution: sprite?.resolution ?? { width: 16, height: 16 },
130
+ });
131
+ const isCompactLayout = useEditorCompactLayout(editorBodyRef);
132
+ useEffect(() => {
133
+ if (!isCompactLayout) setPaletteOpen(false);
134
+ }, [isCompactLayout]);
135
+
136
+ if (!sprite) {
137
+ return (
138
+ <PxArtShell path={path} subtitle="invalid pixel art" shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
139
+ <div className={styles.drawingToolHint}>This file is not valid pixel art.</div>
140
+ </PxArtShell>
141
+ );
142
+ }
143
+
144
+ const { width, height } = sprite.resolution;
145
+ const cursor = picking ? 'crosshair' : (TOOL_CURSORS[tool] ?? 'default');
146
+
147
+ function togglePalette(next) {
148
+ setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
149
+ }
150
+
151
+ // Push a draft Sprite to the file (live, mid-stroke). Records ONE undo
152
+ // snapshot for the whole gesture and dedups by serialized text.
153
+ function liveSprite(nextSprite) {
154
+ const stroke = strokeRef.current;
155
+ if (!stroke) return;
156
+ const nextText = serializeModel(nextSprite);
157
+ if (nextText === stroke.draftText) return;
158
+ if (!stroke.recorded) {
159
+ history.recordSnapshot();
160
+ stroke.recorded = true;
161
+ }
162
+ stroke.draftText = nextText;
163
+ onChange(nextText);
164
+ }
165
+ // Paint/erase strokes accumulate in a canvas-window matrix; merge it back into
166
+ // the (possibly larger/offset) cel so off-canvas pixels are preserved.
167
+ function liveStroke(nextCells) {
168
+ const stroke = strokeRef.current;
169
+ if (!stroke || nextCells === stroke.draftCells) return;
170
+ stroke.draftCells = nextCells;
171
+ liveSprite(withCellsAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextCells));
172
+ }
173
+ function commitCells(nextCells) {
174
+ history.commit(serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells)));
175
+ }
176
+ function startTool(event) {
177
+ const point = eventToCell(event, width, height);
178
+ if (!point) return;
179
+ event.currentTarget.setPointerCapture(event.pointerId);
180
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
181
+ // The eyedropper is a transient picker layered over the active tool: when
182
+ // engaged it intercepts the pick, leaves the underlying tool untouched, and
183
+ // exits picker mode so the previously selected tool resumes after one pick.
184
+ if (picking) {
185
+ setActiveKey(sampleKey(cells, point));
186
+ setPicking(false);
187
+ return;
188
+ }
189
+ if (tool === 'fill') {
190
+ const next = fillSwapMode ? swapKey(cells, point, activeKey) : fillRegion(cells, point, activeKey);
191
+ if (next !== cells) commitCells(next);
192
+ return;
193
+ }
194
+ if (tool === 'erase' && eraseFillMode) {
195
+ const next = fillRegion(cells, point, TRANSPARENT);
196
+ if (next !== cells) commitCells(next);
197
+ return;
198
+ }
199
+ strokeRef.current = {
200
+ tool,
201
+ startPoint: point,
202
+ lastPoint: point,
203
+ base: sprite,
204
+ layerIndex: selection.layerIndex,
205
+ frameIndex: selection.frameIndex,
206
+ // Offset of the cel at gesture start, so move applies an ABSOLUTE offset
207
+ // (start + total drag) and accumulates losslessly across gestures.
208
+ startOffset: cellOffsetAt(sprite, selection.layerIndex, selection.frameIndex),
209
+ draftCells: cells,
210
+ draftText: null,
211
+ recorded: false,
212
+ };
213
+ updateStroke(point);
214
+ }
215
+ function updateTool(event) {
216
+ const stroke = strokeRef.current;
217
+ if (!stroke || event.buttons !== 1) return;
218
+ const point = eventToCell(event, width, height);
219
+ if (point) updateStroke(point);
220
+ }
221
+ // Track the hovered cell for the non-destructive tool preview overlay. While a
222
+ // stroke is active the preview is hidden so it never fights the live paint.
223
+ function handlePointerMove(event) {
224
+ updateTool(event);
225
+ if (strokeRef.current) {
226
+ setHoverCell(null);
227
+ return;
228
+ }
229
+ const point = eventToCell(event, width, height);
230
+ setHoverCell((prev) => (samePoint(prev, point) ? prev : point));
231
+ }
232
+ function clearHover() {
233
+ setHoverCell(null);
234
+ }
235
+ function updateStroke(point) {
236
+ const stroke = strokeRef.current;
237
+ if (!stroke) return;
238
+ if (stroke.tool === 'brush') {
239
+ liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
240
+ stroke.lastPoint = point;
241
+ } else if (stroke.tool === 'erase') {
242
+ liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, TRANSPARENT, eraseSize));
243
+ stroke.lastPoint = point;
244
+ } else if (stroke.tool === 'move-all') {
245
+ // Translate the cel's OFFSET by the drag delta instead of shifting and
246
+ // clipping pixels — the full cel image is preserved, so pushing art off
247
+ // the canvas edge and back loses nothing (even across multiple gestures).
248
+ const x = stroke.startOffset.x + (point.x - stroke.startPoint.x);
249
+ const y = stroke.startOffset.y + (point.y - stroke.startPoint.y);
250
+ liveSprite(setCellOffset(stroke.base, stroke.layerIndex, stroke.frameIndex, x, y));
251
+ }
252
+ }
253
+ function finishTool() {
254
+ strokeRef.current = null;
255
+ }
256
+
257
+ const actions = buildActions({
258
+ sprite,
259
+ selection,
260
+ history,
261
+ setSelection: setRawSelection,
262
+ setPlaying,
263
+ playing,
264
+ setOnion,
265
+ });
266
+
267
+ return (
268
+ <PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
269
+ <div className={styles.drawingEditor}>
270
+ <div className={tl.editorLeft}>
271
+ <div className={tl.artboardRegion}>
272
+ <div className={styles.artboardToolColumn}>
273
+ <PixelToolStrip
274
+ tools={PIXEL_TOOLS}
275
+ tool={tool}
276
+ onSelectTool={(id) => {
277
+ setPicking(false);
278
+ setTool(id);
279
+ }}
280
+ ariaLabel="Sprite tools"
281
+ />
282
+ </div>
283
+ <div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
284
+ <div className={styles.artboardStack}>
285
+ <div ref={canvasSizeBarRef}>
286
+ <CanvasSizeBar
287
+ width={width}
288
+ height={height}
289
+ onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
290
+ />
291
+ </div>
292
+ <PixelArtboard
293
+ canvasRef={canvasRef}
294
+ style={{ ...canvasStyle, cursor }}
295
+ handlers={{
296
+ onPointerDown: startTool,
297
+ onPointerMove: handlePointerMove,
298
+ onPointerUp: finishTool,
299
+ onPointerCancel: finishTool,
300
+ onPointerLeave: clearHover,
301
+ }}
302
+ />
303
+ </div>
304
+ </div>
305
+ <div className={styles.paintColumn}>
306
+ <PaintStrip
307
+ toolState={toolState}
308
+ activeKey={activeKey}
309
+ activeColor={EDITOR_PALETTE[activeKey]}
310
+ paletteOpen={paletteOpen}
311
+ onTogglePalette={togglePalette}
312
+ colorButtonRef={colorButtonRef}
313
+ onSelectKey={setActiveKey}
314
+ paletteKeys={EDITOR_KEYS}
315
+ palette={EDITOR_PALETTE}
316
+ />
317
+ </div>
318
+ </div>
319
+ <PxArtTimeline
320
+ sprite={sprite}
321
+ selection={selection}
322
+ playing={playing}
323
+ onion={onion}
324
+ actions={actions}
325
+ />
326
+ </div>
327
+ <PxArtInspectorDock
328
+ toolState={toolState}
329
+ activeKey={activeKey}
330
+ onSelectKey={setActiveKey}
331
+ paletteKeys={EDITOR_KEYS}
332
+ palette={EDITOR_PALETTE}
333
+ />
334
+ </div>
335
+ </PxArtShell>
336
+ );
337
+ }
338
+
339
+ // Build the timeline's mutation contract. Each action composes an immutable
340
+ // model op with a history commit, updating the (layer, frame) selection where a
341
+ // structural change moves it.
342
+ function buildActions({ sprite, selection, history, setSelection, setPlaying, playing, setOnion }) {
343
+ const { layerIndex, frameIndex } = selection;
344
+ const commit = (next) => history.commit(serializeModel(next));
345
+ return {
346
+ selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
347
+ addLayer: (li) => {
348
+ commit(addLayer(sprite, li));
349
+ setSelection({ layerIndex: li + 1, frameIndex });
350
+ },
351
+ deleteLayer: (li) => {
352
+ commit(deleteLayer(sprite, li));
353
+ setSelection({ layerIndex: Math.max(li - 1, 0), frameIndex });
354
+ },
355
+ duplicateLayer: (li) => {
356
+ commit(duplicateLayer(sprite, li));
357
+ setSelection({ layerIndex: li + 1, frameIndex });
358
+ },
359
+ moveLayer: (li, dir) => {
360
+ commit(moveLayer(sprite, li, dir));
361
+ setSelection({ layerIndex: li + dir, frameIndex });
362
+ },
363
+ patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
364
+ addFrame: (fi) => {
365
+ commit(addFrame(sprite, fi));
366
+ setSelection({ layerIndex, frameIndex: fi + 1 });
367
+ },
368
+ deleteFrame: (fi) => {
369
+ commit(deleteFrame(sprite, fi));
370
+ setSelection({ layerIndex, frameIndex: Math.max(fi - 1, 0) });
371
+ },
372
+ duplicateFrame: (fi) => {
373
+ commit(duplicateFrame(sprite, fi));
374
+ setSelection({ layerIndex, frameIndex: fi + 1 });
375
+ },
376
+ moveFrame: (fi, dir) => {
377
+ commit(moveFrame(sprite, fi, dir));
378
+ setSelection({ layerIndex, frameIndex: fi + dir });
379
+ },
380
+ setFrameDuration: (fi, ms) => commit(setFrameDuration(sprite, fi, ms)),
381
+ setDefaultDuration: (ms) => commit(setDefaultDuration(sprite, ms)),
382
+ linkCell: (src) => commit(linkCell(sprite, layerIndex, frameIndex, src)),
383
+ unlinkCell: () => commit(unlinkCell(sprite, layerIndex, frameIndex)),
384
+ addTag: () => commit(addTag(sprite)),
385
+ patchTag: (i, patch) => commit(patchTag(sprite, i, patch)),
386
+ deleteTag: (i) => commit(deleteTag(sprite, i)),
387
+ setDefaultTag: (name) => commit(setDefaultTag(sprite, name)),
388
+ togglePlay: () => setPlaying(!playing),
389
+ setOnionEnabled: (enabled) => setOnion((prev) => ({ ...prev, enabled })),
390
+ setOnionRange: (range) => setOnion((prev) => ({ ...prev, range: Math.max(1, range || 1) })),
391
+ };
392
+ }
393
+
394
+ function PxArtShell({ path, subtitle, shell, chrome, editorBodyRef, children }) {
395
+ return (
396
+ <>
397
+ <PixelEditorHeader title={basename(path)} subtitle={subtitle} shell={shell} chrome={chrome} />
398
+ <EditorBody ref={editorBodyRef}>{children}</EditorBody>
399
+ </>
400
+ );
401
+ }
402
+
403
+ function pxArtSubtitle(sprite) {
404
+ const { width, height } = sprite.resolution;
405
+ const layers = sprite.layers.length;
406
+ const frames = frameCount(sprite);
407
+ return `${width} x ${height} · ${layers} layer${layers === 1 ? '' : 's'} · ${frames} frame${frames === 1 ? '' : 's'}`;
408
+ }
409
+
410
+ // ---------------------------------------------------------------------------
411
+ // artboard rendering: composite active frame + onion skins
412
+ // ---------------------------------------------------------------------------
413
+
414
+ // Redraw the artboard whenever the sprite text or any preview input changes,
415
+ // layering the render-only tool ghost on top of the composited frame. Gated on
416
+ // `text` (deriveSprite returns a fresh object each render).
417
+ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, p) {
418
+ useEffect(() => {
419
+ const canvas = canvasRef.current;
420
+ if (!canvas || !sprite) return;
421
+ renderArtboard(canvas, sprite, previewIndex, onion, playing, buildToolPreview({ sprite, playing, ...p }));
422
+ }, [
423
+ text,
424
+ previewIndex,
425
+ playing,
426
+ onion.enabled,
427
+ onion.range,
428
+ p.hoverCell,
429
+ p.tool,
430
+ p.picking,
431
+ p.brushSize,
432
+ p.eraseSize,
433
+ p.activeKey,
434
+ p.selection.layerIndex,
435
+ p.selection.frameIndex,
436
+ ]);
437
+ }
438
+
439
+ // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
440
+ function useToolShortcuts(ctx) {
441
+ const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
442
+ useEffect(() => {
443
+ function onKeyDown(event) {
444
+ if (handleToolShortcut(event, ctx)) event.preventDefault();
445
+ }
446
+ window.addEventListener('keydown', onKeyDown);
447
+ return () => window.removeEventListener('keydown', onKeyDown);
448
+ }, [tool, picking, setTool, setPicking, setBrushSize, setEraseSize]);
449
+ }
450
+
451
+ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
452
+ const ctx = setupSpriteCanvas(canvas, sprite.resolution);
453
+ if (!ctx) return;
454
+ if (onion.enabled && !playing) {
455
+ for (let d = onion.range; d >= 1; d--) {
456
+ blitOnion(ctx, sprite, frameIndex - d, ONION_WARM, d);
457
+ blitOnion(ctx, sprite, frameIndex + d, ONION_COOL, d);
458
+ }
459
+ }
460
+ ctx.globalAlpha = 1;
461
+ ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
462
+ if (preview) drawToolPreview(ctx, canvas, preview);
463
+ }
464
+
465
+ function blitOnion(ctx, sprite, idx, color, dist) {
466
+ if (idx < 0 || idx >= frameCount(sprite)) return;
467
+ ctx.globalAlpha = Math.max(0.12, 0.5 / dist);
468
+ ctx.drawImage(onionTint(frameCanvas(sprite, idx), color), 0, 0);
469
+ }
470
+
471
+ function frameCanvas(sprite, idx) {
472
+ const canvas = document.createElement('canvas');
473
+ renderSpriteFrame(sprite, idx, canvas);
474
+ return canvas;
475
+ }
476
+
477
+ // Colorize an already-rendered frame toward `color` over its own silhouette
478
+ // (source-atop), so onion ghosts read as warm (past) / cool (future) washes.
479
+ function onionTint(src, color) {
480
+ const canvas = document.createElement('canvas');
481
+ canvas.width = src.width;
482
+ canvas.height = src.height;
483
+ const ctx = canvas.getContext('2d');
484
+ if (!ctx) return src;
485
+ ctx.imageSmoothingEnabled = false;
486
+ ctx.drawImage(src, 0, 0);
487
+ ctx.globalCompositeOperation = 'source-atop';
488
+ ctx.globalAlpha = 0.55;
489
+ ctx.fillStyle = color;
490
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
491
+ return canvas;
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // keyboard shortcuts: tool selection + active size step
496
+ // ---------------------------------------------------------------------------
497
+
498
+ // Single-letter tool picks. The eyedropper ('i') is handled separately because
499
+ // it toggles the transient picker rather than swapping the sidebar tool.
500
+ const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', e: 'erase', v: 'move-all' };
501
+
502
+ // True when focus is on a text-entry control, so typing there isn't hijacked.
503
+ function isTypingTarget(node) {
504
+ if (!node || !node.tagName) return false;
505
+ const tag = node.tagName.toLowerCase();
506
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || node.isContentEditable;
507
+ }
508
+
509
+ // Step the active size through the SAME discrete BRUSH_SIZES the slider uses,
510
+ // clamped to its first/last entry (the size UI's min/max).
511
+ function stepBrushSize(current, delta) {
512
+ const index = Math.max(0, BRUSH_SIZES.indexOf(current));
513
+ const next = Math.min(BRUSH_SIZES.length - 1, Math.max(0, index + delta));
514
+ return BRUSH_SIZES[next];
515
+ }
516
+
517
+ // Resize the active tool with [ / ]. Only brush/erase have a size; other tools
518
+ // make this a no-op (returns false so the caller leaves the key alone).
519
+ function adjustToolSize(delta, ctx) {
520
+ if (ctx.tool === 'brush') {
521
+ ctx.setBrushSize((prev) => stepBrushSize(prev, delta));
522
+ return true;
523
+ }
524
+ if (ctx.tool === 'erase') {
525
+ ctx.setEraseSize((prev) => stepBrushSize(prev, delta));
526
+ return true;
527
+ }
528
+ return false;
529
+ }
530
+
531
+ // Map a keydown to a tool action; returns true when it consumed the key. Bails
532
+ // on modifier combos (undo/redo, alt behaviors) and while typing in a field.
533
+ function handleToolShortcut(event, ctx) {
534
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
535
+ if (isTypingTarget(event.target || document.activeElement)) return false;
536
+ const key = event.key.toLowerCase();
537
+ const toolId = SHORTCUT_TOOLS[key];
538
+ if (toolId) {
539
+ ctx.setPicking(false);
540
+ ctx.setTool(toolId);
541
+ return true;
542
+ }
543
+ if (key === 'i') {
544
+ ctx.setPicking(!ctx.picking);
545
+ return true;
546
+ }
547
+ if (event.key === '[' || event.key === ']') {
548
+ return adjustToolSize(event.key === '[' ? -1 : 1, ctx);
549
+ }
550
+ return false;
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // tool hover preview: a render-only ghost of where the current tool would act
555
+ // ---------------------------------------------------------------------------
556
+
557
+ const PREVIEW_PAINT_ALPHA = 0.5;
558
+ const PREVIEW_WASH_ALPHA = 0.35;
559
+ const PREVIEW_HIGHLIGHT_ALPHA = 0.45;
560
+ // A reserved sentinel key (not a palette key) used to flood-mark the fill region
561
+ // for the region-accurate fill preview, via the editor's own fillRegion.
562
+ const FILL_PREVIEW_SENTINEL = '\u0000';
563
+
564
+ function samePoint(a, b) {
565
+ if (a === b) return true;
566
+ if (!a || !b) return false;
567
+ return a.x === b.x && a.y === b.y;
568
+ }
569
+
570
+ // The exact set of cells a fill at `start` would recolor, computed by reusing
571
+ // fillRegion with a sentinel key and collecting the marked cells.
572
+ function fillPreviewCells(cells, start) {
573
+ const filled = fillRegion(cells, start, FILL_PREVIEW_SENTINEL);
574
+ if (filled === cells) return [];
575
+ const out = [];
576
+ for (let y = 0; y < filled.length; y++) {
577
+ const row = filled[y];
578
+ for (let x = 0; x < row.length; x++) {
579
+ if (row[x] === FILL_PREVIEW_SENTINEL) out.push({ x, y });
580
+ }
581
+ }
582
+ return out;
583
+ }
584
+
585
+ // Describe the overlay to draw for the hovered cell, or null when nothing should
586
+ // show (no hover, playback, or the move-all tool which has no footprint).
587
+ function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSize, eraseSize, activeKey, playing }) {
588
+ if (!hoverCell || playing) return null;
589
+ const color = EDITOR_PALETTE[activeKey] || '#ffffff';
590
+ if (picking) return { kind: 'cell', cell: hoverCell };
591
+ if (tool === 'brush') return { kind: 'footprint', cell: hoverCell, size: brushSize, color };
592
+ if (tool === 'erase') return { kind: 'erase', cell: hoverCell, size: eraseSize };
593
+ if (tool === 'fill') {
594
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
595
+ return { kind: 'region', cells: fillPreviewCells(cells, hoverCell), color };
596
+ }
597
+ return null;
598
+ }
599
+
600
+ // One native canvas unit corresponds to (native / displayed) pixels; sizing
601
+ // outlines to that keeps them ~1 screen pixel regardless of the artboard zoom.
602
+ function previewLineWidth(canvas) {
603
+ return canvas.clientWidth ? canvas.width / canvas.clientWidth : 0.15;
604
+ }
605
+
606
+ // Translucent fill of a brush/erase dab footprint, matching how paintLine lays
607
+ // down `size` cells around the center so the ghost lines up with real paint.
608
+ function fillDabCells(ctx, cell, size, color, alpha) {
609
+ ctx.globalAlpha = alpha;
610
+ ctx.fillStyle = color;
611
+ forEachDab(size, (dx, dy) => ctx.fillRect(cell.x + dx, cell.y + dy, 1, 1));
612
+ }
613
+
614
+ // A thin "cut" outline around a dab footprint's bounds, so erase reads as a
615
+ // removal region rather than a paint stroke.
616
+ function strokeDabBounds(ctx, cell, size, lineWidth) {
617
+ let minX = Infinity;
618
+ let minY = Infinity;
619
+ let maxX = -Infinity;
620
+ let maxY = -Infinity;
621
+ forEachDab(size, (dx, dy) => {
622
+ minX = Math.min(minX, dx);
623
+ minY = Math.min(minY, dy);
624
+ maxX = Math.max(maxX, dx);
625
+ maxY = Math.max(maxY, dy);
626
+ });
627
+ if (minX === Infinity) return;
628
+ ctx.globalAlpha = 0.85;
629
+ ctx.strokeStyle = '#1c1c1c';
630
+ ctx.lineWidth = lineWidth;
631
+ const half = lineWidth / 2;
632
+ ctx.strokeRect(cell.x + minX + half, cell.y + minY + half, maxX - minX + 1 - lineWidth, maxY - minY + 1 - lineWidth);
633
+ }
634
+
635
+ function fillCellList(ctx, cells, color, alpha) {
636
+ ctx.globalAlpha = alpha;
637
+ ctx.fillStyle = color;
638
+ for (const cell of cells) ctx.fillRect(cell.x, cell.y, 1, 1);
639
+ }
640
+
641
+ // Render-only: paint the ghost overlay for the current tool onto the artboard
642
+ // after the sprite. Never mutates the sprite, history, or serialized output.
643
+ function drawToolPreview(ctx, canvas, preview) {
644
+ ctx.save();
645
+ const lineWidth = previewLineWidth(canvas);
646
+ if (preview.kind === 'footprint') {
647
+ fillDabCells(ctx, preview.cell, preview.size, preview.color, PREVIEW_PAINT_ALPHA);
648
+ } else if (preview.kind === 'erase') {
649
+ fillDabCells(ctx, preview.cell, preview.size, '#ffffff', PREVIEW_WASH_ALPHA);
650
+ strokeDabBounds(ctx, preview.cell, preview.size, lineWidth);
651
+ } else if (preview.kind === 'region') {
652
+ fillCellList(ctx, preview.cells, preview.color, PREVIEW_HIGHLIGHT_ALPHA);
653
+ } else if (preview.kind === 'cell') {
654
+ fillCellList(ctx, [preview.cell], '#ffffff', PREVIEW_HIGHLIGHT_ALPHA);
655
+ ctx.globalAlpha = 0.9;
656
+ ctx.strokeStyle = '#1c1c1c';
657
+ ctx.lineWidth = lineWidth;
658
+ const half = lineWidth / 2;
659
+ ctx.strokeRect(preview.cell.x + half, preview.cell.y + half, 1 - lineWidth, 1 - lineWidth);
660
+ }
661
+ ctx.restore();
662
+ }