castle-web-cli 0.4.115 → 0.4.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +2 -2
- package/dist/agent.d.ts +9 -9
- package/dist/agent.js +590 -589
- package/dist/castleJson.d.ts +6 -0
- package/dist/castleJson.js +10 -0
- package/dist/editorConfig.js +27 -3
- package/dist/imports.d.ts +4 -0
- package/dist/imports.js +59 -10
- package/dist/init.d.ts +3 -0
- package/dist/init.js +99 -87
- package/dist/install.d.ts +1 -1
- package/dist/install.js +26 -24
- package/dist/shell/assets/{index-CUamb8rK.js → index-DiPlPGyg.js} +2 -2
- package/dist/shell/index.html +1 -1
- package/dist/vitePlugins.js +1 -1
- package/kits/physics-2d/CLAUDE.md +28 -16
- package/kits/physics-2d/behaviors/Joints.jsx +1 -1
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -12
- package/kits/physics-2d/blueprints/ball.scene +1 -1
- package/kits/physics-2d/blueprints/block.scene +1 -1
- package/kits/physics-2d/blueprints/cauldron.scene +1 -1
- package/kits/physics-2d/blueprints/crate.scene +1 -1
- package/kits/physics-2d/castle.json +11 -3
- package/kits/physics-2d/docs/pxart-format.md +537 -51
- package/kits/physics-2d/editors/PxArtEditor.jsx +1794 -65
- package/kits/physics-2d/editors/brushFit.js +535 -0
- package/kits/physics-2d/editors/brushShapes.js +140 -0
- package/kits/physics-2d/editors/mediaFile.js +12 -1
- package/kits/physics-2d/editors/pathOverlay.js +340 -0
- package/kits/physics-2d/editors/pathTools.js +1906 -0
- package/kits/physics-2d/editors/pixelCanvas.js +13 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +2 -2
- package/kits/physics-2d/editors/pixelGeometry.js +4 -2
- package/kits/physics-2d/editors/pixelInspector.jsx +410 -37
- package/kits/physics-2d/editors/pxArtEditorModel.js +172 -16
- package/kits/physics-2d/editors/pxArtTimeline.jsx +163 -43
- package/kits/physics-2d/editors/pxArtTimeline.module.css +31 -5
- package/kits/physics-2d/engine/assets.js +1 -1
- package/kits/physics-2d/engine/blueprint.js +3 -3
- package/kits/physics-2d/engine/files.js +3 -1
- package/kits/physics-2d/engine/liveReload.js +1 -1
- package/kits/physics-2d/engine/physics/jointArt.js +3 -3
- package/kits/physics-2d/engine/pxart.js +153 -35
- package/kits/physics-2d/engine/pxartPath.js +1356 -0
- package/kits/physics-2d/engine/pxartSmooth.js +276 -125
- package/kits/physics-2d/engine/ui.jsx +22 -1
- package/kits/physics-2d/engine/ui.module.css +36 -12
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/scripts/draw.mjs +7 -7
- package/kits/physics-2d/scripts/import-svg.mjs +1231 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +92 -0
- package/package.json +1 -1
- /package/kits/physics-2d/drawings/{block.pxart → block.sprite} +0 -0
- /package/kits/physics-2d/drawings/{cauldron.pxart → cauldron.sprite} +0 -0
- /package/kits/physics-2d/drawings/{joint-rope.pxart → joint-rope.sprite} +0 -0
|
@@ -1,16 +1,81 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { frameCount, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
|
|
3
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { displayFinish, frameCount, isVectorRenderer, renderSpriteFrame, TRANSPARENT } from '../engine/pxart';
|
|
3
|
+
import { emptyPathData, LATTICE, magneticCellPull, subpathEdgeCount, subpathPointAt, subpathPoints, subpathVertexCount } from '../engine/pxartPath';
|
|
4
|
+
import {
|
|
5
|
+
renderSmoothCellsPreview,
|
|
6
|
+
renderSmoothCompositeFrame,
|
|
7
|
+
renderSmoothSpriteFrame,
|
|
8
|
+
} from '../engine/pxartSmooth';
|
|
4
9
|
import { basename } from '../engine/files';
|
|
5
10
|
import { EditorBody, Icon, IconButton, styles } from '../engine/ui';
|
|
6
|
-
import { eventToCell } from './pixelCanvas';
|
|
11
|
+
import { eventToCell, eventToPoint } from './pixelCanvas';
|
|
7
12
|
import { forEachDab } from './pixelGeometry';
|
|
13
|
+
import { drawPathOverlay } from './pathOverlay';
|
|
14
|
+
import {
|
|
15
|
+
appendAnchorToSubpath,
|
|
16
|
+
appendShape,
|
|
17
|
+
applyAnchorsDrag,
|
|
18
|
+
applyEdgeBow,
|
|
19
|
+
applyShapesDrag,
|
|
20
|
+
anchorKey,
|
|
21
|
+
bringShapesForward,
|
|
22
|
+
canBringShapesForward,
|
|
23
|
+
canPunchHole,
|
|
24
|
+
canSendShapesBackward,
|
|
25
|
+
closePenShape,
|
|
26
|
+
collectAnchorStarts,
|
|
27
|
+
collectShapesPointStarts,
|
|
28
|
+
duplicateShapes,
|
|
29
|
+
flipShapesHorizontal,
|
|
30
|
+
flipShapesVertical,
|
|
31
|
+
hitTestShape,
|
|
32
|
+
hitTestShapeAnchor,
|
|
33
|
+
hitTestShapeEdge,
|
|
34
|
+
hitTestAnchorsInRect,
|
|
35
|
+
hitTestFillToolTarget,
|
|
36
|
+
hitTestPathEdge,
|
|
37
|
+
hitTestShapesInRect,
|
|
38
|
+
HIT_RADIUS_SCREEN_PX,
|
|
39
|
+
EDGE_HIT_SCREEN_PX,
|
|
40
|
+
OPEN_END_HIT_SCREEN_PX,
|
|
41
|
+
hitRadiusCells,
|
|
42
|
+
absorbRetracedSubpaths,
|
|
43
|
+
insertAnchorOnEdge,
|
|
44
|
+
spliceLimbIntoShape,
|
|
45
|
+
isOpenPenShape,
|
|
46
|
+
isOpenSubpath,
|
|
47
|
+
punchHole,
|
|
48
|
+
hitTestOpenEnd,
|
|
49
|
+
removeEdgesFromShape,
|
|
50
|
+
reverseSubpath,
|
|
51
|
+
removeSubpath,
|
|
52
|
+
startLimbOnShape,
|
|
53
|
+
recolorTopShapeAt,
|
|
54
|
+
removeAnchorsFromShape,
|
|
55
|
+
removeShape,
|
|
56
|
+
removeShapes,
|
|
57
|
+
rotateShapes90CW,
|
|
58
|
+
sendShapesBackward,
|
|
59
|
+
setShapesFill,
|
|
60
|
+
setShapesStroke,
|
|
61
|
+
shapeFromPenDraft,
|
|
62
|
+
sharedShapeSlot,
|
|
63
|
+
snapPenAnchor,
|
|
64
|
+
previewPenLanding,
|
|
65
|
+
toggleAnchorInList,
|
|
66
|
+
toggleIndexInList,
|
|
67
|
+
withCoincidentAnchors,
|
|
68
|
+
} from './pathTools';
|
|
69
|
+
import { appendInkPoint, fitBrushInk, shapeFromBrushFit } from './brushFit';
|
|
70
|
+
import { ghostFromBrushDrag, shapeFromBrushDrag } from './brushShapes';
|
|
8
71
|
import { PixelArtboard, PixelEditorHeader, usePixelEditorShell } from './pixelEditorChrome';
|
|
9
72
|
import {
|
|
10
73
|
BRUSH_SIZES,
|
|
11
74
|
CanvasSizeBar,
|
|
12
|
-
|
|
75
|
+
FinishBar,
|
|
13
76
|
PaintStrip,
|
|
77
|
+
PATH_TOOLS,
|
|
78
|
+
PATH_BRUSH_MODE_BY_KEY,
|
|
14
79
|
PIXEL_TOOLS,
|
|
15
80
|
PixelToolStrip,
|
|
16
81
|
PxArtInspectorDock,
|
|
@@ -49,6 +114,7 @@ import {
|
|
|
49
114
|
linkCell,
|
|
50
115
|
moveFrame,
|
|
51
116
|
moveLayer,
|
|
117
|
+
pathAt,
|
|
52
118
|
patchLayer,
|
|
53
119
|
patchTag,
|
|
54
120
|
resizeSprite,
|
|
@@ -56,11 +122,13 @@ import {
|
|
|
56
122
|
setCellOffset,
|
|
57
123
|
setDefaultDuration,
|
|
58
124
|
setDefaultTag,
|
|
59
|
-
|
|
125
|
+
setFinish,
|
|
60
126
|
setFrameDuration,
|
|
127
|
+
setLayerKind,
|
|
61
128
|
setupSpriteCanvas,
|
|
62
129
|
unlinkCell,
|
|
63
130
|
withCellsAt,
|
|
131
|
+
withPathAt,
|
|
64
132
|
} from './pxArtEditorModel';
|
|
65
133
|
import { frameDurations, playbackOrder, resolveActiveTag, usePreviewPlayback } from './pxArtPlayback';
|
|
66
134
|
import { PxArtTimeline } from './pxArtTimeline';
|
|
@@ -103,13 +171,30 @@ const TOOL_CURSORS = {
|
|
|
103
171
|
shape: 'crosshair',
|
|
104
172
|
select: 'crosshair',
|
|
105
173
|
'move-all': 'move',
|
|
174
|
+
'path-select': 'crosshair',
|
|
175
|
+
bend: 'crosshair',
|
|
106
176
|
};
|
|
107
177
|
|
|
178
|
+
function isPathBrushStroke(stroke) {
|
|
179
|
+
return stroke?.tool === 'brush' && stroke.brushMode != null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Tools valid while a path layer is active (includes shared ones like fill).
|
|
183
|
+
const PATH_TOOL_IDS = new Set(PATH_TOOLS.map((candidate) => candidate.id));
|
|
184
|
+
// Path-only tools — leaving a path layer remaps these to brush. Shared tools
|
|
185
|
+
// (fill) must NOT be in this set or bitmap Fill immediately snaps back to Brush.
|
|
186
|
+
const PATH_ONLY_TOOL_IDS = new Set(
|
|
187
|
+
PATH_TOOLS.map((candidate) => candidate.id).filter(
|
|
188
|
+
(id) => !PIXEL_TOOLS.some((pixel) => pixel.id === id)
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
|
|
108
192
|
export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
109
193
|
const canvasRef = useRef(null);
|
|
110
194
|
const smoothRef = useRef(null);
|
|
111
195
|
const overlayRef = useRef(null);
|
|
112
196
|
const strokeRef = useRef(null);
|
|
197
|
+
const refitActiveBrushStrokeRef = useRef(() => {});
|
|
113
198
|
// The serialized text this editor last wrote itself. Lets us tell our own
|
|
114
199
|
// edits apart from external rewrites (undo/redo) so the marquee can be dropped
|
|
115
200
|
// when the art changes out from under it.
|
|
@@ -124,6 +209,35 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
124
209
|
// active cel. `antsOffset` ticks the marching-ants animation while it's shown.
|
|
125
210
|
const [marquee, setMarquee] = useState(null);
|
|
126
211
|
const [antsOffset, setAntsOffset] = useState(0);
|
|
212
|
+
// Path-layer freehand ink (unquantized cell coords) + throttled fit ghost.
|
|
213
|
+
const [brushInk, setBrushInk] = useState([]);
|
|
214
|
+
const [brushGhost, setBrushGhost] = useState(null);
|
|
215
|
+
const brushInkRef = useRef([]);
|
|
216
|
+
brushInkRef.current = brushInk;
|
|
217
|
+
// Index of a 1-point open pen stub, so leave/Escape can drop it once (Strict Mode safe).
|
|
218
|
+
const penStubIndexRef = useRef(-1);
|
|
219
|
+
// The limb currently being drawn onto an existing shape: { shapeIndex, subIndex }.
|
|
220
|
+
// Null while the pen is doing its ordinary one-subpath-per-shape work.
|
|
221
|
+
const penLimbRef = useRef(null);
|
|
222
|
+
const penBakeAtRef = useRef(0);
|
|
223
|
+
// Selected shape indices in the active path layer's shapes[] stack.
|
|
224
|
+
const [selectedShapeIndices, setSelectedShapeIndices] = useState([]);
|
|
225
|
+
// Within-shape anchor selection (only meaningful when exactly one shape is selected).
|
|
226
|
+
const [selectedAnchors, setSelectedAnchors] = useState([]);
|
|
227
|
+
// Edges picked out of the selected shape: { shapeIndex, edges: [{ subIndex,
|
|
228
|
+
// edgeIndex }] }. Only meaningful while that shape is the sole selection; see
|
|
229
|
+
// activeEdges, which drops the pick once it stops describing what is there.
|
|
230
|
+
const [selectedEdges, setSelectedEdges] = useState(null);
|
|
231
|
+
const [hoverEdge, setHoverEdge] = useState(null);
|
|
232
|
+
// Time and identity of the last edge click, for double-click to take the run.
|
|
233
|
+
const lastEdgeClickRef = useRef({ at: 0, key: '' });
|
|
234
|
+
// Rubber-band rect for path-select marquee (cell coords), or null.
|
|
235
|
+
const [shapeMarquee, setShapeMarquee] = useState(null);
|
|
236
|
+
const [hoverPoint, setHoverPoint] = useState(null);
|
|
237
|
+
const [hoverInsertEdge, setHoverInsertEdge] = useState(null);
|
|
238
|
+
const [fillHoverTarget, setFillHoverTarget] = useState(null);
|
|
239
|
+
// History-free path for pen hover bake preview (null = show committed art only).
|
|
240
|
+
const [pathBakePreview, setPathBakePreview] = useState(null);
|
|
127
241
|
const toolState = usePixelToolState();
|
|
128
242
|
const {
|
|
129
243
|
tool,
|
|
@@ -134,6 +248,8 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
134
248
|
fillSwapMode,
|
|
135
249
|
shapeMode,
|
|
136
250
|
shapeFill,
|
|
251
|
+
brushMode,
|
|
252
|
+
setBrushMode,
|
|
137
253
|
brushSize,
|
|
138
254
|
setBrushSize,
|
|
139
255
|
eraseSize,
|
|
@@ -151,13 +267,41 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
151
267
|
? clampSelection(sprite, rawSelection.layerIndex, rawSelection.frameIndex)
|
|
152
268
|
: rawSelection;
|
|
153
269
|
|
|
270
|
+
const activeLayer = sprite?.layers[selection.layerIndex];
|
|
271
|
+
const isPathLayer = activeLayer?.kind === 'path';
|
|
272
|
+
const activePath = sprite && isPathLayer ? pathAt(sprite, selection.layerIndex, selection.frameIndex) : emptyPathData();
|
|
273
|
+
// A pick only counts while its shape is still the sole selection and every
|
|
274
|
+
// edge in it still exists, so an edit elsewhere leaves a stale pick inert
|
|
275
|
+
// rather than deleting whatever moved into those slots.
|
|
276
|
+
const activeEdges = useMemo(() => {
|
|
277
|
+
if (!selectedEdges || selectedShapeIndices.length !== 1) return null;
|
|
278
|
+
// Anchors and edges are both sub-shape picks, and Delete has to mean one of
|
|
279
|
+
// them. Whichever was picked last wins: picking an edge clears the anchors,
|
|
280
|
+
// and a live anchor selection makes an older edge pick inert rather than
|
|
281
|
+
// leaving Delete pointed at something the overlay stopped emphasising.
|
|
282
|
+
if (selectedAnchors.length) return null;
|
|
283
|
+
if (selectedShapeIndices[0] !== selectedEdges.shapeIndex) return null;
|
|
284
|
+
const shape = activePath?.shapes?.[selectedEdges.shapeIndex];
|
|
285
|
+
if (!shape) return null;
|
|
286
|
+
const edges = selectedEdges.edges.filter(({ subIndex, edgeIndex }) => {
|
|
287
|
+
const sub = shape.subpaths?.[subIndex];
|
|
288
|
+
return sub && edgeIndex < subpathEdgeCount(sub);
|
|
289
|
+
});
|
|
290
|
+
return edges.length ? { shapeIndex: selectedEdges.shapeIndex, edges } : null;
|
|
291
|
+
}, [selectedEdges, selectedAnchors, selectedShapeIndices, activePath]);
|
|
292
|
+
|
|
154
293
|
const activeTag = sprite ? resolveActiveTag(sprite, sprite.defaultTag) : null;
|
|
155
294
|
const order = sprite ? playbackOrder(sprite, activeTag) : [0];
|
|
156
295
|
const durations = sprite ? frameDurations(sprite, order) : [100];
|
|
157
296
|
const repeat = activeTag ? activeTag.repeat : 0;
|
|
158
297
|
const previewIndex = usePreviewPlayback({ order, durations, repeat }, playing, selection.frameIndex);
|
|
159
298
|
|
|
160
|
-
|
|
299
|
+
const artboardSprite =
|
|
300
|
+
pathBakePreview && sprite
|
|
301
|
+
? withPathAt(sprite, selection.layerIndex, selection.frameIndex, pathBakePreview)
|
|
302
|
+
: sprite;
|
|
303
|
+
|
|
304
|
+
useArtboardRender(canvasRef, text, artboardSprite, previewIndex, onion, playing, {
|
|
161
305
|
selection,
|
|
162
306
|
hoverCell,
|
|
163
307
|
tool,
|
|
@@ -165,6 +309,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
165
309
|
brushSize,
|
|
166
310
|
eraseSize,
|
|
167
311
|
activeKey,
|
|
312
|
+
pathBakePreview,
|
|
168
313
|
});
|
|
169
314
|
const { canvasStyle, displaySize } = useArtboardFit({
|
|
170
315
|
wrapRef: canvasWrapRef,
|
|
@@ -180,6 +325,20 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
180
325
|
const [zoom, setZoom] = useState(1);
|
|
181
326
|
const zoomRef = useRef(zoom);
|
|
182
327
|
zoomRef.current = zoom;
|
|
328
|
+
const pathCellPull =
|
|
329
|
+
displaySize?.width && sprite?.resolution?.width
|
|
330
|
+
? magneticCellPull(sprite.resolution.width, displaySize.width, zoom)
|
|
331
|
+
: LATTICE / 2;
|
|
332
|
+
const pathCellPx =
|
|
333
|
+
displaySize?.width && sprite?.resolution?.width
|
|
334
|
+
? (displaySize.width * zoom) / sprite.resolution.width
|
|
335
|
+
: 16;
|
|
336
|
+
const pathHitRadii = {
|
|
337
|
+
anchor: hitRadiusCells(HIT_RADIUS_SCREEN_PX, pathCellPx),
|
|
338
|
+
edge: hitRadiusCells(EDGE_HIT_SCREEN_PX, pathCellPx),
|
|
339
|
+
stroke: hitRadiusCells(EDGE_HIT_SCREEN_PX, pathCellPx),
|
|
340
|
+
openEnd: hitRadiusCells(OPEN_END_HIT_SCREEN_PX, pathCellPx),
|
|
341
|
+
};
|
|
183
342
|
// Set zoom to `nextRaw` (clamped) while pinning the point under (anchorX,
|
|
184
343
|
// anchorY) client coords — defaulting to the viewport center. Scroll is fixed
|
|
185
344
|
// up on the next frame, once the resized canvas has laid out.
|
|
@@ -304,8 +463,18 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
304
463
|
// effect has no way to know the wrap resized (nothing about `text`,
|
|
305
464
|
// `frameIndex`, or `cornerRadius` changes on a pure container resize), so
|
|
306
465
|
// it would keep rendering at whatever scale was last measured.
|
|
307
|
-
useSmoothPreviewRender(smoothRef, text,
|
|
308
|
-
useToolShortcuts({
|
|
466
|
+
useSmoothPreviewRender(smoothRef, text, artboardSprite, previewIndex, canvasStyle?.width, pathBakePreview);
|
|
467
|
+
useToolShortcuts({
|
|
468
|
+
tool,
|
|
469
|
+
picking,
|
|
470
|
+
brushMode,
|
|
471
|
+
setTool,
|
|
472
|
+
setPicking,
|
|
473
|
+
setBrushSize,
|
|
474
|
+
setEraseSize,
|
|
475
|
+
setBrushMode,
|
|
476
|
+
isPathLayer,
|
|
477
|
+
});
|
|
309
478
|
const isCompactLayout = useEditorCompactLayout(editorBodyRef);
|
|
310
479
|
useEffect(() => {
|
|
311
480
|
if (!isCompactLayout) setPaletteOpen(false);
|
|
@@ -319,14 +488,83 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
319
488
|
useEffect(() => {
|
|
320
489
|
setMarquee(null);
|
|
321
490
|
}, [selection.layerIndex, selection.frameIndex]);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (isPathLayer && !PATH_TOOL_IDS.has(tool)) setTool('brush');
|
|
493
|
+
else if (!isPathLayer && PATH_ONLY_TOOL_IDS.has(tool)) setTool('brush');
|
|
494
|
+
}, [isPathLayer, tool, setTool]);
|
|
495
|
+
useEffect(() => {
|
|
496
|
+
// Points are already in the file; drop a lone 1-point stub when leaving pen mode.
|
|
497
|
+
if (!(isPathLayer && tool === 'brush' && brushMode === 'pen')) {
|
|
498
|
+
dropLoneOpenPenPoint();
|
|
499
|
+
}
|
|
500
|
+
if (tool !== 'path-select') {
|
|
501
|
+
setShapeMarquee(null);
|
|
502
|
+
// Keep selection across brush/bend ↔ select so the live shape stays useful.
|
|
503
|
+
if (!isPathLayer || (tool !== 'brush' && tool !== 'bend')) {
|
|
504
|
+
setSelectedShapeIndices([]);
|
|
505
|
+
setSelectedAnchors([]);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!isPathLayer || tool !== 'brush') {
|
|
509
|
+
clearBrushGesture();
|
|
510
|
+
} else if (brushMode !== 'pen') {
|
|
511
|
+
setPathBakePreview(null);
|
|
512
|
+
}
|
|
513
|
+
}, [tool, brushMode, isPathLayer]);
|
|
514
|
+
useEffect(() => {
|
|
515
|
+
clearBrushGesture();
|
|
516
|
+
setSelectedShapeIndices([]);
|
|
517
|
+
setSelectedAnchors([]);
|
|
518
|
+
setSelectedEdges(null);
|
|
519
|
+
setShapeMarquee(null);
|
|
520
|
+
setHoverEdge(null);
|
|
521
|
+
setHoverPoint(null);
|
|
522
|
+
setHoverInsertEdge(null);
|
|
523
|
+
setFillHoverTarget(null);
|
|
524
|
+
penLimbRef.current = null;
|
|
525
|
+
penStubIndexRef.current = -1;
|
|
526
|
+
lastEdgeClickRef.current = { at: 0, key: '' };
|
|
527
|
+
}, [selection.layerIndex, selection.frameIndex]);
|
|
528
|
+
// Selection is UI-only — clear on play, never restored by undo.
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (!playing) return;
|
|
531
|
+
setSelectedShapeIndices([]);
|
|
532
|
+
setSelectedAnchors([]);
|
|
533
|
+
setShapeMarquee(null);
|
|
534
|
+
clearBrushGesture();
|
|
535
|
+
}, [playing]);
|
|
536
|
+
// Undo/redo (or any external rewrite) restores the art text but not UI chrome.
|
|
537
|
+
// Keep shape indices that still exist so pen point-undo leaves the open stroke
|
|
538
|
+
// selected; drop anything out of range.
|
|
326
539
|
useEffect(() => {
|
|
327
540
|
if (text === lastSelfTextRef.current) return;
|
|
328
541
|
lastSelfTextRef.current = text;
|
|
329
542
|
setMarquee(null);
|
|
543
|
+
setShapeMarquee(null);
|
|
544
|
+
// Brush create-drags preview via pathBakePreview (no mid-gesture writes).
|
|
545
|
+
// Clearing them here on a stale fs echo left the rest of the drag blank.
|
|
546
|
+
if (!isPathBrushStroke(strokeRef.current)) clearBrushGesture();
|
|
547
|
+
const spr = deriveSprite(text);
|
|
548
|
+
const nextPath =
|
|
549
|
+
spr && selection ? pathAt(spr, selection.layerIndex, selection.frameIndex) : null;
|
|
550
|
+
const n = nextPath?.shapes?.length ?? 0;
|
|
551
|
+
setSelectedShapeIndices((prev) => prev.filter((i) => i >= 0 && i < n));
|
|
552
|
+
// Anchor picks used to be dropped wholesale here. That made any rewrite that
|
|
553
|
+
// did not match our own last write — a stale echo of the very art we just
|
|
554
|
+
// committed, say — deselect the point mid-gesture. Keep the refs that still
|
|
555
|
+
// name a live point, on the same terms the shape indices above are kept.
|
|
556
|
+
setSelectedAnchors((prev) => {
|
|
557
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
558
|
+
const shape = sole >= 0 && sole < n ? nextPath?.shapes?.[sole] : null;
|
|
559
|
+
if (!shape) return prev.length ? [] : prev;
|
|
560
|
+
const kept = prev.filter((a) => {
|
|
561
|
+
const sub = shape.subpaths?.[a.subIndex];
|
|
562
|
+
return sub && a.pointIndex < subpathVertexCount(sub);
|
|
563
|
+
});
|
|
564
|
+
return kept.length === prev.length ? prev : kept;
|
|
565
|
+
});
|
|
566
|
+
if (penStubIndexRef.current >= n) penStubIndexRef.current = -1;
|
|
567
|
+
if (penLimbRef.current && penLimbRef.current.shapeIndex >= n) penLimbRef.current = null;
|
|
330
568
|
}, [text]);
|
|
331
569
|
// March the ants only while a selection is visible.
|
|
332
570
|
useEffect(() => {
|
|
@@ -334,12 +572,81 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
334
572
|
const id = setInterval(() => setAntsOffset((o) => (o + 1) % 1000), 80);
|
|
335
573
|
return () => clearInterval(id);
|
|
336
574
|
}, [tool, marquee]);
|
|
337
|
-
// Draw the marching-ants selection on a display-resolution overlay canvas so
|
|
338
|
-
// the outline stays crisp (drawing it on the upscaled native artboard renders
|
|
339
|
-
// the thin dashes sub-pixel and washed out).
|
|
340
575
|
useEffect(() => {
|
|
341
|
-
|
|
342
|
-
|
|
576
|
+
if (isPathLayer) {
|
|
577
|
+
drawPathOverlay(overlayRef.current, sprite?.resolution, playing ? null : activePath, {
|
|
578
|
+
tool,
|
|
579
|
+
brushMode,
|
|
580
|
+
palette: EDITOR_PALETTE,
|
|
581
|
+
brushInk,
|
|
582
|
+
brushGhost,
|
|
583
|
+
hoverPoint,
|
|
584
|
+
hoverInsertEdge,
|
|
585
|
+
penLimb: penLimbRef.current,
|
|
586
|
+
selectedShapeIndices,
|
|
587
|
+
selectedAnchors,
|
|
588
|
+
selectedEdges: activeEdges,
|
|
589
|
+
hoverEdge,
|
|
590
|
+
shapeMarquee,
|
|
591
|
+
fillHoverTarget,
|
|
592
|
+
activeColor: EDITOR_PALETTE[activeKey],
|
|
593
|
+
smoothFinish: isVectorRenderer(sprite?.renderer),
|
|
594
|
+
});
|
|
595
|
+
} else {
|
|
596
|
+
const finish = displayFinish(sprite);
|
|
597
|
+
const preview =
|
|
598
|
+
playing || !finish.smooth
|
|
599
|
+
? null
|
|
600
|
+
: buildToolPreview({
|
|
601
|
+
sprite,
|
|
602
|
+
selection,
|
|
603
|
+
hoverCell,
|
|
604
|
+
tool,
|
|
605
|
+
picking,
|
|
606
|
+
brushSize,
|
|
607
|
+
eraseSize,
|
|
608
|
+
activeKey,
|
|
609
|
+
playing,
|
|
610
|
+
});
|
|
611
|
+
renderPixelOverlay(overlayRef.current, sprite?.resolution, {
|
|
612
|
+
marquee: playing ? null : marquee,
|
|
613
|
+
antsOffset,
|
|
614
|
+
preview,
|
|
615
|
+
cornerRadius: finish.cornerRadius,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
}, [
|
|
619
|
+
isPathLayer,
|
|
620
|
+
activePath,
|
|
621
|
+
tool,
|
|
622
|
+
brushMode,
|
|
623
|
+
marquee,
|
|
624
|
+
antsOffset,
|
|
625
|
+
playing,
|
|
626
|
+
canvasStyle,
|
|
627
|
+
brushInk,
|
|
628
|
+
brushGhost,
|
|
629
|
+
hoverPoint,
|
|
630
|
+
hoverInsertEdge,
|
|
631
|
+
selectedShapeIndices,
|
|
632
|
+
selectedAnchors,
|
|
633
|
+
activeEdges,
|
|
634
|
+
hoverEdge,
|
|
635
|
+
shapeMarquee,
|
|
636
|
+
fillHoverTarget,
|
|
637
|
+
activeKey,
|
|
638
|
+
hoverCell,
|
|
639
|
+
picking,
|
|
640
|
+
brushSize,
|
|
641
|
+
eraseSize,
|
|
642
|
+
selection.layerIndex,
|
|
643
|
+
selection.frameIndex,
|
|
644
|
+
sprite?.cornerRadius,
|
|
645
|
+
sprite?.renderer,
|
|
646
|
+
sprite?.resolution.width,
|
|
647
|
+
sprite?.resolution.height,
|
|
648
|
+
text,
|
|
649
|
+
]);
|
|
343
650
|
// Escape deselects; Delete/Backspace clears the selected pixels. Both only
|
|
344
651
|
// while the select tool is active and a marquee exists.
|
|
345
652
|
useEffect(() => {
|
|
@@ -358,6 +665,110 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
358
665
|
window.addEventListener('keydown', onKeyDown);
|
|
359
666
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
360
667
|
}, [tool, marquee, text, selection.layerIndex, selection.frameIndex]);
|
|
668
|
+
// Path-layer keyboard: pen close/open, shape/anchor delete / z-order / nudge / duplicate.
|
|
669
|
+
// Plain [ / ] are free on path tools (brush-size adjust only binds on brush/erase).
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
if (!isPathLayer) return undefined;
|
|
672
|
+
function onKeyDown(event) {
|
|
673
|
+
if (isTypingTarget(event.target || document.activeElement)) return;
|
|
674
|
+
if (tool === 'brush') {
|
|
675
|
+
if (brushMode === 'pen') {
|
|
676
|
+
if (event.key === 'Escape') {
|
|
677
|
+
endPenSession({ dropLone: true });
|
|
678
|
+
event.preventDefault();
|
|
679
|
+
} else if (event.key === 'Enter') {
|
|
680
|
+
closeLivePenShape();
|
|
681
|
+
event.preventDefault();
|
|
682
|
+
}
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (event.key === 'Escape') {
|
|
686
|
+
cancelBrushGesture();
|
|
687
|
+
event.preventDefault();
|
|
688
|
+
}
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (tool !== 'path-select') return;
|
|
692
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
693
|
+
if (mod && !event.altKey && event.key.toLowerCase() === 'a') {
|
|
694
|
+
const n = activePath?.shapes?.length ?? 0;
|
|
695
|
+
setSelectedShapeIndices(Array.from({ length: n }, (_, i) => i));
|
|
696
|
+
setSelectedAnchors([]);
|
|
697
|
+
setSelectedEdges(null);
|
|
698
|
+
event.preventDefault();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (mod && !event.altKey && event.key.toLowerCase() === 'd') {
|
|
702
|
+
if (selectedShapeIndices.length) {
|
|
703
|
+
duplicateSelectedShapes();
|
|
704
|
+
event.preventDefault();
|
|
705
|
+
}
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (event.key === 'Escape') {
|
|
709
|
+
setSelectedShapeIndices([]);
|
|
710
|
+
setSelectedAnchors([]);
|
|
711
|
+
setSelectedEdges(null);
|
|
712
|
+
setShapeMarquee(null);
|
|
713
|
+
event.preventDefault();
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (!selectedShapeIndices.length) return;
|
|
717
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
718
|
+
if (!mod && !event.altKey) {
|
|
719
|
+
deleteSelection();
|
|
720
|
+
event.preventDefault();
|
|
721
|
+
}
|
|
722
|
+
} else if (event.key === ']' && !event.altKey && !mod) {
|
|
723
|
+
bringSelectedForward();
|
|
724
|
+
event.preventDefault();
|
|
725
|
+
} else if (event.key === '[' && !event.altKey && !mod) {
|
|
726
|
+
sendSelectedBackward();
|
|
727
|
+
event.preventDefault();
|
|
728
|
+
} else if (
|
|
729
|
+
(event.key === 'ArrowLeft' ||
|
|
730
|
+
event.key === 'ArrowRight' ||
|
|
731
|
+
event.key === 'ArrowUp' ||
|
|
732
|
+
event.key === 'ArrowDown') &&
|
|
733
|
+
!mod
|
|
734
|
+
) {
|
|
735
|
+
nudgeSelection(event.key, !!event.altKey);
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
window.addEventListener('keydown', onKeyDown);
|
|
740
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
741
|
+
}, [
|
|
742
|
+
isPathLayer,
|
|
743
|
+
tool,
|
|
744
|
+
brushMode,
|
|
745
|
+
selectedShapeIndices,
|
|
746
|
+
selectedAnchors,
|
|
747
|
+
text,
|
|
748
|
+
selection.layerIndex,
|
|
749
|
+
selection.frameIndex,
|
|
750
|
+
activeKey,
|
|
751
|
+
activePath,
|
|
752
|
+
]);
|
|
753
|
+
// Shift/Alt during a brush primitive drag only update on pointermove unless
|
|
754
|
+
// we also listen here — toggling mid-stroke otherwise feels random.
|
|
755
|
+
useEffect(() => {
|
|
756
|
+
function onBrushModifierKey(event) {
|
|
757
|
+
if (event.key !== 'Shift' && event.key !== 'Alt') return;
|
|
758
|
+
if (isTypingTarget(event.target || document.activeElement)) return;
|
|
759
|
+
const stroke = strokeRef.current;
|
|
760
|
+
if (!isPathBrushStroke(stroke) || stroke.brushMode === 'pen') return;
|
|
761
|
+
stroke.halfPixel = event.getModifierState('Alt');
|
|
762
|
+
if (stroke.brushMode !== 'freehand') stroke.constrain = event.getModifierState('Shift');
|
|
763
|
+
refitActiveBrushStrokeRef.current();
|
|
764
|
+
}
|
|
765
|
+
window.addEventListener('keydown', onBrushModifierKey);
|
|
766
|
+
window.addEventListener('keyup', onBrushModifierKey);
|
|
767
|
+
return () => {
|
|
768
|
+
window.removeEventListener('keydown', onBrushModifierKey);
|
|
769
|
+
window.removeEventListener('keyup', onBrushModifierKey);
|
|
770
|
+
};
|
|
771
|
+
}, []);
|
|
361
772
|
|
|
362
773
|
if (!sprite) {
|
|
363
774
|
return (
|
|
@@ -373,12 +784,47 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
373
784
|
? 'crosshair'
|
|
374
785
|
: overSelection
|
|
375
786
|
? 'move'
|
|
376
|
-
:
|
|
787
|
+
: isPathLayer && tool === 'brush'
|
|
788
|
+
? 'crosshair'
|
|
789
|
+
: (TOOL_CURSORS[tool] ?? 'default');
|
|
377
790
|
|
|
378
791
|
function togglePalette(next) {
|
|
379
792
|
setPaletteOpen((previous) => (typeof next === 'boolean' ? next : !previous));
|
|
380
793
|
}
|
|
381
794
|
|
|
795
|
+
function brushModifiersFromEvent(event) {
|
|
796
|
+
if (!event?.getModifierState) {
|
|
797
|
+
return { halfPixel: !!event?.altKey, constrain: !!event?.shiftKey };
|
|
798
|
+
}
|
|
799
|
+
return {
|
|
800
|
+
halfPixel: event.getModifierState('Alt'),
|
|
801
|
+
constrain: event.getModifierState('Shift'),
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function applyBrushModifiers(stroke, event) {
|
|
806
|
+
if (!isPathBrushStroke(stroke) || !event) return;
|
|
807
|
+
const { halfPixel, constrain } = brushModifiersFromEvent(event);
|
|
808
|
+
stroke.halfPixel = halfPixel;
|
|
809
|
+
if (stroke.brushMode !== 'freehand') stroke.constrain = constrain;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function refitActiveBrushStroke() {
|
|
813
|
+
const stroke = strokeRef.current;
|
|
814
|
+
if (!isPathBrushStroke(stroke) || stroke.brushMode === 'pen') return;
|
|
815
|
+
if (stroke.brushMode === 'freehand') {
|
|
816
|
+
refitBrushGhost(brushInkRef.current, stroke.halfPixel);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
refitBrushPrimitiveGhost(
|
|
820
|
+
stroke.startPoint,
|
|
821
|
+
stroke.lastPoint ?? stroke.startPoint,
|
|
822
|
+
stroke.halfPixel,
|
|
823
|
+
stroke.constrain
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
refitActiveBrushStrokeRef.current = refitActiveBrushStroke;
|
|
827
|
+
|
|
382
828
|
// Push a draft Sprite to the file (live, mid-stroke). Records ONE undo
|
|
383
829
|
// snapshot for the whole gesture and dedups by serialized text.
|
|
384
830
|
function liveSprite(nextSprite) {
|
|
@@ -386,6 +832,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
386
832
|
if (!stroke) return;
|
|
387
833
|
const nextText = serializeModel(nextSprite);
|
|
388
834
|
if (nextText === stroke.draftText) return;
|
|
835
|
+
// Click-without-drag (path select, etc.): identical to gesture-start text —
|
|
836
|
+
// do not open a history entry or notify.
|
|
837
|
+
if (!stroke.recorded && stroke.baseText != null && nextText === stroke.baseText) return;
|
|
389
838
|
if (!stroke.recorded) {
|
|
390
839
|
history.recordSnapshot();
|
|
391
840
|
stroke.recorded = true;
|
|
@@ -437,6 +886,11 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
437
886
|
if (!stroke) return;
|
|
438
887
|
strokeRef.current = null;
|
|
439
888
|
if (stroke.tool === 'select') setMarquee(stroke.originRect ?? null);
|
|
889
|
+
// The path rubber band is drawn from its own state rather than from the
|
|
890
|
+
// selection, so unlike the pixel marquee it survives an abort unless it is
|
|
891
|
+
// cleared here; there is no prior band to roll back to.
|
|
892
|
+
if (stroke.tool === 'path-select') setShapeMarquee(null);
|
|
893
|
+
if (isPathBrushStroke(stroke)) clearBrushGesture();
|
|
440
894
|
if (stroke.recorded) {
|
|
441
895
|
const baseText = serializeModel(stroke.base);
|
|
442
896
|
lastSelfTextRef.current = baseText;
|
|
@@ -447,6 +901,879 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
447
901
|
history.cancelSnapshot();
|
|
448
902
|
}
|
|
449
903
|
}
|
|
904
|
+
function commitPath(nextPath, options) {
|
|
905
|
+
// Hold the invariant that an edge exists once, after any edit rather than
|
|
906
|
+
// only the one that happened to produce a duplicate. Skipped mid-limb: a
|
|
907
|
+
// half-drawn run can lie on the outline on its way somewhere else.
|
|
908
|
+
const settled = penLimbRef.current ? nextPath : absorbRetracedSubpaths(nextPath);
|
|
909
|
+
const nextText = serializeModel(withPathAt(sprite, selection.layerIndex, selection.frameIndex, settled));
|
|
910
|
+
lastSelfTextRef.current = nextText;
|
|
911
|
+
history.commit(nextText, options);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Run the same pass at the end of a drag.
|
|
915
|
+
*
|
|
916
|
+
* A gesture's final state arrives through liveSprite, which cannot absorb as
|
|
917
|
+
* it goes: mid-drag a run may legitimately lie along the outline on its way
|
|
918
|
+
* somewhere else. Settling on release is what stops dragging from being the
|
|
919
|
+
* one way to leave a duplicate edge in the file. This extends the gesture's
|
|
920
|
+
* open history entry rather than opening a second one, so it undoes together
|
|
921
|
+
* with the move that caused it.
|
|
922
|
+
*/
|
|
923
|
+
function settleDraggedPath(stroke) {
|
|
924
|
+
if (!stroke?.recorded || !stroke.draftText) return;
|
|
925
|
+
if (stroke.tool !== 'path-select' && stroke.tool !== 'bend') return;
|
|
926
|
+
if (penLimbRef.current) return;
|
|
927
|
+
const spr = deriveSprite(stroke.draftText);
|
|
928
|
+
const drawn = spr ? pathAt(spr, stroke.layerIndex, stroke.frameIndex) : null;
|
|
929
|
+
if (!drawn) return;
|
|
930
|
+
const settled = absorbRetracedSubpaths(drawn);
|
|
931
|
+
if (settled === drawn) return;
|
|
932
|
+
const nextText = serializeModel(
|
|
933
|
+
withPathAt(spr, stroke.layerIndex, stroke.frameIndex, settled)
|
|
934
|
+
);
|
|
935
|
+
lastSelfTextRef.current = nextText;
|
|
936
|
+
onChange(nextText);
|
|
937
|
+
}
|
|
938
|
+
function clearBrushGesture() {
|
|
939
|
+
brushInkRef.current = [];
|
|
940
|
+
setBrushInk([]);
|
|
941
|
+
setBrushGhost(null);
|
|
942
|
+
setPathBakePreview(null);
|
|
943
|
+
if (isPathBrushStroke(strokeRef.current)) strokeRef.current = null;
|
|
944
|
+
}
|
|
945
|
+
function cancelBrushGesture() {
|
|
946
|
+
clearBrushGesture();
|
|
947
|
+
}
|
|
948
|
+
// History-free live bake (same channel as pen hover). Mutating the file on
|
|
949
|
+
// every pointermove was thrashing onChange/text effects and could abort the
|
|
950
|
+
// gesture mid-drag — preview would vanish until mouse-up.
|
|
951
|
+
function liveBrushBake(shape) {
|
|
952
|
+
const stroke = strokeRef.current;
|
|
953
|
+
if (!isPathBrushStroke(stroke) || !stroke.path) return;
|
|
954
|
+
if (!shape) return;
|
|
955
|
+
setPathBakePreview(appendShape(stroke.path, shape));
|
|
956
|
+
}
|
|
957
|
+
function refitBrushGhost(ink, halfPixel = false) {
|
|
958
|
+
const fit = fitBrushInk(ink, { halfPixel, cellPull: pathCellPull });
|
|
959
|
+
const shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
|
|
960
|
+
// Cyan ghost stays visible even when the bake blends into existing pixels.
|
|
961
|
+
// Skip clearing on a null fit so the preview doesn't blink in the
|
|
962
|
+
// degenerate snap cell around the gesture start.
|
|
963
|
+
if (shape) setBrushGhost(shape);
|
|
964
|
+
liveBrushBake(shape);
|
|
965
|
+
}
|
|
966
|
+
function refitBrushPrimitiveGhost(start, end, halfPixel = false, constrain = false) {
|
|
967
|
+
const stroke = strokeRef.current;
|
|
968
|
+
const mode = stroke?.brushMode ?? brushMode;
|
|
969
|
+
const shape = ghostFromBrushDrag(mode, start, end, { halfPixel, constrain, activeKey, cellPull: pathCellPull });
|
|
970
|
+
if (shape) setBrushGhost(shape);
|
|
971
|
+
liveBrushBake(shape);
|
|
972
|
+
}
|
|
973
|
+
function commitBrushGesture(endPoint) {
|
|
974
|
+
const stroke = strokeRef.current;
|
|
975
|
+
if (!isPathBrushStroke(stroke)) return;
|
|
976
|
+
const halfPixel = !!stroke.halfPixel;
|
|
977
|
+
const constrain = !!stroke.constrain;
|
|
978
|
+
let shape = null;
|
|
979
|
+
if (stroke.brushMode === 'freehand') {
|
|
980
|
+
const fit = fitBrushInk(brushInkRef.current, { halfPixel, cellPull: pathCellPull });
|
|
981
|
+
shape = fit ? shapeFromBrushFit(fit, activeKey) : null;
|
|
982
|
+
} else {
|
|
983
|
+
const start = stroke.startPoint;
|
|
984
|
+
const end = endPoint ?? stroke.lastPoint ?? start;
|
|
985
|
+
shape = shapeFromBrushDrag(stroke.brushMode, start, end, {
|
|
986
|
+
halfPixel,
|
|
987
|
+
constrain,
|
|
988
|
+
activeKey,
|
|
989
|
+
cellPull: pathCellPull,
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
const base = stroke.base;
|
|
993
|
+
const path = stroke.path;
|
|
994
|
+
const layerIndex = stroke.layerIndex;
|
|
995
|
+
const frameIndex = stroke.frameIndex;
|
|
996
|
+
const top = path?.shapes?.length ?? 0;
|
|
997
|
+
clearBrushGesture();
|
|
998
|
+
if (!shape || !base) return;
|
|
999
|
+
const nextText = serializeModel(withPathAt(base, layerIndex, frameIndex, appendShape(path, shape)));
|
|
1000
|
+
lastSelfTextRef.current = nextText;
|
|
1001
|
+
history.commit(nextText);
|
|
1002
|
+
setSelectedShapeIndices([top]);
|
|
1003
|
+
setSelectedAnchors([]);
|
|
1004
|
+
}
|
|
1005
|
+
/** Does a click here come back to the live subpath's first anchor and close it?
|
|
1006
|
+
* A limb closes the same way: coming back to where it started makes a loop,
|
|
1007
|
+
* and a loop is what fills. Landing anywhere else on the outline is a splice. */
|
|
1008
|
+
function penClickWouldClose(target, sx, sy) {
|
|
1009
|
+
if (!target) return false;
|
|
1010
|
+
const pts = subpathPoints(activePath.shapes[target.shapeIndex].subpaths[target.subIndex]);
|
|
1011
|
+
if (pts.length < 3) return false;
|
|
1012
|
+
const [fx, fy] = pts[0];
|
|
1013
|
+
return (sx - fx) * (sx - fx) + (sy - fy) * (sy - fy) <= 0.6 * 0.6;
|
|
1014
|
+
}
|
|
1015
|
+
function buildPenHoverBakePath(point, halfPixel) {
|
|
1016
|
+
const path = activePath;
|
|
1017
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
1018
|
+
const target = livePenTarget();
|
|
1019
|
+
const preview = previewPenLanding(path, sole, point.x, point.y, halfPixel, pathCellPull);
|
|
1020
|
+
const [sx, sy] = preview.point;
|
|
1021
|
+
|
|
1022
|
+
if (penClickWouldClose(target, sx, sy)) {
|
|
1023
|
+
const closed = closePenShape(path, target.shapeIndex, target.subIndex);
|
|
1024
|
+
return closed !== path ? closed : null;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// A branch about to be rooted previews as the shape with its new anchor.
|
|
1028
|
+
if (!target && sole >= 0) {
|
|
1029
|
+
const root = penBranchRoot(point, sole);
|
|
1030
|
+
if (root) return root.path;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Preview the merged silhouette, so which of the two runs gets replaced is
|
|
1034
|
+
// visible before committing to it.
|
|
1035
|
+
const spliced = spliceAt(point, penLimbRef.current);
|
|
1036
|
+
if (spliced) return spliced;
|
|
1037
|
+
|
|
1038
|
+
if (preview.edge) {
|
|
1039
|
+
const inserted = insertAnchorOnEdge(
|
|
1040
|
+
path,
|
|
1041
|
+
preview.edge.shapeIndex,
|
|
1042
|
+
preview.edge.subIndex,
|
|
1043
|
+
preview.edge.edgeIndex,
|
|
1044
|
+
point.x,
|
|
1045
|
+
point.y,
|
|
1046
|
+
pathCellPull
|
|
1047
|
+
);
|
|
1048
|
+
return inserted?.path ?? null;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (target) {
|
|
1052
|
+
return appendAnchorToSubpath(path, target.shapeIndex, target.subIndex, [sx, sy]);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
return appendShape(path, shapeFromPenDraft([[sx, sy]], { closed: false, activeKey }));
|
|
1056
|
+
}
|
|
1057
|
+
function updatePenHoverBake(point, halfPixel) {
|
|
1058
|
+
const now = performance.now();
|
|
1059
|
+
if (now - penBakeAtRef.current < 30) return;
|
|
1060
|
+
penBakeAtRef.current = now;
|
|
1061
|
+
setPathBakePreview(buildPenHoverBakePath(point, halfPixel));
|
|
1062
|
+
}
|
|
1063
|
+
function livePenShapeIndex() {
|
|
1064
|
+
if (selectedShapeIndices.length !== 1) return -1;
|
|
1065
|
+
const i = selectedShapeIndices[0];
|
|
1066
|
+
return isOpenPenShape(activePath?.shapes?.[i]) ? i : -1;
|
|
1067
|
+
}
|
|
1068
|
+
/** The open subpath the next pen click extends, or null. A limb wins if one is live. */
|
|
1069
|
+
function livePenTarget() {
|
|
1070
|
+
const limb = penLimbRef.current;
|
|
1071
|
+
if (limb) {
|
|
1072
|
+
const sub = activePath?.shapes?.[limb.shapeIndex]?.subpaths?.[limb.subIndex];
|
|
1073
|
+
if (isOpenSubpath(sub)) return limb;
|
|
1074
|
+
penLimbRef.current = null;
|
|
1075
|
+
}
|
|
1076
|
+
const i = livePenShapeIndex();
|
|
1077
|
+
return i >= 0 ? { shapeIndex: i, subIndex: 0 } : null;
|
|
1078
|
+
}
|
|
1079
|
+
function dropLoneLimbPoint() {
|
|
1080
|
+
const limb = penLimbRef.current;
|
|
1081
|
+
penLimbRef.current = null;
|
|
1082
|
+
if (!limb) return false;
|
|
1083
|
+
const sub = activePath?.shapes?.[limb.shapeIndex]?.subpaths?.[limb.subIndex];
|
|
1084
|
+
if (isOpenSubpath(sub) && subpathVertexCount(sub) === 1) {
|
|
1085
|
+
commitPath(removeSubpath(activePath, limb.shapeIndex, limb.subIndex));
|
|
1086
|
+
}
|
|
1087
|
+
setSelectedShapeIndices([]);
|
|
1088
|
+
setSelectedAnchors([]);
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
function dropLoneOpenPenPoint() {
|
|
1092
|
+
if (dropLoneLimbPoint()) return;
|
|
1093
|
+
const i = penStubIndexRef.current;
|
|
1094
|
+
if (i < 0) return;
|
|
1095
|
+
penStubIndexRef.current = -1;
|
|
1096
|
+
const shape = activePath?.shapes?.[i];
|
|
1097
|
+
if (!isOpenPenShape(shape)) return;
|
|
1098
|
+
const pts = subpathPoints(shape.subpaths[0]);
|
|
1099
|
+
if (pts.length !== 1) return;
|
|
1100
|
+
commitPath(removeShape(activePath, i));
|
|
1101
|
+
setSelectedShapeIndices([]);
|
|
1102
|
+
setSelectedAnchors([]);
|
|
1103
|
+
}
|
|
1104
|
+
function endPenSession({ dropLone = false } = {}) {
|
|
1105
|
+
if (dropLone) dropLoneOpenPenPoint();
|
|
1106
|
+
else {
|
|
1107
|
+
penStubIndexRef.current = -1;
|
|
1108
|
+
penLimbRef.current = null;
|
|
1109
|
+
}
|
|
1110
|
+
setSelectedShapeIndices([]);
|
|
1111
|
+
setSelectedAnchors([]);
|
|
1112
|
+
}
|
|
1113
|
+
/** Where a branch would root on `shapeIndex`: an existing anchor, or a fresh one
|
|
1114
|
+
* inserted into the edge under the cursor. Returns `{ path, at }` or null. */
|
|
1115
|
+
function penBranchRoot(point, shapeIndex) {
|
|
1116
|
+
const anchor = hitTestShapeAnchor(activePath, shapeIndex, point.x, point.y, pathHitRadii.anchor);
|
|
1117
|
+
if (anchor) {
|
|
1118
|
+
const p = subpathPointAt(activePath.shapes[shapeIndex].subpaths[anchor.subIndex], anchor.pointIndex);
|
|
1119
|
+
return { path: activePath, at: p };
|
|
1120
|
+
}
|
|
1121
|
+
const edge = hitTestShapeEdge(activePath, shapeIndex, point.x, point.y, pathHitRadii.edge);
|
|
1122
|
+
if (!edge) return null;
|
|
1123
|
+
const inserted = insertAnchorOnEdge(
|
|
1124
|
+
activePath,
|
|
1125
|
+
edge.shapeIndex,
|
|
1126
|
+
edge.subIndex,
|
|
1127
|
+
edge.edgeIndex,
|
|
1128
|
+
point.x,
|
|
1129
|
+
point.y,
|
|
1130
|
+
pathCellPull
|
|
1131
|
+
);
|
|
1132
|
+
if (!inserted) return null;
|
|
1133
|
+
const { subIndex, pointIndex } = inserted.anchor;
|
|
1134
|
+
return { path: inserted.path, at: subpathPointAt(inserted.path.shapes[shapeIndex].subpaths[subIndex], pointIndex) };
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* A live limb whose next click lands back on a closed subpath of the same
|
|
1138
|
+
* shape is a splice: the drawn run replaces one run of that loop and the two
|
|
1139
|
+
* become a single closed path. Pure — returns the resulting path or null, so
|
|
1140
|
+
* the hover preview and the click can share one answer.
|
|
1141
|
+
*
|
|
1142
|
+
* Landing on the limb's own geometry is not a splice; only closed subpaths
|
|
1143
|
+
* are candidates, which is what keeps a stroked branch a branch.
|
|
1144
|
+
*/
|
|
1145
|
+
function spliceAt(point, limb) {
|
|
1146
|
+
if (!limb) return null;
|
|
1147
|
+
const shape = activePath?.shapes?.[limb.shapeIndex];
|
|
1148
|
+
const closedSub = (subIndex) => !!shape?.subpaths?.[subIndex]?.closed;
|
|
1149
|
+
if (subpathVertexCount(shape?.subpaths?.[limb.subIndex]) < 2) return null;
|
|
1150
|
+
|
|
1151
|
+
let rooted = activePath;
|
|
1152
|
+
let at = null;
|
|
1153
|
+
const anchor = hitTestShapeAnchor(activePath, limb.shapeIndex, point.x, point.y, pathHitRadii.anchor);
|
|
1154
|
+
// A junction holds one anchor per subpath meeting there, and the hit lands
|
|
1155
|
+
// on whichever was stored last — usually a divider left by an earlier
|
|
1156
|
+
// splice. Resolve through the site to the loop's own copy, or landing on an
|
|
1157
|
+
// existing junction would fail to close the region.
|
|
1158
|
+
const onLoop = anchor
|
|
1159
|
+
? withCoincidentAnchors(activePath, limb.shapeIndex, [anchor]).find((a) =>
|
|
1160
|
+
closedSub(a.subIndex)
|
|
1161
|
+
)
|
|
1162
|
+
: null;
|
|
1163
|
+
if (onLoop) {
|
|
1164
|
+
at = subpathPointAt(shape.subpaths[onLoop.subIndex], onLoop.pointIndex);
|
|
1165
|
+
} else {
|
|
1166
|
+
const edge = hitTestShapeEdge(activePath, limb.shapeIndex, point.x, point.y, pathHitRadii.edge);
|
|
1167
|
+
if (!edge || !closedSub(edge.subIndex)) return null;
|
|
1168
|
+
const inserted = insertAnchorOnEdge(
|
|
1169
|
+
activePath,
|
|
1170
|
+
edge.shapeIndex,
|
|
1171
|
+
edge.subIndex,
|
|
1172
|
+
edge.edgeIndex,
|
|
1173
|
+
point.x,
|
|
1174
|
+
point.y,
|
|
1175
|
+
pathCellPull
|
|
1176
|
+
);
|
|
1177
|
+
if (!inserted) return null;
|
|
1178
|
+
rooted = inserted.path;
|
|
1179
|
+
at = subpathPointAt(
|
|
1180
|
+
rooted.shapes[limb.shapeIndex].subpaths[inserted.anchor.subIndex],
|
|
1181
|
+
inserted.anchor.pointIndex
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
if (!at) return null;
|
|
1185
|
+
const extended = appendAnchorToSubpath(rooted, limb.shapeIndex, limb.subIndex, at);
|
|
1186
|
+
return spliceLimbIntoShape(extended, limb.shapeIndex, limb.subIndex);
|
|
1187
|
+
}
|
|
1188
|
+
function startPenBranch(point, shapeIndex) {
|
|
1189
|
+
const root = penBranchRoot(point, shapeIndex);
|
|
1190
|
+
if (!root) return false;
|
|
1191
|
+
const limb = startLimbOnShape(root.path, shapeIndex, root.at);
|
|
1192
|
+
if (!limb) return false;
|
|
1193
|
+
penStubIndexRef.current = -1;
|
|
1194
|
+
penLimbRef.current = { shapeIndex, subIndex: limb.subIndex };
|
|
1195
|
+
commitPath(limb.path);
|
|
1196
|
+
setSelectedShapeIndices([shapeIndex]);
|
|
1197
|
+
setSelectedAnchors([]);
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Clicking the loose end of an open subpath picks the pen back up there.
|
|
1202
|
+
*
|
|
1203
|
+
* Without this the only open subpath the pen could carry on with was a lone
|
|
1204
|
+
* one, so a loop opened by cutting an edge out of a shape that also carries a
|
|
1205
|
+
* line — which is most of them — could only be closed again by undoing.
|
|
1206
|
+
*/
|
|
1207
|
+
function resumeOpenEnd(sx, sy, shapeIndex) {
|
|
1208
|
+
const hit = hitTestOpenEnd(activePath, shapeIndex, sx, sy, pathHitRadii.openEnd);
|
|
1209
|
+
if (!hit) return false;
|
|
1210
|
+
if (hit.end === 'head') {
|
|
1211
|
+
const turned = reverseSubpath(activePath, shapeIndex, hit.subIndex);
|
|
1212
|
+
if (turned !== activePath) commitPath(turned);
|
|
1213
|
+
}
|
|
1214
|
+
penLimbRef.current = { shapeIndex, subIndex: hit.subIndex };
|
|
1215
|
+
penStubIndexRef.current = -1;
|
|
1216
|
+
setSelectedShapeIndices([shapeIndex]);
|
|
1217
|
+
setSelectedAnchors([]);
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
function closeLivePenShape() {
|
|
1221
|
+
const target = livePenTarget();
|
|
1222
|
+
penLimbRef.current = null;
|
|
1223
|
+
if (!target) return false;
|
|
1224
|
+
const i = target.shapeIndex;
|
|
1225
|
+
const next = closePenShape(activePath, i, target.subIndex);
|
|
1226
|
+
if (next === activePath) return false;
|
|
1227
|
+
penStubIndexRef.current = -1;
|
|
1228
|
+
commitPath(next);
|
|
1229
|
+
// Keep the closed shape selected so edge-insert / further edits stay available.
|
|
1230
|
+
setSelectedShapeIndices([i]);
|
|
1231
|
+
setSelectedAnchors([]);
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
function deleteSelection() {
|
|
1235
|
+
if (!selectedShapeIndices.length) return;
|
|
1236
|
+
if (activeEdges) {
|
|
1237
|
+
const next = removeEdgesFromShape(activePath, activeEdges.shapeIndex, activeEdges.edges);
|
|
1238
|
+
commitPath(next);
|
|
1239
|
+
setSelectedEdges(null);
|
|
1240
|
+
setSelectedAnchors([]);
|
|
1241
|
+
// Cutting a shape's last edge takes the shape with it.
|
|
1242
|
+
if (!next?.shapes?.[activeEdges.shapeIndex]) setSelectedShapeIndices([]);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (selectedShapeIndices.length === 1 && selectedAnchors.length) {
|
|
1246
|
+
const shapeIndex = selectedShapeIndices[0];
|
|
1247
|
+
const { path: next, anchors: advanced } = removeAnchorsFromShape(
|
|
1248
|
+
activePath,
|
|
1249
|
+
shapeIndex,
|
|
1250
|
+
selectedAnchors
|
|
1251
|
+
);
|
|
1252
|
+
commitPath(next);
|
|
1253
|
+
if (!next?.shapes?.[shapeIndex]) {
|
|
1254
|
+
setSelectedShapeIndices([]);
|
|
1255
|
+
setSelectedAnchors([]);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
// Advance selection to next survivors so repeated Delete chews along the path.
|
|
1259
|
+
setSelectedAnchors(advanced);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
commitPath(removeShapes(activePath, selectedShapeIndices));
|
|
1263
|
+
setSelectedShapeIndices([]);
|
|
1264
|
+
setSelectedAnchors([]);
|
|
1265
|
+
}
|
|
1266
|
+
function bringSelectedForward() {
|
|
1267
|
+
if (!canBringShapesForward(activePath, selectedShapeIndices)) return;
|
|
1268
|
+
const { path: next, indices } = bringShapesForward(activePath, selectedShapeIndices);
|
|
1269
|
+
commitPath(next);
|
|
1270
|
+
setSelectedShapeIndices(indices);
|
|
1271
|
+
setSelectedAnchors([]);
|
|
1272
|
+
}
|
|
1273
|
+
function sendSelectedBackward() {
|
|
1274
|
+
if (!canSendShapesBackward(activePath, selectedShapeIndices)) return;
|
|
1275
|
+
const { path: next, indices } = sendShapesBackward(activePath, selectedShapeIndices);
|
|
1276
|
+
commitPath(next);
|
|
1277
|
+
setSelectedShapeIndices(indices);
|
|
1278
|
+
setSelectedAnchors([]);
|
|
1279
|
+
}
|
|
1280
|
+
function duplicateSelectedShapes() {
|
|
1281
|
+
if (!selectedShapeIndices.length) return;
|
|
1282
|
+
const { path: next, indices } = duplicateShapes(activePath, selectedShapeIndices, 1, 1);
|
|
1283
|
+
commitPath(next);
|
|
1284
|
+
setSelectedShapeIndices(indices);
|
|
1285
|
+
setSelectedAnchors([]);
|
|
1286
|
+
}
|
|
1287
|
+
function punchSelectedHole() {
|
|
1288
|
+
const result = punchHole(activePath, selectedShapeIndices);
|
|
1289
|
+
if (!result) return;
|
|
1290
|
+
commitPath(result.path);
|
|
1291
|
+
setSelectedShapeIndices([result.outerIndex]);
|
|
1292
|
+
setSelectedAnchors([]);
|
|
1293
|
+
}
|
|
1294
|
+
function nudgeSelection(arrowKey, halfCell) {
|
|
1295
|
+
if (!selectedShapeIndices.length) return;
|
|
1296
|
+
let dx = 0;
|
|
1297
|
+
let dy = 0;
|
|
1298
|
+
const step = halfCell ? 0.5 : 1;
|
|
1299
|
+
if (arrowKey === 'ArrowLeft') dx = -step;
|
|
1300
|
+
else if (arrowKey === 'ArrowRight') dx = step;
|
|
1301
|
+
else if (arrowKey === 'ArrowUp') dy = -step;
|
|
1302
|
+
else if (arrowKey === 'ArrowDown') dy = step;
|
|
1303
|
+
else return;
|
|
1304
|
+
const coalesceKey = `path-nudge:${halfCell ? 'h' : 'w'}:${arrowKey}`;
|
|
1305
|
+
const commitNudge = (path) => commitPath(path, { coalesceKey });
|
|
1306
|
+
// A sub-shape pick moves by its anchors, whether it was made by clicking
|
|
1307
|
+
// them or by picking the edges between them — the arrows have to agree with
|
|
1308
|
+
// what dragging the same selection does.
|
|
1309
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
1310
|
+
const picked = activeEdges
|
|
1311
|
+
? anchorsForEdges(activePath, sole, activeEdges.edges)
|
|
1312
|
+
: sole >= 0 && selectedAnchors.length
|
|
1313
|
+
? selectedAnchors
|
|
1314
|
+
: null;
|
|
1315
|
+
if (picked?.length) {
|
|
1316
|
+
const anchors = withCoincidentAnchors(activePath, sole, picked);
|
|
1317
|
+
const starts = collectAnchorStarts(activePath, sole, anchors);
|
|
1318
|
+
commitNudge(applyAnchorsDrag(activePath, sole, anchors, starts, dx, dy, halfCell, pathCellPull));
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
const pointStarts = collectShapesPointStarts(activePath, selectedShapeIndices);
|
|
1322
|
+
const next = applyShapesDrag(
|
|
1323
|
+
activePath,
|
|
1324
|
+
selectedShapeIndices,
|
|
1325
|
+
pointStarts,
|
|
1326
|
+
dx,
|
|
1327
|
+
dy,
|
|
1328
|
+
halfCell,
|
|
1329
|
+
pathCellPull
|
|
1330
|
+
);
|
|
1331
|
+
commitNudge(next);
|
|
1332
|
+
}
|
|
1333
|
+
const fillSlot = sharedShapeSlot(activePath, selectedShapeIndices, 'fill');
|
|
1334
|
+
const strokeSlot = sharedShapeSlot(activePath, selectedShapeIndices, 'stroke');
|
|
1335
|
+
const pathActions = {
|
|
1336
|
+
hasSelection: selectedShapeIndices.length > 0,
|
|
1337
|
+
selectedShapeIndices,
|
|
1338
|
+
fillSlot,
|
|
1339
|
+
strokeSlot,
|
|
1340
|
+
canForward: canBringShapesForward(activePath, selectedShapeIndices),
|
|
1341
|
+
canBackward: canSendShapesBackward(activePath, selectedShapeIndices),
|
|
1342
|
+
canPunchHole: canPunchHole(activePath, selectedShapeIndices),
|
|
1343
|
+
deleteSelected: deleteSelection,
|
|
1344
|
+
bringForward: bringSelectedForward,
|
|
1345
|
+
sendBackward: sendSelectedBackward,
|
|
1346
|
+
punchHole: punchSelectedHole,
|
|
1347
|
+
flipH: () => {
|
|
1348
|
+
if (!selectedShapeIndices.length) return;
|
|
1349
|
+
commitPath(flipShapesHorizontal(activePath, selectedShapeIndices));
|
|
1350
|
+
},
|
|
1351
|
+
flipV: () => {
|
|
1352
|
+
if (!selectedShapeIndices.length) return;
|
|
1353
|
+
commitPath(flipShapesVertical(activePath, selectedShapeIndices));
|
|
1354
|
+
},
|
|
1355
|
+
rotateCW: () => {
|
|
1356
|
+
if (!selectedShapeIndices.length) return;
|
|
1357
|
+
commitPath(rotateShapes90CW(activePath, selectedShapeIndices));
|
|
1358
|
+
},
|
|
1359
|
+
setFill: (key) => {
|
|
1360
|
+
if (!selectedShapeIndices.length) return;
|
|
1361
|
+
commitPath(setShapesFill(activePath, selectedShapeIndices, key));
|
|
1362
|
+
},
|
|
1363
|
+
setStroke: (key) => {
|
|
1364
|
+
if (!selectedShapeIndices.length) return;
|
|
1365
|
+
commitPath(setShapesStroke(activePath, selectedShapeIndices, key));
|
|
1366
|
+
},
|
|
1367
|
+
};
|
|
1368
|
+
function startPenStroke(point, event) {
|
|
1369
|
+
const halfPixel = !!event?.altKey;
|
|
1370
|
+
const [sx, sy] = snapPenAnchor(point.x, point.y, halfPixel, pathCellPull);
|
|
1371
|
+
const target = livePenTarget();
|
|
1372
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
1373
|
+
|
|
1374
|
+
if (penClickWouldClose(target, sx, sy)) {
|
|
1375
|
+
closeLivePenShape();
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Nothing live, but a shape is selected: a click on it starts a branch there
|
|
1380
|
+
// and keeps drawing, rather than dropping a point and ending the operation.
|
|
1381
|
+
if (!target && sole >= 0 && resumeOpenEnd(sx, sy, sole)) return;
|
|
1382
|
+
if (!target && sole >= 0 && startPenBranch(point, sole)) return;
|
|
1383
|
+
|
|
1384
|
+
// A limb landing back on the loop it was drawn from splices into it, rather
|
|
1385
|
+
// than dropping a stray anchor and leaving the region unclosable.
|
|
1386
|
+
const limb = penLimbRef.current;
|
|
1387
|
+
const spliced = spliceAt(point, limb);
|
|
1388
|
+
if (spliced) {
|
|
1389
|
+
penLimbRef.current = null;
|
|
1390
|
+
penStubIndexRef.current = -1;
|
|
1391
|
+
commitPath(spliced);
|
|
1392
|
+
setSelectedShapeIndices([limb.shapeIndex]);
|
|
1393
|
+
setSelectedAnchors([]);
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// Click on the selected shape's edge → insert (open or closed).
|
|
1398
|
+
if (target && sole >= 0) {
|
|
1399
|
+
const edge = hitTestShapeEdge(activePath, sole, point.x, point.y, pathHitRadii.edge);
|
|
1400
|
+
if (edge) {
|
|
1401
|
+
const inserted = insertAnchorOnEdge(
|
|
1402
|
+
activePath,
|
|
1403
|
+
edge.shapeIndex,
|
|
1404
|
+
edge.subIndex,
|
|
1405
|
+
edge.edgeIndex,
|
|
1406
|
+
point.x,
|
|
1407
|
+
point.y,
|
|
1408
|
+
pathCellPull
|
|
1409
|
+
);
|
|
1410
|
+
if (inserted) {
|
|
1411
|
+
penStubIndexRef.current = -1;
|
|
1412
|
+
commitPath(inserted.path);
|
|
1413
|
+
setSelectedAnchors([inserted.anchor]);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
if (target) {
|
|
1420
|
+
penStubIndexRef.current = -1;
|
|
1421
|
+
commitPath(
|
|
1422
|
+
appendAnchorToSubpath(activePath, target.shapeIndex, target.subIndex, [sx, sy])
|
|
1423
|
+
);
|
|
1424
|
+
setSelectedAnchors([]);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
// Start a new open stroked shape at this anchor.
|
|
1429
|
+
const shape = shapeFromPenDraft([[sx, sy]], { closed: false, activeKey });
|
|
1430
|
+
const next = appendShape(activePath, shape);
|
|
1431
|
+
const top = (next.shapes?.length ?? 1) - 1;
|
|
1432
|
+
penStubIndexRef.current = top;
|
|
1433
|
+
commitPath(next);
|
|
1434
|
+
setSelectedShapeIndices([top]);
|
|
1435
|
+
setSelectedAnchors([]);
|
|
1436
|
+
}
|
|
1437
|
+
const edgeKey = (e) => `${e.subIndex}:${e.edgeIndex}`;
|
|
1438
|
+
/** The anchors at both ends of each edge, deduplicated. */
|
|
1439
|
+
function anchorsForEdges(pathData, shapeIndex, edges) {
|
|
1440
|
+
const out = [];
|
|
1441
|
+
const seen = new Set();
|
|
1442
|
+
for (const { subIndex, edgeIndex } of edges) {
|
|
1443
|
+
const sub = pathData?.shapes?.[shapeIndex]?.subpaths?.[subIndex];
|
|
1444
|
+
if (!sub) continue;
|
|
1445
|
+
const count = subpathVertexCount(sub);
|
|
1446
|
+
// The last edge of a closed subpath comes back round to point 0.
|
|
1447
|
+
for (const pointIndex of [edgeIndex, (edgeIndex + 1) % count]) {
|
|
1448
|
+
const id = `${subIndex}:${pointIndex}`;
|
|
1449
|
+
if (seen.has(id)) continue;
|
|
1450
|
+
seen.add(id);
|
|
1451
|
+
out.push({ subIndex, pointIndex });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return out;
|
|
1455
|
+
}
|
|
1456
|
+
function edgesAreSelected(refs) {
|
|
1457
|
+
const have = new Set((activeEdges?.edges ?? []).map(edgeKey));
|
|
1458
|
+
return refs.length > 0 && refs.every((e) => have.has(edgeKey(e)));
|
|
1459
|
+
}
|
|
1460
|
+
/** Every edge of the subpath an edge belongs to, for double-click. */
|
|
1461
|
+
function edgesOfSubpath(pathData, shapeIndex, subIndex) {
|
|
1462
|
+
const sub = pathData?.shapes?.[shapeIndex]?.subpaths?.[subIndex];
|
|
1463
|
+
const count = sub ? subpathEdgeCount(sub) : 0;
|
|
1464
|
+
return Array.from({ length: count }, (_, edgeIndex) => ({ subIndex, edgeIndex }));
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* What an edge press means. Pressing an edge that is already picked moves the
|
|
1468
|
+
* pick; pressing anywhere else on the outline still moves the shape, which is
|
|
1469
|
+
* how a shape has always been dragged by its stroke.
|
|
1470
|
+
*/
|
|
1471
|
+
function edgePressIntent(pathData, shapeIndex, x, y) {
|
|
1472
|
+
const hit = hitTestShapeEdge(pathData, shapeIndex, x, y, pathHitRadii.edge);
|
|
1473
|
+
if (!hit) return null;
|
|
1474
|
+
const ref = { subIndex: hit.subIndex, edgeIndex: hit.edgeIndex };
|
|
1475
|
+
const now = performance.now();
|
|
1476
|
+
const last = lastEdgeClickRef.current;
|
|
1477
|
+
const double = now - last.at < 400 && last.key === edgeKey(ref);
|
|
1478
|
+
lastEdgeClickRef.current = { at: now, key: edgeKey(ref) };
|
|
1479
|
+
return { ref, double, moving: edgesAreSelected([ref]) };
|
|
1480
|
+
}
|
|
1481
|
+
function startPathSelectStroke(point, event) {
|
|
1482
|
+
const pathData = activePath;
|
|
1483
|
+
const shift = !!event?.shiftKey;
|
|
1484
|
+
const baseText = serializeModel(sprite);
|
|
1485
|
+
const soleIndex = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : null;
|
|
1486
|
+
|
|
1487
|
+
// Single-shape only: anchors and edge hits (edge drag moves the shape).
|
|
1488
|
+
if (soleIndex != null) {
|
|
1489
|
+
const anchor = hitTestShapeAnchor(pathData, soleIndex, point.x, point.y, pathHitRadii.anchor);
|
|
1490
|
+
if (anchor) {
|
|
1491
|
+
// Picking an anchor drops the edge pick outright instead of leaving it
|
|
1492
|
+
// stored-but-inert: a pick still in storage springs back into view the
|
|
1493
|
+
// moment the anchors clear, reading as the selection reverting.
|
|
1494
|
+
setSelectedEdges(null);
|
|
1495
|
+
if (shift) {
|
|
1496
|
+
setSelectedAnchors((prev) => toggleAnchorInList(prev, anchor));
|
|
1497
|
+
strokeRef.current = null;
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
const anchorRef = { subIndex: anchor.subIndex, pointIndex: anchor.pointIndex };
|
|
1501
|
+
const already =
|
|
1502
|
+
selectedAnchors.length > 0 &&
|
|
1503
|
+
selectedAnchors.some(
|
|
1504
|
+
(a) => a.subIndex === anchorRef.subIndex && a.pointIndex === anchorRef.pointIndex
|
|
1505
|
+
);
|
|
1506
|
+
let anchorsForDrag;
|
|
1507
|
+
let pendingCollapse = null;
|
|
1508
|
+
if (already && selectedAnchors.length > 1) {
|
|
1509
|
+
// Click-vs-drag: keep multi until up without drag collapses to this anchor.
|
|
1510
|
+
anchorsForDrag = selectedAnchors;
|
|
1511
|
+
pendingCollapse = anchorRef;
|
|
1512
|
+
} else {
|
|
1513
|
+
anchorsForDrag = [anchorRef];
|
|
1514
|
+
setSelectedAnchors([anchorRef]);
|
|
1515
|
+
}
|
|
1516
|
+
// Selection stays what was clicked; the drag carries the whole junction.
|
|
1517
|
+
anchorsForDrag = withCoincidentAnchors(pathData, soleIndex, anchorsForDrag);
|
|
1518
|
+
const starts = collectAnchorStarts(pathData, soleIndex, anchorsForDrag);
|
|
1519
|
+
strokeRef.current = {
|
|
1520
|
+
tool: 'path-select',
|
|
1521
|
+
mode: 'anchors',
|
|
1522
|
+
base: sprite,
|
|
1523
|
+
baseText,
|
|
1524
|
+
layerIndex: selection.layerIndex,
|
|
1525
|
+
frameIndex: selection.frameIndex,
|
|
1526
|
+
path: pathData,
|
|
1527
|
+
shapeIndex: soleIndex,
|
|
1528
|
+
anchors: anchorsForDrag,
|
|
1529
|
+
starts,
|
|
1530
|
+
pendingCollapse,
|
|
1531
|
+
startPoint: point,
|
|
1532
|
+
dragged: false,
|
|
1533
|
+
draftText: null,
|
|
1534
|
+
recorded: false,
|
|
1535
|
+
};
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
const intent = edgePressIntent(pathData, soleIndex, point.x, point.y);
|
|
1539
|
+
if (intent?.moving) {
|
|
1540
|
+
// An edge moves by its endpoints, so this is an anchor drag whose
|
|
1541
|
+
// anchors happen to be picked out by edges instead of clicked directly.
|
|
1542
|
+
setSelectedAnchors([]);
|
|
1543
|
+
const anchors = withCoincidentAnchors(
|
|
1544
|
+
pathData,
|
|
1545
|
+
soleIndex,
|
|
1546
|
+
anchorsForEdges(pathData, soleIndex, activeEdges.edges)
|
|
1547
|
+
);
|
|
1548
|
+
strokeRef.current = {
|
|
1549
|
+
tool: 'path-select',
|
|
1550
|
+
mode: 'anchors',
|
|
1551
|
+
base: sprite,
|
|
1552
|
+
baseText,
|
|
1553
|
+
layerIndex: selection.layerIndex,
|
|
1554
|
+
frameIndex: selection.frameIndex,
|
|
1555
|
+
path: pathData,
|
|
1556
|
+
shapeIndex: soleIndex,
|
|
1557
|
+
anchors,
|
|
1558
|
+
starts: collectAnchorStarts(pathData, soleIndex, anchors),
|
|
1559
|
+
pendingCollapse: null,
|
|
1560
|
+
startPoint: point,
|
|
1561
|
+
dragged: false,
|
|
1562
|
+
draftText: null,
|
|
1563
|
+
recorded: false,
|
|
1564
|
+
};
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
if (intent) {
|
|
1568
|
+
setSelectedAnchors([]);
|
|
1569
|
+
const pointStarts = collectShapesPointStarts(pathData, [soleIndex]);
|
|
1570
|
+
strokeRef.current = {
|
|
1571
|
+
tool: 'path-select',
|
|
1572
|
+
mode: 'shapes',
|
|
1573
|
+
base: sprite,
|
|
1574
|
+
baseText,
|
|
1575
|
+
layerIndex: selection.layerIndex,
|
|
1576
|
+
frameIndex: selection.frameIndex,
|
|
1577
|
+
path: pathData,
|
|
1578
|
+
shapeIndices: [soleIndex],
|
|
1579
|
+
pointStarts,
|
|
1580
|
+
startPoint: point,
|
|
1581
|
+
dragged: false,
|
|
1582
|
+
pendingCollapse: null,
|
|
1583
|
+
// Dragging an unpicked edge still moves the shape. Releasing without
|
|
1584
|
+
// moving means the press was aimed at the edge itself.
|
|
1585
|
+
pendingEdge: { ref: intent.ref, double: intent.double, additive: shift },
|
|
1586
|
+
draftText: null,
|
|
1587
|
+
recorded: false,
|
|
1588
|
+
};
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
const hit = hitTestShape(pathData, point.x, point.y, pathHitRadii.stroke);
|
|
1594
|
+
if (hit >= 0) {
|
|
1595
|
+
if (shift) {
|
|
1596
|
+
setSelectedShapeIndices((prev) => toggleIndexInList(prev, hit));
|
|
1597
|
+
setSelectedAnchors([]);
|
|
1598
|
+
strokeRef.current = null;
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
if (selectedShapeIndices.includes(hit)) {
|
|
1602
|
+
// Pointer-down on already-selected: don't collapse yet; drag moves all.
|
|
1603
|
+
setSelectedAnchors([]);
|
|
1604
|
+
strokeRef.current = {
|
|
1605
|
+
tool: 'path-select',
|
|
1606
|
+
mode: 'shapes',
|
|
1607
|
+
base: sprite,
|
|
1608
|
+
baseText,
|
|
1609
|
+
layerIndex: selection.layerIndex,
|
|
1610
|
+
frameIndex: selection.frameIndex,
|
|
1611
|
+
path: pathData,
|
|
1612
|
+
shapeIndices: selectedShapeIndices.slice(),
|
|
1613
|
+
pointStarts: collectShapesPointStarts(pathData, selectedShapeIndices),
|
|
1614
|
+
pendingCollapse: hit,
|
|
1615
|
+
startPoint: point,
|
|
1616
|
+
dragged: false,
|
|
1617
|
+
draftText: null,
|
|
1618
|
+
recorded: false,
|
|
1619
|
+
};
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
// Plain click on unselected → select only it; allow immediate drag.
|
|
1623
|
+
setSelectedShapeIndices([hit]);
|
|
1624
|
+
setSelectedAnchors([]);
|
|
1625
|
+
strokeRef.current = {
|
|
1626
|
+
tool: 'path-select',
|
|
1627
|
+
mode: 'shapes',
|
|
1628
|
+
base: sprite,
|
|
1629
|
+
baseText,
|
|
1630
|
+
layerIndex: selection.layerIndex,
|
|
1631
|
+
frameIndex: selection.frameIndex,
|
|
1632
|
+
path: pathData,
|
|
1633
|
+
shapeIndices: [hit],
|
|
1634
|
+
pointStarts: collectShapesPointStarts(pathData, [hit]),
|
|
1635
|
+
pendingCollapse: null,
|
|
1636
|
+
startPoint: point,
|
|
1637
|
+
dragged: false,
|
|
1638
|
+
draftText: null,
|
|
1639
|
+
recorded: false,
|
|
1640
|
+
};
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// Empty area: marquee (shift adds shapes; alt selects anchors on sole shape).
|
|
1645
|
+
const alt = !!event?.altKey;
|
|
1646
|
+
if (alt && soleIndex == null) {
|
|
1647
|
+
strokeRef.current = null;
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
if (alt && soleIndex != null) {
|
|
1651
|
+
if (!shift) setSelectedAnchors([]);
|
|
1652
|
+
strokeRef.current = {
|
|
1653
|
+
tool: 'path-select',
|
|
1654
|
+
mode: 'marquee',
|
|
1655
|
+
target: 'anchors',
|
|
1656
|
+
shapeIndex: soleIndex,
|
|
1657
|
+
additive: shift,
|
|
1658
|
+
base: sprite,
|
|
1659
|
+
baseText,
|
|
1660
|
+
layerIndex: selection.layerIndex,
|
|
1661
|
+
frameIndex: selection.frameIndex,
|
|
1662
|
+
startPoint: point,
|
|
1663
|
+
lastPoint: point,
|
|
1664
|
+
originAnchors: shift ? selectedAnchors.map((a) => ({ ...a })) : [],
|
|
1665
|
+
draftText: null,
|
|
1666
|
+
recorded: false,
|
|
1667
|
+
};
|
|
1668
|
+
const cell = { x: Math.floor(point.x), y: Math.floor(point.y) };
|
|
1669
|
+
setShapeMarquee({ x: cell.x, y: cell.y, w: 1, h: 1 });
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
setSelectedAnchors([]);
|
|
1673
|
+
strokeRef.current = {
|
|
1674
|
+
tool: 'path-select',
|
|
1675
|
+
mode: 'marquee',
|
|
1676
|
+
target: 'shapes',
|
|
1677
|
+
additive: shift,
|
|
1678
|
+
base: sprite,
|
|
1679
|
+
baseText,
|
|
1680
|
+
layerIndex: selection.layerIndex,
|
|
1681
|
+
frameIndex: selection.frameIndex,
|
|
1682
|
+
startPoint: point,
|
|
1683
|
+
lastPoint: point,
|
|
1684
|
+
originSelection: shift ? selectedShapeIndices.slice() : [],
|
|
1685
|
+
draftText: null,
|
|
1686
|
+
recorded: false,
|
|
1687
|
+
};
|
|
1688
|
+
if (!shift) {
|
|
1689
|
+
setSelectedShapeIndices([]);
|
|
1690
|
+
}
|
|
1691
|
+
const cell = { x: Math.floor(point.x), y: Math.floor(point.y) };
|
|
1692
|
+
setShapeMarquee({ x: cell.x, y: cell.y, w: 1, h: 1 });
|
|
1693
|
+
}
|
|
1694
|
+
function startBrushStroke(point, event) {
|
|
1695
|
+
if (brushMode === 'pen') {
|
|
1696
|
+
setPathBakePreview(null);
|
|
1697
|
+
startPenStroke(point, event);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
const { halfPixel, constrain } = brushModifiersFromEvent(event);
|
|
1701
|
+
const base = sprite;
|
|
1702
|
+
const baseText = serializeModel(sprite);
|
|
1703
|
+
const path = activePath;
|
|
1704
|
+
if (brushMode === 'freehand') {
|
|
1705
|
+
const ink = appendInkPoint([], point.x, point.y);
|
|
1706
|
+
brushInkRef.current = ink;
|
|
1707
|
+
setBrushInk(ink.slice());
|
|
1708
|
+
setBrushGhost(null);
|
|
1709
|
+
strokeRef.current = {
|
|
1710
|
+
tool: 'brush',
|
|
1711
|
+
brushMode: 'freehand',
|
|
1712
|
+
lastFitAt: 0,
|
|
1713
|
+
halfPixel,
|
|
1714
|
+
base,
|
|
1715
|
+
baseText,
|
|
1716
|
+
layerIndex: selection.layerIndex,
|
|
1717
|
+
frameIndex: selection.frameIndex,
|
|
1718
|
+
path,
|
|
1719
|
+
draftText: null,
|
|
1720
|
+
recorded: false,
|
|
1721
|
+
};
|
|
1722
|
+
refitBrushGhost(ink, halfPixel);
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
brushInkRef.current = [];
|
|
1726
|
+
setBrushInk([]);
|
|
1727
|
+
setBrushGhost(null);
|
|
1728
|
+
strokeRef.current = {
|
|
1729
|
+
tool: 'brush',
|
|
1730
|
+
brushMode,
|
|
1731
|
+
startPoint: point,
|
|
1732
|
+
lastPoint: point,
|
|
1733
|
+
halfPixel,
|
|
1734
|
+
constrain,
|
|
1735
|
+
base,
|
|
1736
|
+
baseText,
|
|
1737
|
+
layerIndex: selection.layerIndex,
|
|
1738
|
+
frameIndex: selection.frameIndex,
|
|
1739
|
+
path,
|
|
1740
|
+
draftText: null,
|
|
1741
|
+
recorded: false,
|
|
1742
|
+
};
|
|
1743
|
+
refitBrushPrimitiveGhost(point, point, halfPixel, constrain);
|
|
1744
|
+
}
|
|
1745
|
+
function startBendStroke(point) {
|
|
1746
|
+
const edge = hitTestPathEdge(activePath, point.x, point.y, pathHitRadii.edge);
|
|
1747
|
+
if (!edge) return;
|
|
1748
|
+
// Hit-any-edge: adopt the owning shape so inspector / Delete stay in sync.
|
|
1749
|
+
setSelectedShapeIndices([edge.shapeIndex]);
|
|
1750
|
+
setSelectedAnchors([]);
|
|
1751
|
+
strokeRef.current = {
|
|
1752
|
+
tool: 'bend',
|
|
1753
|
+
mode: 'bend',
|
|
1754
|
+
base: sprite,
|
|
1755
|
+
baseText: serializeModel(sprite),
|
|
1756
|
+
layerIndex: selection.layerIndex,
|
|
1757
|
+
frameIndex: selection.frameIndex,
|
|
1758
|
+
path: activePath,
|
|
1759
|
+
edge,
|
|
1760
|
+
startPoint: point,
|
|
1761
|
+
draftText: null,
|
|
1762
|
+
recorded: false,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
function startPathTool(point, event) {
|
|
1766
|
+
if (tool === 'fill') {
|
|
1767
|
+
const next = recolorTopShapeAt(activePath, point.x, point.y, activeKey);
|
|
1768
|
+
if (next !== activePath) commitPath(next);
|
|
1769
|
+
} else if (tool === 'brush') {
|
|
1770
|
+
startBrushStroke(point, event);
|
|
1771
|
+
} else if (tool === 'path-select') {
|
|
1772
|
+
startPathSelectStroke(point, event);
|
|
1773
|
+
} else if (tool === 'bend') {
|
|
1774
|
+
startBendStroke(point);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
450
1777
|
function startTool(event) {
|
|
451
1778
|
// Pull keyboard focus into this editor iframe so Cmd+Z reaches our undo
|
|
452
1779
|
// handler (bound on this iframe's window) instead of falling through to the
|
|
@@ -454,6 +1781,23 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
454
1781
|
// after clicking a non-focusable canvas, so we focus it explicitly. Done
|
|
455
1782
|
// before the early-return so even a click outside a cell claims focus.
|
|
456
1783
|
event.currentTarget.focus({ preventScroll: true });
|
|
1784
|
+
if (isPathLayer) {
|
|
1785
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1786
|
+
const point = eventToPoint(event, width, height);
|
|
1787
|
+
if (picking) {
|
|
1788
|
+
const cells = cellsAt(sprite, selection.layerIndex, selection.frameIndex);
|
|
1789
|
+
setActiveKey(
|
|
1790
|
+
sampleKey(cells, {
|
|
1791
|
+
x: Math.min(width - 1, Math.floor(point.x)),
|
|
1792
|
+
y: Math.min(height - 1, Math.floor(point.y)),
|
|
1793
|
+
})
|
|
1794
|
+
);
|
|
1795
|
+
setPicking(false);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
startPathTool(point, event);
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
457
1801
|
const point = eventToCell(event, width, height);
|
|
458
1802
|
if (!point) return;
|
|
459
1803
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
@@ -520,13 +1864,72 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
520
1864
|
function updateTool(event) {
|
|
521
1865
|
const stroke = strokeRef.current;
|
|
522
1866
|
if (!stroke || event.buttons !== 1) return;
|
|
1867
|
+
if (isPathLayer) {
|
|
1868
|
+
updateStroke(eventToPoint(event, width, height), event);
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
523
1871
|
const point = eventToCell(event, width, height);
|
|
524
|
-
if (point) updateStroke(point);
|
|
1872
|
+
if (point) updateStroke(point, event);
|
|
525
1873
|
}
|
|
526
1874
|
// Track the hovered cell for the non-destructive tool preview overlay. While a
|
|
527
1875
|
// stroke is active the preview is hidden so it never fights the live paint.
|
|
528
1876
|
function handlePointerMove(event) {
|
|
529
1877
|
updateTool(event);
|
|
1878
|
+
if (isPathLayer) {
|
|
1879
|
+
if (strokeRef.current) {
|
|
1880
|
+
setHoverPoint(null);
|
|
1881
|
+
setHoverInsertEdge(null);
|
|
1882
|
+
setHoverEdge(null);
|
|
1883
|
+
setFillHoverTarget(null);
|
|
1884
|
+
// Brush create-drags write pathBakePreview in updateTool; clearing it
|
|
1885
|
+
// here made the last setState(null) win every move and blank the bake.
|
|
1886
|
+
if (!isPathBrushStroke(strokeRef.current)) setPathBakePreview(null);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
const point = eventToPoint(event, width, height);
|
|
1890
|
+
const halfPixel = !!event.altKey;
|
|
1891
|
+
// Brush: snapped landing ghost (pen mode also previews edge-insert + bake).
|
|
1892
|
+
// Bend: highlight the edge that would bend.
|
|
1893
|
+
if (tool === 'brush' && brushMode === 'pen') {
|
|
1894
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
1895
|
+
const preview = previewPenLanding(activePath, sole, point.x, point.y, halfPixel, pathCellPull);
|
|
1896
|
+
setHoverPoint(preview.point);
|
|
1897
|
+
setHoverInsertEdge(preview.edge);
|
|
1898
|
+
setFillHoverTarget(null);
|
|
1899
|
+
updatePenHoverBake(point, halfPixel);
|
|
1900
|
+
} else if (tool === 'brush') {
|
|
1901
|
+
setHoverPoint(snapPenAnchor(point.x, point.y, halfPixel, pathCellPull));
|
|
1902
|
+
setHoverInsertEdge(null);
|
|
1903
|
+
setFillHoverTarget(null);
|
|
1904
|
+
setPathBakePreview(null);
|
|
1905
|
+
} else if (tool === 'bend') {
|
|
1906
|
+
setHoverPoint(null);
|
|
1907
|
+
setHoverInsertEdge(hitTestPathEdge(activePath, point.x, point.y, pathHitRadii.edge));
|
|
1908
|
+
setFillHoverTarget(null);
|
|
1909
|
+
setPathBakePreview(null);
|
|
1910
|
+
} else if (tool === 'fill') {
|
|
1911
|
+
setHoverPoint(null);
|
|
1912
|
+
setHoverInsertEdge(null);
|
|
1913
|
+
setFillHoverTarget(hitTestFillToolTarget(activePath, point.x, point.y));
|
|
1914
|
+
setPathBakePreview(null);
|
|
1915
|
+
} else if (tool === 'path-select') {
|
|
1916
|
+
const sole = selectedShapeIndices.length === 1 ? selectedShapeIndices[0] : -1;
|
|
1917
|
+
const hit = sole >= 0 ? hitTestShapeEdge(activePath, sole, point.x, point.y, pathHitRadii.edge) : null;
|
|
1918
|
+
setHoverEdge(
|
|
1919
|
+
hit ? { shapeIndex: sole, subIndex: hit.subIndex, edgeIndex: hit.edgeIndex } : null
|
|
1920
|
+
);
|
|
1921
|
+
setHoverPoint([point.x, point.y]);
|
|
1922
|
+
setHoverInsertEdge(null);
|
|
1923
|
+
setFillHoverTarget(null);
|
|
1924
|
+
setPathBakePreview(null);
|
|
1925
|
+
} else {
|
|
1926
|
+
setHoverPoint([point.x, point.y]);
|
|
1927
|
+
setHoverInsertEdge(null);
|
|
1928
|
+
setFillHoverTarget(null);
|
|
1929
|
+
setPathBakePreview(null);
|
|
1930
|
+
}
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
530
1933
|
if (strokeRef.current) {
|
|
531
1934
|
setHoverCell(null);
|
|
532
1935
|
return;
|
|
@@ -536,8 +1939,15 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
536
1939
|
}
|
|
537
1940
|
function clearHover() {
|
|
538
1941
|
setHoverCell(null);
|
|
1942
|
+
setHoverPoint(null);
|
|
1943
|
+
setHoverInsertEdge(null);
|
|
1944
|
+
setHoverEdge(null);
|
|
1945
|
+
setFillHoverTarget(null);
|
|
1946
|
+
// pointerleave still fires under capture when exiting the artboard box —
|
|
1947
|
+
// don't wipe an in-flight brush bake preview.
|
|
1948
|
+
if (!isPathBrushStroke(strokeRef.current)) setPathBakePreview(null);
|
|
539
1949
|
}
|
|
540
|
-
function updateStroke(point) {
|
|
1950
|
+
function updateStroke(point, event) {
|
|
541
1951
|
const stroke = strokeRef.current;
|
|
542
1952
|
if (!stroke) return;
|
|
543
1953
|
if (stroke.tool === 'select') {
|
|
@@ -552,7 +1962,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
552
1962
|
stroke.lastPoint = point;
|
|
553
1963
|
return;
|
|
554
1964
|
}
|
|
555
|
-
if (stroke.tool === 'brush') {
|
|
1965
|
+
if (stroke.tool === 'brush' && stroke.brushMode == null) {
|
|
556
1966
|
liveStroke(paintLine(stroke.draftCells, stroke.lastPoint, point, activeKey, brushSize));
|
|
557
1967
|
stroke.lastPoint = point;
|
|
558
1968
|
} else if (stroke.tool === 'erase') {
|
|
@@ -569,10 +1979,103 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
569
1979
|
const x = stroke.startOffset.x + (point.x - stroke.startPoint.x);
|
|
570
1980
|
const y = stroke.startOffset.y + (point.y - stroke.startPoint.y);
|
|
571
1981
|
liveSprite(setCellOffset(stroke.base, stroke.layerIndex, stroke.frameIndex, x, y));
|
|
1982
|
+
} else if (isPathBrushStroke(stroke)) {
|
|
1983
|
+
applyBrushModifiers(stroke, event);
|
|
1984
|
+
if (stroke.brushMode === 'freehand') {
|
|
1985
|
+
const ink = brushInkRef.current;
|
|
1986
|
+
appendInkPoint(ink, point.x, point.y);
|
|
1987
|
+
brushInkRef.current = ink;
|
|
1988
|
+
setBrushInk(ink.slice());
|
|
1989
|
+
const now = performance.now();
|
|
1990
|
+
if (now - (stroke.lastFitAt || 0) >= 30) {
|
|
1991
|
+
stroke.lastFitAt = now;
|
|
1992
|
+
refitBrushGhost(ink, stroke.halfPixel);
|
|
1993
|
+
}
|
|
1994
|
+
} else {
|
|
1995
|
+
stroke.lastPoint = point;
|
|
1996
|
+
refitBrushPrimitiveGhost(stroke.startPoint, point, stroke.halfPixel, stroke.constrain);
|
|
1997
|
+
}
|
|
1998
|
+
} else if (stroke.tool === 'path-select') {
|
|
1999
|
+
const dx = point.x - stroke.startPoint.x;
|
|
2000
|
+
const dy = point.y - stroke.startPoint.y;
|
|
2001
|
+
if (Math.hypot(dx, dy) > 0.2) stroke.dragged = true;
|
|
2002
|
+
if (stroke.mode === 'marquee') {
|
|
2003
|
+
stroke.lastPoint = point;
|
|
2004
|
+
setShapeMarquee(
|
|
2005
|
+
rectFromPoints(
|
|
2006
|
+
{ x: Math.floor(stroke.startPoint.x), y: Math.floor(stroke.startPoint.y) },
|
|
2007
|
+
{ x: Math.floor(point.x), y: Math.floor(point.y) },
|
|
2008
|
+
width,
|
|
2009
|
+
height
|
|
2010
|
+
)
|
|
2011
|
+
);
|
|
2012
|
+
} else if (stroke.mode === 'anchors') {
|
|
2013
|
+
liveSprite(
|
|
2014
|
+
withPathAt(
|
|
2015
|
+
stroke.base,
|
|
2016
|
+
stroke.layerIndex,
|
|
2017
|
+
stroke.frameIndex,
|
|
2018
|
+
applyAnchorsDrag(
|
|
2019
|
+
stroke.path,
|
|
2020
|
+
stroke.shapeIndex,
|
|
2021
|
+
stroke.anchors,
|
|
2022
|
+
stroke.starts,
|
|
2023
|
+
dx,
|
|
2024
|
+
dy,
|
|
2025
|
+
!!event?.altKey,
|
|
2026
|
+
pathCellPull
|
|
2027
|
+
)
|
|
2028
|
+
)
|
|
2029
|
+
);
|
|
2030
|
+
} else if (stroke.mode === 'shapes') {
|
|
2031
|
+
liveSprite(
|
|
2032
|
+
withPathAt(
|
|
2033
|
+
stroke.base,
|
|
2034
|
+
stroke.layerIndex,
|
|
2035
|
+
stroke.frameIndex,
|
|
2036
|
+
applyShapesDrag(
|
|
2037
|
+
stroke.path,
|
|
2038
|
+
stroke.shapeIndices,
|
|
2039
|
+
stroke.pointStarts,
|
|
2040
|
+
dx,
|
|
2041
|
+
dy,
|
|
2042
|
+
!!event?.altKey,
|
|
2043
|
+
pathCellPull
|
|
2044
|
+
)
|
|
2045
|
+
)
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
} else if (stroke.tool === 'bend') {
|
|
2049
|
+
updateBendStroke(point);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
function updateBendStroke(point) {
|
|
2053
|
+
const stroke = strokeRef.current;
|
|
2054
|
+
if (!stroke || stroke.tool !== 'bend') return;
|
|
2055
|
+
const nextPath = applyEdgeBow(
|
|
2056
|
+
stroke.path,
|
|
2057
|
+
stroke.edge.shapeIndex,
|
|
2058
|
+
stroke.edge.subIndex,
|
|
2059
|
+
stroke.edge.edgeIndex,
|
|
2060
|
+
point.x,
|
|
2061
|
+
point.y
|
|
2062
|
+
);
|
|
2063
|
+
if (nextPath) {
|
|
2064
|
+
stroke.currentPath = nextPath;
|
|
2065
|
+
liveSprite(withPathAt(stroke.base, stroke.layerIndex, stroke.frameIndex, nextPath));
|
|
572
2066
|
}
|
|
573
2067
|
}
|
|
574
|
-
function finishTool() {
|
|
2068
|
+
function finishTool(event) {
|
|
575
2069
|
const stroke = strokeRef.current;
|
|
2070
|
+
if (isPathBrushStroke(stroke)) {
|
|
2071
|
+
// Honor modifiers at release — stroke.constrain is only updated on move, so
|
|
2072
|
+
// releasing Shift before mouse-up would otherwise commit a loose oval.
|
|
2073
|
+
if (event && stroke.brushMode !== 'freehand') {
|
|
2074
|
+
applyBrushModifiers(stroke, event);
|
|
2075
|
+
}
|
|
2076
|
+
commitBrushGesture(stroke.lastPoint);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
576
2079
|
if (stroke && stroke.tool === 'select') {
|
|
577
2080
|
if (stroke.mode === 'new' && samePoint(stroke.startPoint, stroke.lastPoint)) {
|
|
578
2081
|
// A click with no drag deselects.
|
|
@@ -583,7 +2086,71 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
583
2086
|
// ended fully off-canvas.
|
|
584
2087
|
setMarquee((rect) => clampRectToCanvas(rect, width, height));
|
|
585
2088
|
}
|
|
2089
|
+
} else if (stroke && stroke.tool === 'path-select') {
|
|
2090
|
+
if (stroke.mode === 'marquee') {
|
|
2091
|
+
setShapeMarquee(null);
|
|
2092
|
+
if (stroke.dragged) {
|
|
2093
|
+
const rect = rectFromPoints(
|
|
2094
|
+
{ x: Math.floor(stroke.startPoint.x), y: Math.floor(stroke.startPoint.y) },
|
|
2095
|
+
{
|
|
2096
|
+
x: Math.floor((stroke.lastPoint ?? stroke.startPoint).x),
|
|
2097
|
+
y: Math.floor((stroke.lastPoint ?? stroke.startPoint).y),
|
|
2098
|
+
},
|
|
2099
|
+
width,
|
|
2100
|
+
height
|
|
2101
|
+
);
|
|
2102
|
+
if (stroke.target === 'anchors') {
|
|
2103
|
+
const hits = hitTestAnchorsInRect(activePath, stroke.shapeIndex, rect);
|
|
2104
|
+
if (stroke.additive) {
|
|
2105
|
+
const merged = new Map();
|
|
2106
|
+
for (const anchor of [...stroke.originAnchors, ...hits]) {
|
|
2107
|
+
merged.set(anchorKey(anchor), anchor);
|
|
2108
|
+
}
|
|
2109
|
+
setSelectedAnchors([...merged.values()]);
|
|
2110
|
+
} else {
|
|
2111
|
+
setSelectedAnchors(hits);
|
|
2112
|
+
}
|
|
2113
|
+
} else {
|
|
2114
|
+
const hits = hitTestShapesInRect(activePath, rect);
|
|
2115
|
+
if (stroke.additive) {
|
|
2116
|
+
const merged = new Set([...stroke.originSelection, ...hits]);
|
|
2117
|
+
setSelectedShapeIndices([...merged].sort((a, b) => a - b));
|
|
2118
|
+
} else {
|
|
2119
|
+
setSelectedShapeIndices(hits);
|
|
2120
|
+
}
|
|
2121
|
+
setSelectedAnchors([]);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
// Click on empty with no drag: selection already cleared on pointer-down
|
|
2125
|
+
// (additive click-without-drag leaves originSelection alone).
|
|
2126
|
+
} else if (stroke.mode === 'shapes' && stroke.pendingCollapse != null && !stroke.dragged) {
|
|
2127
|
+
setSelectedShapeIndices([stroke.pendingCollapse]);
|
|
2128
|
+
setSelectedAnchors([]);
|
|
2129
|
+
} else if (stroke.mode === 'shapes' && stroke.pendingEdge && !stroke.dragged) {
|
|
2130
|
+
const shapeIndex = stroke.shapeIndices[0];
|
|
2131
|
+
const { ref, double, additive } = stroke.pendingEdge;
|
|
2132
|
+
const picked = double
|
|
2133
|
+
? edgesOfSubpath(activePath, shapeIndex, ref.subIndex)
|
|
2134
|
+
: [ref];
|
|
2135
|
+
setSelectedEdges((prev) => {
|
|
2136
|
+
const keep = additive && prev?.shapeIndex === shapeIndex ? prev.edges : [];
|
|
2137
|
+
const merged = new Map(keep.map((e) => [edgeKey(e), e]));
|
|
2138
|
+
// Shift-clicking an edge that is already in the pick takes it back out.
|
|
2139
|
+
for (const e of picked) {
|
|
2140
|
+
const id = edgeKey(e);
|
|
2141
|
+
if (additive && !double && merged.has(id)) merged.delete(id);
|
|
2142
|
+
else merged.set(id, e);
|
|
2143
|
+
}
|
|
2144
|
+
return merged.size ? { shapeIndex, edges: [...merged.values()] } : null;
|
|
2145
|
+
});
|
|
2146
|
+
setSelectedAnchors([]);
|
|
2147
|
+
} else if (stroke.mode === 'anchors' && stroke.pendingCollapse != null && !stroke.dragged) {
|
|
2148
|
+
setSelectedAnchors([stroke.pendingCollapse]);
|
|
2149
|
+
}
|
|
586
2150
|
}
|
|
2151
|
+
// After the selection above has resolved against the path as dragged, so a
|
|
2152
|
+
// subpath absorbed here cannot shift the indices that resolution just used.
|
|
2153
|
+
settleDraggedPath(stroke);
|
|
587
2154
|
strokeRef.current = null;
|
|
588
2155
|
}
|
|
589
2156
|
|
|
@@ -596,6 +2163,9 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
596
2163
|
playing,
|
|
597
2164
|
setOnion,
|
|
598
2165
|
});
|
|
2166
|
+
function selectPaletteKey(key) {
|
|
2167
|
+
setActiveKey(key);
|
|
2168
|
+
}
|
|
599
2169
|
|
|
600
2170
|
return (
|
|
601
2171
|
<PxArtShell path={path} subtitle={pxArtSubtitle(sprite)} shell={headerShell} chrome={chrome} editorBodyRef={editorBodyRef}>
|
|
@@ -604,13 +2174,13 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
604
2174
|
<div className={tl.artboardRegion} style={{ position: 'relative' }}>
|
|
605
2175
|
<div className={styles.artboardToolColumn}>
|
|
606
2176
|
<PixelToolStrip
|
|
607
|
-
tools={PIXEL_TOOLS}
|
|
2177
|
+
tools={isPathLayer ? PATH_TOOLS : PIXEL_TOOLS}
|
|
608
2178
|
tool={tool}
|
|
609
2179
|
onSelectTool={(id) => {
|
|
610
2180
|
setPicking(false);
|
|
611
2181
|
setTool(id);
|
|
612
2182
|
}}
|
|
613
|
-
ariaLabel=
|
|
2183
|
+
ariaLabel={isPathLayer ? 'Path tools' : 'Sprite tools'}
|
|
614
2184
|
/>
|
|
615
2185
|
{/* Undo/redo in their own band below the tools (the flow mount shows
|
|
616
2186
|
no editor header, so this is the on-screen affordance here). */}
|
|
@@ -644,9 +2214,10 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
644
2214
|
height={height}
|
|
645
2215
|
onResize={(w, h) => history.commit(serializeModel(resizeSprite(sprite, w, h)))}
|
|
646
2216
|
/>
|
|
647
|
-
<
|
|
648
|
-
|
|
649
|
-
|
|
2217
|
+
<FinishBar
|
|
2218
|
+
renderer={sprite.renderer}
|
|
2219
|
+
cornerRadius={sprite.cornerRadius}
|
|
2220
|
+
onChange={(finish) => history.commit(serializeModel(setFinish(sprite, finish)))}
|
|
650
2221
|
/>
|
|
651
2222
|
</div>
|
|
652
2223
|
</div>
|
|
@@ -662,7 +2233,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
662
2233
|
canvasRef={canvasRef}
|
|
663
2234
|
smoothRef={smoothRef}
|
|
664
2235
|
overlayRef={overlayRef}
|
|
665
|
-
|
|
2236
|
+
smoothPreview={displayFinish(sprite).smooth}
|
|
2237
|
+
style={{
|
|
2238
|
+
...artboardCanvasStyle,
|
|
2239
|
+
...latticeBackground(isPathLayer, artboardCanvasStyle, width, height),
|
|
2240
|
+
cursor,
|
|
2241
|
+
}}
|
|
666
2242
|
handlers={{
|
|
667
2243
|
onPointerDown: startTool,
|
|
668
2244
|
onPointerMove: handlePointerMove,
|
|
@@ -682,11 +2258,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
682
2258
|
paletteOpen={paletteOpen}
|
|
683
2259
|
onTogglePalette={togglePalette}
|
|
684
2260
|
colorButtonRef={colorButtonRef}
|
|
685
|
-
onSelectKey={
|
|
2261
|
+
onSelectKey={selectPaletteKey}
|
|
686
2262
|
paletteKeys={EDITOR_KEYS}
|
|
687
2263
|
palette={EDITOR_PALETTE}
|
|
688
2264
|
moveActions={moveActions}
|
|
689
2265
|
selectActions={selectActions}
|
|
2266
|
+
pathActions={isPathLayer ? pathActions : null}
|
|
690
2267
|
/>
|
|
691
2268
|
</div>
|
|
692
2269
|
<div style={PXART_ZOOM_CONTROLS_STYLE}>
|
|
@@ -705,6 +2282,7 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
705
2282
|
</div>
|
|
706
2283
|
<PxArtTimeline
|
|
707
2284
|
sprite={sprite}
|
|
2285
|
+
text={text}
|
|
708
2286
|
selection={selection}
|
|
709
2287
|
playing={playing}
|
|
710
2288
|
onion={onion}
|
|
@@ -714,11 +2292,12 @@ export function PxArtEditor({ path, text, onChange, ...chrome }) {
|
|
|
714
2292
|
<PxArtInspectorDock
|
|
715
2293
|
toolState={toolState}
|
|
716
2294
|
activeKey={activeKey}
|
|
717
|
-
onSelectKey={
|
|
2295
|
+
onSelectKey={selectPaletteKey}
|
|
718
2296
|
paletteKeys={EDITOR_KEYS}
|
|
719
2297
|
palette={EDITOR_PALETTE}
|
|
720
2298
|
moveActions={moveActions}
|
|
721
2299
|
selectActions={selectActions}
|
|
2300
|
+
pathActions={isPathLayer ? pathActions : null}
|
|
722
2301
|
/>
|
|
723
2302
|
</div>
|
|
724
2303
|
</PxArtShell>
|
|
@@ -733,8 +2312,8 @@ function buildActions({ sprite, selection, history, setSelection, setPlaying, pl
|
|
|
733
2312
|
const commit = (next) => history.commit(serializeModel(next));
|
|
734
2313
|
return {
|
|
735
2314
|
selectCell: (li, fi) => setSelection({ layerIndex: li, frameIndex: fi }),
|
|
736
|
-
addLayer: (li) => {
|
|
737
|
-
commit(addLayer(sprite, li));
|
|
2315
|
+
addLayer: (li, kind = 'pixel') => {
|
|
2316
|
+
commit(addLayer(sprite, li, kind));
|
|
738
2317
|
setSelection({ layerIndex: li + 1, frameIndex });
|
|
739
2318
|
},
|
|
740
2319
|
deleteLayer: (li) => {
|
|
@@ -750,6 +2329,7 @@ function buildActions({ sprite, selection, history, setSelection, setPlaying, pl
|
|
|
750
2329
|
setSelection({ layerIndex: li + dir, frameIndex });
|
|
751
2330
|
},
|
|
752
2331
|
patchLayer: (li, patch) => commit(patchLayer(sprite, li, patch)),
|
|
2332
|
+
setLayerKind: (li, kind) => commit(setLayerKind(sprite, li, kind)),
|
|
753
2333
|
addFrame: (fi) => {
|
|
754
2334
|
commit(addFrame(sprite, fi));
|
|
755
2335
|
setSelection({ layerIndex, frameIndex: fi + 1 });
|
|
@@ -822,6 +2402,7 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
|
|
|
822
2402
|
p.activeKey,
|
|
823
2403
|
p.selection.layerIndex,
|
|
824
2404
|
p.selection.frameIndex,
|
|
2405
|
+
p.pathBakePreview,
|
|
825
2406
|
]);
|
|
826
2407
|
}
|
|
827
2408
|
|
|
@@ -836,12 +2417,13 @@ function useArtboardRender(canvasRef, text, sprite, previewIndex, onion, playing
|
|
|
836
2417
|
// triggers a re-render at the new supersample scale, not just a content
|
|
837
2418
|
// change. The supersample factor auto-fits to the displayed size (capped
|
|
838
2419
|
// between 4x and 16x).
|
|
839
|
-
function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth) {
|
|
2420
|
+
function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidth, bakePreview) {
|
|
840
2421
|
const cornerRadius = sprite?.cornerRadius ?? 0;
|
|
2422
|
+
const vector = isVectorRenderer(sprite?.renderer);
|
|
841
2423
|
useEffect(() => {
|
|
842
2424
|
const canvas = smoothRef.current;
|
|
843
2425
|
if (!canvas) return;
|
|
844
|
-
if (!sprite || cornerRadius
|
|
2426
|
+
if (!sprite || (!vector && cornerRadius === 0)) {
|
|
845
2427
|
canvas.width = 0;
|
|
846
2428
|
canvas.height = 0;
|
|
847
2429
|
return;
|
|
@@ -849,20 +2431,42 @@ function useSmoothPreviewRender(smoothRef, text, sprite, frameIndex, displayWidt
|
|
|
849
2431
|
const cssWidth = canvas.clientWidth || sprite.resolution.width;
|
|
850
2432
|
const dpr = window.devicePixelRatio || 1;
|
|
851
2433
|
const autoScale = Math.min(16, Math.max(4, Math.round((cssWidth * dpr) / sprite.resolution.width)));
|
|
852
|
-
|
|
853
|
-
|
|
2434
|
+
if (vector) {
|
|
2435
|
+
renderSmoothCompositeFrame(sprite, frameIndex, canvas, { scale: autoScale });
|
|
2436
|
+
} else {
|
|
2437
|
+
renderSmoothSpriteFrame(sprite, frameIndex, canvas, { scale: autoScale, cornerRadius });
|
|
2438
|
+
}
|
|
2439
|
+
}, [text, frameIndex, smoothRef, cornerRadius, vector, displayWidth, bakePreview]);
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// Snap-lattice dot field for path layers — always on the artboard frame so it
|
|
2443
|
+
// stays consistent across Pixel / ¼ / ½ / Smooth (shows through transparent
|
|
2444
|
+
// canvas areas; the inline background overrides the frame checkerboard).
|
|
2445
|
+
function latticeBackground(isPathLayer, canvasStyle, gridWidth, gridHeight) {
|
|
2446
|
+
if (!isPathLayer || !canvasStyle) return {};
|
|
2447
|
+
const pitchX = parseFloat(canvasStyle.width) / gridWidth;
|
|
2448
|
+
const pitchY = parseFloat(canvasStyle.height) / gridHeight;
|
|
2449
|
+
if (!Number.isFinite(pitchX) || !Number.isFinite(pitchY)) return {};
|
|
2450
|
+
// Dots centered on cell centers (half-cell offset from the corner lattice).
|
|
2451
|
+
const dots =
|
|
2452
|
+
Math.min(pitchX, pitchY) >= 5
|
|
2453
|
+
? `radial-gradient(circle, rgba(255, 255, 255, 0.28) 1px, transparent 1.6px) ` +
|
|
2454
|
+
`0 0 / ${pitchX}px ${pitchY}px, `
|
|
2455
|
+
: '';
|
|
2456
|
+
return { background: `${dots}#121212` };
|
|
854
2457
|
}
|
|
855
2458
|
|
|
856
2459
|
// Window-scoped tool keyboard shortcuts, mounted only while the editor is.
|
|
857
2460
|
function useToolShortcuts(ctx) {
|
|
858
|
-
const { tool, picking, setTool, setPicking, setBrushSize, setEraseSize } =
|
|
2461
|
+
const { tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer } =
|
|
2462
|
+
ctx;
|
|
859
2463
|
useEffect(() => {
|
|
860
2464
|
function onKeyDown(event) {
|
|
861
2465
|
if (handleToolShortcut(event, ctx)) event.preventDefault();
|
|
862
2466
|
}
|
|
863
2467
|
window.addEventListener('keydown', onKeyDown);
|
|
864
2468
|
return () => window.removeEventListener('keydown', onKeyDown);
|
|
865
|
-
}, [tool, picking, setTool, setPicking, setBrushSize, setEraseSize]);
|
|
2469
|
+
}, [tool, picking, brushMode, setTool, setPicking, setBrushSize, setEraseSize, setBrushMode, isPathLayer]);
|
|
866
2470
|
}
|
|
867
2471
|
|
|
868
2472
|
function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
|
|
@@ -875,27 +2479,26 @@ function renderArtboard(canvas, sprite, frameIndex, onion, playing, preview) {
|
|
|
875
2479
|
}
|
|
876
2480
|
}
|
|
877
2481
|
ctx.globalAlpha = 1;
|
|
878
|
-
//
|
|
879
|
-
// BEHIND this one (see useSmoothPreviewRender); leaving this canvas
|
|
880
|
-
// transparent
|
|
881
|
-
//
|
|
882
|
-
|
|
2482
|
+
// Any non-Pixel finish is drawn by the display-resolution .drawingSmooth
|
|
2483
|
+
// canvas BEHIND this one (see useSmoothPreviewRender); leaving this canvas
|
|
2484
|
+
// transparent lets that show through while pointer math, the marquee, and the
|
|
2485
|
+
// tool-preview ghost below all keep working in native grid units.
|
|
2486
|
+
const { smooth } = displayFinish(sprite);
|
|
2487
|
+
if (!smooth) {
|
|
883
2488
|
ctx.drawImage(frameCanvas(sprite, frameIndex), 0, 0);
|
|
884
2489
|
}
|
|
885
|
-
|
|
2490
|
+
// Smooth-finish previews render on the display-resolution overlay so they
|
|
2491
|
+
// match the supersampled corner kernel instead of blurring on upscale.
|
|
2492
|
+
if (preview && !smooth) drawToolPreview(ctx, canvas, preview);
|
|
886
2493
|
}
|
|
887
2494
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
// outline is crisp and bold rather than sub-pixel on the upscaled artboard.
|
|
891
|
-
// `rect` is in canvas cells; null clears the overlay.
|
|
892
|
-
function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
|
|
893
|
-
if (!canvas || !resolution) return;
|
|
2495
|
+
function setupOverlayCanvas(canvas, resolution) {
|
|
2496
|
+
if (!canvas || !resolution) return null;
|
|
894
2497
|
const ctx = canvas.getContext('2d');
|
|
895
|
-
if (!ctx) return;
|
|
2498
|
+
if (!ctx) return null;
|
|
896
2499
|
const cssW = canvas.clientWidth;
|
|
897
2500
|
const cssH = canvas.clientHeight;
|
|
898
|
-
if (!cssW || !cssH) return;
|
|
2501
|
+
if (!cssW || !cssH) return null;
|
|
899
2502
|
const dpr = window.devicePixelRatio || 1;
|
|
900
2503
|
const bw = Math.round(cssW * dpr);
|
|
901
2504
|
const bh = Math.round(cssH * dpr);
|
|
@@ -903,21 +2506,36 @@ function renderMarqueeOverlay(canvas, resolution, rect, antsOffset) {
|
|
|
903
2506
|
if (canvas.height !== bh) canvas.height = bh;
|
|
904
2507
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
905
2508
|
ctx.clearRect(0, 0, bw, bh);
|
|
906
|
-
|
|
2509
|
+
return {
|
|
2510
|
+
ctx,
|
|
2511
|
+
dpr,
|
|
2512
|
+
sx: bw / resolution.width,
|
|
2513
|
+
sy: bh / resolution.height,
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
// Display-resolution overlay for pixel layers: smooth tool preview + marquee.
|
|
2518
|
+
function renderPixelOverlay(canvas, resolution, { marquee, antsOffset, preview, cornerRadius }) {
|
|
2519
|
+
const overlay = setupOverlayCanvas(canvas, resolution);
|
|
2520
|
+
if (!overlay) return;
|
|
2521
|
+
const { ctx, dpr, sx, sy } = overlay;
|
|
2522
|
+
const scale = (sx + sy) / 2;
|
|
2523
|
+
|
|
2524
|
+
if (preview && cornerRadius !== 0) {
|
|
2525
|
+
drawSmoothToolPreview(ctx, preview, { scale, sx, sy, dpr, cornerRadius });
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (!marquee) return;
|
|
907
2529
|
|
|
908
|
-
const sx = bw / resolution.width;
|
|
909
|
-
const sy = bh / resolution.height;
|
|
910
2530
|
const lineWidth = Math.max(2, Math.round(2 * dpr));
|
|
911
2531
|
const dash = Math.max(5, Math.round(5 * dpr));
|
|
912
|
-
// Advance ~2 device px per tick (not a whole dash) so the ants crawl smoothly
|
|
913
|
-
// instead of teleporting a full dash each frame.
|
|
914
2532
|
const step = Math.max(1, Math.round(dpr));
|
|
915
2533
|
const move = (antsOffset * step) % (dash * 2);
|
|
916
2534
|
const half = lineWidth / 2;
|
|
917
|
-
const x =
|
|
918
|
-
const y =
|
|
919
|
-
const w = Math.max(lineWidth,
|
|
920
|
-
const h = Math.max(lineWidth,
|
|
2535
|
+
const x = marquee.x * sx + half;
|
|
2536
|
+
const y = marquee.y * sy + half;
|
|
2537
|
+
const w = Math.max(lineWidth, marquee.w * sx - lineWidth);
|
|
2538
|
+
const h = Math.max(lineWidth, marquee.h * sy - lineWidth);
|
|
921
2539
|
|
|
922
2540
|
ctx.lineWidth = lineWidth;
|
|
923
2541
|
ctx.setLineDash([dash, dash]);
|
|
@@ -994,7 +2612,21 @@ function onionTint(src, color) {
|
|
|
994
2612
|
|
|
995
2613
|
// Single-letter tool picks. The eyedropper ('i') is handled separately because
|
|
996
2614
|
// it toggles the transient picker rather than swapping the sidebar tool.
|
|
997
|
-
|
|
2615
|
+
// Path layers: V select, B brush/freehand, P/L/R/O brush sub-modes, N bend, G fill.
|
|
2616
|
+
const PIXEL_SHORTCUT_TOOLS = {
|
|
2617
|
+
b: 'brush',
|
|
2618
|
+
g: 'fill',
|
|
2619
|
+
s: 'shape',
|
|
2620
|
+
m: 'select',
|
|
2621
|
+
e: 'erase',
|
|
2622
|
+
v: 'move-all',
|
|
2623
|
+
};
|
|
2624
|
+
const PATH_SHORTCUT_TOOLS = {
|
|
2625
|
+
v: 'path-select',
|
|
2626
|
+
b: 'brush',
|
|
2627
|
+
n: 'bend',
|
|
2628
|
+
g: 'fill',
|
|
2629
|
+
};
|
|
998
2630
|
|
|
999
2631
|
// True when focus is on a text-entry control, so typing there isn't hijacked.
|
|
1000
2632
|
function isTypingTarget(node) {
|
|
@@ -1014,7 +2646,7 @@ function stepBrushSize(current, delta) {
|
|
|
1014
2646
|
// Resize the active tool with [ / ]. Only brush/erase have a size; other tools
|
|
1015
2647
|
// make this a no-op (returns false so the caller leaves the key alone).
|
|
1016
2648
|
function adjustToolSize(delta, ctx) {
|
|
1017
|
-
if (ctx.tool === 'brush') {
|
|
2649
|
+
if (ctx.tool === 'brush' && !ctx.isPathLayer) {
|
|
1018
2650
|
ctx.setBrushSize((prev) => stepBrushSize(prev, delta));
|
|
1019
2651
|
return true;
|
|
1020
2652
|
}
|
|
@@ -1031,7 +2663,25 @@ function handleToolShortcut(event, ctx) {
|
|
|
1031
2663
|
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
1032
2664
|
if (isTypingTarget(event.target || document.activeElement)) return false;
|
|
1033
2665
|
const key = event.key.toLowerCase();
|
|
1034
|
-
|
|
2666
|
+
if (ctx.isPathLayer) {
|
|
2667
|
+
const brushMode = PATH_BRUSH_MODE_BY_KEY[key];
|
|
2668
|
+
if (brushMode) {
|
|
2669
|
+
ctx.setPicking(false);
|
|
2670
|
+
ctx.setTool('brush');
|
|
2671
|
+
ctx.setBrushMode?.(brushMode);
|
|
2672
|
+
return true;
|
|
2673
|
+
}
|
|
2674
|
+
if (key === 'b') {
|
|
2675
|
+
ctx.setPicking(false);
|
|
2676
|
+
if (ctx.tool === 'brush' && ctx.brushMode !== 'freehand') {
|
|
2677
|
+
ctx.setBrushMode?.('freehand');
|
|
2678
|
+
return true;
|
|
2679
|
+
}
|
|
2680
|
+
ctx.setTool('brush');
|
|
2681
|
+
return true;
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
const toolId = (ctx.isPathLayer ? PATH_SHORTCUT_TOOLS : PIXEL_SHORTCUT_TOOLS)[key];
|
|
1035
2685
|
if (toolId) {
|
|
1036
2686
|
ctx.setPicking(false);
|
|
1037
2687
|
ctx.setTool(toolId);
|
|
@@ -1138,6 +2788,85 @@ function fillCellList(ctx, cells, color, alpha) {
|
|
|
1138
2788
|
for (const cell of cells) ctx.fillRect(cell.x, cell.y, 1, 1);
|
|
1139
2789
|
}
|
|
1140
2790
|
|
|
2791
|
+
function dabFootprintCells(cell, size) {
|
|
2792
|
+
const cells = [];
|
|
2793
|
+
forEachDab(size, (dx, dy) => cells.push({ x: cell.x + dx, y: cell.y + dy }));
|
|
2794
|
+
return cells;
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
function dabFootprintBounds(cell, size) {
|
|
2798
|
+
let minX = Infinity;
|
|
2799
|
+
let minY = Infinity;
|
|
2800
|
+
let maxX = -Infinity;
|
|
2801
|
+
let maxY = -Infinity;
|
|
2802
|
+
forEachDab(size, (dx, dy) => {
|
|
2803
|
+
minX = Math.min(minX, dx);
|
|
2804
|
+
minY = Math.min(minY, dy);
|
|
2805
|
+
maxX = Math.max(maxX, dx);
|
|
2806
|
+
maxY = Math.max(maxY, dy);
|
|
2807
|
+
});
|
|
2808
|
+
if (minX === Infinity) return null;
|
|
2809
|
+
return { minX, minY, maxX, maxY };
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
// Smooth-finish tool ghost at display resolution (overlay canvas).
|
|
2813
|
+
function drawSmoothToolPreview(ctx, preview, { scale, sx, sy, dpr, cornerRadius }) {
|
|
2814
|
+
const lineWidth = Math.max(2, Math.round(2 * dpr));
|
|
2815
|
+
const half = lineWidth / 2;
|
|
2816
|
+
|
|
2817
|
+
if (preview.kind === 'footprint') {
|
|
2818
|
+
renderSmoothCellsPreview(ctx, dabFootprintCells(preview.cell, preview.size), preview.color, {
|
|
2819
|
+
scale,
|
|
2820
|
+
cornerRadius,
|
|
2821
|
+
alpha: PREVIEW_PAINT_ALPHA,
|
|
2822
|
+
});
|
|
2823
|
+
} else if (preview.kind === 'erase') {
|
|
2824
|
+
renderSmoothCellsPreview(
|
|
2825
|
+
ctx,
|
|
2826
|
+
dabFootprintCells(preview.cell, preview.size),
|
|
2827
|
+
'#ffffff',
|
|
2828
|
+
{ scale, cornerRadius, alpha: PREVIEW_WASH_ALPHA },
|
|
2829
|
+
);
|
|
2830
|
+
const bounds = dabFootprintBounds(preview.cell, preview.size);
|
|
2831
|
+
if (bounds) {
|
|
2832
|
+
ctx.save();
|
|
2833
|
+
ctx.globalAlpha = 0.85;
|
|
2834
|
+
ctx.strokeStyle = '#1c1c1c';
|
|
2835
|
+
ctx.lineWidth = lineWidth;
|
|
2836
|
+
ctx.strokeRect(
|
|
2837
|
+
(preview.cell.x + bounds.minX) * sx + half,
|
|
2838
|
+
(preview.cell.y + bounds.minY) * sy + half,
|
|
2839
|
+
(bounds.maxX - bounds.minX + 1) * sx - lineWidth,
|
|
2840
|
+
(bounds.maxY - bounds.minY + 1) * sy - lineWidth,
|
|
2841
|
+
);
|
|
2842
|
+
ctx.restore();
|
|
2843
|
+
}
|
|
2844
|
+
} else if (preview.kind === 'region') {
|
|
2845
|
+
renderSmoothCellsPreview(ctx, preview.cells, preview.color, {
|
|
2846
|
+
scale,
|
|
2847
|
+
cornerRadius,
|
|
2848
|
+
alpha: PREVIEW_HIGHLIGHT_ALPHA,
|
|
2849
|
+
});
|
|
2850
|
+
} else if (preview.kind === 'cell') {
|
|
2851
|
+
renderSmoothCellsPreview(ctx, [preview.cell], '#ffffff', {
|
|
2852
|
+
scale,
|
|
2853
|
+
cornerRadius,
|
|
2854
|
+
alpha: PREVIEW_HIGHLIGHT_ALPHA,
|
|
2855
|
+
});
|
|
2856
|
+
ctx.save();
|
|
2857
|
+
ctx.globalAlpha = 0.9;
|
|
2858
|
+
ctx.strokeStyle = '#1c1c1c';
|
|
2859
|
+
ctx.lineWidth = lineWidth;
|
|
2860
|
+
ctx.strokeRect(
|
|
2861
|
+
preview.cell.x * sx + half,
|
|
2862
|
+
preview.cell.y * sy + half,
|
|
2863
|
+
sx - lineWidth,
|
|
2864
|
+
sy - lineWidth,
|
|
2865
|
+
);
|
|
2866
|
+
ctx.restore();
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
1141
2870
|
// Render-only: paint the ghost overlay for the current tool onto the artboard
|
|
1142
2871
|
// after the sprite. Never mutates the sprite, history, or serialized output.
|
|
1143
2872
|
function drawToolPreview(ctx, canvas, preview) {
|