castle-web-cli 0.4.84 → 0.4.86

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 (90) hide show
  1. package/dist/ide.js +35 -13
  2. package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
  3. package/dist/shell/index.html +1 -1
  4. package/kits/basic-2d/CLAUDE.md +3 -3
  5. package/kits/basic-2d/behaviors/Collider.jsx +172 -26
  6. package/kits/basic-2d/behaviors/Layout.jsx +24 -0
  7. package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
  8. package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
  9. package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
  10. package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
  11. package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
  12. package/kits/basic-2d/engine/collider.js +60 -6
  13. package/kits/basic-2d/engine/scene.js +28 -2
  14. package/kits/basic-2d/engine/systemRegistry.js +12 -0
  15. package/kits/basic-2d/engine/ui.jsx +2 -0
  16. package/kits/basic-2d/main.jsx +3 -2
  17. package/kits/physics-2d/.prettierrc +8 -0
  18. package/kits/physics-2d/CLAUDE.md +329 -0
  19. package/kits/physics-2d/behaviors/Camera.jsx +43 -0
  20. package/kits/physics-2d/behaviors/Collider.jsx +213 -0
  21. package/kits/physics-2d/behaviors/Goal.jsx +29 -0
  22. package/kits/physics-2d/behaviors/Layout.jsx +53 -0
  23. package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
  24. package/kits/physics-2d/behaviors/tint.js +47 -0
  25. package/kits/physics-2d/blueprints/ball.scene +14 -0
  26. package/kits/physics-2d/blueprints/block.scene +12 -0
  27. package/kits/physics-2d/blueprints/cauldron.scene +18 -0
  28. package/kits/physics-2d/blueprints/crate.scene +14 -0
  29. package/kits/physics-2d/blueprints/goal.scene +12 -0
  30. package/kits/physics-2d/castle.json +13 -0
  31. package/kits/physics-2d/docs/pxart-format.md +377 -0
  32. package/kits/physics-2d/drawings/block.pxart +25 -0
  33. package/kits/physics-2d/drawings/cauldron.pxart +113 -0
  34. package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
  35. package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
  36. package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
  37. package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
  38. package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
  39. package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
  40. package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
  41. package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
  42. package/kits/physics-2d/editors/editorHistory.js +157 -0
  43. package/kits/physics-2d/editors/inspectorSheet.js +13 -0
  44. package/kits/physics-2d/editors/pixelCanvas.js +11 -0
  45. package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
  46. package/kits/physics-2d/editors/pixelGeometry.js +140 -0
  47. package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
  48. package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
  49. package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
  50. package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
  51. package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
  52. package/kits/physics-2d/editors/pxArtTools.js +232 -0
  53. package/kits/physics-2d/editors/useArtboardFit.js +102 -0
  54. package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
  55. package/kits/physics-2d/engine/SceneUI.jsx +59 -0
  56. package/kits/physics-2d/engine/assets.js +15 -0
  57. package/kits/physics-2d/engine/autoInspector.jsx +70 -0
  58. package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
  59. package/kits/physics-2d/engine/blueprint.js +521 -0
  60. package/kits/physics-2d/engine/collider.js +200 -0
  61. package/kits/physics-2d/engine/files.js +117 -0
  62. package/kits/physics-2d/engine/liveReload.js +88 -0
  63. package/kits/physics-2d/engine/pxart.js +1032 -0
  64. package/kits/physics-2d/engine/pxartSmooth.js +222 -0
  65. package/kits/physics-2d/engine/scene.js +685 -0
  66. package/kits/physics-2d/engine/spriteGeometry.js +32 -0
  67. package/kits/physics-2d/engine/systemRegistry.js +12 -0
  68. package/kits/physics-2d/engine/ui.jsx +688 -0
  69. package/kits/physics-2d/engine/ui.module.css +2287 -0
  70. package/kits/physics-2d/eslint.config.js +71 -0
  71. package/kits/physics-2d/index.html +24 -0
  72. package/kits/physics-2d/main.jsx +24 -0
  73. package/kits/physics-2d/package-lock.json +2706 -0
  74. package/kits/physics-2d/package.json +42 -0
  75. package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
  76. package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
  77. package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
  78. package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
  79. package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
  80. package/kits/physics-2d/physics/controls.js +79 -0
  81. package/kits/physics-2d/physics/extensions/collider.js +15 -0
  82. package/kits/physics-2d/physics/index.js +26 -0
  83. package/kits/physics-2d/physics/matterBridge.js +126 -0
  84. package/kits/physics-2d/pnpm-lock.yaml +1761 -0
  85. package/kits/physics-2d/scenes/main.scene +12 -0
  86. package/kits/physics-2d/scenes/sandbox.scene +13 -0
  87. package/kits/physics-2d/scripts/draw.mjs +121 -0
  88. package/kits/physics-2d/systems/physics.js +8 -0
  89. package/kits/physics-2d/vite.config.js +1 -0
  90. package/package.json +1 -1
@@ -0,0 +1,954 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
3
+ import { renderSmoothSpriteFrame } from '../engine/pxartSmooth';
4
+ import { basename } from '../engine/files';
5
+ import { EditorBody, styles } from '../engine/ui';
6
+ import { eventToCell } from './pixelCanvas';
7
+ import { forEachDab } from './pixelGeometry';
8
+ import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
9
+ import {
10
+ BRUSH_SIZES,
11
+ CanvasSizeBar,
12
+ CornerRadiusBar,
13
+ PaintStrip,
14
+ PIXEL_TOOLS,
15
+ PixelToolStrip,
16
+ PxArtInspectorDock,
17
+ usePixelToolState,
18
+ } from './pixelInspector';
19
+ import { useArtboardFit, useEditorCompactLayout } from './useArtboardFit';
20
+ import {
21
+ DEFAULT_KEY,
22
+ EDITOR_KEYS,
23
+ EDITOR_PALETTE,
24
+ clearRegion,
25
+ fillRegion,
26
+ flipCellsH,
27
+ flipCellsV,
28
+ moveRegion,
29
+ paintLine,
30
+ paintShape,
31
+ rotateCellsCW,
32
+ sampleKey,
33
+ swapKey,
34
+ } from './pxArtTools';
35
+ import {
36
+ addFrame,
37
+ addLayer,
38
+ addTag,
39
+ cellOffsetAt,
40
+ cellsAt,
41
+ clampSelection,
42
+ deleteFrame,
43
+ deleteLayer,
44
+ deleteTag,
45
+ deriveSprite,
46
+ duplicateFrame,
47
+ duplicateLayer,
48
+ linkCell,
49
+ moveFrame,
50
+ moveLayer,
51
+ patchLayer,
52
+ patchTag,
53
+ resizeSprite,
54
+ serializeModel,
55
+ setCellOffset,
56
+ setDefaultDuration,
57
+ setDefaultTag,
58
+ setCornerRadius,
59
+ setFrameDuration,
60
+ setupSpriteCanvas,
61
+ unlinkCell,
62
+ withCellsAt,
63
+ } from './pxArtEditorModel';
64
+ import { frameDurations, playbackOrder, resolveActiveTag, usePreviewPlayback } from './pxArtPlayback';
65
+ import { PxArtTimeline } from './pxArtTimeline';
66
+ import tl from './pxArtTimeline.module.css';
67
+
68
+ // Aseprite-style editor for the full .pxart sprite format: a full layers x frames
69
+ // timeline below a compositing artboard. The artboard composites the active
70
+ // frame's visible layers (renderSpriteFrame) so the artist sees the whole image
71
+ // while painting targets the active layer's cell, over a FIXED Endesga-64
72
+ // palette and power-of-two resolutions. Sprites that lose nothing in the compact
73
+ // form serialize to compact; anything richer serializes to the full form.
74
+ const ONION_WARM = '#ff7a3c';
75
+ const ONION_COOL = '#3ca0ff';
76
+
77
+ // Native CSS cursors over the artboard, keyed by the active tool. The transient
78
+ // eyedropper (picker mode) overrides any of these with 'crosshair'. Tools not
79
+ // listed fall back to the default cursor.
80
+ const TOOL_CURSORS = {
81
+ brush: 'cell',
82
+ erase: 'cell',
83
+ fill: 'crosshair',
84
+ shape: 'crosshair',
85
+ select: 'crosshair',
86
+ 'move-all': 'move',
87
+ };
88
+
89
+ export function PxArtEditor({ path, text, onChange, ...chrome }) {
90
+ const canvasRef = useRef(null);
91
+ const smoothRef = useRef(null);
92
+ const overlayRef = useRef(null);
93
+ const strokeRef = useRef(null);
94
+ // The serialized text this editor last wrote itself. Lets us tell our own
95
+ // edits apart from external rewrites (undo/redo) so the marquee can be dropped
96
+ // when the art changes out from under it.
97
+ const lastSelfTextRef = useRef(text);
98
+ const [activeKey, setActiveKey] = useState(DEFAULT_KEY);
99
+ const [rawSelection, setRawSelection] = useState({ layerIndex: 0, frameIndex: 0 });
100
+ const [playing, setPlaying] = useState(false);
101
+ const [onion, setOnion] = useState({ enabled: false, range: 1 });
102
+ const [hoverCell, setHoverCell] = useState(null);
103
+ // The marquee selection rectangle { x, y, w, h } in canvas cells, or null. It
104
+ // is a MODE of the select tool: cleared when leaving the tool or changing the
105
+ // active cel. `antsOffset` ticks the marching-ants animation while it's shown.
106
+ const [marquee, setMarquee] = useState(null);
107
+ const [antsOffset, setAntsOffset] = useState(0);
108
+ const toolState = usePixelToolState();
109
+ const {
110
+ tool,
111
+ setTool,
112
+ picking,
113
+ setPicking,
114
+ eraseFillMode,
115
+ fillSwapMode,
116
+ shapeMode,
117
+ shapeFill,
118
+ brushSize,
119
+ setBrushSize,
120
+ eraseSize,
121
+ setEraseSize,
122
+ } = toolState;
123
+ const { history, headerShell } = usePixelEditorShell(text, onChange, path);
124
+ const canvasWrapRef = useRef(null);
125
+ const canvasSizeBarRef = useRef(null);
126
+ const colorButtonRef = useRef(null);
127
+ const editorBodyRef = useRef(null);
128
+ const [paletteOpen, setPaletteOpen] = useState(false);
129
+
130
+ const sprite = deriveSprite(text);
131
+ const selection = sprite
132
+ ? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
133
+ : rawSelection;
134
+
135
+ const activeTag = sprite ? resolveActiveTag(sprite, sprite.defaultTag) : null;
136
+ const order = sprite ? playbackOrder(sprite, activeTag) : [0];
137
+ const durations = sprite ? frameDurations(sprite, order) : [100];
138
+ const repeat = activeTag ? activeTag.repeat : 0;
139
+ const previewIndex = usePreviewPlayback({ order, durations, repeat }, playing, selection.frameIndex);
140
+
141
+ useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, {
142
+ selection,
143
+ hoverCell,
144
+ tool,
145
+ picking,
146
+ brushSize,
147
+ eraseSize,
148
+ activeKey,
149
+ });
150
+ const { canvasStyle } = useArtboardFit({
151
+ wrapRef: canvasWrapRef,
152
+ sizeBarRef: canvasSizeBarRef,
153
+ resolution: sprite?.resolution ?? { width: 16, height: 16 },
154
+ });
155
+ // `canvasStyle`'s width is the artboard's CURRENT display size (recomputed
156
+ // by useArtboardFit's own ResizeObserver whenever the wrap resizes — e.g. a
157
+ // dockview panel drag). Feeding it in here is what keeps the supersampled
158
+ // canvas's resolution in sync with that display size: without it, this
159
+ // effect has no way to know the wrap resized (nothing about `text`,
160
+ // `frameIndex`, or `cornerRadius` changes on a pure container resize), so
161
+ // it would keep rendering at whatever scale was last measured.
162
+ useSmoothPreviewRender(smoothRef, text, sprite, previewIndex, canvasStyle?.width);
163
+ useToolShortcuts({ tool, picking, setTool, setPicking, setBrushSize, setEraseSize });
164
+ const isCompactLayout = useEditorCompactLayout(editorBodyRef);
165
+ useEffect(() => {
166
+ if (!isCompactLayout) setPaletteOpen(false);
167
+ }, [isCompactLayout]);
168
+ // The marquee is a mode of the select tool: drop it when switching to another
169
+ // tool or when the active cel (layer/frame) changes, so it never lingers over
170
+ // pixels it no longer refers to.
171
+ useEffect(() => {
172
+ if (tool !== 'select') setMarquee(null);
173
+ }, [tool]);
174
+ useEffect(() => {
175
+ setMarquee(null);
176
+ }, [selection.layerIndex, selection.frameIndex]);
177
+ // Undo/redo (or any external rewrite) restores the art text but not the
178
+ // separate marquee state, which would strand the selection box over the wrong
179
+ // pixels. A text value this editor didn't produce means an external change, so
180
+ // drop the selection to keep it from ever desyncing from the art.
181
+ useEffect(() => {
182
+ if (text === lastSelfTextRef.current) return;
183
+ lastSelfTextRef.current = text;
184
+ setMarquee(null);
185
+ }, [text]);
186
+ // March the ants only while a selection is visible.
187
+ useEffect(() => {
188
+ if (tool !== 'select' || !marquee) return undefined;
189
+ const id = setInterval(() => setAntsOffset((o) => (o + 1) % 1000), 80);
190
+ return () => clearInterval(id);
191
+ }, [tool, marquee]);
192
+ // Draw the marching-ants selection on a display-resolution overlay canvas so
193
+ // the outline stays crisp (drawing it on the upscaled native artboard renders
194
+ // the thin dashes sub-pixel and washed out).
195
+ useEffect(() => {
196
+ renderMarqueeOverlay(overlayRef.current, sprite?.resolution, playing ? null : marquee, antsOffset);
197
+ }, [marquee, antsOffset, playing, canvasStyle, sprite?.resolution.width, sprite?.resolution.height]);
198
+ // Escape deselects; Delete/Backspace clears the selected pixels. Both only
199
+ // while the select tool is active and a marquee exists.
200
+ useEffect(() => {
201
+ if (tool !== 'select' || !marquee) return undefined;
202
+ function onKeyDown(event) {
203
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
204
+ if (isTypingTarget(event.target || document.activeElement)) return;
205
+ if (event.key === 'Escape') {
206
+ setMarquee(null);
207
+ event.preventDefault();
208
+ } else if (event.key === 'Delete' || event.key === 'Backspace') {
209
+ deleteMarqueePixels();
210
+ event.preventDefault();
211
+ }
212
+ }
213
+ window.addEventListener('keydown', onKeyDown);
214
+ return () => window.removeEventListener('keydown', onKeyDown);
215
+ }, [tool, marquee, text, selection.layerIndex, selection.frameIndex]);
216
+
217
+ if (!sprite) {
218
+ return (
219
+ <PxArtShell path={path} subtitle="invalid pixel art" shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
220
+ <div className={styles.drawingToolHint}>This file is not valid pixel art.</div>
221
+ </PxArtShell>
222
+ );
223
+ }
224
+
225
+ const { width, height } = sprite.resolution;
226
+ const overSelection = tool === 'select' && marquee && hoverCell && pointInRect(hoverCell, marquee);
227
+ const cursor = picking
228
+ ? 'crosshair'
229
+ : overSelection
230
+ ? 'move'
231
+ : (TOOL_CURSORS[tool] ?? 'default');
232
+
233
+ function togglePalette(next) {
234
+ setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
235
+ }
236
+
237
+ // Push a draft Sprite to the file (live, mid-stroke). Records ONE undo
238
+ // snapshot for the whole gesture and dedups by serialized text.
239
+ function liveSprite(nextSprite) {
240
+ const stroke = strokeRef.current;
241
+ if (!stroke) return;
242
+ const nextText = serializeModel(nextSprite);
243
+ if (nextText === stroke.draftText) return;
244
+ if (!stroke.recorded) {
245
+ history.recordSnapshot();
246
+ stroke.recorded = true;
247
+ }
248
+ stroke.draftText = nextText;
249
+ lastSelfTextRef.current = nextText;
250
+ onChange(nextText);
251
+ }
252
+ // Paint/erase strokes accumulate in a canvas-window matrix; merge it back into
253
+ // the (possibly larger/offset) cel so off-canvas pixels are preserved.
254
+ function liveStroke(nextCells) {
255
+ const stroke = strokeRef.current;
256
+ if (!stroke || nextCells === stroke.draftCells) return;
257
+ stroke.draftCells = nextCells;
258
+ liveSprite(withCellsAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextCells));
259
+ }
260
+ function commitCells(nextCells) {
261
+ const nextText = serializeModel(withCellsAt(sprite, selection.layerIndex, selection.frameIndex, nextCells));
262
+ lastSelfTextRef.current = nextText;
263
+ history.commit(nextText);
264
+ }
265
+ // Transform the active cel's canvas-window pixels in one history step (the
266
+ // move tool's flip / rotate actions). A no-op transform commits nothing.
267
+ function transformActiveCel(transform) {
268
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
269
+ const next = transform(cells);
270
+ commitCells(next);
271
+ }
272
+ const moveActions = {
273
+ flipH: () => transformActiveCel(flipCellsH),
274
+ flipV: () => transformActiveCel(flipCellsV),
275
+ rotateCW: () => transformActiveCel(rotateCellsCW),
276
+ };
277
+ // Clear the marquee region's pixels to transparent (keeps the selection rect).
278
+ function deleteMarqueePixels() {
279
+ if (!marquee) return;
280
+ transformActiveCel((cells) => clearRegion(cells, marquee));
281
+ }
282
+ const selectActions = {
283
+ hasSelection: !!marquee,
284
+ deletePixels: deleteMarqueePixels,
285
+ };
286
+ function startTool(event) {
287
+ // Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
288
+ // handler (bound on this iframe's window) instead of falling through to the
289
+ // browser. Safari in particular won't keep keyboard focus in a subframe
290
+ // after clicking a non-focusable canvas, so we focus it explicitly. Done
291
+ // before the early-return so even a click outside a cell claims focus.
292
+ event.currentTarget.focus({ preventScroll: true });
293
+ const point = eventToCell(event, width, height);
294
+ if (!point) return;
295
+ event.currentTarget.setPointerCapture(event.pointerId);
296
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
297
+ // The eyedropper is a transient picker layered over the active tool: when
298
+ // engaged it intercepts the pick, leaves the underlying tool untouched, and
299
+ // exits picker mode so the previously selected tool resumes after one pick.
300
+ if (picking) {
301
+ setActiveKey(sampleKey(cells, point));
302
+ setPicking(false);
303
+ return;
304
+ }
305
+ if (tool === 'select') {
306
+ // Pointer down inside the current selection starts a move (lift & float);
307
+ // anywhere else begins a fresh marquee rectangle.
308
+ const moving = marquee && pointInRect(point, marquee);
309
+ strokeRef.current = {
310
+ tool: 'select',
311
+ mode: moving ? 'move' : 'new',
312
+ startPoint: point,
313
+ lastPoint: point,
314
+ base: sprite,
315
+ layerIndex: selection.layerIndex,
316
+ frameIndex: selection.frameIndex,
317
+ originCells: cells,
318
+ originRect: moving ? marquee : null,
319
+ draftCells: cells,
320
+ draftText: null,
321
+ recorded: false,
322
+ };
323
+ if (!moving) setMarquee({ x: point.x, y: point.y, w: 1, h: 1 });
324
+ updateStroke(point);
325
+ return;
326
+ }
327
+ if (tool === 'fill') {
328
+ const next = fillSwapMode ? swapKey(cells, point, activeKey) : fillRegion(cells, point, activeKey);
329
+ if (next !== cells) commitCells(next);
330
+ return;
331
+ }
332
+ if (tool === 'erase' && eraseFillMode) {
333
+ const next = fillRegion(cells, point, TRANSPARENT);
334
+ if (next !== cells) commitCells(next);
335
+ return;
336
+ }
337
+ strokeRef.current = {
338
+ tool,
339
+ startPoint: point,
340
+ lastPoint: point,
341
+ base: sprite,
342
+ layerIndex: selection.layerIndex,
343
+ frameIndex: selection.frameIndex,
344
+ // Offset of the cel at gesture start, so move applies an ABSOLUTE offset
345
+ // (start + total drag) and accumulates losslessly across gestures.
346
+ startOffset: cellOffsetAt(sprite, selection.layerIndex, selection.frameIndex),
347
+ draftCells: cells,
348
+ // The cells at gesture start, kept immutable so the shape tools recompute
349
+ // from a clean slate on each move instead of accumulating partial shapes.
350
+ originCells: cells,
351
+ draftText: null,
352
+ recorded: false,
353
+ };
354
+ updateStroke(point);
355
+ }
356
+ function updateTool(event) {
357
+ const stroke = strokeRef.current;
358
+ if (!stroke || event.buttons !== 1) return;
359
+ const point = eventToCell(event, width, height);
360
+ if (point) updateStroke(point);
361
+ }
362
+ // Track the hovered cell for the non-destructive tool preview overlay. While a
363
+ // stroke is active the preview is hidden so it never fights the live paint.
364
+ function handlePointerMove(event) {
365
+ updateTool(event);
366
+ if (strokeRef.current) {
367
+ setHoverCell(null);
368
+ return;
369
+ }
370
+ const point = eventToCell(event, width, height);
371
+ setHoverCell((prev) => (samePoint(prev, point) ? prev : point));
372
+ }
373
+ function clearHover() {
374
+ setHoverCell(null);
375
+ }
376
+ function updateStroke(point) {
377
+ const stroke = strokeRef.current;
378
+ if (!stroke) return;
379
+ if (stroke.tool === 'select') {
380
+ if (stroke.mode === 'new') {
381
+ setMarquee(rectFromPoints(stroke.startPoint, point, width, height));
382
+ } else {
383
+ const dx = point.x - stroke.startPoint.x;
384
+ const dy = point.y - stroke.startPoint.y;
385
+ liveStroke(moveRegion(stroke.originCells, stroke.originRect, dx, dy));
386
+ setMarquee({ ...stroke.originRect, x: stroke.originRect.x + dx, y: stroke.originRect.y + dy });
387
+ }
388
+ stroke.lastPoint = point;
389
+ return;
390
+ }
391
+ if (stroke.tool === 'brush') {
392
+ liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
393
+ stroke.lastPoint = point;
394
+ } else if (stroke.tool === 'erase') {
395
+ liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, TRANSPARENT, eraseSize));
396
+ stroke.lastPoint = point;
397
+ } else if (stroke.tool === 'shape') {
398
+ // Recompute from the gesture-start snapshot so the shape rubber-bands to
399
+ // the current point rather than stacking each intermediate draft.
400
+ liveStroke(paintShape(stroke.originCells, stroke.startPoint, point, activeKey, shapeMode, shapeFill));
401
+ } else if (stroke.tool === 'move-all') {
402
+ // Translate the cel's OFFSET by the drag delta instead of shifting and
403
+ // clipping pixels — the full cel image is preserved, so pushing art off
404
+ // the canvas edge and back loses nothing (even across multiple gestures).
405
+ const x = stroke.startOffset.x + (point.x - stroke.startPoint.x);
406
+ const y = stroke.startOffset.y + (point.y - stroke.startPoint.y);
407
+ liveSprite(setCellOffset(stroke.base, stroke.layerIndex, stroke.frameIndex, x, y));
408
+ }
409
+ }
410
+ function finishTool() {
411
+ const stroke = strokeRef.current;
412
+ if (stroke && stroke.tool === 'select') {
413
+ if (stroke.mode === 'new' && samePoint(stroke.startPoint, stroke.lastPoint)) {
414
+ // A click with no drag deselects.
415
+ setMarquee(null);
416
+ } else if (stroke.mode === 'move') {
417
+ // Keep the ants box meaningful: clip the moved rect to the canvas (the
418
+ // pixels themselves were already clipped on commit), dropping it if it
419
+ // ended fully off-canvas.
420
+ setMarquee((rect) => clampRectToCanvas(rect, width, height));
421
+ }
422
+ }
423
+ strokeRef.current = null;
424
+ }
425
+
426
+ const actions = buildActions({
427
+ sprite,
428
+ selection,
429
+ history,
430
+ setSelection: setRawSelection,
431
+ setPlaying,
432
+ playing,
433
+ setOnion,
434
+ });
435
+
436
+ return (
437
+ <PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
438
+ <div className={styles.drawingEditor}>
439
+ <div className={tl.editorLeft}>
440
+ <div className={tl.artboardRegion}>
441
+ <div className={styles.artboardToolColumn}>
442
+ <PixelToolStrip
443
+ tools={PIXEL_TOOLS}
444
+ tool={tool}
445
+ onSelectTool={(id) => {
446
+ setPicking(false);
447
+ setTool(id);
448
+ }}
449
+ ariaLabel="Sprite tools"
450
+ />
451
+ </div>
452
+ <div ref={canvasWrapRef} className={styles.drawingCanvasWrap}>
453
+ <div className={styles.artboardStack}>
454
+ <div ref={canvasSizeBarRef} className={styles.canvasTopStack}>
455
+ <div className={styles.canvasTopBar}>
456
+ <CanvasSizeBar
457
+ width={width}
458
+ height={height}
459
+ onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
460
+ />
461
+ <CornerRadiusBar
462
+ radius={sprite.cornerRadius}
463
+ onChange={(radius) => history.commit(serializeModel(setCornerRadius(sprite, radius)))}
464
+ />
465
+ </div>
466
+ </div>
467
+ <PixelArtboard
468
+ canvasRef={canvasRef}
469
+ smoothRef={smoothRef}
470
+ overlayRef={overlayRef}
471
+ style={{ ...canvasStyle, cursor }}
472
+ handlers={{
473
+ onPointerDown: startTool,
474
+ onPointerMove: handlePointerMove,
475
+ onPointerUp: finishTool,
476
+ onPointerCancel: finishTool,
477
+ onPointerLeave: clearHover,
478
+ }}
479
+ />
480
+ </div>
481
+ </div>
482
+ <div className={styles.paintColumn}>
483
+ <PaintStrip
484
+ toolState={toolState}
485
+ activeKey={activeKey}
486
+ activeColor={EDITOR_PALETTE[activeKey]}
487
+ paletteOpen={paletteOpen}
488
+ onTogglePalette={togglePalette}
489
+ colorButtonRef={colorButtonRef}
490
+ onSelectKey={setActiveKey}
491
+ paletteKeys={EDITOR_KEYS}
492
+ palette={EDITOR_PALETTE}
493
+ moveActions={moveActions}
494
+ selectActions={selectActions}
495
+ />
496
+ </div>
497
+ </div>
498
+ <PxArtTimeline
499
+ sprite={sprite}
500
+ selection={selection}
501
+ playing={playing}
502
+ onion={onion}
503
+ actions={actions}
504
+ />
505
+ </div>
506
+ <PxArtInspectorDock
507
+ toolState={toolState}
508
+ activeKey={activeKey}
509
+ onSelectKey={setActiveKey}
510
+ paletteKeys={EDITOR_KEYS}
511
+ palette={EDITOR_PALETTE}
512
+ moveActions={moveActions}
513
+ selectActions={selectActions}
514
+ />
515
+ </div>
516
+ </PxArtShell>
517
+ );
518
+ }
519
+
520
+ // Build the timeline's mutation contract. Each action composes an immutable
521
+ // model op with a history commit, updating the (layer, frame) selection where a
522
+ // structural change moves it.
523
+ function buildActions({ sprite, selection, history, setSelection, setPlaying, playing, setOnion }) {
524
+ const { layerIndex, frameIndex } = selection;
525
+ const commit = (next) => history.commit(serializeModel(next));
526
+ return {
527
+ selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
528
+ addLayer: (li) => {
529
+ commit(addLayer(sprite, li));
530
+ setSelection({ layerIndex: li + 1, frameIndex });
531
+ },
532
+ deleteLayer: (li) => {
533
+ commit(deleteLayer(sprite, li));
534
+ setSelection({ layerIndex: Math.max(li - 1, 0), frameIndex });
535
+ },
536
+ duplicateLayer: (li) => {
537
+ commit(duplicateLayer(sprite, li));
538
+ setSelection({ layerIndex: li + 1, frameIndex });
539
+ },
540
+ moveLayer: (li, dir) => {
541
+ commit(moveLayer(sprite, li, dir));
542
+ setSelection({ layerIndex: li + dir, frameIndex });
543
+ },
544
+ patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
545
+ addFrame: (fi) => {
546
+ commit(addFrame(sprite, fi));
547
+ setSelection({ layerIndex, frameIndex: fi + 1 });
548
+ },
549
+ deleteFrame: (fi) => {
550
+ commit(deleteFrame(sprite, fi));
551
+ setSelection({ layerIndex, frameIndex: Math.max(fi - 1, 0) });
552
+ },
553
+ duplicateFrame: (fi) => {
554
+ commit(duplicateFrame(sprite, fi));
555
+ setSelection({ layerIndex, frameIndex: fi + 1 });
556
+ },
557
+ moveFrame: (fi, dir) => {
558
+ commit(moveFrame(sprite, fi, dir));
559
+ setSelection({ layerIndex, frameIndex: fi + dir });
560
+ },
561
+ setFrameDuration: (fi, ms) => commit(setFrameDuration(sprite, fi, ms)),
562
+ setDefaultDuration: (ms) => commit(setDefaultDuration(sprite, ms)),
563
+ linkCell: (src) => commit(linkCell(sprite, layerIndex, frameIndex, src)),
564
+ unlinkCell: () => commit(unlinkCell(sprite, layerIndex, frameIndex)),
565
+ addTag: () => commit(addTag(sprite)),
566
+ patchTag: (i, patch) => commit(patchTag(sprite, i, patch)),
567
+ deleteTag: (i) => commit(deleteTag(sprite, i)),
568
+ setDefaultTag: (name) => commit(setDefaultTag(sprite, name)),
569
+ togglePlay: () => setPlaying(!playing),
570
+ setOnionEnabled: (enabled) => setOnion((prev) => ({ ...prev, enabled })),
571
+ setOnionRange: (range) => setOnion((prev) => ({ ...prev, range: Math.max(1, range || 1) })),
572
+ };
573
+ }
574
+
575
+ function PxArtShell({ path, subtitle, shell, chrome, editorBodyRef, children }) {
576
+ return (
577
+ <>
578
+ <PixelEditorHeader title={basename(path)} subtitle={subtitle} shell={shell} chrome={chrome} />
579
+ <EditorBody ref={editorBodyRef}>{children}</EditorBody>
580
+ </>
581
+ );
582
+ }
583
+
584
+ function pxArtSubtitle(sprite) {
585
+ const { width, height } = sprite.resolution;
586
+ const layers = sprite.layers.length;
587
+ const frames = frameCount(sprite);
588
+ return `${width} x ${height} · ${layers} layer${layers === 1 ? '' : 's'} · ${frames} frame${frames === 1 ? '' : 's'}`;
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // artboard rendering: composite active frame + onion skins
593
+ // ---------------------------------------------------------------------------
594
+
595
+ // Redraw the artboard whenever the sprite text or any preview input changes,
596
+ // layering the render-only tool ghost on top of the composited frame. Gated on
597
+ // `text` (deriveSprite returns a fresh object each render).
598
+ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing, p) {
599
+ useEffect(() => {
600
+ const canvas = canvasRef.current;
601
+ if (!canvas || !sprite) return;
602
+ renderArtboard(canvas, sprite, previewIndex, onion, playing, buildToolPreview({ sprite, playing, ...p }));
603
+ }, [
604
+ text,
605
+ previewIndex,
606
+ playing,
607
+ onion.enabled,
608
+ onion.range,
609
+ p.hoverCell,
610
+ p.tool,
611
+ p.picking,
612
+ p.brushSize,
613
+ p.eraseSize,
614
+ p.activeKey,
615
+ p.selection.layerIndex,
616
+ p.selection.frameIndex,
617
+ ]);
618
+ }
619
+
620
+ // Render the "smooth" preview into its own display-resolution canvas, behind
621
+ // the interactive artboard (see .drawingSmooth). Left empty when
622
+ // `cornerRadius` is 0 (sharp/pixel) — the interactive canvas's own unrounded
623
+ // frame draw is what shows then. Runs off `text`/`previewIndex` rather than
624
+ // the `sprite` object (a fresh object every render) so it only redraws on an
625
+ // actual content/frame/radius change, not on every hover-driven re-render of
626
+ // the tool preview — PLUS `displayWidth` (the artboard's current CSS width
627
+ // from useArtboardFit), so a wrap resize (e.g. a dockview panel drag) also
628
+ // triggers a re-render at the new supersample scale, not just a content
629
+ // change. The supersample factor auto-fits to the displayed size (capped
630
+ // between 4x and 16x).
631
+ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
632
+ const cornerRadius = sprite?.cornerRadius ?? 0;
633
+ useEffect(() => {
634
+ const canvas = smoothRef.current;
635
+ if (!canvas) return;
636
+ if (!sprite || cornerRadius <= 0) {
637
+ canvas.width = 0;
638
+ canvas.height = 0;
639
+ return;
640
+ }
641
+ const cssWidth = canvas.clientWidth || sprite.resolution.width;
642
+ const dpr = window.devicePixelRatio || 1;
643
+ const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
644
+ renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
645
+ }, [text, frameIndex, smoothRef, cornerRadius, displayWidth]);
646
+ }
647
+
648
+ // Window-scoped tool keyboard shortcuts, mounted only while the editor is.
649
+ function useToolShortcuts(ctx) {
650
+ const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } = ctx;
651
+ useEffect(() => {
652
+ function onKeyDown(event) {
653
+ if (handleToolShortcut(event, ctx)) event.preventDefault();
654
+ }
655
+ window.addEventListener('keydown', onKeyDown);
656
+ return () => window.removeEventListener('keydown', onKeyDown);
657
+ }, [tool, picking, setTool, setPicking, setBrushSize, setEraseSize]);
658
+ }
659
+
660
+ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
661
+ const ctx = setupSpriteCanvas(canvas, sprite.resolution);
662
+ if (!ctx) return;
663
+ if (onion.enabled && !playing) {
664
+ for (let d = onion.range; d >= 1; d--) {
665
+ blitOnion(ctx, sprite, frameIndex - d, ONION_WARM, d);
666
+ blitOnion(ctx, sprite, frameIndex + d, ONION_COOL, d);
667
+ }
668
+ }
669
+ ctx.globalAlpha = 1;
670
+ // "Smooth" sprites are drawn by the display-resolution .drawingSmooth canvas
671
+ // BEHIND this one (see useSmoothPreviewRender); leaving this canvas
672
+ // transparent here lets that show through while pointer math, the marquee,
673
+ // and the tool-preview ghost below all keep working in native grid units.
674
+ if (sprite.cornerRadius <= 0) {
675
+ ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
676
+ }
677
+ if (preview) drawToolPreview(ctx, canvas, preview);
678
+ }
679
+
680
+ // Render-only animated marching ants for the marquee selection, drawn on a
681
+ // display-resolution overlay canvas (1 device pixel per CSS pixel) so the dashed
682
+ // outline is crisp and bold rather than sub-pixel on the upscaled artboard.
683
+ // `rect` is in canvas cells; null clears the overlay.
684
+ function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
685
+ if (!canvas || !resolution) return;
686
+ const ctx = canvas.getContext('2d');
687
+ if (!ctx) return;
688
+ const cssW = canvas.clientWidth;
689
+ const cssH = canvas.clientHeight;
690
+ if (!cssW || !cssH) return;
691
+ const dpr = window.devicePixelRatio || 1;
692
+ const bw = Math.round(cssW * dpr);
693
+ const bh = Math.round(cssH * dpr);
694
+ if (canvas.width !== bw) canvas.width = bw;
695
+ if (canvas.height !== bh) canvas.height = bh;
696
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
697
+ ctx.clearRect(0, 0, bw, bh);
698
+ if (!rect) return;
699
+
700
+ const sx = bw / resolution.width;
701
+ const sy = bh / resolution.height;
702
+ const lineWidth = Math.max(2, Math.round(2 * dpr));
703
+ const dash = Math.max(5, Math.round(5 * dpr));
704
+ // Advance ~2 device px per tick (not a whole dash) so the ants crawl smoothly
705
+ // instead of teleporting a full dash each frame.
706
+ const step = Math.max(1, Math.round(dpr));
707
+ const move = (antsOffset * step) % (dash * 2);
708
+ const half = lineWidth / 2;
709
+ const x = rect.x * sx + half;
710
+ const y = rect.y * sy + half;
711
+ const w = Math.max(lineWidth, rect.w * sx - lineWidth);
712
+ const h = Math.max(lineWidth, rect.h * sy - lineWidth);
713
+
714
+ ctx.lineWidth = lineWidth;
715
+ ctx.setLineDash([dash, dash]);
716
+ ctx.strokeStyle = '#000000';
717
+ ctx.lineDashOffset = -move;
718
+ ctx.strokeRect(x, y, w, h);
719
+ ctx.strokeStyle = '#ffffff';
720
+ ctx.lineDashOffset = -move + dash;
721
+ ctx.strokeRect(x, y, w, h);
722
+ }
723
+
724
+ // Build a marquee rect { x, y, w, h } from two cell points, normalized and
725
+ // clamped to the canvas (min 1x1).
726
+ function rectFromPoints(a, b, width, height) {
727
+ const x0 = Math.max(0, Math.min(a.x, b.x));
728
+ const y0 = Math.max(0, Math.min(a.y, b.y));
729
+ const x1 = Math.min(width - 1, Math.max(a.x, b.x));
730
+ const y1 = Math.min(height - 1, Math.max(a.y, b.y));
731
+ return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
732
+ }
733
+
734
+ function pointInRect(point, rect) {
735
+ return (
736
+ point.x >= rect.x &&
737
+ point.x < rect.x + rect.w &&
738
+ point.y >= rect.y &&
739
+ point.y < rect.y + rect.h
740
+ );
741
+ }
742
+
743
+ // Intersect a marquee rect with the canvas; null when it ends up fully off it.
744
+ function clampRectToCanvas(rect, width, height) {
745
+ if (!rect) return null;
746
+ const x0 = Math.max(0, rect.x);
747
+ const y0 = Math.max(0, rect.y);
748
+ const x1 = Math.min(width, rect.x + rect.w);
749
+ const y1 = Math.min(height, rect.y + rect.h);
750
+ if (x1 <= x0 || y1 <= y0) return null;
751
+ return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
752
+ }
753
+
754
+ function blitOnion(ctx, sprite, idx, color, dist) {
755
+ if (idx < 0 || idx >= frameCount(sprite)) return;
756
+ ctx.globalAlpha = Math.max(0.12, 0.5 / dist);
757
+ ctx.drawImage(onionTint(frameCanvas(sprite, idx), color), 0, 0);
758
+ }
759
+
760
+ function frameCanvas(sprite, idx) {
761
+ const canvas = document.createElement('canvas');
762
+ renderSpriteFrame(sprite, idx, canvas);
763
+ return canvas;
764
+ }
765
+
766
+ // Colorize an already-rendered frame toward `color` over its own silhouette
767
+ // (source-atop), so onion ghosts read as warm (past) / cool (future) washes.
768
+ function onionTint(src, color) {
769
+ const canvas = document.createElement('canvas');
770
+ canvas.width = src.width;
771
+ canvas.height = src.height;
772
+ const ctx = canvas.getContext('2d');
773
+ if (!ctx) return src;
774
+ ctx.imageSmoothingEnabled = false;
775
+ ctx.drawImage(src, 0, 0);
776
+ ctx.globalCompositeOperation = 'source-atop';
777
+ ctx.globalAlpha = 0.55;
778
+ ctx.fillStyle = color;
779
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
780
+ return canvas;
781
+ }
782
+
783
+ // ---------------------------------------------------------------------------
784
+ // keyboard shortcuts: tool selection + active size step
785
+ // ---------------------------------------------------------------------------
786
+
787
+ // Single-letter tool picks. The eyedropper ('i') is handled separately because
788
+ // it toggles the transient picker rather than swapping the sidebar tool.
789
+ const SHORTCUT_TOOLS = { b: 'brush', g: 'fill', s: 'shape', m: 'select', e: 'erase', v: 'move-all' };
790
+
791
+ // True when focus is on a text-entry control, so typing there isn't hijacked.
792
+ function isTypingTarget(node) {
793
+ if (!node || !node.tagName) return false;
794
+ const tag = node.tagName.toLowerCase();
795
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || node.isContentEditable;
796
+ }
797
+
798
+ // Step the active size through the SAME discrete BRUSH_SIZES the slider uses,
799
+ // clamped to its first/last entry (the size UI's min/max).
800
+ function stepBrushSize(current, delta) {
801
+ const index = Math.max(0, BRUSH_SIZES.indexOf(current));
802
+ const next = Math.min(BRUSH_SIZES.length - 1, Math.max(0, index + delta));
803
+ return BRUSH_SIZES[next];
804
+ }
805
+
806
+ // Resize the active tool with [ / ]. Only brush/erase have a size; other tools
807
+ // make this a no-op (returns false so the caller leaves the key alone).
808
+ function adjustToolSize(delta, ctx) {
809
+ if (ctx.tool === 'brush') {
810
+ ctx.setBrushSize((prev) => stepBrushSize(prev, delta));
811
+ return true;
812
+ }
813
+ if (ctx.tool === 'erase') {
814
+ ctx.setEraseSize((prev) => stepBrushSize(prev, delta));
815
+ return true;
816
+ }
817
+ return false;
818
+ }
819
+
820
+ // Map a keydown to a tool action; returns true when it consumed the key. Bails
821
+ // on modifier combos (undo/redo, alt behaviors) and while typing in a field.
822
+ function handleToolShortcut(event, ctx) {
823
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
824
+ if (isTypingTarget(event.target || document.activeElement)) return false;
825
+ const key = event.key.toLowerCase();
826
+ const toolId = SHORTCUT_TOOLS[key];
827
+ if (toolId) {
828
+ ctx.setPicking(false);
829
+ ctx.setTool(toolId);
830
+ return true;
831
+ }
832
+ if (key === 'i') {
833
+ ctx.setPicking(!ctx.picking);
834
+ return true;
835
+ }
836
+ if (event.key === '[' || event.key === ']') {
837
+ return adjustToolSize(event.key === '[' ? -1 : 1, ctx);
838
+ }
839
+ return false;
840
+ }
841
+
842
+ // ---------------------------------------------------------------------------
843
+ // tool hover preview: a render-only ghost of where the current tool would act
844
+ // ---------------------------------------------------------------------------
845
+
846
+ const PREVIEW_PAINT_ALPHA = 0.5;
847
+ const PREVIEW_WASH_ALPHA = 0.35;
848
+ const PREVIEW_HIGHLIGHT_ALPHA = 0.45;
849
+ // A reserved sentinel key (not a palette key) used to flood-mark the fill region
850
+ // for the region-accurate fill preview, via the editor's own fillRegion.
851
+ const FILL_PREVIEW_SENTINEL = '\u0000';
852
+
853
+ function samePoint(a, b) {
854
+ if (a === b) return true;
855
+ if (!a || !b) return false;
856
+ return a.x === b.x && a.y === b.y;
857
+ }
858
+
859
+ // The exact set of cells a fill at `start` would recolor, computed by reusing
860
+ // fillRegion with a sentinel key and collecting the marked cells.
861
+ function fillPreviewCells(cells, start) {
862
+ const filled = fillRegion(cells, start, FILL_PREVIEW_SENTINEL);
863
+ if (filled === cells) return [];
864
+ const out = [];
865
+ for (let y = 0; y < filled.length; y++) {
866
+ const row = filled[y];
867
+ for (let x = 0; x < row.length; x++) {
868
+ if (row[x] === FILL_PREVIEW_SENTINEL) out.push({ x, y });
869
+ }
870
+ }
871
+ return out;
872
+ }
873
+
874
+ // Describe the overlay to draw for the hovered cell, or null when nothing should
875
+ // show (no hover, playback, or the move-all tool which has no footprint).
876
+ function buildToolPreview({ sprite, selection, hoverCell, tool, picking, brushSize, eraseSize, activeKey, playing }) {
877
+ if (!hoverCell || playing) return null;
878
+ const color = EDITOR_PALETTE[activeKey] || '#ffffff';
879
+ if (picking) return { kind: 'cell', cell: hoverCell };
880
+ if (tool === 'brush') return { kind: 'footprint', cell: hoverCell, size: brushSize, color };
881
+ if (tool === 'erase') return { kind: 'erase', cell: hoverCell, size: eraseSize };
882
+ if (tool === 'fill') {
883
+ const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
884
+ return { kind: 'region', cells: fillPreviewCells(cells, hoverCell), color };
885
+ }
886
+ // The shape tool needs two points to draw, so before a drag we just mark the
887
+ // hovered start cell; the live shape itself shows once the drag begins.
888
+ if (tool === 'shape') return { kind: 'cell', cell: hoverCell };
889
+ return null;
890
+ }
891
+
892
+ // One native canvas unit corresponds to (native / displayed) pixels; sizing
893
+ // outlines to that keeps them ~1 screen pixel regardless of the artboard zoom.
894
+ function previewLineWidth(canvas) {
895
+ return canvas.clientWidth ? canvas.width / canvas.clientWidth : 0.15;
896
+ }
897
+
898
+ // Translucent fill of a brush/erase dab footprint, matching how paintLine lays
899
+ // down `size` cells around the center so the ghost lines up with real paint.
900
+ function fillDabCells(ctx, cell, size, color, alpha) {
901
+ ctx.globalAlpha = alpha;
902
+ ctx.fillStyle = color;
903
+ forEachDab(size, (dx, dy) => ctx.fillRect(cell.x + dx, cell.y + dy, 1, 1));
904
+ }
905
+
906
+ // A thin "cut" outline around a dab footprint's bounds, so erase reads as a
907
+ // removal region rather than a paint stroke.
908
+ function strokeDabBounds(ctx, cell, size, lineWidth) {
909
+ let minX = Infinity;
910
+ let minY = Infinity;
911
+ let maxX = -Infinity;
912
+ let maxY = -Infinity;
913
+ forEachDab(size, (dx, dy) => {
914
+ minX = Math.min(minX, dx);
915
+ minY = Math.min(minY, dy);
916
+ maxX = Math.max(maxX, dx);
917
+ maxY = Math.max(maxY, dy);
918
+ });
919
+ if (minX === Infinity) return;
920
+ ctx.globalAlpha = 0.85;
921
+ ctx.strokeStyle = '#1c1c1c';
922
+ ctx.lineWidth = lineWidth;
923
+ const half = lineWidth / 2;
924
+ ctx.strokeRect(cell.x + minX + half, cell.y + minY + half, maxX - minX + 1 - lineWidth, maxY - minY + 1 - lineWidth);
925
+ }
926
+
927
+ function fillCellList(ctx, cells, color, alpha) {
928
+ ctx.globalAlpha = alpha;
929
+ ctx.fillStyle = color;
930
+ for (const cell of cells) ctx.fillRect(cell.x, cell.y, 1, 1);
931
+ }
932
+
933
+ // Render-only: paint the ghost overlay for the current tool onto the artboard
934
+ // after the sprite. Never mutates the sprite, history, or serialized output.
935
+ function drawToolPreview(ctx, canvas, preview) {
936
+ ctx.save();
937
+ const lineWidth = previewLineWidth(canvas);
938
+ if (preview.kind === 'footprint') {
939
+ fillDabCells(ctx, preview.cell, preview.size, preview.color, PREVIEW_PAINT_ALPHA);
940
+ } else if (preview.kind === 'erase') {
941
+ fillDabCells(ctx, preview.cell, preview.size, '#ffffff', PREVIEW_WASH_ALPHA);
942
+ strokeDabBounds(ctx, preview.cell, preview.size, lineWidth);
943
+ } else if (preview.kind === 'region') {
944
+ fillCellList(ctx, preview.cells, preview.color, PREVIEW_HIGHLIGHT_ALPHA);
945
+ } else if (preview.kind === 'cell') {
946
+ fillCellList(ctx, [preview.cell], '#ffffff', PREVIEW_HIGHLIGHT_ALPHA);
947
+ ctx.globalAlpha = 0.9;
948
+ ctx.strokeStyle = '#1c1c1c';
949
+ ctx.lineWidth = lineWidth;
950
+ const half = lineWidth / 2;
951
+ ctx.strokeRect(preview.cell.x + half, preview.cell.y + half, 1 - lineWidth, 1 - lineWidth);
952
+ }
953
+ ctx.restore();
954
+ }