castle-web-cli 0.4.123 → 0.4.124
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/castleJson.d.ts +2 -0
- package/dist/castleJson.js +39 -0
- package/dist/headlessCover.d.ts +13 -0
- package/dist/headlessCover.js +99 -0
- package/dist/ide.js +12 -42
- package/dist/imports.js +42 -2
- package/dist/index.js +1 -9
- package/dist/init.d.ts +1 -0
- package/dist/init.js +15 -1
- package/dist/preview.d.ts +0 -1
- package/dist/preview.js +0 -58
- package/dist/save-deck.js +52 -1
- package/dist/serve.js +16 -0
- package/dist/shell/assets/Basteleur-Bold-CK8LF7Pt.woff +0 -0
- package/dist/shell/assets/Basteleur-Bold-DKFKedNb.woff2 +0 -0
- package/dist/shell/assets/index-BfOPkSej.css +1 -0
- package/dist/shell/assets/index-u0nYFqbF.js +434 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +2 -2
- package/kits/physics-2d/castle.json +10 -2
- package/kits/physics-2d/docs/pxart-format.md +33 -26
- package/kits/physics-2d/editors/PxArtEditor.jsx +120 -49
- package/kits/physics-2d/editors/SingleEditor.jsx +6 -3
- package/kits/physics-2d/editors/StyleEditor.jsx +95 -0
- package/kits/physics-2d/editors/pathOverlay.js +1 -1
- package/kits/physics-2d/editors/pathTools.js +9 -1
- package/kits/physics-2d/editors/pixelGeometry.js +14 -13
- package/kits/physics-2d/editors/pixelInspector.jsx +202 -53
- package/kits/physics-2d/editors/pxArtEditorModel.js +8 -63
- package/kits/physics-2d/editors/pxArtTools.js +3 -43
- package/kits/physics-2d/editors/styleEditor.module.css +105 -0
- package/kits/physics-2d/editors/styleTheme.js +16 -0
- package/kits/physics-2d/engine/files.js +2 -1
- package/kits/physics-2d/engine/liveReload.js +4 -3
- package/kits/physics-2d/engine/palettes.js +636 -0
- package/kits/physics-2d/engine/pxart.js +6 -6
- package/kits/physics-2d/engine/svgImport.js +1056 -0
- package/kits/physics-2d/engine/ui.jsx +2 -0
- package/kits/physics-2d/engine/ui.module.css +54 -9
- package/kits/physics-2d/package-lock.json +1 -1
- package/kits/physics-2d/package.json +1 -0
- package/kits/physics-2d/scripts/deckTheme.mjs +25 -0
- package/kits/physics-2d/scripts/draw.mjs +5 -3
- package/kits/physics-2d/scripts/import-svg.mjs +16 -1069
- package/kits/physics-2d/scripts/palette.mjs +10 -0
- package/kits/physics-2d/scripts/svg-emission-guide.md +5 -3
- package/kits/physics-2d/theme.style +3 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-CARLHafh.css +0 -1
- package/dist/shell/assets/index-DWgt4KzC.js +0 -434
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Deck-palette picker for `theme.style`. Official entries come from
|
|
2
|
+
// OFFICIAL_PALETTES; a custom `{ name, colors }` in the file shows as a
|
|
3
|
+
// read-only selected card. Opening the editor never writes — only an
|
|
4
|
+
// explicit official pick does.
|
|
5
|
+
|
|
6
|
+
import { createElement } from 'react';
|
|
7
|
+
import { OFFICIAL_PALETTES, parseThemeData, resolveDeckPalette } from '../engine/palettes';
|
|
8
|
+
import { EditorBody } from '../engine/ui';
|
|
9
|
+
import { applyOfficialPalette } from './styleTheme';
|
|
10
|
+
import styles from './styleEditor.module.css';
|
|
11
|
+
|
|
12
|
+
function cardClassName(selected, clickable) {
|
|
13
|
+
return [styles.card, selected ? styles.cardSelected : '', clickable ? '' : styles.cardStatic]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(' ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function swatchStrip(colors) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={styles.strip} aria-hidden="true">
|
|
21
|
+
{colors.map((hex, index) => (
|
|
22
|
+
<span key={`${hex}:${index}`} className={styles.chip} style={{ backgroundColor: hex }} />
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paletteCard({ name, colors, selected, onPick, badge, cardKey }) {
|
|
29
|
+
const className = cardClassName(selected, Boolean(onPick));
|
|
30
|
+
const body = (
|
|
31
|
+
<>
|
|
32
|
+
<div className={styles.cardHead}>
|
|
33
|
+
<div className={styles.cardName}>{name}</div>
|
|
34
|
+
<div className={styles.cardMeta}>
|
|
35
|
+
{badge ? <span className={styles.badge}>{badge}</span> : null}
|
|
36
|
+
<span>{colors.length} colors</span>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
{swatchStrip(colors)}
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
if (!onPick) {
|
|
43
|
+
return (
|
|
44
|
+
<div key={cardKey} className={className}>
|
|
45
|
+
{body}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return (
|
|
50
|
+
<button key={cardKey} type="button" className={className} onClick={onPick} aria-pressed={selected}>
|
|
51
|
+
{body}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function customCardName(palette) {
|
|
57
|
+
const name = palette?.name?.trim();
|
|
58
|
+
return name && name !== 'custom' ? name : 'Custom';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function StyleEditor({ text, onChange }) {
|
|
62
|
+
const current = resolveDeckPalette(parseThemeData(text));
|
|
63
|
+
const isCustom = current.id == null;
|
|
64
|
+
function pick(id) {
|
|
65
|
+
onChange(applyOfficialPalette(text, id));
|
|
66
|
+
}
|
|
67
|
+
return createElement(
|
|
68
|
+
EditorBody,
|
|
69
|
+
null,
|
|
70
|
+
<div className={styles.root}>
|
|
71
|
+
{isCustom
|
|
72
|
+
? paletteCard({
|
|
73
|
+
name: customCardName(current),
|
|
74
|
+
colors: current.colors,
|
|
75
|
+
selected: true,
|
|
76
|
+
badge: 'Custom',
|
|
77
|
+
})
|
|
78
|
+
: null}
|
|
79
|
+
{isCustom ? (
|
|
80
|
+
<p className={styles.replaceNote}>Picking an official palette replaces this custom palette.</p>
|
|
81
|
+
) : null}
|
|
82
|
+
<div className={styles.list}>
|
|
83
|
+
{Object.values(OFFICIAL_PALETTES).map((palette) =>
|
|
84
|
+
paletteCard({
|
|
85
|
+
cardKey: palette.id,
|
|
86
|
+
name: palette.name,
|
|
87
|
+
colors: palette.colors,
|
|
88
|
+
selected: current.id === palette.id,
|
|
89
|
+
onPick: () => pick(palette.id),
|
|
90
|
+
})
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -171,7 +171,7 @@ function drawFillToolPreview(ctx, path, ui, at, sx, sy) {
|
|
|
171
171
|
if (!shape) return;
|
|
172
172
|
|
|
173
173
|
ctx.save();
|
|
174
|
-
if (target.slot === 'fill'
|
|
174
|
+
if (target.slot === 'fill') {
|
|
175
175
|
const geometry = buildShapeFillPath2D(shape, at);
|
|
176
176
|
if (geometry) {
|
|
177
177
|
ctx.fillStyle = color;
|
|
@@ -813,12 +813,20 @@ export function setEdgeBow(path, shapeIndex, subIndex, edgeIndex, bow) {
|
|
|
813
813
|
});
|
|
814
814
|
}
|
|
815
815
|
|
|
816
|
+
/**
|
|
817
|
+
* Fill-tool target at (x,y): stroke first, then interior, top-most shape down.
|
|
818
|
+
*
|
|
819
|
+
* An interior counts whether or not the shape already has a fill, so the tool
|
|
820
|
+
* ADDS one to a stroke-only shape — which also means an unfilled shape is not a
|
|
821
|
+
* hole here the way it is for `hitTestShape`. Only closed subpaths bound a fill,
|
|
822
|
+
* so an all-open shape has no interior to hit even if it still carries a key.
|
|
823
|
+
*/
|
|
816
824
|
export function hitTestFillToolTarget(path, x, y) {
|
|
817
825
|
const shapes = path?.shapes ?? [];
|
|
818
826
|
for (let i = shapes.length - 1; i >= 0; i--) {
|
|
819
827
|
const shape = shapes[i];
|
|
820
828
|
if (nearStrokedOutline(shape, x, y)) return { shapeIndex: i, slot: 'stroke' };
|
|
821
|
-
if (
|
|
829
|
+
if (shapeContainsPoint(shape, x, y)) return { shapeIndex: i, slot: 'fill' };
|
|
822
830
|
}
|
|
823
831
|
return null;
|
|
824
832
|
}
|
|
@@ -107,15 +107,16 @@ function edgeCross(a, b, cx, cy) {
|
|
|
107
107
|
return (b.x - a.x) * (cy - a.y) - (b.y - a.y) * (cx - a.x);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
// Visit the cells of
|
|
111
|
-
//
|
|
112
|
-
// barycentric point-in-triangle test over the
|
|
113
|
-
// edges. A shared `seen` set dedups cells
|
|
110
|
+
// Visit the cells of a right triangle whose hypotenuse is the drag `from`→`to`
|
|
111
|
+
// and whose right angle sits at (from.x, to.y) — same corner rule as the path
|
|
112
|
+
// brush triangle. `filled` uses a barycentric point-in-triangle test over the
|
|
113
|
+
// box; the outline walks the three edges. A shared `seen` set dedups cells
|
|
114
|
+
// shared by adjacent edges/scanlines.
|
|
114
115
|
export function forEachTriangleCells(from, to, filled, visit) {
|
|
115
116
|
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
117
|
+
const start = { x: from.x, y: from.y };
|
|
118
|
+
const right = { x: from.x, y: to.y };
|
|
119
|
+
const end = { x: to.x, y: to.y };
|
|
119
120
|
const seen = new Set();
|
|
120
121
|
const emit = (x, y) => {
|
|
121
122
|
const key = `${x},${y}`;
|
|
@@ -126,9 +127,9 @@ export function forEachTriangleCells(from, to, filled, visit) {
|
|
|
126
127
|
if (filled) {
|
|
127
128
|
for (let y = y0; y <= y1; y++) {
|
|
128
129
|
for (let x = x0; x <= x1; x++) {
|
|
129
|
-
const w0 = edgeCross(
|
|
130
|
-
const w1 = edgeCross(
|
|
131
|
-
const w2 = edgeCross(
|
|
130
|
+
const w0 = edgeCross(right, end, x, y);
|
|
131
|
+
const w1 = edgeCross(end, start, x, y);
|
|
132
|
+
const w2 = edgeCross(start, right, x, y);
|
|
132
133
|
const hasNeg = w0 < 0 || w1 < 0 || w2 < 0;
|
|
133
134
|
const hasPos = w0 > 0 || w1 > 0 || w2 > 0;
|
|
134
135
|
if (!(hasNeg && hasPos)) emit(x, y);
|
|
@@ -136,7 +137,7 @@ export function forEachTriangleCells(from, to, filled, visit) {
|
|
|
136
137
|
}
|
|
137
138
|
return;
|
|
138
139
|
}
|
|
139
|
-
forEachLinePoint(
|
|
140
|
-
forEachLinePoint(
|
|
141
|
-
forEachLinePoint(
|
|
140
|
+
forEachLinePoint(start, right, emit);
|
|
141
|
+
forEachLinePoint(right, end, emit);
|
|
142
|
+
forEachLinePoint(end, start, emit);
|
|
142
143
|
}
|
|
@@ -18,7 +18,8 @@ export const PIXEL_TOOLS = [
|
|
|
18
18
|
];
|
|
19
19
|
|
|
20
20
|
// Path-layer tools: brush (pen / freehand / primitives), select (move/z-order),
|
|
21
|
-
// bend (drag bows on any hit edge), fill (
|
|
21
|
+
// bend (drag bows on any hit edge), fill (paint a shape's fill or stroke —
|
|
22
|
+
// an interior click adds a fill to a stroke-only shape).
|
|
22
23
|
export const PATH_TOOLS = [
|
|
23
24
|
{ id: 'brush', label: 'Brush', icon: 'pencil', shortcut: 'B' },
|
|
24
25
|
{ id: 'fill', label: 'Fill', icon: 'fill', shortcut: 'G' },
|
|
@@ -27,8 +28,9 @@ export const PATH_TOOLS = [
|
|
|
27
28
|
];
|
|
28
29
|
|
|
29
30
|
// Sub-modes for the shape tool. Each drags out an axis-aligned box: line draws
|
|
30
|
-
// the diagonal, square the rectangle, circle the inscribed ellipse, triangle
|
|
31
|
-
//
|
|
31
|
+
// the diagonal, square the rectangle, circle the inscribed ellipse, triangle a
|
|
32
|
+
// right triangle (hypotenuse is the drag; right angle at (start.x, end.y)).
|
|
33
|
+
// `filled` (a separate toggle) applies to the closed three.
|
|
32
34
|
export const SHAPE_MODES = [
|
|
33
35
|
{ id: 'line', label: 'Line', icon: 'slash' },
|
|
34
36
|
{ id: 'square', label: 'Square', icon: 'square' },
|
|
@@ -219,51 +221,132 @@ function ResolutionSelect({ label, value, onChange }) {
|
|
|
219
221
|
);
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
|
|
224
|
+
function sameHex(a, b) {
|
|
225
|
+
return !!a && !!b && a.toLowerCase() === b.toLowerCase();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function NoneSwatch({ selected, onSelect }) {
|
|
229
|
+
return (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
className={cx(styles.swatch, selected && styles.swatchSelected)}
|
|
233
|
+
title="None"
|
|
234
|
+
aria-label="None"
|
|
235
|
+
style={{ position: 'relative', background: 'transparent' }}
|
|
236
|
+
onClick={onSelect}>
|
|
237
|
+
<span
|
|
238
|
+
aria-hidden
|
|
239
|
+
style={{
|
|
240
|
+
position: 'absolute',
|
|
241
|
+
inset: 4,
|
|
242
|
+
background:
|
|
243
|
+
'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
</button>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Swatch strip shared by the docked sidebar and the compact popover.
|
|
251
|
+
// `keys` paints a sprite's working palette; `hexes` paints a picker list.
|
|
252
|
+
// `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
|
|
223
253
|
export function PaletteGrid({
|
|
224
254
|
keys,
|
|
255
|
+
hexes,
|
|
225
256
|
palette,
|
|
226
257
|
activeKey,
|
|
258
|
+
activeHex,
|
|
227
259
|
onSelectKey,
|
|
260
|
+
onSelectHex,
|
|
228
261
|
allowNone = false,
|
|
229
262
|
noneSelected = false,
|
|
230
263
|
onSelectNone,
|
|
264
|
+
wrap = false,
|
|
231
265
|
}) {
|
|
266
|
+
const items = hexes
|
|
267
|
+
? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
|
|
268
|
+
: (keys ?? []).map((key) => ({
|
|
269
|
+
id: key,
|
|
270
|
+
hex: palette?.[key],
|
|
271
|
+
selected: activeKey === key || sameHex(activeHex, palette?.[key]),
|
|
272
|
+
onClick: () => onSelectKey?.(key),
|
|
273
|
+
}));
|
|
274
|
+
if (!allowNone && !items.length) return null;
|
|
232
275
|
return (
|
|
233
|
-
<div className={styles.palette}>
|
|
234
|
-
{allowNone ?
|
|
276
|
+
<div className={wrap ? styles.paletteSpriteRow : styles.palette}>
|
|
277
|
+
{allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
|
|
278
|
+
{items.map((item) => (
|
|
235
279
|
<button
|
|
280
|
+
key={item.id}
|
|
236
281
|
type="button"
|
|
237
|
-
className={cx(styles.swatch,
|
|
238
|
-
title=
|
|
239
|
-
|
|
240
|
-
|
|
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}
|
|
253
|
-
{keys.map((key) => (
|
|
254
|
-
<button
|
|
255
|
-
key={key}
|
|
256
|
-
type="button"
|
|
257
|
-
className={cx(styles.swatch, activeKey === key && styles.swatchSelected)}
|
|
258
|
-
title={palette[key]}
|
|
259
|
-
style={{ background: palette[key] }}
|
|
260
|
-
onClick={() => onSelectKey(key)}
|
|
282
|
+
className={cx(styles.swatch, item.selected && styles.swatchSelected)}
|
|
283
|
+
title={item.hex}
|
|
284
|
+
style={{ background: item.hex }}
|
|
285
|
+
onClick={item.onClick}
|
|
261
286
|
/>
|
|
262
287
|
))}
|
|
263
288
|
</div>
|
|
264
289
|
);
|
|
265
290
|
}
|
|
266
291
|
|
|
292
|
+
function PalettePager({ name, onPage }) {
|
|
293
|
+
return (
|
|
294
|
+
<div className={styles.palettePager}>
|
|
295
|
+
<span className={styles.palettePagerName}>{name}</span>
|
|
296
|
+
<div className={styles.palettePagerBtns}>
|
|
297
|
+
<IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
|
|
298
|
+
<IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Unlabeled sprite-color row (when the sprite has colors, or when fill/stroke
|
|
305
|
+
// needs a none swatch) plus a paged official-palette grid. Paging only changes
|
|
306
|
+
// which named page is shown — it does not write theme.style.
|
|
307
|
+
export function PaletteSections({
|
|
308
|
+
spriteKeys,
|
|
309
|
+
spritePalette,
|
|
310
|
+
pages,
|
|
311
|
+
pageIndex,
|
|
312
|
+
onPage,
|
|
313
|
+
activeKey,
|
|
314
|
+
activeHex,
|
|
315
|
+
onSelectKey,
|
|
316
|
+
onSelectHex,
|
|
317
|
+
allowNone = false,
|
|
318
|
+
noneSelected = false,
|
|
319
|
+
onSelectNone,
|
|
320
|
+
paletteFull = false,
|
|
321
|
+
}) {
|
|
322
|
+
const page = pages?.[pageIndex] ?? pages?.[0];
|
|
323
|
+
const showSpriteRow = !!spriteKeys?.length || allowNone;
|
|
324
|
+
return (
|
|
325
|
+
<div>
|
|
326
|
+
{showSpriteRow ? (
|
|
327
|
+
<PaletteGrid
|
|
328
|
+
keys={spriteKeys}
|
|
329
|
+
palette={spritePalette}
|
|
330
|
+
activeKey={activeKey}
|
|
331
|
+
activeHex={activeHex}
|
|
332
|
+
onSelectKey={onSelectKey}
|
|
333
|
+
allowNone={allowNone}
|
|
334
|
+
noneSelected={noneSelected}
|
|
335
|
+
onSelectNone={onSelectNone}
|
|
336
|
+
wrap
|
|
337
|
+
/>
|
|
338
|
+
) : null}
|
|
339
|
+
{page ? (
|
|
340
|
+
<>
|
|
341
|
+
<PalettePager name={page.name} onPage={onPage} />
|
|
342
|
+
<PaletteGrid hexes={page.colors} activeHex={activeHex} onSelectHex={onSelectHex} />
|
|
343
|
+
</>
|
|
344
|
+
) : null}
|
|
345
|
+
{paletteFull ? <div className={styles.paletteFullNote}>Palette is full (64 colors). Recolor an existing swatch instead.</div> : null}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
267
350
|
// Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
|
|
268
351
|
export function PalettePopover({ open, anchorRef, onClose, children }) {
|
|
269
352
|
const popoverRef = useRef(null);
|
|
@@ -276,7 +359,7 @@ export function PalettePopover({ open, anchorRef, onClose, children }) {
|
|
|
276
359
|
const anchor = anchorRef.current.getBoundingClientRect();
|
|
277
360
|
const rect = popoverRef.current?.getBoundingClientRect();
|
|
278
361
|
const margin = 8;
|
|
279
|
-
const width = rect?.width ??
|
|
362
|
+
const width = rect?.width ?? 231;
|
|
280
363
|
const height = rect?.height ?? 320;
|
|
281
364
|
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
|
282
365
|
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
|
@@ -464,7 +547,18 @@ const ShapeSwatch = React.forwardRef(function ShapeSwatch(
|
|
|
464
547
|
);
|
|
465
548
|
});
|
|
466
549
|
|
|
467
|
-
function ShapeInspectorButtons({
|
|
550
|
+
function ShapeInspectorButtons({
|
|
551
|
+
pathActions,
|
|
552
|
+
palette,
|
|
553
|
+
paletteKeys,
|
|
554
|
+
pages,
|
|
555
|
+
pageIndex,
|
|
556
|
+
onPage,
|
|
557
|
+
onSelectHex,
|
|
558
|
+
paletteFull,
|
|
559
|
+
buttonClass,
|
|
560
|
+
groupClass,
|
|
561
|
+
}) {
|
|
468
562
|
const [slotPicker, setSlotPicker] = useState(null);
|
|
469
563
|
const fillRef = useRef(null);
|
|
470
564
|
const strokeRef = useRef(null);
|
|
@@ -485,9 +579,10 @@ function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass,
|
|
|
485
579
|
setSlotPicker(null);
|
|
486
580
|
}
|
|
487
581
|
|
|
488
|
-
function
|
|
489
|
-
if (
|
|
490
|
-
|
|
582
|
+
function applyColor(hex) {
|
|
583
|
+
if (hex) onSelectHex?.(hex);
|
|
584
|
+
if (slotPicker === 'fill') pathActions?.setFill(hex ?? null);
|
|
585
|
+
else if (slotPicker === 'stroke') pathActions?.setStroke(hex ?? null);
|
|
491
586
|
closePicker();
|
|
492
587
|
}
|
|
493
588
|
|
|
@@ -590,14 +685,20 @@ function ShapeInspectorButtons({ pathActions, palette, paletteKeys, buttonClass,
|
|
|
590
685
|
{slotPicker === 'fill' ? 'Fill' : 'Stroke'}
|
|
591
686
|
</span>
|
|
592
687
|
</div>
|
|
593
|
-
<
|
|
594
|
-
|
|
595
|
-
|
|
688
|
+
<PaletteSections
|
|
689
|
+
spriteKeys={paletteKeys}
|
|
690
|
+
spritePalette={palette}
|
|
691
|
+
pages={pages}
|
|
692
|
+
pageIndex={pageIndex}
|
|
693
|
+
onPage={onPage}
|
|
596
694
|
activeKey={pickerKey}
|
|
695
|
+
activeHex={pickerKey ? palette?.[pickerKey] : null}
|
|
696
|
+
onSelectKey={(key) => applyColor(palette?.[key])}
|
|
697
|
+
onSelectHex={applyColor}
|
|
597
698
|
allowNone
|
|
598
699
|
noneSelected={pickerNoneSelected}
|
|
599
|
-
onSelectNone={() =>
|
|
600
|
-
|
|
700
|
+
onSelectNone={() => applyColor(null)}
|
|
701
|
+
paletteFull={paletteFull}
|
|
601
702
|
/>
|
|
602
703
|
</PalettePopover>
|
|
603
704
|
</>
|
|
@@ -614,8 +715,13 @@ export function PaintStrip({
|
|
|
614
715
|
onTogglePalette,
|
|
615
716
|
colorButtonRef,
|
|
616
717
|
onSelectKey,
|
|
718
|
+
onSelectHex,
|
|
617
719
|
paletteKeys,
|
|
618
720
|
palette,
|
|
721
|
+
pages,
|
|
722
|
+
pageIndex,
|
|
723
|
+
onPage,
|
|
724
|
+
paletteFull,
|
|
619
725
|
moveActions,
|
|
620
726
|
selectActions,
|
|
621
727
|
pathActions,
|
|
@@ -662,6 +768,15 @@ export function PaintStrip({
|
|
|
662
768
|
style={{ background: activeColor }}
|
|
663
769
|
onClick={onTogglePalette}
|
|
664
770
|
/>
|
|
771
|
+
<button
|
|
772
|
+
type="button"
|
|
773
|
+
className={cx(styles.paintStripModeButton, picking && styles.drawingToolSelected)}
|
|
774
|
+
title="Pick color"
|
|
775
|
+
aria-label="Pick color"
|
|
776
|
+
aria-pressed={picking ? 'true' : 'false'}
|
|
777
|
+
onClick={() => setPicking(!picking)}>
|
|
778
|
+
<Icon name="eyedropper" />
|
|
779
|
+
</button>
|
|
665
780
|
</div>
|
|
666
781
|
) : null}
|
|
667
782
|
{showBrushSize || showEraseSize ? (
|
|
@@ -772,6 +887,11 @@ export function PaintStrip({
|
|
|
772
887
|
pathActions={pathActions}
|
|
773
888
|
palette={palette}
|
|
774
889
|
paletteKeys={paletteKeys}
|
|
890
|
+
pages={pages}
|
|
891
|
+
pageIndex={pageIndex}
|
|
892
|
+
onPage={onPage}
|
|
893
|
+
onSelectHex={onSelectHex}
|
|
894
|
+
paletteFull={paletteFull}
|
|
775
895
|
buttonClass={styles.paintStripModeButton}
|
|
776
896
|
groupClass={styles.paintStripSection}
|
|
777
897
|
/>
|
|
@@ -780,22 +900,24 @@ export function PaintStrip({
|
|
|
780
900
|
<PalettePopover open={paletteOpen} anchorRef={colorButtonRef} onClose={() => onTogglePalette(false)}>
|
|
781
901
|
<div className={styles.palettePopoverHeader}>
|
|
782
902
|
<span className={styles.palettePopoverTitle}>Palette</span>
|
|
783
|
-
<IconButton
|
|
784
|
-
icon="eyedropper"
|
|
785
|
-
label="Pick color"
|
|
786
|
-
active={picking}
|
|
787
|
-
aria-pressed={picking ? 'true' : 'false'}
|
|
788
|
-
onClick={() => setPicking(!picking)}
|
|
789
|
-
/>
|
|
790
903
|
</div>
|
|
791
|
-
<
|
|
792
|
-
|
|
793
|
-
|
|
904
|
+
<PaletteSections
|
|
905
|
+
spriteKeys={paletteKeys}
|
|
906
|
+
spritePalette={palette}
|
|
907
|
+
pages={pages}
|
|
908
|
+
pageIndex={pageIndex}
|
|
909
|
+
onPage={onPage}
|
|
794
910
|
activeKey={activeKey}
|
|
911
|
+
activeHex={activeColor}
|
|
795
912
|
onSelectKey={(key) => {
|
|
796
913
|
onSelectKey(key);
|
|
797
914
|
onTogglePalette(false);
|
|
798
915
|
}}
|
|
916
|
+
onSelectHex={(hex) => {
|
|
917
|
+
onSelectHex(hex);
|
|
918
|
+
onTogglePalette(false);
|
|
919
|
+
}}
|
|
920
|
+
paletteFull={paletteFull}
|
|
799
921
|
/>
|
|
800
922
|
</PalettePopover>
|
|
801
923
|
</>
|
|
@@ -806,9 +928,15 @@ export function PaintStrip({
|
|
|
806
928
|
export function PxArtInspectorDock({
|
|
807
929
|
toolState,
|
|
808
930
|
activeKey,
|
|
931
|
+
activeHex,
|
|
809
932
|
onSelectKey,
|
|
933
|
+
onSelectHex,
|
|
810
934
|
paletteKeys,
|
|
811
935
|
palette,
|
|
936
|
+
pages,
|
|
937
|
+
pageIndex,
|
|
938
|
+
onPage,
|
|
939
|
+
paletteFull,
|
|
812
940
|
moveActions,
|
|
813
941
|
selectActions,
|
|
814
942
|
pathActions,
|
|
@@ -829,11 +957,17 @@ export function PxArtInspectorDock({
|
|
|
829
957
|
onClick={() => setPicking(!picking)}
|
|
830
958
|
/>
|
|
831
959
|
}>
|
|
832
|
-
<
|
|
833
|
-
|
|
834
|
-
|
|
960
|
+
<PaletteSections
|
|
961
|
+
spriteKeys={paletteKeys}
|
|
962
|
+
spritePalette={palette}
|
|
963
|
+
pages={pages}
|
|
964
|
+
pageIndex={pageIndex}
|
|
965
|
+
onPage={onPage}
|
|
835
966
|
activeKey={activeKey}
|
|
967
|
+
activeHex={activeHex}
|
|
836
968
|
onSelectKey={onSelectKey}
|
|
969
|
+
onSelectHex={onSelectHex}
|
|
970
|
+
paletteFull={paletteFull}
|
|
837
971
|
/>
|
|
838
972
|
</Panel>
|
|
839
973
|
) : null}
|
|
@@ -844,6 +978,11 @@ export function PxArtInspectorDock({
|
|
|
844
978
|
pathActions={pathActions}
|
|
845
979
|
palette={palette}
|
|
846
980
|
paletteKeys={paletteKeys}
|
|
981
|
+
pages={pages}
|
|
982
|
+
pageIndex={pageIndex}
|
|
983
|
+
onPage={onPage}
|
|
984
|
+
onSelectHex={onSelectHex}
|
|
985
|
+
paletteFull={paletteFull}
|
|
847
986
|
/>
|
|
848
987
|
</div>
|
|
849
988
|
</aside>
|
|
@@ -876,6 +1015,11 @@ export function ToolSettingsPanel({
|
|
|
876
1015
|
pathActions,
|
|
877
1016
|
palette,
|
|
878
1017
|
paletteKeys,
|
|
1018
|
+
pages,
|
|
1019
|
+
pageIndex,
|
|
1020
|
+
onPage,
|
|
1021
|
+
onSelectHex,
|
|
1022
|
+
paletteFull,
|
|
879
1023
|
}) {
|
|
880
1024
|
const {
|
|
881
1025
|
tool,
|
|
@@ -987,6 +1131,11 @@ export function ToolSettingsPanel({
|
|
|
987
1131
|
pathActions={pathActions}
|
|
988
1132
|
palette={palette}
|
|
989
1133
|
paletteKeys={paletteKeys}
|
|
1134
|
+
pages={pages}
|
|
1135
|
+
pageIndex={pageIndex}
|
|
1136
|
+
onPage={onPage}
|
|
1137
|
+
onSelectHex={onSelectHex}
|
|
1138
|
+
paletteFull={paletteFull}
|
|
990
1139
|
buttonClass={styles.drawingToolButton}
|
|
991
1140
|
groupClass={styles.drawingShapeModeRow}
|
|
992
1141
|
/>
|
|
@@ -19,12 +19,8 @@ import {
|
|
|
19
19
|
TRANSPARENT,
|
|
20
20
|
VECTOR_RENDERER,
|
|
21
21
|
} from '../engine/pxart';
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
EDITOR_KEYS,
|
|
25
|
-
EDITOR_PALETTE,
|
|
26
|
-
editorKeyForHex,
|
|
27
|
-
} from './pxArtTools';
|
|
22
|
+
import { paletteRecord } from '../engine/palettes';
|
|
23
|
+
import { blankCells } from './pxArtTools';
|
|
28
24
|
import {
|
|
29
25
|
bakePathCell,
|
|
30
26
|
clonePathData,
|
|
@@ -36,15 +32,11 @@ import {
|
|
|
36
32
|
// ============================================================================
|
|
37
33
|
// Working-state helpers for the full Sprite the PxArt editor edits in memory.
|
|
38
34
|
//
|
|
39
|
-
// The editor
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
// (returns a fresh Sprite) so it flows straight into the text undo/redo history.
|
|
35
|
+
// The editor works on the sprite's own palette. Opening never remaps colors;
|
|
36
|
+
// painting allocates keys into that palette as needed. Every mutation is
|
|
37
|
+
// immutable (returns a fresh Sprite) so it flows into the text undo/redo history.
|
|
43
38
|
// ============================================================================
|
|
44
39
|
|
|
45
|
-
// The Endesga-64 palette as an ordered { key, hex } list (the full palette shape).
|
|
46
|
-
export const EDITOR_ORDERED = EDITOR_KEYS.map((key) => ({ key, hex: EDITOR_PALETTE[key] }));
|
|
47
|
-
|
|
48
40
|
// Size a canvas to a sprite resolution, disable smoothing, and clear it.
|
|
49
41
|
// Returns the 2D context (or null). Shared by the artboard + thumbnail renders.
|
|
50
42
|
export function setupSpriteCanvas(canvas, resolution) {
|
|
@@ -75,14 +67,14 @@ export function forEachGridCell(grid, width, height, visit) {
|
|
|
75
67
|
export function deriveSprite(text) {
|
|
76
68
|
if (!(text ?? '').trim()) return blankSprite();
|
|
77
69
|
const parsed = parseFull(text);
|
|
78
|
-
return parsed
|
|
70
|
+
return parsed ?? null;
|
|
79
71
|
}
|
|
80
72
|
|
|
81
73
|
function blankSprite() {
|
|
82
74
|
const { width, height } = DEFAULT_RESOLUTION;
|
|
83
75
|
return {
|
|
84
76
|
resolution: { width, height },
|
|
85
|
-
palette:
|
|
77
|
+
palette: [],
|
|
86
78
|
// The lone frame inherits the global duration (no per-frame override).
|
|
87
79
|
frames: [{}],
|
|
88
80
|
defaultDurationMs: DEFAULT_DURATION_MS,
|
|
@@ -119,53 +111,6 @@ function gridCell(grid, x, y) {
|
|
|
119
111
|
return x || y ? { grid, x, y } : { grid };
|
|
120
112
|
}
|
|
121
113
|
|
|
122
|
-
// Remap every grid char onto the nearest Endesga-64 key and swap in the full
|
|
123
|
-
// palette, leaving links / null cells / structure untouched. Idempotent for an
|
|
124
|
-
// already-normalized sprite.
|
|
125
|
-
function normalizeToPalette(sprite) {
|
|
126
|
-
const lookup = paletteLookup(sprite.palette);
|
|
127
|
-
const layers = sprite.layers.map((layer) => ({
|
|
128
|
-
...layer,
|
|
129
|
-
cells: layer.cells.map((cell) => remapCell(cell, lookup)),
|
|
130
|
-
}));
|
|
131
|
-
return { ...sprite, palette: EDITOR_ORDERED, layers };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function remapCell(cell, lookup) {
|
|
135
|
-
if (cell == null) return null;
|
|
136
|
-
if ('link' in cell) return { link: cell.link };
|
|
137
|
-
// Preserve the cel's offset while remapping its palette keys.
|
|
138
|
-
const next = gridCell(cell.grid.map((row) => remapRow(row, lookup)), cell.x ?? 0, cell.y ?? 0);
|
|
139
|
-
return cell.path ? { ...next, path: remapPathKeys(cell.path, lookup) } : next;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function remapPathKeys(path, lookup) {
|
|
143
|
-
const next = clonePathData(path);
|
|
144
|
-
for (const shape of next.shapes) {
|
|
145
|
-
if (shape.fill) shape.fill = remapKey(shape.fill, lookup);
|
|
146
|
-
if (shape.stroke) shape.stroke = remapKey(shape.stroke, lookup);
|
|
147
|
-
}
|
|
148
|
-
return next;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function remapKey(key, lookup) {
|
|
152
|
-
const hex = colorForKeyV2(lookup, key);
|
|
153
|
-
return hex ? editorKeyForHex(hex) : key;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function remapRow(row, lookup) {
|
|
157
|
-
let out = '';
|
|
158
|
-
for (const ch of row) {
|
|
159
|
-
if (ch === TRANSPARENT) {
|
|
160
|
-
out += TRANSPARENT;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const hex = colorForKeyV2(lookup, ch);
|
|
164
|
-
out += hex ? editorKeyForHex(hex) : TRANSPARENT;
|
|
165
|
-
}
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
114
|
// ---------------------------------------------------------------------------
|
|
170
115
|
// cells <-> (layer, frame)
|
|
171
116
|
// ---------------------------------------------------------------------------
|
|
@@ -798,7 +743,7 @@ function shiftCellOffset(cell, dx, dy, width, height) {
|
|
|
798
743
|
// representable; if not, it correctly forces the full form.
|
|
799
744
|
export function serializeModel(sprite) {
|
|
800
745
|
const compact = serializeCompact({
|
|
801
|
-
...fromCells(
|
|
746
|
+
...fromCells(paletteRecord(sprite.palette), cellsAt(sprite, 0, 0)),
|
|
802
747
|
renderer: sprite.renderer,
|
|
803
748
|
cornerRadius: sprite.cornerRadius,
|
|
804
749
|
});
|