castle-web-cli 0.4.84 → 0.4.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ide.js +35 -13
- package/dist/shell/assets/{index-BOgm5T3W.js → index-BJLaUTJE.js} +21 -21
- package/dist/shell/index.html +1 -1
- package/kits/physics-2d/.prettierrc +8 -0
- package/kits/physics-2d/CLAUDE.md +329 -0
- package/kits/physics-2d/behaviors/Camera.jsx +43 -0
- package/kits/physics-2d/behaviors/Collider.jsx +199 -0
- package/kits/physics-2d/behaviors/Goal.jsx +29 -0
- package/kits/physics-2d/behaviors/Layout.jsx +53 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +352 -0
- package/kits/physics-2d/behaviors/tint.js +47 -0
- package/kits/physics-2d/blueprints/ball.scene +14 -0
- package/kits/physics-2d/blueprints/block.scene +12 -0
- package/kits/physics-2d/blueprints/cauldron.scene +18 -0
- package/kits/physics-2d/blueprints/crate.scene +14 -0
- package/kits/physics-2d/blueprints/goal.scene +12 -0
- package/kits/physics-2d/castle.json +13 -0
- package/kits/physics-2d/docs/pxart-format.md +377 -0
- package/kits/physics-2d/drawings/block.pxart +25 -0
- package/kits/physics-2d/drawings/cauldron.pxart +113 -0
- package/kits/physics-2d/editors/BlueprintLibrary.jsx +247 -0
- package/kits/physics-2d/editors/ErrorBoundary.jsx +59 -0
- package/kits/physics-2d/editors/PlayOnly.jsx +31 -0
- package/kits/physics-2d/editors/PxArtEditor.jsx +954 -0
- package/kits/physics-2d/editors/SceneEditor.jsx +1681 -0
- package/kits/physics-2d/editors/SelectionOverlay.jsx +909 -0
- package/kits/physics-2d/editors/SingleEditor.jsx +122 -0
- package/kits/physics-2d/editors/behaviorRegistry.js +30 -0
- package/kits/physics-2d/editors/editorHistory.js +157 -0
- package/kits/physics-2d/editors/inspectorSheet.js +13 -0
- package/kits/physics-2d/editors/pixelCanvas.js +11 -0
- package/kits/physics-2d/editors/pixelEditorChrome.jsx +74 -0
- package/kits/physics-2d/editors/pixelGeometry.js +140 -0
- package/kits/physics-2d/editors/pixelInspector.jsx +633 -0
- package/kits/physics-2d/editors/pxArtEditorModel.js +732 -0
- package/kits/physics-2d/editors/pxArtPlayback.js +92 -0
- package/kits/physics-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/physics-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/physics-2d/editors/pxArtTools.js +232 -0
- package/kits/physics-2d/editors/useArtboardFit.js +102 -0
- package/kits/physics-2d/engine/ScenePlayer.jsx +196 -0
- package/kits/physics-2d/engine/SceneUI.jsx +59 -0
- package/kits/physics-2d/engine/assets.js +15 -0
- package/kits/physics-2d/engine/autoInspector.jsx +70 -0
- package/kits/physics-2d/engine/blueprint.js +521 -0
- package/kits/physics-2d/engine/collider.js +196 -0
- package/kits/physics-2d/engine/files.js +117 -0
- package/kits/physics-2d/engine/liveReload.js +88 -0
- package/kits/physics-2d/engine/pxart.js +1032 -0
- package/kits/physics-2d/engine/pxartSmooth.js +222 -0
- package/kits/physics-2d/engine/scene.js +686 -0
- package/kits/physics-2d/engine/spriteGeometry.js +32 -0
- package/kits/physics-2d/engine/ui.jsx +688 -0
- package/kits/physics-2d/engine/ui.module.css +2287 -0
- package/kits/physics-2d/eslint.config.js +71 -0
- package/kits/physics-2d/index.html +24 -0
- package/kits/physics-2d/main.jsx +24 -0
- package/kits/physics-2d/package-lock.json +2706 -0
- package/kits/physics-2d/package.json +42 -0
- package/kits/physics-2d/physics/PhysicsSystem.js +290 -0
- package/kits/physics-2d/physics/behaviors/AnalogStick.jsx +101 -0
- package/kits/physics-2d/physics/behaviors/Draggable.jsx +79 -0
- package/kits/physics-2d/physics/behaviors/RigidBody.jsx +55 -0
- package/kits/physics-2d/physics/behaviors/Slingshot.jsx +118 -0
- package/kits/physics-2d/physics/controls.js +79 -0
- package/kits/physics-2d/physics/index.js +26 -0
- package/kits/physics-2d/physics/matterBridge.js +126 -0
- package/kits/physics-2d/pnpm-lock.yaml +1761 -0
- package/kits/physics-2d/scenes/main.scene +12 -0
- package/kits/physics-2d/scenes/sandbox.scene +13 -0
- package/kits/physics-2d/scripts/draw.mjs +121 -0
- package/kits/physics-2d/vite.config.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Single-editor mount for the panel system. The shell (dockview) opens this as
|
|
2
|
+
// its own iframe via the deck route in main.jsx:
|
|
3
|
+
// ?file=<path>[&editor=<id>] -> <SingleEditor> (one editor for one file)
|
|
4
|
+
// The shell only routes the kit's RICH file types here (scene / pxart); file
|
|
5
|
+
// browsing and the code/text editor are now builtin shell panels.
|
|
6
|
+
|
|
7
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
8
|
+
import { onBeforeRestart, onSaveReloadState, takeReloadState, writeFile } from 'castle-web-sdk';
|
|
9
|
+
import { getFileKind } from '../engine/files';
|
|
10
|
+
import { useLiveDeckFiles } from '../engine/liveReload';
|
|
11
|
+
import { collectAssets } from '../engine/assets';
|
|
12
|
+
import { MainEditor, styles } from '../engine/ui';
|
|
13
|
+
import { PxArtEditor } from './PxArtEditor';
|
|
14
|
+
import { SceneEditor } from './SceneEditor';
|
|
15
|
+
|
|
16
|
+
// Debounced writeFile per path, flushed on reload -- the same save behavior the
|
|
17
|
+
// combined editor (App.jsx) uses, factored for a single-editor mount.
|
|
18
|
+
function useFileSaver() {
|
|
19
|
+
const timers = useRef({});
|
|
20
|
+
const versions = useRef({});
|
|
21
|
+
const pending = useRef({});
|
|
22
|
+
function commit(path, text, version) {
|
|
23
|
+
return writeFile(path, text)
|
|
24
|
+
.then(() => {
|
|
25
|
+
if (versions.current[path] === version && pending.current[path] === text) {
|
|
26
|
+
delete pending.current[path];
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.catch((error) => {
|
|
30
|
+
if (versions.current[path] !== version) return;
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
console.error(`Failed to save ${path}: ${message}`);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function schedule(path, text) {
|
|
36
|
+
const version = (versions.current[path] ?? 0) + 1;
|
|
37
|
+
versions.current[path] = version;
|
|
38
|
+
pending.current[path] = text;
|
|
39
|
+
if (timers.current[path]) window.clearTimeout(timers.current[path]);
|
|
40
|
+
// Short-ish debounce: writes now drive the live play/panel updates, so a
|
|
41
|
+
// drag should reflect soon after the user pauses.
|
|
42
|
+
timers.current[path] = window.setTimeout(() => {
|
|
43
|
+
delete timers.current[path];
|
|
44
|
+
void commit(path, text, version);
|
|
45
|
+
}, 800);
|
|
46
|
+
}
|
|
47
|
+
useEffect(
|
|
48
|
+
() =>
|
|
49
|
+
onBeforeRestart(async () => {
|
|
50
|
+
for (const timer of Object.values(timers.current)) window.clearTimeout(timer);
|
|
51
|
+
timers.current = {};
|
|
52
|
+
await Promise.all(
|
|
53
|
+
Object.entries(pending.current).map(([path, text]) =>
|
|
54
|
+
commit(path, text, versions.current[path] ?? 0)
|
|
55
|
+
)
|
|
56
|
+
);
|
|
57
|
+
}),
|
|
58
|
+
[]
|
|
59
|
+
);
|
|
60
|
+
function hasPending(path) {
|
|
61
|
+
return pending.current[path] !== undefined;
|
|
62
|
+
}
|
|
63
|
+
return { schedule, hasPending };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SingleEditor({ path, editor }) {
|
|
67
|
+
// Selection survives a code-change reload: stashed via the SDK's save-state
|
|
68
|
+
// hook right before the reload, picked back up here on boot.
|
|
69
|
+
const stashKey = `single-editor:${path}`;
|
|
70
|
+
const [stash] = useState(() => takeReloadState(stashKey));
|
|
71
|
+
const { schedule, hasPending } = useFileSaver();
|
|
72
|
+
// Live deck files; our own in-flight (debounced, unsaved) edits win over the
|
|
73
|
+
// fs echo of a stale write, so a drag in progress isn't clobbered.
|
|
74
|
+
const { files, setFiles } = useLiveDeckFiles({ shouldSkipPath: hasPending });
|
|
75
|
+
const [selectedActorIds, setSelectedActorIds] = useState(stash?.selectedActorIds ?? []);
|
|
76
|
+
const [multiSelectMode, setMultiSelectMode] = useState(stash?.multiSelectMode ?? false);
|
|
77
|
+
useEffect(
|
|
78
|
+
() => onSaveReloadState(stashKey, () => ({ selectedActorIds, multiSelectMode })),
|
|
79
|
+
[stashKey, selectedActorIds, multiSelectMode]
|
|
80
|
+
);
|
|
81
|
+
const { sprites } = collectAssets(files);
|
|
82
|
+
// Optimistic cross-file edit: fold the new text into live files state NOW
|
|
83
|
+
// (so merged previews update this frame) and debounce the real write; the
|
|
84
|
+
// fs echo of our own write is skipped while pending (shouldSkipPath above).
|
|
85
|
+
// Used for this editor's own file AND for blueprint-file edits made from a
|
|
86
|
+
// scene panel's blueprint inspector.
|
|
87
|
+
function onChangeFile(targetPath, nextText) {
|
|
88
|
+
setFiles((current) => ({ ...current, [targetPath]: nextText }));
|
|
89
|
+
schedule(targetPath, nextText);
|
|
90
|
+
}
|
|
91
|
+
function onChange(nextText) {
|
|
92
|
+
onChangeFile(path, nextText);
|
|
93
|
+
}
|
|
94
|
+
const kind = editor || getFileKind(path);
|
|
95
|
+
const text = files[path] ?? '';
|
|
96
|
+
let body = null;
|
|
97
|
+
if (kind === 'scene') {
|
|
98
|
+
body = (
|
|
99
|
+
<SceneEditor
|
|
100
|
+
path={path}
|
|
101
|
+
text={text}
|
|
102
|
+
files={files}
|
|
103
|
+
sprites={sprites}
|
|
104
|
+
onChange={onChange}
|
|
105
|
+
onChangeFile={onChangeFile}
|
|
106
|
+
selectedActorIds={selectedActorIds}
|
|
107
|
+
onSelectActorIds={setSelectedActorIds}
|
|
108
|
+
multiSelectMode={multiSelectMode}
|
|
109
|
+
onSetMultiSelectMode={setMultiSelectMode}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
} else if (kind === 'pxart') {
|
|
113
|
+
body = <PxArtEditor path={path} text={text} onChange={onChange} />;
|
|
114
|
+
}
|
|
115
|
+
// No `else`: code/text files are handled by the builtin shell code editor, so
|
|
116
|
+
// the shell never routes them here. `body` stays null for any other kind.
|
|
117
|
+
return (
|
|
118
|
+
<div className={styles.panelBare}>
|
|
119
|
+
<MainEditor>{body}</MainEditor>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Discover every behavior class from `behaviors/*.jsx` and the physics module's
|
|
2
|
+
// `physics/behaviors/*.jsx`. Vite HMR is off, so a newly-added behavior file is
|
|
3
|
+
// picked up on the next reload/restart. The physics glob is what lets the
|
|
4
|
+
// self-contained physics module ship its behaviors without polluting the core
|
|
5
|
+
// `behaviors/` folder — a kit adopts physics by copying `physics/` in.
|
|
6
|
+
const modules = {
|
|
7
|
+
...import.meta.glob('../behaviors/*.jsx', { eager: true }),
|
|
8
|
+
...import.meta.glob('../physics/behaviors/*.jsx', { eager: true }),
|
|
9
|
+
};
|
|
10
|
+
function isBehaviorClass(value) {
|
|
11
|
+
return typeof value === 'function' && typeof value.behaviorName === 'string';
|
|
12
|
+
}
|
|
13
|
+
function collectBehaviors() {
|
|
14
|
+
const found = new Map();
|
|
15
|
+
for (const mod of Object.values(modules)) {
|
|
16
|
+
for (const exported of Object.values(mod)) {
|
|
17
|
+
if (isBehaviorClass(exported)) found.set(exported.behaviorName, exported);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Layout first (every actor needs it), then alphabetical.
|
|
21
|
+
return [...found.values()].sort((a, b) => {
|
|
22
|
+
if (a.behaviorName === 'Layout') return -1;
|
|
23
|
+
if (b.behaviorName === 'Layout') return 1;
|
|
24
|
+
return a.behaviorName.localeCompare(b.behaviorName);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export const behaviorClasses = collectBehaviors();
|
|
28
|
+
export function findBehaviorClass(behaviorName) {
|
|
29
|
+
return behaviorClasses.find((candidate) => candidate.behaviorName === behaviorName);
|
|
30
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
const HISTORY_LIMIT = 50;
|
|
3
|
+
// Time window (ms) within which consecutive commits sharing the same
|
|
4
|
+
// coalesceKey collapse into a single undo entry (e.g. dragging a color
|
|
5
|
+
// picker or repeatedly clicking a number stepper). Sliding: each coalesced
|
|
6
|
+
// commit refreshes the window.
|
|
7
|
+
const COALESCE_WINDOW_MS = 800;
|
|
8
|
+
|
|
9
|
+
function historyStorageKey(path) {
|
|
10
|
+
return `castle-edit-history:${path}`;
|
|
11
|
+
}
|
|
12
|
+
// Best-effort sessionStorage read/write so undo/redo survives the editor
|
|
13
|
+
// iframe reloading (e.g. after `npm run restart`). Quota errors or disabled
|
|
14
|
+
// storage (private browsing, etc.) degrade silently to in-memory-only history.
|
|
15
|
+
function loadStoredHistory(path) {
|
|
16
|
+
if (!path) return null;
|
|
17
|
+
try {
|
|
18
|
+
const raw = sessionStorage.getItem(historyStorageKey(path));
|
|
19
|
+
if (!raw) return null;
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!parsed || !Array.isArray(parsed.undo) || !Array.isArray(parsed.redo)) return null;
|
|
22
|
+
return {
|
|
23
|
+
undo: parsed.undo.slice(-HISTORY_LIMIT),
|
|
24
|
+
redo: parsed.redo.slice(0, HISTORY_LIMIT),
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function saveStoredHistory(path, history) {
|
|
31
|
+
if (!path) return;
|
|
32
|
+
try {
|
|
33
|
+
sessionStorage.setItem(historyStorageKey(path), JSON.stringify(history));
|
|
34
|
+
} catch {
|
|
35
|
+
// Storage full or unavailable -- history still works in-memory this session.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Drop entries at the end of `stack` equal to `text`: snapshots recorded at
|
|
39
|
+
// gesture start (recordSnapshot) that the gesture never actually changed.
|
|
40
|
+
function trimTrailingNoOps(stack, text) {
|
|
41
|
+
let end = stack.length;
|
|
42
|
+
while (end > 0 && stack[end - 1] === text) end--;
|
|
43
|
+
return end === stack.length ? stack : stack.slice(0, end);
|
|
44
|
+
}
|
|
45
|
+
// Same idea for the redo stack, which is read from the front.
|
|
46
|
+
function trimLeadingNoOps(stack, text) {
|
|
47
|
+
let start = 0;
|
|
48
|
+
while (start < stack.length && stack[start] === text) start++;
|
|
49
|
+
return start === 0 ? stack : stack.slice(start);
|
|
50
|
+
}
|
|
51
|
+
// Text-undo/redo for file-backed editors. `text` is the canonical current
|
|
52
|
+
// value; `onChange` writes the new value back. The hook owns the undo/redo
|
|
53
|
+
// stacks; it never mutates `text` directly. `path`, when given, persists the
|
|
54
|
+
// stacks to sessionStorage keyed by file path.
|
|
55
|
+
export function useEditHistory(text, onChange, path) {
|
|
56
|
+
const [history, setHistory] = useState(() => loadStoredHistory(path) ?? { undo: [], redo: [] });
|
|
57
|
+
// Mirror the latest stacks so undo/redo can read them synchronously in the
|
|
58
|
+
// event handler. onChange writes the PARENT's state, so it must never run
|
|
59
|
+
// inside a setHistory updater -- updaters execute in React's render phase,
|
|
60
|
+
// which would update the parent while this editor renders (setState-in-render).
|
|
61
|
+
const historyRef = useRef(history);
|
|
62
|
+
historyRef.current = history;
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
saveStoredHistory(path, history);
|
|
65
|
+
}, [path, history]);
|
|
66
|
+
// { key, time } of the most recent coalescable commit. Undo/redo/
|
|
67
|
+
// recordSnapshot null this out so a later commit never coalesces across them.
|
|
68
|
+
const coalesceRef = useRef({ key: null, time: 0 });
|
|
69
|
+
function commit(nextText, { coalesceKey } = {}) {
|
|
70
|
+
if (nextText === text) return;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const last = coalesceRef.current;
|
|
73
|
+
const coalescing =
|
|
74
|
+
coalesceKey != null && last.key === coalesceKey && now - last.time <= COALESCE_WINDOW_MS;
|
|
75
|
+
coalesceRef.current = { key: coalesceKey ?? null, time: now };
|
|
76
|
+
if (coalescing) {
|
|
77
|
+
setHistory((current) => ({ ...current, redo: [] }));
|
|
78
|
+
} else {
|
|
79
|
+
setHistory((current) => ({
|
|
80
|
+
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
81
|
+
redo: [],
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
onChange(nextText);
|
|
85
|
+
}
|
|
86
|
+
function recordSnapshot() {
|
|
87
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
88
|
+
setHistory((current) => ({
|
|
89
|
+
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
90
|
+
redo: [],
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
function undo() {
|
|
94
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
95
|
+
const current = historyRef.current;
|
|
96
|
+
const stack = trimTrailingNoOps(current.undo, text);
|
|
97
|
+
const previous = stack.at(-1);
|
|
98
|
+
if (previous === undefined) return;
|
|
99
|
+
setHistory({
|
|
100
|
+
undo: stack.slice(0, -1),
|
|
101
|
+
redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
|
|
102
|
+
});
|
|
103
|
+
onChange(previous);
|
|
104
|
+
}
|
|
105
|
+
function redo() {
|
|
106
|
+
coalesceRef.current = { key: null, time: 0 };
|
|
107
|
+
const current = historyRef.current;
|
|
108
|
+
const stack = trimLeadingNoOps(current.redo, text);
|
|
109
|
+
const next = stack[0];
|
|
110
|
+
if (next === undefined) return;
|
|
111
|
+
setHistory({
|
|
112
|
+
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
113
|
+
redo: stack.slice(1),
|
|
114
|
+
});
|
|
115
|
+
onChange(next);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
commit,
|
|
119
|
+
undo,
|
|
120
|
+
redo,
|
|
121
|
+
canUndo: trimTrailingNoOps(history.undo, text).length > 0,
|
|
122
|
+
canRedo: trimLeadingNoOps(history.redo, text).length > 0,
|
|
123
|
+
recordSnapshot,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function useUndoRedoShortcuts(history, enabled = true) {
|
|
128
|
+
const historyRef = useRef(history);
|
|
129
|
+
historyRef.current = history;
|
|
130
|
+
const enabledRef = useRef(enabled);
|
|
131
|
+
enabledRef.current = enabled;
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
function onKeyDown(event) {
|
|
134
|
+
if (!enabledRef.current) return;
|
|
135
|
+
if (event.defaultPrevented || isEditableTarget(event.target)) return;
|
|
136
|
+
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
if (event.shiftKey) historyRef.current.redo();
|
|
139
|
+
else historyRef.current.undo();
|
|
140
|
+
}
|
|
141
|
+
// Capture phase, not bubble: this must win Cmd+Z/Cmd+Shift+Z before it can
|
|
142
|
+
// fall through to the browser's own undo, and before any other listener
|
|
143
|
+
// further down the tree (e.g. a widget that stops propagation on its own
|
|
144
|
+
// keydown) can swallow it first. This only matters once this iframe
|
|
145
|
+
// actually HAS keyboard focus (see EditorBody's pointer-down focus claim)
|
|
146
|
+
// -- capture order can't help if the event never reaches this window.
|
|
147
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
148
|
+
return () => window.removeEventListener('keydown', onKeyDown, true);
|
|
149
|
+
}, []);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isEditableTarget(target) {
|
|
153
|
+
return (
|
|
154
|
+
target instanceof Element &&
|
|
155
|
+
!!target.closest('input, textarea, select, [contenteditable="true"]')
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { styles, useMobileSheet } from '../engine/ui';
|
|
2
|
+
|
|
3
|
+
// Inspector panel as a drag-resizable bottom sheet on compact viewports, docked
|
|
4
|
+
// on desktop. Used by the pixel-art (PxArt) editor. `inspectorOpen` drives
|
|
5
|
+
// visibility; the sheet hook owns the resize height and the settle-on-release
|
|
6
|
+
// behavior (drag the grab handle to resize, tap it to toggle peek / expanded).
|
|
7
|
+
export function useInspectorSheet(inspectorOpen) {
|
|
8
|
+
return useMobileSheet({
|
|
9
|
+
open: inspectorOpen,
|
|
10
|
+
baseClassName: styles.inspector,
|
|
11
|
+
storageKey: 'pxart-inspector',
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Map a pointer event over a pixel-art canvas element to integer cell
|
|
2
|
+
// coordinates within a `width x height` raster, or null when the pointer is
|
|
3
|
+
// outside the raster. Used by the PxArt editor, whose artboard stretches a
|
|
4
|
+
// native-resolution canvas to fit via CSS.
|
|
5
|
+
export function eventToCell(event, width, height) {
|
|
6
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
7
|
+
const x = Math.floor(((event.clientX - rect.left) / rect.width) * width);
|
|
8
|
+
const y = Math.floor(((event.clientY - rect.top) / rect.height) * height);
|
|
9
|
+
if (x < 0 || y < 0 || x >= width || y >= height) return null;
|
|
10
|
+
return { x, y };
|
|
11
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { EditorHeader, IconButton, styles } from '../engine/ui';
|
|
3
|
+
import { useEditHistory, useUndoRedoShortcuts } from './editorHistory';
|
|
4
|
+
|
|
5
|
+
// Shared shell state for the pixel editors: text undo/redo history and undo/redo
|
|
6
|
+
// keyboard shortcuts. Returns the `history` controller and `headerShell` for the
|
|
7
|
+
// header (undo/redo only — paint controls live in the artboard layout).
|
|
8
|
+
export function usePixelEditorShell(text, onChange, path) {
|
|
9
|
+
const history = useEditHistory(text, onChange, path);
|
|
10
|
+
useUndoRedoShortcuts(history);
|
|
11
|
+
const headerShell = { history };
|
|
12
|
+
return { history, headerShell };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function PixelEditorTools({ history }) {
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<IconButton icon="undo" label="Undo" onClick={history.undo} disabled={!history.canUndo} />
|
|
19
|
+
<IconButton icon="redo" label="Redo" onClick={history.redo} disabled={!history.canRedo} />
|
|
20
|
+
</>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The full editor header shared by the pixel editors: title/subtitle plus undo/redo.
|
|
25
|
+
export function PixelEditorHeader({ title, subtitle, shell, chrome }) {
|
|
26
|
+
return (
|
|
27
|
+
<EditorHeader
|
|
28
|
+
title={title}
|
|
29
|
+
subtitle={subtitle}
|
|
30
|
+
right={<PixelEditorTools history={shell.history} />}
|
|
31
|
+
onToggleFiles={chrome.onToggleFiles}
|
|
32
|
+
filesOpen={chrome.filesOpen}
|
|
33
|
+
headerHost={chrome.headerHost}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The native-resolution canvas inside its artboard frame. `style` sets the fitted
|
|
39
|
+
// display size, and is applied ONLY to the frame: all three canvases inside are
|
|
40
|
+
// absolutely positioned with `width/height: 100%`, so they fill the frame's
|
|
41
|
+
// content box identically. (Passing `style` to a canvas too would size it to the
|
|
42
|
+
// frame's BORDER box — the frame is border-box with a 1px border — leaving the
|
|
43
|
+
// canvas ~2px larger than its siblings and anchored top-left, which shifts the
|
|
44
|
+
// smooth-preview vs. pixel render out of alignment.) `handlers` are the pointer
|
|
45
|
+
// callbacks the editor wires for tools. `overlayRef` is a sibling canvas at
|
|
46
|
+
// display resolution used for crisp overlays (marching-ants selection) that would
|
|
47
|
+
// otherwise render sub-pixel on the upscaled native canvas. `smoothRef` is a
|
|
48
|
+
// sibling canvas BEHIND the main one, holding the corner-rounded render for
|
|
49
|
+
// "smooth"-mode sprites (empty/unused in "pixel" mode) so the interactive canvas
|
|
50
|
+
// above it can stay at native resolution — pointer math and tool-preview overlays
|
|
51
|
+
// are unaffected by the render mode.
|
|
52
|
+
export function PixelArtboard({ canvasRef, smoothRef, overlayRef, style, handlers }) {
|
|
53
|
+
return (
|
|
54
|
+
<div className={styles.drawingArtboard}>
|
|
55
|
+
<div className={styles.drawingArtboardFrame} style={style}>
|
|
56
|
+
<canvas ref={smoothRef} className={styles.drawingSmooth} aria-hidden="true" />
|
|
57
|
+
<canvas
|
|
58
|
+
ref={canvasRef}
|
|
59
|
+
className={styles.drawingCanvas}
|
|
60
|
+
// Focusable so a pointer-down can pull keyboard focus into this editor
|
|
61
|
+
// iframe (see startTool). Without it, Safari often leaves keyboard focus
|
|
62
|
+
// on the top document and Cmd+Z falls through to the browser's own undo.
|
|
63
|
+
tabIndex={-1}
|
|
64
|
+
onPointerDown={handlers.onPointerDown}
|
|
65
|
+
onPointerMove={handlers.onPointerMove}
|
|
66
|
+
onPointerUp={handlers.onPointerUp}
|
|
67
|
+
onPointerCancel={handlers.onPointerCancel}
|
|
68
|
+
onPointerLeave={handlers.onPointerLeave}
|
|
69
|
+
/>
|
|
70
|
+
<canvas ref={overlayRef} className={styles.drawingOverlay} aria-hidden="true" />
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Pure pixel-raster geometry for the PxArt tools. These helpers know nothing
|
|
2
|
+
// about pixel storage (flat array vs cell matrix) — they only emit integer
|
|
3
|
+
// coordinates, so the editor can apply its own write.
|
|
4
|
+
|
|
5
|
+
// Visit the integer offsets covering one brush "dab" of `size`, relative to the
|
|
6
|
+
// dab center. A diameter < 4 paints a solid square; 4+ uses a circular mask so
|
|
7
|
+
// larger brushes round off. `visit(dx, dy)` receives signed offsets.
|
|
8
|
+
export function forEachDab(size, visit) {
|
|
9
|
+
const diameter = Math.max(1, Math.round(size));
|
|
10
|
+
const offset = Math.floor(diameter / 2);
|
|
11
|
+
const useCircle = diameter >= 4;
|
|
12
|
+
const radius = diameter * 0.5;
|
|
13
|
+
const radiusSquared = radius * radius;
|
|
14
|
+
const centerOffset = (diameter - 1) * 0.5;
|
|
15
|
+
for (let ly = 0; ly < diameter; ly++) {
|
|
16
|
+
for (let lx = 0; lx < diameter; lx++) {
|
|
17
|
+
if (useCircle) {
|
|
18
|
+
const dx = lx - centerOffset;
|
|
19
|
+
const dy = ly - centerOffset;
|
|
20
|
+
if (dx * dx + dy * dy > radiusSquared) continue;
|
|
21
|
+
}
|
|
22
|
+
visit(lx - offset, ly - offset);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Visit the deduped integer points along the segment from `from` to `to`,
|
|
28
|
+
// supersampled so a fast drag paints a continuous run. `visit(x, y)` receives
|
|
29
|
+
// rounded cell coordinates.
|
|
30
|
+
export function forEachLinePoint(from, to, visit) {
|
|
31
|
+
const dx = to.x - from.x;
|
|
32
|
+
const dy = to.y - from.y;
|
|
33
|
+
const distance = Math.hypot(dx, dy);
|
|
34
|
+
const steps = Math.max(1, Math.ceil(distance * 2));
|
|
35
|
+
let lastKey = '';
|
|
36
|
+
for (let step = 0; step <= steps; step++) {
|
|
37
|
+
const t = step / steps;
|
|
38
|
+
const x = Math.round(from.x + dx * t);
|
|
39
|
+
const y = Math.round(from.y + dy * t);
|
|
40
|
+
const key = `${x},${y}`;
|
|
41
|
+
if (key === lastKey) continue;
|
|
42
|
+
lastKey = key;
|
|
43
|
+
visit(x, y);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
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
|
+
function shapeBounds(from, to) {
|
|
50
|
+
return {
|
|
51
|
+
x0: Math.min(from.x, to.x),
|
|
52
|
+
x1: Math.max(from.x, to.x),
|
|
53
|
+
y0: Math.min(from.y, to.y),
|
|
54
|
+
y1: Math.max(from.y, to.y),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Visit the cells of the rectangle bounded by `from`/`to`. `filled` paints the
|
|
59
|
+
// solid box; otherwise only the 1px border. `visit(x, y)` receives cell coords.
|
|
60
|
+
export function forEachRectCells(from, to, filled, visit) {
|
|
61
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
62
|
+
for (let y = y0; y <= y1; y++) {
|
|
63
|
+
for (let x = x0; x <= x1; x++) {
|
|
64
|
+
if (filled || x === x0 || x === x1 || y === y0 || y === y1) visit(x, y);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Visit the cells of the ellipse inscribed in the `from`/`to` box. A cell is
|
|
70
|
+
// "inside" when its center falls within the normalized ellipse; the half-cell
|
|
71
|
+
// radius padding lets tiny drags still fill their box. The outline keeps only
|
|
72
|
+
// inside cells with at least one 4-neighbor outside, so it reads as a closed
|
|
73
|
+
// ring with no gaps.
|
|
74
|
+
export function forEachEllipseCells(from, to, filled, visit) {
|
|
75
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
76
|
+
const cx = (x0 + x1) / 2;
|
|
77
|
+
const cy = (y0 + y1) / 2;
|
|
78
|
+
const rx = (x1 - x0) / 2 + 0.5;
|
|
79
|
+
const ry = (y1 - y0) / 2 + 0.5;
|
|
80
|
+
const inside = (x, y) => {
|
|
81
|
+
const nx = (x - cx) / rx;
|
|
82
|
+
const ny = (y - cy) / ry;
|
|
83
|
+
return nx * nx + ny * ny <= 1;
|
|
84
|
+
};
|
|
85
|
+
for (let y = y0; y <= y1; y++) {
|
|
86
|
+
for (let x = x0; x <= x1; x++) {
|
|
87
|
+
if (!inside(x, y)) continue;
|
|
88
|
+
if (filled) {
|
|
89
|
+
visit(x, y);
|
|
90
|
+
} else if (
|
|
91
|
+
!inside(x - 1, y) ||
|
|
92
|
+
!inside(x + 1, y) ||
|
|
93
|
+
!inside(x, y - 1) ||
|
|
94
|
+
!inside(x, y + 1)
|
|
95
|
+
) {
|
|
96
|
+
visit(x, y);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Cross-product sign of point c relative to the directed edge a->b. Used both
|
|
103
|
+
// to build the triangle outline and to test point containment for the fill.
|
|
104
|
+
function edgeCross(a, b, cx, cy) {
|
|
105
|
+
return (b.x - a.x) * (cy - a.y) - (b.y - a.y) * (cx - a.x);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Visit the cells of an isosceles triangle inscribed in the `from`/`to` box:
|
|
109
|
+
// apex centered on the top edge, base spanning the bottom edge. `filled` uses a
|
|
110
|
+
// barycentric point-in-triangle test over the box; the outline walks the three
|
|
111
|
+
// edges. A shared `seen` set dedups cells shared by adjacent edges/scanlines.
|
|
112
|
+
export function forEachTriangleCells(from, to, filled, visit) {
|
|
113
|
+
const { x0, x1, y0, y1 } = shapeBounds(from, to);
|
|
114
|
+
const apex = { x: Math.round((x0 + x1) / 2), y: y0 };
|
|
115
|
+
const left = { x: x0, y: y1 };
|
|
116
|
+
const right = { x: x1, y: y1 };
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const emit = (x, y) => {
|
|
119
|
+
const key = `${x},${y}`;
|
|
120
|
+
if (seen.has(key)) return;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
visit(x, y);
|
|
123
|
+
};
|
|
124
|
+
if (filled) {
|
|
125
|
+
for (let y = y0; y <= y1; y++) {
|
|
126
|
+
for (let x = x0; x <= x1; x++) {
|
|
127
|
+
const w0 = edgeCross(left, right, x, y);
|
|
128
|
+
const w1 = edgeCross(right, apex, x, y);
|
|
129
|
+
const w2 = edgeCross(apex, left, x, y);
|
|
130
|
+
const hasNeg = w0 < 0 || w1 < 0 || w2 < 0;
|
|
131
|
+
const hasPos = w0 > 0 || w1 > 0 || w2 > 0;
|
|
132
|
+
if (!(hasNeg && hasPos)) emit(x, y);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
forEachLinePoint(apex, left, emit);
|
|
138
|
+
forEachLinePoint(left, right, emit);
|
|
139
|
+
forEachLinePoint(right, apex, emit);
|
|
140
|
+
}
|