castle-web-cli 0.4.177 → 0.4.179
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-failures.js +4 -4
- package/dist/agent-prompts.d.ts +9 -1
- package/dist/agent-prompts.js +15 -2
- package/dist/agent.js +125 -40
- package/dist/deckLocatorShape.d.ts +16 -0
- package/dist/deckLocatorShape.js +28 -0
- package/dist/editorConfig.d.ts +1 -0
- package/dist/shell/assets/index-C5DS_7UM.js +447 -0
- package/dist/shell/assets/index-CoU3ETYM.css +1 -0
- package/dist/shell/index.html +2 -2
- package/kits/base/CLAUDE.md +3 -0
- package/kits/base/castle.json +21 -11
- package/kits/physics-2d/CLAUDE.md +41 -8
- package/kits/physics-2d/behaviors/AnalogStick.jsx +80 -4
- package/kits/physics-2d/behaviors/Slingshot.jsx +14 -2
- package/kits/physics-2d/behaviors/Sprite.jsx +17 -8
- package/kits/physics-2d/behaviors/Style.jsx +270 -0
- package/kits/physics-2d/behaviors/Text.jsx +213 -0
- package/kits/physics-2d/behaviors/Video.jsx +8 -4
- package/kits/physics-2d/blueprints/text.scene +12 -0
- package/kits/physics-2d/castle.json +10 -6
- package/kits/physics-2d/editors/SceneEditor.jsx +41 -0
- package/kits/physics-2d/editors/deckFont.js +65 -55
- package/kits/physics-2d/editors/fontPreview.js +6 -38
- package/kits/physics-2d/editors/pixelInspector.jsx +4 -154
- package/kits/physics-2d/engine/blueprint.js +28 -0
- package/kits/physics-2d/engine/fonts.js +125 -23
- package/kits/physics-2d/engine/paletteField.jsx +235 -0
- package/kits/physics-2d/engine/physics/controls.js +5 -81
- package/kits/physics-2d/engine/popoverDismiss.js +17 -0
- package/kits/physics-2d/engine/scene.js +12 -3
- package/kits/physics-2d/engine/spriteField.jsx +2 -15
- package/kits/physics-2d/engine/tap.js +90 -0
- package/kits/physics-2d/engine/text.js +94 -0
- package/kits/physics-2d/engine/ui.jsx +20 -0
- package/kits/physics-2d/engine/ui.module.css +10 -2
- package/kits/physics-3d/castle.json +4 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-BWOEraUy.js +0 -447
- package/dist/shell/assets/index-BkVF1OXc.css +0 -1
|
@@ -27,12 +27,14 @@ import {
|
|
|
27
27
|
screenToCard,
|
|
28
28
|
setActorComponent,
|
|
29
29
|
} from '../engine/scene';
|
|
30
|
+
import { fontsReady, ensureOfficialFace } from '../engine/fonts';
|
|
30
31
|
import {
|
|
31
32
|
addActorWithBlueprint,
|
|
32
33
|
blueprintDropXY,
|
|
33
34
|
cascadeDeleteBlueprint,
|
|
34
35
|
countBlueprintInstances,
|
|
35
36
|
createBlueprint,
|
|
37
|
+
forkBlueprint,
|
|
36
38
|
getBlueprintTemplate,
|
|
37
39
|
importAliasOf,
|
|
38
40
|
isBlueprintPath,
|
|
@@ -679,6 +681,22 @@ export function SceneEditor({
|
|
|
679
681
|
void writeFile(result.blueprintFile.path, result.blueprintFile.text);
|
|
680
682
|
commitScene(result.sceneData);
|
|
681
683
|
};
|
|
684
|
+
const onForkBlueprint = () => {
|
|
685
|
+
// New deck-owned file from the selected blueprint's template. Instances of
|
|
686
|
+
// the source are untouched — unlike new-from-actor, which reparents one.
|
|
687
|
+
if (!selectedBlueprintPath) return;
|
|
688
|
+
const result = forkBlueprint(files, selectedBlueprintPath);
|
|
689
|
+
if (!result) return;
|
|
690
|
+
if (onChangeFile) {
|
|
691
|
+
if (result.drawingFile) onChangeFile(result.drawingFile.path, result.drawingFile.text);
|
|
692
|
+
onChangeFile(result.blueprintFile.path, result.blueprintFile.text);
|
|
693
|
+
} else {
|
|
694
|
+
if (result.drawingFile) void writeFile(result.drawingFile.path, result.drawingFile.text);
|
|
695
|
+
void writeFile(result.blueprintFile.path, result.blueprintFile.text);
|
|
696
|
+
}
|
|
697
|
+
onSelectBlueprint(result.blueprintFile.path);
|
|
698
|
+
setFocusName(result.blueprintFile.path);
|
|
699
|
+
};
|
|
682
700
|
return (
|
|
683
701
|
<>
|
|
684
702
|
<EditorHeader
|
|
@@ -846,6 +864,7 @@ export function SceneEditor({
|
|
|
846
864
|
files={files}
|
|
847
865
|
onEditSprite={onEditSprite}
|
|
848
866
|
onViewSource={onViewSource}
|
|
867
|
+
onFork={onForkBlueprint}
|
|
849
868
|
autoFocusName={focusName === selectedBlueprintPath}
|
|
850
869
|
onNameFocused={() => setFocusName(null)}
|
|
851
870
|
onRename={blueprintActions.rename}
|
|
@@ -1745,6 +1764,17 @@ function useScenePlayLoop({
|
|
|
1745
1764
|
const canvas = canvasRef.current;
|
|
1746
1765
|
const ctx = canvas.getContext('2d');
|
|
1747
1766
|
if (!ctx) return undefined;
|
|
1767
|
+
// Play pane awaits this before the first frame; the editor must kick the
|
|
1768
|
+
// same install or canvas text (Text actors, badges) keeps the fallback face.
|
|
1769
|
+
// The loop keeps drawing, so the face snaps in once document.fonts settles.
|
|
1770
|
+
void fontsReady();
|
|
1771
|
+
// Faces chosen mid-session (or present on actors before fonts.generated.js
|
|
1772
|
+
// was reloaded) are not in the eager module fontsReady reads — pull those
|
|
1773
|
+
// in so edit mode matches play without a panel reload.
|
|
1774
|
+
for (const actor of sceneData.actors ?? []) {
|
|
1775
|
+
const font = actor?.components?.Text?.font;
|
|
1776
|
+
if (font) void ensureOfficialFace(font);
|
|
1777
|
+
}
|
|
1748
1778
|
const viewport = isPlaying ? undefined : editViewportForZoom(editCameraRef.current.zoom);
|
|
1749
1779
|
configureSceneCanvas(canvas, ctx, viewport);
|
|
1750
1780
|
let raf = 0;
|
|
@@ -1956,6 +1986,7 @@ function BlueprintInspector({
|
|
|
1956
1986
|
files,
|
|
1957
1987
|
onEditSprite,
|
|
1958
1988
|
onViewSource,
|
|
1989
|
+
onFork,
|
|
1959
1990
|
autoFocusName,
|
|
1960
1991
|
onNameFocused,
|
|
1961
1992
|
onRename,
|
|
@@ -2028,6 +2059,16 @@ function BlueprintInspector({
|
|
|
2028
2059
|
</span>
|
|
2029
2060
|
</div>
|
|
2030
2061
|
</div>
|
|
2062
|
+
{onFork ? (
|
|
2063
|
+
<button
|
|
2064
|
+
type="button"
|
|
2065
|
+
className={styles.instanceForkButton}
|
|
2066
|
+
onClick={onFork}
|
|
2067
|
+
title="New deck blueprint copied from this one">
|
|
2068
|
+
<Icon name="clone" />
|
|
2069
|
+
<span>Fork blueprint</span>
|
|
2070
|
+
</button>
|
|
2071
|
+
) : null}
|
|
2031
2072
|
</div>
|
|
2032
2073
|
<ActorInspector
|
|
2033
2074
|
ownerKey={blueprintPath}
|
|
@@ -1,18 +1,24 @@
|
|
|
1
|
-
// Writing the deck's
|
|
1
|
+
// Writing the deck's fonts: the generated module, and keeping it honest.
|
|
2
2
|
//
|
|
3
|
-
// `theme.style` records
|
|
4
|
-
// the
|
|
5
|
-
// writes both together
|
|
3
|
+
// `theme.style` records the PRIMARY face; `fonts.generated.js` carries bytes
|
|
4
|
+
// for that face plus any `Text.font` values used in the deck (engine/fonts.js).
|
|
5
|
+
// The Theme picker writes both together for the primary; the Text inspector
|
|
6
|
+
// rewrites the module when a per-actor face is chosen.
|
|
6
7
|
//
|
|
7
8
|
// They can still drift -- someone edits `theme.style` in the code editor, or
|
|
8
9
|
// takes a scene file from another deck. `syncDeckFontModule` is the repair, run
|
|
9
|
-
// once per edit-mode panel load
|
|
10
|
-
// what the record asks for and rewrites the module when they disagree. Play
|
|
11
|
-
// mode never repairs, and never needs to: a published deck's module was on disk
|
|
12
|
-
// when `save-deck` bundled it.
|
|
10
|
+
// once per edit-mode panel load.
|
|
13
11
|
|
|
14
12
|
import * as castleSdk from 'castle-web-sdk';
|
|
15
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
DECK_FONT_MODULE,
|
|
15
|
+
collectTextFontsFromFiles,
|
|
16
|
+
fontsGeneratedSource,
|
|
17
|
+
generatedFontName,
|
|
18
|
+
generatedFontNames,
|
|
19
|
+
orderedDeckFonts,
|
|
20
|
+
themeFontName,
|
|
21
|
+
} from '../engine/fonts';
|
|
16
22
|
import { initialFiles } from '../engine/files';
|
|
17
23
|
import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
|
|
18
24
|
|
|
@@ -22,67 +28,71 @@ import { parseThemeData, THEME_STYLE_PATH } from '../engine/palettes';
|
|
|
22
28
|
// nothing, which renders the same and just leaves a stale file behind.
|
|
23
29
|
const deleteFile = castleSdk.deleteFile ?? null;
|
|
24
30
|
|
|
25
|
-
/** The whole content of `fonts.generated.js` for
|
|
26
|
-
export function deckFontModuleSource(name) {
|
|
27
|
-
return
|
|
28
|
-
//
|
|
29
|
-
// \`theme.style\` records that this deck's font is ${name}; this module is what
|
|
30
|
-
// puts ${name}'s bytes in the published bundle. It has to name the face in a
|
|
31
|
-
// static import: reaching it through the name in \`theme.style\` instead would
|
|
32
|
-
// inline all nine Castle faces into the deck, since no bundler can tell which
|
|
33
|
-
// one a runtime lookup reads.
|
|
34
|
-
export { install${name} as installDeckFont } from 'castle-web-fonts';
|
|
35
|
-
export const deckFont = '${name}';
|
|
36
|
-
`;
|
|
31
|
+
/** The whole content of `fonts.generated.js` for a primary + extras. */
|
|
32
|
+
export function deckFontModuleSource(name, extras = []) {
|
|
33
|
+
return fontsGeneratedSource(name, extras);
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
/** An empty module, for the SDK too old to delete a file. Renders as no deck
|
|
40
|
-
* font, same as the file being absent. */
|
|
41
36
|
function emptyModuleSource() {
|
|
42
|
-
return
|
|
43
|
-
//
|
|
44
|
-
// This deck has no font of its own; text renders in the default sans stack.
|
|
45
|
-
export const deckFont = null;
|
|
46
|
-
`;
|
|
37
|
+
return fontsGeneratedSource(null, []);
|
|
47
38
|
}
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
function textFontsFromDeck() {
|
|
41
|
+
return collectTextFontsFromFiles(initialFiles);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Point the deck at `name` as primary, or at no primary when null. Preserves
|
|
45
|
+
* faces Text actors already use. Writes through the host's debounced saver. */
|
|
52
46
|
export function writeDeckFontModule(onChangeFile, name) {
|
|
47
|
+
const extras = textFontsFromDeck();
|
|
53
48
|
if (name) {
|
|
54
|
-
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name));
|
|
49
|
+
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(name, extras));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Primary cleared: keep Text-only faces if any, else remove / empty the module.
|
|
53
|
+
const remaining = orderedDeckFonts(null, extras);
|
|
54
|
+
if (remaining.length) {
|
|
55
|
+
onChangeFile(DECK_FONT_MODULE, deckFontModuleSource(remaining[0], remaining));
|
|
55
56
|
return;
|
|
56
57
|
}
|
|
57
58
|
if (deleteFile) void deleteFile(DECK_FONT_MODULE);
|
|
58
59
|
else onChangeFile(DECK_FONT_MODULE, emptyModuleSource());
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
/** After a Text.font change: ensure that face (plus theme + other Text fonts)
|
|
63
|
+
* is in fonts.generated.js. `pendingFont` covers the value not yet on disk. */
|
|
64
|
+
export function ensureTextFontsInModule(files, pendingFont) {
|
|
65
|
+
const primary = themeFontName(parseThemeData(files?.[THEME_STYLE_PATH])) ?? generatedFontName;
|
|
66
|
+
const extras = collectTextFontsFromFiles(files);
|
|
67
|
+
if (pendingFont) extras.push(pendingFont);
|
|
68
|
+
const next = orderedDeckFonts(primary, extras);
|
|
69
|
+
const prev = generatedFontNames;
|
|
70
|
+
if (next.length === prev.length && next.every((name, i) => name === prev[i])) {
|
|
71
|
+
if ((primary ?? null) === (generatedFontName ?? null)) return null;
|
|
72
|
+
}
|
|
73
|
+
const source = next.length
|
|
74
|
+
? fontsGeneratedSource(primary ?? next[0], next)
|
|
75
|
+
: emptyModuleSource();
|
|
76
|
+
void castleSdk.writeFile(DECK_FONT_MODULE, source).catch(() => {});
|
|
77
|
+
return `Text fonts -- rewriting ${DECK_FONT_MODULE} (${next.join(', ') || 'none'})`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Edit mode only: make `fonts.generated.js` match theme primary + Text fonts.
|
|
67
81
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* file, so the repair goes through the durable save queue like every other
|
|
71
|
-
* write -- see below. Picking "None" in the editor still deletes the file
|
|
72
|
-
* outright; that is a person clicking, with a connection that is up. */
|
|
82
|
+
* Both sides are read from the build-time snapshot. Returns what it did, or
|
|
83
|
+
* null when there was nothing to do. */
|
|
73
84
|
export function syncDeckFontModule() {
|
|
74
85
|
const wanted = themeFontName(parseThemeData(initialFiles[THEME_STYLE_PATH]));
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
void castleSdk
|
|
82
|
-
DECK_FONT_MODULE,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
? `${THEME_STYLE_PATH} asks for ${wanted} -- rewriting ${DECK_FONT_MODULE}`
|
|
86
|
+
const extras = textFontsFromDeck();
|
|
87
|
+
const next = orderedDeckFonts(wanted, extras);
|
|
88
|
+
const prev = generatedFontNames;
|
|
89
|
+
const primaryMatch = (wanted ?? null) === (generatedFontName ?? null);
|
|
90
|
+
const listMatch = next.length === prev.length && next.every((name, i) => name === prev[i]);
|
|
91
|
+
if (primaryMatch && listMatch) return null;
|
|
92
|
+
void castleSdk
|
|
93
|
+
.writeFile(DECK_FONT_MODULE, next.length ? fontsGeneratedSource(wanted ?? next[0], next) : emptyModuleSource())
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
return next.length
|
|
96
|
+
? `fonts -- rewriting ${DECK_FONT_MODULE} (primary ${wanted ?? next[0]}; ${next.join(', ')})`
|
|
87
97
|
: `${THEME_STYLE_PATH} sets no font -- clearing ${DECK_FONT_MODULE}`;
|
|
88
98
|
}
|
|
@@ -4,48 +4,16 @@
|
|
|
4
4
|
// deck must never do: importing all nine would inline 495 KB into every
|
|
5
5
|
// published deck, and the whole physics-2d bundle is under 900 KB.
|
|
6
6
|
//
|
|
7
|
-
// So the specimens are not imported, they are FETCHED
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// place an editor runs, so nothing is lost. A face that doesn't arrive leaves
|
|
12
|
-
// its card in the UI font, and the picker still picks.
|
|
7
|
+
// So the specimens are not imported, they are FETCHED (see ensureOfficialFace).
|
|
8
|
+
// That only works while editing locally — which is the only place an editor
|
|
9
|
+
// runs, so nothing is lost. A face that doesn't arrive leaves its card in the
|
|
10
|
+
// UI font, and the picker still picks.
|
|
13
11
|
|
|
14
12
|
import { useEffect, useState } from 'react';
|
|
15
|
-
import { OFFICIAL_FONTS } from '../engine/fonts';
|
|
13
|
+
import { OFFICIAL_FONTS, ensureOfficialFace } from '../engine/fonts';
|
|
16
14
|
|
|
17
15
|
const FACE_NAMES = OFFICIAL_FONTS.map((face) => face.name);
|
|
18
16
|
|
|
19
|
-
const faceUrl = (name) => `/node_modules/castle-web-fonts/woff2/${name}.woff2`;
|
|
20
|
-
|
|
21
|
-
// Module-scope so the panel's re-renders don't refetch, and so switching files
|
|
22
|
-
// and back is instant. Values are promises resolving to true / false.
|
|
23
|
-
const requests = new Map();
|
|
24
|
-
|
|
25
|
-
function request(name) {
|
|
26
|
-
const pending = requests.get(name);
|
|
27
|
-
if (pending) return pending;
|
|
28
|
-
const load = loadFace(name);
|
|
29
|
-
requests.set(name, load);
|
|
30
|
-
return load;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function loadFace(name) {
|
|
34
|
-
if (typeof FontFace === 'undefined') return false;
|
|
35
|
-
try {
|
|
36
|
-
// Registered under the face's real family name, so a card just asks for
|
|
37
|
-
// `font-family: Tektur`. When the deck's own font is already installed this
|
|
38
|
-
// adds a second face for that family from the identical bytes — which
|
|
39
|
-
// renders identically, and costs one entry in document.fonts.
|
|
40
|
-
const face = new FontFace(name, `url("${faceUrl(name)}") format("woff2")`);
|
|
41
|
-
await face.load();
|
|
42
|
-
document.fonts.add(face);
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false; // no serve, or an install without the package: no specimen.
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
17
|
/** Load every Castle face's specimen and report which have arrived. Cards
|
|
50
18
|
* re-render into their own face as each one lands. */
|
|
51
19
|
export function usePreviewFaces() {
|
|
@@ -53,7 +21,7 @@ export function usePreviewFaces() {
|
|
|
53
21
|
useEffect(() => {
|
|
54
22
|
let live = true;
|
|
55
23
|
for (const name of FACE_NAMES) {
|
|
56
|
-
void
|
|
24
|
+
void ensureOfficialFace(name).then((ok) => {
|
|
57
25
|
if (!ok || !live) return;
|
|
58
26
|
setLoaded((current) => (current.has(name) ? current : new Set(current).add(name)));
|
|
59
27
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import { createPortal } from 'react-dom';
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
3
2
|
import { GRID_RENDERER, RESOLUTION_STEPS, VECTOR_RENDERER } from '../engine/pxart';
|
|
3
|
+
import { PaletteGrid, PalettePager, PalettePopover } from '../engine/paletteField';
|
|
4
4
|
import { cx, Icon, IconButton, Panel, styles } from '../engine/ui';
|
|
5
5
|
|
|
6
|
+
export { NoneSwatch, PaletteGrid, PalettePager, PalettePopover, sameHex } from '../engine/paletteField';
|
|
7
|
+
|
|
6
8
|
// Brush/erase diameters offered by the pixel editors' size sliders.
|
|
7
9
|
export const BRUSH_SIZES = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32];
|
|
8
10
|
|
|
@@ -223,86 +225,6 @@ function ResolutionSelect({ label, value, onChange, disabled = false }) {
|
|
|
223
225
|
);
|
|
224
226
|
}
|
|
225
227
|
|
|
226
|
-
function sameHex(a, b) {
|
|
227
|
-
return !!a && !!b && a.toLowerCase() === b.toLowerCase();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function NoneSwatch({ selected, onSelect }) {
|
|
231
|
-
return (
|
|
232
|
-
<button
|
|
233
|
-
type="button"
|
|
234
|
-
className={cx(styles.swatch, selected && styles.swatchSelected)}
|
|
235
|
-
title="None"
|
|
236
|
-
aria-label="None"
|
|
237
|
-
style={{ position: 'relative', background: 'transparent' }}
|
|
238
|
-
onClick={onSelect}>
|
|
239
|
-
<span
|
|
240
|
-
aria-hidden
|
|
241
|
-
style={{
|
|
242
|
-
position: 'absolute',
|
|
243
|
-
inset: 4,
|
|
244
|
-
background:
|
|
245
|
-
'linear-gradient(to top right, transparent calc(50% - 1px), #e66 calc(50% - 1px), #e66 calc(50% + 1px), transparent calc(50% + 1px))',
|
|
246
|
-
}}
|
|
247
|
-
/>
|
|
248
|
-
</button>
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
// Swatch strip shared by the docked sidebar and the compact popover.
|
|
253
|
-
// `keys` paints a sprite's working palette; `hexes` paints a picker list.
|
|
254
|
-
// `wrap` is the unlabeled sprite row; the named official page is an 8-col grid.
|
|
255
|
-
export function PaletteGrid({
|
|
256
|
-
keys,
|
|
257
|
-
hexes,
|
|
258
|
-
palette,
|
|
259
|
-
activeKey,
|
|
260
|
-
activeHex,
|
|
261
|
-
onSelectKey,
|
|
262
|
-
onSelectHex,
|
|
263
|
-
allowNone = false,
|
|
264
|
-
noneSelected = false,
|
|
265
|
-
onSelectNone,
|
|
266
|
-
wrap = false,
|
|
267
|
-
}) {
|
|
268
|
-
const items = hexes
|
|
269
|
-
? hexes.map((hex) => ({ id: hex, hex, selected: sameHex(activeHex, hex), onClick: () => onSelectHex?.(hex) }))
|
|
270
|
-
: (keys ?? []).map((key) => ({
|
|
271
|
-
id: key,
|
|
272
|
-
hex: palette?.[key],
|
|
273
|
-
selected: activeKey === key || sameHex(activeHex, palette?.[key]),
|
|
274
|
-
onClick: () => onSelectKey?.(key),
|
|
275
|
-
}));
|
|
276
|
-
if (!allowNone && !items.length) return null;
|
|
277
|
-
return (
|
|
278
|
-
<div className={wrap ? styles.paletteSpriteRow : styles.palette}>
|
|
279
|
-
{allowNone ? <NoneSwatch selected={noneSelected} onSelect={onSelectNone} /> : null}
|
|
280
|
-
{items.map((item) => (
|
|
281
|
-
<button
|
|
282
|
-
key={item.id}
|
|
283
|
-
type="button"
|
|
284
|
-
className={cx(styles.swatch, item.selected && styles.swatchSelected)}
|
|
285
|
-
title={item.hex}
|
|
286
|
-
style={{ background: item.hex }}
|
|
287
|
-
onClick={item.onClick}
|
|
288
|
-
/>
|
|
289
|
-
))}
|
|
290
|
-
</div>
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function PalettePager({ name, onPage }) {
|
|
295
|
-
return (
|
|
296
|
-
<div className={styles.palettePager}>
|
|
297
|
-
<span className={styles.palettePagerName}>{name}</span>
|
|
298
|
-
<div className={styles.palettePagerBtns}>
|
|
299
|
-
<IconButton icon="chevron-left" label="Previous palette" onClick={() => onPage(-1)} />
|
|
300
|
-
<IconButton icon="chevron-right" label="Next palette" onClick={() => onPage(1)} />
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
228
|
// Unlabeled sprite-color row (when the sprite has colors, or when fill/stroke
|
|
307
229
|
// needs a none swatch) plus a paged official-palette grid. Paging only changes
|
|
308
230
|
// which named page is shown — it does not write theme.style.
|
|
@@ -349,78 +271,6 @@ export function PaletteSections({
|
|
|
349
271
|
);
|
|
350
272
|
}
|
|
351
273
|
|
|
352
|
-
// Anchored palette + eyedropper popover for compact/mobile paint-strip color tap.
|
|
353
|
-
export function PalettePopover({ open, anchorRef, onClose, children }) {
|
|
354
|
-
const popoverRef = useRef(null);
|
|
355
|
-
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
356
|
-
|
|
357
|
-
useLayoutEffect(() => {
|
|
358
|
-
if (!open || !anchorRef.current) return;
|
|
359
|
-
|
|
360
|
-
function positionPopover() {
|
|
361
|
-
const anchor = anchorRef.current.getBoundingClientRect();
|
|
362
|
-
const rect = popoverRef.current?.getBoundingClientRect();
|
|
363
|
-
const margin = 8;
|
|
364
|
-
const width = rect?.width ?? 231;
|
|
365
|
-
const height = rect?.height ?? 320;
|
|
366
|
-
const maxLeft = Math.max(margin, window.innerWidth - width - margin);
|
|
367
|
-
const maxTop = Math.max(margin, window.innerHeight - height - margin);
|
|
368
|
-
const leftSide = anchor.left - width - margin;
|
|
369
|
-
const rightSide = anchor.right + margin;
|
|
370
|
-
// Prefer the side with room, then clamp a too-wide picker inside this
|
|
371
|
-
// iframe rather than sending it beyond either viewport edge.
|
|
372
|
-
const left =
|
|
373
|
-
leftSide >= margin
|
|
374
|
-
? leftSide
|
|
375
|
-
: rightSide <= maxLeft
|
|
376
|
-
? rightSide
|
|
377
|
-
: Math.min(maxLeft, Math.max(margin, anchor.left + anchor.width / 2 - width / 2));
|
|
378
|
-
const top = Math.min(maxTop, Math.max(margin, anchor.top));
|
|
379
|
-
setPosition({ top, left });
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
positionPopover();
|
|
383
|
-
window.addEventListener('resize', positionPopover);
|
|
384
|
-
window.addEventListener('scroll', positionPopover, true);
|
|
385
|
-
return () => {
|
|
386
|
-
window.removeEventListener('resize', positionPopover);
|
|
387
|
-
window.removeEventListener('scroll', positionPopover, true);
|
|
388
|
-
};
|
|
389
|
-
}, [open, anchorRef]);
|
|
390
|
-
|
|
391
|
-
useEffect(() => {
|
|
392
|
-
if (!open) return undefined;
|
|
393
|
-
function onKeyDown(event) {
|
|
394
|
-
if (event.key === 'Escape') onClose();
|
|
395
|
-
}
|
|
396
|
-
function onPointerDown(event) {
|
|
397
|
-
if (popoverRef.current?.contains(event.target) || anchorRef.current?.contains(event.target)) {
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
onClose();
|
|
401
|
-
}
|
|
402
|
-
window.addEventListener('keydown', onKeyDown);
|
|
403
|
-
window.addEventListener('pointerdown', onPointerDown);
|
|
404
|
-
return () => {
|
|
405
|
-
window.removeEventListener('keydown', onKeyDown);
|
|
406
|
-
window.removeEventListener('pointerdown', onPointerDown);
|
|
407
|
-
};
|
|
408
|
-
}, [open, onClose, anchorRef]);
|
|
409
|
-
|
|
410
|
-
if (!open) return null;
|
|
411
|
-
return createPortal(
|
|
412
|
-
<div
|
|
413
|
-
ref={popoverRef}
|
|
414
|
-
className={styles.palettePopover}
|
|
415
|
-
style={{ top: position.top, left: position.left }}
|
|
416
|
-
role="dialog"
|
|
417
|
-
aria-label="Palette">
|
|
418
|
-
{children}
|
|
419
|
-
</div>,
|
|
420
|
-
document.body
|
|
421
|
-
);
|
|
422
|
-
}
|
|
423
|
-
|
|
424
274
|
// The shape tool's sub-mode picker, shared by the compact paint strip and the
|
|
425
275
|
// wide-layout settings panel. `buttonClass` styles each glyph button for its
|
|
426
276
|
// host strip; `radio` switches the a11y semantics to a radiogroup.
|
|
@@ -696,6 +696,34 @@ export function newBlueprintFromActor(files, behaviors, sceneData, actorId) {
|
|
|
696
696
|
return { sceneData: next, blueprintFile };
|
|
697
697
|
}
|
|
698
698
|
|
|
699
|
+
// "Fork blueprint": mint a NEW deck-owned blueprint whose template is a copy of
|
|
700
|
+
// the selected one. Additive — existing instances keep pointing at the source.
|
|
701
|
+
// An import's art is copied into the deck (same as creating from a kit preset)
|
|
702
|
+
// so the fork is editable; a deck-owned source keeps sharing its art refs.
|
|
703
|
+
export function forkBlueprint(files, blueprintPath) {
|
|
704
|
+
const template = getBlueprintTemplate(files, blueprintPath);
|
|
705
|
+
if (!template) return null;
|
|
706
|
+
const existingPaths = new Set(Object.keys(files ?? {}));
|
|
707
|
+
const existingNames = listBlueprints(files)
|
|
708
|
+
.filter((blueprint) => !isImportedPath(blueprint.path))
|
|
709
|
+
.map((blueprint) => blueprint.name);
|
|
710
|
+
const base = `${template.name || 'Blueprint'} (forked)`;
|
|
711
|
+
const name = mintNameFromBase(existingNames, base);
|
|
712
|
+
const path =
|
|
713
|
+
mintPathFromName(existingPaths, name, BLUEPRINTS_DIR, '.scene') ??
|
|
714
|
+
mintBlueprintPath([...existingPaths]);
|
|
715
|
+
const components = structuredClone(template.components ?? {});
|
|
716
|
+
let drawingFile = null;
|
|
717
|
+
if (isImportedPath(blueprintPath)) {
|
|
718
|
+
drawingFile = adoptBuiltinArt(files, components, existingPaths, name);
|
|
719
|
+
}
|
|
720
|
+
for (const props of Object.values(components ?? {})) reprojectRefsIn(props, files, path);
|
|
721
|
+
return {
|
|
722
|
+
blueprintFile: { path, name, components, text: formatBlueprintFileText(name, components) },
|
|
723
|
+
drawingFile,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
699
727
|
// Every `scenes/*.scene` file's parsed data, for callers that need to scan
|
|
700
728
|
// every scene in the deck (cascade delete, the instance-count badge/confirm).
|
|
701
729
|
// Skips unparseable files rather than throwing -- a mid-edit scene with
|