castle-web-cli 0.4.115 → 0.4.117
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 +103 -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
|
@@ -9,3 +9,16 @@ export function eventToCell(event, width, height) {
|
|
|
9
9
|
if (x < 0 || y < 0 || x >= width || y >= height) return null;
|
|
10
10
|
return { x, y };
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// Fractional cell coordinates for vector-path tools. Unlike eventToCell,
|
|
14
|
+
// drags clamp to the canvas edge instead of being rejected.
|
|
15
|
+
export function eventToPoint(event, width, height) {
|
|
16
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
17
|
+
const x = ((event.clientX - rect.left) / rect.width) * width;
|
|
18
|
+
const y = ((event.clientY - rect.top) / rect.height) * height;
|
|
19
|
+
return { x: clamp(x, 0, width), y: clamp(y, 0, height) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clamp(value, lo, hi) {
|
|
23
|
+
return Math.min(hi, Math.max(lo, value));
|
|
24
|
+
}
|
|
@@ -49,14 +49,14 @@ export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
|
|
|
49
49
|
// "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
|
|
50
50
|
// above it can stay at native resolution — pointer math and tool-preview overlays
|
|
51
51
|
// are unaffected by the render mode.
|
|
52
|
-
export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers }) {
|
|
52
|
+
export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers, smoothPreview = false }) {
|
|
53
53
|
return (
|
|
54
54
|
<div className={styles.drawingArtboard}>
|
|
55
55
|
<div className={styles.drawingArtboardFrame} style={style}>
|
|
56
56
|
<canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
|
|
57
57
|
<canvas
|
|
58
58
|
ref={canvasRef}
|
|
59
|
-
className={styles.drawingCanvas}
|
|
59
|
+
className={smoothPreview ? styles.drawingCanvasSmooth : styles.drawingCanvas}
|
|
60
60
|
// Focusable so a pointer-down can pull keyboard focus into this editor
|
|
61
61
|
// iframe (see startTool). Without it, Safari often leaves keyboard focus
|
|
62
62
|
// on the top document and Cmd+Z falls through to the browser's own undo.
|
|
@@ -45,8 +45,10 @@ export function forEachLinePoint(from, to, visit) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
// The shape tools (square/circle/triangle) are bounded by the axis-aligned box
|
|
48
|
-
// the drag sweeps out, so they share this normalized corner unpacking.
|
|
49
|
-
|
|
48
|
+
// the drag sweeps out, so they share this normalized corner unpacking. Exported
|
|
49
|
+
// for the path-layer brush primitives, which sweep the same box in continuous
|
|
50
|
+
// cell coords rather than integer cells.
|
|
51
|
+
export function shapeBounds(from, to) {
|
|
50
52
|
return {
|
|
51
53
|
x0: Math.min(from.x, to.x),
|
|
52
54
|
x1: Math.max(from.x, to.x),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
2
|
import { createPortal } from 'react-dom';
|
|
3
|
-
import { RESOLUTION_STEPS } from '../engine/pxart';
|
|
3
|
+
import { GRID_RENDERER, RESOLUTION_STEPS, VECTOR_RENDERER } from '../engine/pxart';
|
|
4
4
|
import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
|
|
5
5
|
|
|
6
6
|
// Brush/erase diameters offered by the pixel editors' size sliders.
|
|
@@ -17,6 +17,15 @@ export const PIXEL_TOOLS = [
|
|
|
17
17
|
{ id: 'erase', label: 'Erase', icon: 'eraser' },
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
+
// Path-layer tools: brush (pen / freehand / primitives), select (move/z-order),
|
|
21
|
+
// bend (drag bows on any hit edge), fill (recolor fill or stroke).
|
|
22
|
+
export const PATH_TOOLS = [
|
|
23
|
+
{ id: 'brush', label: 'Brush', icon: 'pencil', shortcut: 'B' },
|
|
24
|
+
{ id: 'fill', label: 'Fill', icon: 'fill', shortcut: 'G' },
|
|
25
|
+
{ id: 'path-select', label: 'Select', icon: 'location-arrow', shortcut: 'V' },
|
|
26
|
+
{ id: 'bend', label: 'Bend', icon: 'bezier-curve', shortcut: 'N' },
|
|
27
|
+
];
|
|
28
|
+
|
|
20
29
|
// Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
|
|
21
30
|
// the diagonal, square the rectangle, circle the inscribed ellipse, triangle an
|
|
22
31
|
// isosceles triangle. `filled` (a separate toggle) applies to the closed three.
|
|
@@ -27,6 +36,21 @@ export const SHAPE_MODES = [
|
|
|
27
36
|
{ id: 'triangle', label: 'Triangle', icon: 'triangle' },
|
|
28
37
|
];
|
|
29
38
|
|
|
39
|
+
// Sub-modes for the path brush tool: click-to-place pen, freehand fit, drag primitives.
|
|
40
|
+
export const BRUSH_MODES = [
|
|
41
|
+
{ id: 'freehand', label: 'Freehand', icon: 'paintbrush' },
|
|
42
|
+
{ id: 'pen', label: 'Pen', icon: 'pen-nib', shortcut: 'P' },
|
|
43
|
+
{ id: 'line', label: 'Line', icon: 'slash', shortcut: 'L' },
|
|
44
|
+
{ id: 'rect', label: 'Rectangle', icon: 'square', shortcut: 'R' },
|
|
45
|
+
{ id: 'circle', label: 'Ellipse', icon: 'circle', shortcut: 'O' },
|
|
46
|
+
{ id: 'triangle', label: 'Triangle', icon: 'triangle' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** Path-layer brush sub-mode picks keyed by shortcut letter (lowercase). */
|
|
50
|
+
export const PATH_BRUSH_MODE_BY_KEY = Object.fromEntries(
|
|
51
|
+
BRUSH_MODES.filter((mode) => mode.shortcut).map((mode) => [mode.shortcut.toLowerCase(), mode.id])
|
|
52
|
+
);
|
|
53
|
+
|
|
30
54
|
// Momentary transform actions the move tool exposes (it has no persistent
|
|
31
55
|
// settings). Each `id` maps to a handler on the editor's `moveActions`.
|
|
32
56
|
export const MOVE_ACTIONS = [
|
|
@@ -54,6 +78,7 @@ export function usePixelToolState() {
|
|
|
54
78
|
const [eraseFillMode, setEraseFillMode] = useState(false);
|
|
55
79
|
const [fillSwapMode, setFillSwapMode] = useState(false);
|
|
56
80
|
const [shapeMode, setShapeMode] = useState('line');
|
|
81
|
+
const [brushMode, setBrushMode] = useState('freehand');
|
|
57
82
|
const [shapeFill, setShapeFill] = useState(false);
|
|
58
83
|
return {
|
|
59
84
|
tool,
|
|
@@ -70,6 +95,8 @@ export function usePixelToolState() {
|
|
|
70
95
|
setFillSwapMode,
|
|
71
96
|
shapeMode,
|
|
72
97
|
setShapeMode,
|
|
98
|
+
brushMode,
|
|
99
|
+
setBrushMode,
|
|
73
100
|
shapeFill,
|
|
74
101
|
setShapeFill,
|
|
75
102
|
};
|
|
@@ -84,7 +111,9 @@ export function PixelToolStrip({ tools, tool, onSelectTool, ariaLabel, disabled
|
|
|
84
111
|
<button
|
|
85
112
|
key={candidate.id}
|
|
86
113
|
type="button"
|
|
87
|
-
title={
|
|
114
|
+
title={
|
|
115
|
+
candidate.shortcut ? `${candidate.label} (${candidate.shortcut})` : candidate.label
|
|
116
|
+
}
|
|
88
117
|
aria-label={candidate.label}
|
|
89
118
|
aria-pressed={tool === candidate.id ? 'true' : 'false'}
|
|
90
119
|
disabled={disabled}
|
|
@@ -113,38 +142,57 @@ export function CanvasSizeBar({ width, height, onResize }) {
|
|
|
113
142
|
);
|
|
114
143
|
}
|
|
115
144
|
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
const
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
145
|
+
// Curated finish presets: sharp pixels, two corner radii, and vector-aware
|
|
146
|
+
// smooth rendering.
|
|
147
|
+
// The curated presets over the two finish fields. The format itself is not
|
|
148
|
+
// limited to these — any in-range radius is valid under either renderer (e.g.
|
|
149
|
+
// from hand-edited JSON) — but picking a finish shouldn't mean hunting a slider.
|
|
150
|
+
const FINISH_STEPS = [
|
|
151
|
+
{
|
|
152
|
+
id: 'pixel',
|
|
153
|
+
label: 'Pixel',
|
|
154
|
+
icon: 'square',
|
|
155
|
+
title: 'Sharp (pixel) corners',
|
|
156
|
+
renderer: GRID_RENDERER,
|
|
157
|
+
cornerRadius: 0,
|
|
158
|
+
},
|
|
159
|
+
{ id: 'quarter', label: '¼', title: 'Round corners, radius 0.25', renderer: GRID_RENDERER, cornerRadius: 0.25 },
|
|
160
|
+
{ id: 'half', label: '½', title: 'Round corners, radius 0.5', renderer: GRID_RENDERER, cornerRadius: 0.5 },
|
|
161
|
+
{
|
|
162
|
+
id: 'vector',
|
|
163
|
+
label: 'Smooth',
|
|
164
|
+
icon: 'circle',
|
|
165
|
+
title: 'Smooth vector render (per layer)',
|
|
166
|
+
renderer: VECTOR_RENDERER,
|
|
167
|
+
cornerRadius: 0,
|
|
168
|
+
},
|
|
125
169
|
];
|
|
126
170
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// of the .pxart asset — see docs/pxart-format.md — not
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
// Finish segmented control, meant to sit next to CanvasSizeBar above the
|
|
172
|
+
// artboard. Sets the sprite's file-level `renderer` + `cornerRadius` fields
|
|
173
|
+
// (properties of the .pxart asset — see docs/pxart-format.md — not per-actor
|
|
174
|
+
// Sprite props). Highlights whichever preset the pair exactly matches, or none
|
|
175
|
+
// when it matches no preset.
|
|
176
|
+
export function FinishBar({ renderer, cornerRadius, onChange }) {
|
|
177
|
+
const isOn = (step) =>
|
|
178
|
+
step.renderer === (renderer ?? GRID_RENDERER) && step.cornerRadius === (cornerRadius ?? 0);
|
|
134
179
|
return (
|
|
135
|
-
<div className={styles.
|
|
136
|
-
<span>
|
|
137
|
-
<div className={styles.
|
|
138
|
-
{
|
|
180
|
+
<div className={styles.finishBar} aria-label="Finish">
|
|
181
|
+
<span>Finish</span>
|
|
182
|
+
<div className={styles.finishSegments} role="radiogroup" aria-label="Finish">
|
|
183
|
+
{FINISH_STEPS.map((step) => (
|
|
139
184
|
<button
|
|
140
|
-
key={step.
|
|
185
|
+
key={step.id}
|
|
141
186
|
type="button"
|
|
142
187
|
role="radio"
|
|
143
|
-
aria-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{
|
|
188
|
+
aria-label={step.label}
|
|
189
|
+
aria-checked={isOn(step) ? 'true' : 'false'}
|
|
190
|
+
className={cx(styles.finishSegment, isOn(step) && styles.finishSegmentOn)}
|
|
191
|
+
title={step.title}
|
|
192
|
+
onClick={() =>
|
|
193
|
+
onChange({ renderer: step.renderer, cornerRadius: step.cornerRadius })
|
|
194
|
+
}>
|
|
195
|
+
{step.icon ? <Icon name={step.icon} /> : step.label}
|
|
148
196
|
</button>
|
|
149
197
|
))}
|
|
150
198
|
</div>
|
|
@@ -172,9 +220,36 @@ function ResolutionSelect({ label, value, onChange }) {
|
|
|
172
220
|
}
|
|
173
221
|
|
|
174
222
|
// 12-column palette grid shared by the docked sidebar and the compact popover.
|
|
175
|
-
export function PaletteGrid({
|
|
223
|
+
export function PaletteGrid({
|
|
224
|
+
keys,
|
|
225
|
+
palette,
|
|
226
|
+
activeKey,
|
|
227
|
+
onSelectKey,
|
|
228
|
+
allowNone = false,
|
|
229
|
+
noneSelected = false,
|
|
230
|
+
onSelectNone,
|
|
231
|
+
}) {
|
|
176
232
|
return (
|
|
177
233
|
<div className={styles.palette}>
|
|
234
|
+
{allowNone ? (
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
className={cx(styles.swatch, noneSelected && styles.swatchSelected)}
|
|
238
|
+
title="None"
|
|
239
|
+
aria-label="None"
|
|
240
|
+
style={{ position: 'relative', background: 'transparent' }}
|
|
241
|
+
onClick={onSelectNone}>
|
|
242
|
+
<span
|
|
243
|
+
aria-hidden
|
|
244
|
+
style={{
|
|
245
|
+
position: 'absolute',
|
|
246
|
+
inset: 4,
|
|
247
|
+
background:
|
|
248
|
+
'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
</button>
|
|
252
|
+
) : null}
|
|
178
253
|
{keys.map((key) => (
|
|
179
254
|
<button
|
|
180
255
|
key={key}
|
|
@@ -267,6 +342,31 @@ function ShapeModeButtons({ shapeMode, setShapeMode, buttonClass, radio = false
|
|
|
267
342
|
);
|
|
268
343
|
}
|
|
269
344
|
|
|
345
|
+
function BrushModeButtons({ brushMode, setBrushMode, buttonClass, radio = false }) {
|
|
346
|
+
return (
|
|
347
|
+
<>
|
|
348
|
+
{BRUSH_MODES.map((mode) => {
|
|
349
|
+
const selected = brushMode === mode.id;
|
|
350
|
+
const title = mode.shortcut ? `${mode.label} (${mode.shortcut})` : mode.label;
|
|
351
|
+
return (
|
|
352
|
+
<button
|
|
353
|
+
key={mode.id}
|
|
354
|
+
type="button"
|
|
355
|
+
role={radio ? 'radio' : undefined}
|
|
356
|
+
className={cx(buttonClass, selected && styles.drawingToolSelected)}
|
|
357
|
+
title={title}
|
|
358
|
+
aria-label={mode.label}
|
|
359
|
+
aria-pressed={radio ? undefined : selected ? 'true' : 'false'}
|
|
360
|
+
aria-checked={radio ? (selected ? 'true' : 'false') : undefined}
|
|
361
|
+
onClick={() => setBrushMode(mode.id)}>
|
|
362
|
+
<Icon name={mode.icon} />
|
|
363
|
+
</button>
|
|
364
|
+
);
|
|
365
|
+
})}
|
|
366
|
+
</>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
270
370
|
// The move tool's transform actions (flip H / flip V / rotate CW), shared by the
|
|
271
371
|
// compact paint strip and the wide-layout settings panel. These are momentary
|
|
272
372
|
// buttons (no selected state); `buttonClass` styles them for the host strip.
|
|
@@ -288,6 +388,201 @@ function MoveActionButtons({ moveActions, buttonClass }) {
|
|
|
288
388
|
);
|
|
289
389
|
}
|
|
290
390
|
|
|
391
|
+
const ShapeSwatch = React.forwardRef(function ShapeSwatch(
|
|
392
|
+
{ label, color, none, mixed, onClick, buttonClass },
|
|
393
|
+
ref
|
|
394
|
+
) {
|
|
395
|
+
const title = mixed
|
|
396
|
+
? `${label}: mixed. Click to edit.`
|
|
397
|
+
: `${label}: ${none ? 'none' : color}. Click to edit.`;
|
|
398
|
+
return (
|
|
399
|
+
<button
|
|
400
|
+
ref={ref}
|
|
401
|
+
type="button"
|
|
402
|
+
className={buttonClass}
|
|
403
|
+
title={title}
|
|
404
|
+
aria-label={`${label} ${mixed ? 'mixed' : none ? 'none' : color}`}
|
|
405
|
+
aria-haspopup="dialog"
|
|
406
|
+
onClick={onClick}
|
|
407
|
+
style={{
|
|
408
|
+
position: 'relative',
|
|
409
|
+
background: mixed
|
|
410
|
+
? 'linear-gradient(135deg, #888 50%, #444 50%)'
|
|
411
|
+
: none
|
|
412
|
+
? 'transparent'
|
|
413
|
+
: color,
|
|
414
|
+
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.35)',
|
|
415
|
+
}}>
|
|
416
|
+
{mixed ? (
|
|
417
|
+
<span
|
|
418
|
+
aria-hidden
|
|
419
|
+
style={{
|
|
420
|
+
position: 'absolute',
|
|
421
|
+
inset: 0,
|
|
422
|
+
display: 'grid',
|
|
423
|
+
placeItems: 'center',
|
|
424
|
+
fontSize: 11,
|
|
425
|
+
fontWeight: 700,
|
|
426
|
+
color: '#fff',
|
|
427
|
+
textShadow: '0 1px 1px #000',
|
|
428
|
+
}}>
|
|
429
|
+
±
|
|
430
|
+
</span>
|
|
431
|
+
) : none ? (
|
|
432
|
+
<span
|
|
433
|
+
aria-hidden
|
|
434
|
+
style={{
|
|
435
|
+
position: 'absolute',
|
|
436
|
+
inset: 4,
|
|
437
|
+
background:
|
|
438
|
+
'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
|
|
439
|
+
}}
|
|
440
|
+
/>
|
|
441
|
+
) : null}
|
|
442
|
+
</button>
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass, groupClass }) {
|
|
447
|
+
const [slotPicker, setSlotPicker] = useState(null);
|
|
448
|
+
const fillRef = useRef(null);
|
|
449
|
+
const strokeRef = useRef(null);
|
|
450
|
+
const anchorRef = slotPicker === 'fill' ? fillRef : strokeRef;
|
|
451
|
+
const fillSlot = pathActions?.fillSlot;
|
|
452
|
+
const strokeSlot = pathActions?.strokeSlot;
|
|
453
|
+
const fillMixed = fillSlot === 'mixed';
|
|
454
|
+
const strokeMixed = strokeSlot === 'mixed';
|
|
455
|
+
const fillColor = !fillMixed && fillSlot != null ? palette?.[fillSlot] : null;
|
|
456
|
+
const strokeColor = !strokeMixed && strokeSlot != null ? palette?.[strokeSlot] : null;
|
|
457
|
+
const pickerSlotValue = slotPicker === 'fill' ? fillSlot : slotPicker === 'stroke' ? strokeSlot : null;
|
|
458
|
+
const pickerMixed = pickerSlotValue === 'mixed';
|
|
459
|
+
const pickerKey =
|
|
460
|
+
pickerMixed || pickerSlotValue == null ? null : pickerSlotValue;
|
|
461
|
+
const pickerNoneSelected = !pickerMixed && pickerSlotValue == null;
|
|
462
|
+
|
|
463
|
+
function closePicker() {
|
|
464
|
+
setSlotPicker(null);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function applyPicker(key) {
|
|
468
|
+
if (slotPicker === 'fill') pathActions?.setFill(key);
|
|
469
|
+
else if (slotPicker === 'stroke') pathActions?.setStroke(key);
|
|
470
|
+
closePicker();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<>
|
|
475
|
+
<div className={groupClass} role="group" aria-label="Fill and stroke">
|
|
476
|
+
<ShapeSwatch
|
|
477
|
+
ref={fillRef}
|
|
478
|
+
label="Fill"
|
|
479
|
+
color={fillColor ?? '#888'}
|
|
480
|
+
none={!fillMixed && fillSlot == null}
|
|
481
|
+
mixed={fillMixed}
|
|
482
|
+
onClick={() => setSlotPicker('fill')}
|
|
483
|
+
buttonClass={buttonClass}
|
|
484
|
+
/>
|
|
485
|
+
<ShapeSwatch
|
|
486
|
+
ref={strokeRef}
|
|
487
|
+
label="Stroke"
|
|
488
|
+
color={strokeColor ?? '#888'}
|
|
489
|
+
none={!strokeMixed && strokeSlot == null}
|
|
490
|
+
mixed={strokeMixed}
|
|
491
|
+
onClick={() => setSlotPicker('stroke')}
|
|
492
|
+
buttonClass={buttonClass}
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
495
|
+
<div className={groupClass} role="group" aria-label="Transform and order">
|
|
496
|
+
<button
|
|
497
|
+
type="button"
|
|
498
|
+
className={buttonClass}
|
|
499
|
+
title="Flip horizontal"
|
|
500
|
+
aria-label="Flip horizontal"
|
|
501
|
+
disabled={!pathActions?.hasSelection}
|
|
502
|
+
onClick={pathActions?.flipH}>
|
|
503
|
+
<Icon name="flip-h" />
|
|
504
|
+
</button>
|
|
505
|
+
<button
|
|
506
|
+
type="button"
|
|
507
|
+
className={buttonClass}
|
|
508
|
+
title="Flip vertical"
|
|
509
|
+
aria-label="Flip vertical"
|
|
510
|
+
disabled={!pathActions?.hasSelection}
|
|
511
|
+
onClick={pathActions?.flipV}>
|
|
512
|
+
<Icon name="flip-v" />
|
|
513
|
+
</button>
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
className={buttonClass}
|
|
517
|
+
title="Rotate 90° clockwise"
|
|
518
|
+
aria-label="Rotate 90 degrees clockwise"
|
|
519
|
+
disabled={!pathActions?.hasSelection}
|
|
520
|
+
onClick={pathActions?.rotateCW}>
|
|
521
|
+
<Icon name="redo" />
|
|
522
|
+
</button>
|
|
523
|
+
<button
|
|
524
|
+
type="button"
|
|
525
|
+
className={buttonClass}
|
|
526
|
+
title="Bring forward (])"
|
|
527
|
+
aria-label="Bring shape forward"
|
|
528
|
+
disabled={!pathActions?.canForward}
|
|
529
|
+
onClick={pathActions?.bringForward}>
|
|
530
|
+
<Icon name="chevron-up" />
|
|
531
|
+
</button>
|
|
532
|
+
<button
|
|
533
|
+
type="button"
|
|
534
|
+
className={buttonClass}
|
|
535
|
+
title="Send backward ([)"
|
|
536
|
+
aria-label="Send shape backward"
|
|
537
|
+
disabled={!pathActions?.canBackward}
|
|
538
|
+
onClick={pathActions?.sendBackward}>
|
|
539
|
+
<Icon name="chevron-down" />
|
|
540
|
+
</button>
|
|
541
|
+
</div>
|
|
542
|
+
<div className={groupClass} role="group" aria-label="Delete and punch">
|
|
543
|
+
<button
|
|
544
|
+
type="button"
|
|
545
|
+
className={buttonClass}
|
|
546
|
+
title="Delete shape"
|
|
547
|
+
aria-label="Delete shape"
|
|
548
|
+
disabled={!pathActions?.hasSelection}
|
|
549
|
+
onClick={pathActions?.deleteSelected}>
|
|
550
|
+
<Icon name="trash" />
|
|
551
|
+
</button>
|
|
552
|
+
<button
|
|
553
|
+
type="button"
|
|
554
|
+
className={buttonClass}
|
|
555
|
+
title={
|
|
556
|
+
pathActions?.canPunchHole
|
|
557
|
+
? 'Punch Hole'
|
|
558
|
+
: 'Select one shape that fully contains the others'
|
|
559
|
+
}
|
|
560
|
+
aria-label="Punch Hole"
|
|
561
|
+
disabled={!pathActions?.canPunchHole}
|
|
562
|
+
onClick={pathActions?.punchHole}>
|
|
563
|
+
<Icon name="dot-circle" />
|
|
564
|
+
</button>
|
|
565
|
+
</div>
|
|
566
|
+
<PalettePopover open={!!slotPicker} anchorRef={anchorRef} onClose={closePicker}>
|
|
567
|
+
<div className={styles.palettePopoverHeader}>
|
|
568
|
+
<span className={styles.palettePopoverTitle}>
|
|
569
|
+
{slotPicker === 'fill' ? 'Fill' : 'Stroke'}
|
|
570
|
+
</span>
|
|
571
|
+
</div>
|
|
572
|
+
<PaletteGrid
|
|
573
|
+
keys={paletteKeys}
|
|
574
|
+
palette={palette}
|
|
575
|
+
activeKey={pickerKey}
|
|
576
|
+
allowNone
|
|
577
|
+
noneSelected={pickerNoneSelected}
|
|
578
|
+
onSelectNone={() => applyPicker(null)}
|
|
579
|
+
onSelectKey={applyPicker}
|
|
580
|
+
/>
|
|
581
|
+
</PalettePopover>
|
|
582
|
+
</>
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
291
586
|
// Right-side paint strip (compact layout): color swatch, vertical size slider, and
|
|
292
587
|
// tool-contextual mode toggles. Color tap opens the palette popover.
|
|
293
588
|
export function PaintStrip({
|
|
@@ -302,6 +597,7 @@ export function PaintStrip({
|
|
|
302
597
|
palette,
|
|
303
598
|
moveActions,
|
|
304
599
|
selectActions,
|
|
600
|
+
pathActions,
|
|
305
601
|
}) {
|
|
306
602
|
const {
|
|
307
603
|
tool,
|
|
@@ -313,15 +609,17 @@ export function PaintStrip({
|
|
|
313
609
|
fillSwapMode,
|
|
314
610
|
shapeMode,
|
|
315
611
|
shapeFill,
|
|
612
|
+
brushMode,
|
|
316
613
|
setBrushSize,
|
|
317
614
|
setEraseSize,
|
|
318
615
|
setEraseFillMode,
|
|
319
616
|
setFillSwapMode,
|
|
320
617
|
setShapeMode,
|
|
618
|
+
setBrushMode,
|
|
321
619
|
setShapeFill,
|
|
322
620
|
} = toolState;
|
|
323
621
|
|
|
324
|
-
const showBrushSize = tool === 'brush';
|
|
622
|
+
const showBrushSize = tool === 'brush' && !pathActions;
|
|
325
623
|
const showEraseSize = tool === 'erase';
|
|
326
624
|
const size = showBrushSize ? brushSize : eraseSize;
|
|
327
625
|
const onSizeChange = showBrushSize ? setBrushSize : setEraseSize;
|
|
@@ -372,6 +670,15 @@ export function PaintStrip({
|
|
|
372
670
|
/>
|
|
373
671
|
</div>
|
|
374
672
|
) : null}
|
|
673
|
+
{tool === 'brush' && pathActions ? (
|
|
674
|
+
<div className={styles.paintStripSection}>
|
|
675
|
+
<BrushModeButtons
|
|
676
|
+
brushMode={brushMode}
|
|
677
|
+
setBrushMode={setBrushMode}
|
|
678
|
+
buttonClass={styles.paintStripModeButton}
|
|
679
|
+
/>
|
|
680
|
+
</div>
|
|
681
|
+
) : null}
|
|
375
682
|
{tool === 'move-all' ? (
|
|
376
683
|
<div className={styles.paintStripSection}>
|
|
377
684
|
<MoveActionButtons moveActions={moveActions} buttonClass={styles.paintStripModeButton} />
|
|
@@ -406,7 +713,7 @@ export function PaintStrip({
|
|
|
406
713
|
</button>
|
|
407
714
|
</div>
|
|
408
715
|
) : null}
|
|
409
|
-
{tool === 'fill' ? (
|
|
716
|
+
{tool === 'fill' && !pathActions ? (
|
|
410
717
|
<div className={styles.paintStripSection}>
|
|
411
718
|
<button
|
|
412
719
|
type="button"
|
|
@@ -438,6 +745,16 @@ export function PaintStrip({
|
|
|
438
745
|
</button>
|
|
439
746
|
</div>
|
|
440
747
|
) : null}
|
|
748
|
+
{/* Compact strip: shape actions only on select/bend — brush keeps room for modes. */}
|
|
749
|
+
{(tool === 'path-select' || tool === 'bend') && pathActions?.hasSelection ? (
|
|
750
|
+
<ShapeInspectorButtons
|
|
751
|
+
pathActions={pathActions}
|
|
752
|
+
palette={palette}
|
|
753
|
+
paletteKeys={paletteKeys}
|
|
754
|
+
buttonClass={styles.paintStripModeButton}
|
|
755
|
+
groupClass={styles.paintStripSection}
|
|
756
|
+
/>
|
|
757
|
+
) : null}
|
|
441
758
|
</div>
|
|
442
759
|
<PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
|
|
443
760
|
<div className={styles.palettePopoverHeader}>
|
|
@@ -465,7 +782,16 @@ export function PaintStrip({
|
|
|
465
782
|
}
|
|
466
783
|
|
|
467
784
|
// Wide-layout docked inspector: palette (when relevant) + tool settings only.
|
|
468
|
-
export function PxArtInspectorDock({
|
|
785
|
+
export function PxArtInspectorDock({
|
|
786
|
+
toolState,
|
|
787
|
+
activeKey,
|
|
788
|
+
onSelectKey,
|
|
789
|
+
paletteKeys,
|
|
790
|
+
palette,
|
|
791
|
+
moveActions,
|
|
792
|
+
selectActions,
|
|
793
|
+
pathActions,
|
|
794
|
+
}) {
|
|
469
795
|
const { tool, picking, setPicking } = toolState;
|
|
470
796
|
return (
|
|
471
797
|
<aside className={cx(styles.pxArtInspector, styles.drawingToolsPanel)}>
|
|
@@ -490,13 +816,19 @@ export function PxArtInspectorDock({ toolState, activeKey, onSelectKey, paletteK
|
|
|
490
816
|
/>
|
|
491
817
|
</Panel>
|
|
492
818
|
) : null}
|
|
493
|
-
<ToolSettingsPanel
|
|
819
|
+
<ToolSettingsPanel
|
|
820
|
+
toolState={toolState}
|
|
821
|
+
moveActions={moveActions}
|
|
822
|
+
selectActions={selectActions}
|
|
823
|
+
pathActions={pathActions}
|
|
824
|
+
palette={palette}
|
|
825
|
+
paletteKeys={paletteKeys}
|
|
826
|
+
/>
|
|
494
827
|
</div>
|
|
495
828
|
</aside>
|
|
496
829
|
);
|
|
497
830
|
}
|
|
498
831
|
|
|
499
|
-
// A labeled on/off switch used for the editors' boolean tool modes.
|
|
500
832
|
export function ModeToggle({ label, active, onChange }) {
|
|
501
833
|
return (
|
|
502
834
|
<div className={styles.drawingModeRow}>
|
|
@@ -516,7 +848,14 @@ export function ModeToggle({ label, active, onChange }) {
|
|
|
516
848
|
// The "Tool Settings" panel for the pixel editor: brush/erase size sliders plus
|
|
517
849
|
// the per-tool mode toggle / hint. It reads the whole usePixelToolState bundle
|
|
518
850
|
// to keep callers from re-spreading the same prop list.
|
|
519
|
-
export function ToolSettingsPanel({
|
|
851
|
+
export function ToolSettingsPanel({
|
|
852
|
+
toolState,
|
|
853
|
+
moveActions,
|
|
854
|
+
selectActions,
|
|
855
|
+
pathActions,
|
|
856
|
+
palette,
|
|
857
|
+
paletteKeys,
|
|
858
|
+
}) {
|
|
520
859
|
const {
|
|
521
860
|
tool,
|
|
522
861
|
brushSize,
|
|
@@ -525,11 +864,13 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
|
|
|
525
864
|
fillSwapMode,
|
|
526
865
|
shapeMode,
|
|
527
866
|
shapeFill,
|
|
867
|
+
brushMode,
|
|
528
868
|
setBrushSize,
|
|
529
869
|
setEraseSize,
|
|
530
870
|
setEraseFillMode,
|
|
531
871
|
setFillSwapMode,
|
|
532
872
|
setShapeMode,
|
|
873
|
+
setBrushMode,
|
|
533
874
|
setShapeFill,
|
|
534
875
|
} = toolState;
|
|
535
876
|
return (
|
|
@@ -538,7 +879,7 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
|
|
|
538
879
|
<ToolSizePicker
|
|
539
880
|
label="Brush"
|
|
540
881
|
value={brushSize}
|
|
541
|
-
visible={tool === 'brush'}
|
|
882
|
+
visible={tool === 'brush' && !pathActions}
|
|
542
883
|
onChange={setBrushSize}
|
|
543
884
|
/>
|
|
544
885
|
<ToolSizePicker
|
|
@@ -549,7 +890,7 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
|
|
|
549
890
|
fill={eraseFillMode}
|
|
550
891
|
onFillChange={setEraseFillMode}
|
|
551
892
|
/>
|
|
552
|
-
{tool === 'fill' ? (
|
|
893
|
+
{tool === 'fill' && !pathActions ? (
|
|
553
894
|
<ModeToggle label="Swap Mode" active={fillSwapMode} onChange={setFillSwapMode} />
|
|
554
895
|
) : null}
|
|
555
896
|
{tool === 'shape' ? (
|
|
@@ -568,6 +909,21 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
|
|
|
568
909
|
<ModeToggle label="Fill Shape" active={shapeFill} onChange={setShapeFill} />
|
|
569
910
|
</div>
|
|
570
911
|
) : null}
|
|
912
|
+
{tool === 'brush' && pathActions ? (
|
|
913
|
+
<div className={styles.drawingSizePicker}>
|
|
914
|
+
<div className={styles.drawingSizeLabel}>
|
|
915
|
+
<span>Brush</span>
|
|
916
|
+
</div>
|
|
917
|
+
<div className={styles.drawingShapeModeRow} role="radiogroup" aria-label="Brush mode">
|
|
918
|
+
<BrushModeButtons
|
|
919
|
+
brushMode={brushMode}
|
|
920
|
+
setBrushMode={setBrushMode}
|
|
921
|
+
buttonClass={styles.drawingToolButton}
|
|
922
|
+
radio
|
|
923
|
+
/>
|
|
924
|
+
</div>
|
|
925
|
+
</div>
|
|
926
|
+
) : null}
|
|
571
927
|
{tool === 'move-all' ? (
|
|
572
928
|
<div className={styles.drawingSizePicker}>
|
|
573
929
|
<div className={styles.drawingSizeLabel}>
|
|
@@ -599,6 +955,23 @@ export function ToolSettingsPanel({ toolState, moveActions, selectActions }) {
|
|
|
599
955
|
{tool === 'eyedropper' ? (
|
|
600
956
|
<div className={styles.drawingToolHint}>Click a pixel to make it the active color.</div>
|
|
601
957
|
) : null}
|
|
958
|
+
{pathActions?.hasSelection &&
|
|
959
|
+
(tool === 'path-select' || (tool === 'brush' && pathActions) || tool === 'bend') ? (
|
|
960
|
+
<div className={styles.drawingSizePicker}>
|
|
961
|
+
<div className={styles.drawingSizeLabel}>
|
|
962
|
+
<span>Shape</span>
|
|
963
|
+
</div>
|
|
964
|
+
<div className={styles.drawingShapeModeGroups}>
|
|
965
|
+
<ShapeInspectorButtons
|
|
966
|
+
pathActions={pathActions}
|
|
967
|
+
palette={palette}
|
|
968
|
+
paletteKeys={paletteKeys}
|
|
969
|
+
buttonClass={styles.drawingToolButton}
|
|
970
|
+
groupClass={styles.drawingShapeModeRow}
|
|
971
|
+
/>
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
) : null}
|
|
602
975
|
</div>
|
|
603
976
|
</Panel>
|
|
604
977
|
);
|