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.
- package/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/kits/basic-2d/behaviors/Collider.jsx +172 -26
- package/kits/basic-2d/behaviors/Layout.jsx +24 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +11 -9
- package/kits/basic-2d/editors/SceneEditor.jsx +49 -16
- package/kits/basic-2d/editors/SelectionOverlay.jsx +21 -11
- package/kits/basic-2d/editors/behaviorRegistry.js +9 -3
- package/kits/basic-2d/engine/behaviorExtensions.js +28 -0
- package/kits/basic-2d/engine/collider.js +60 -6
- package/kits/basic-2d/engine/scene.js +28 -2
- package/kits/basic-2d/engine/systemRegistry.js +12 -0
- package/kits/basic-2d/engine/ui.jsx +2 -0
- package/kits/basic-2d/main.jsx +3 -2
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +213 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1696 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/behaviorExtensions.js +28 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +200 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +685 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/systemRegistry.js +12 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/extensions/collider.js +15 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/systems/physics.js +8 -0
- package/kits/physics-2d/vite.config.js +1 -0
- 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
|
+
}
|