castle-web-cli 0.4.71 → 0.4.73
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 +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { useEffect, useRef, useState } from 'react';
|
|
9
9
|
import { onBeforeRestart, writeFile } from 'castle-web-sdk';
|
|
10
|
-
import {
|
|
10
|
+
import { getFileKind, initialFiles } from '../engine/files';
|
|
11
|
+
import { collectAssets } from '../engine/assets';
|
|
11
12
|
import { MainEditor, styles } from '../engine/ui';
|
|
12
13
|
import { CodeEditor } from './CodeEditor';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { PxArtEditor } from './PxArtEditor';
|
|
15
|
+
import { FileBrowser } from './FileBrowser';
|
|
15
16
|
import { SceneEditor } from './SceneEditor';
|
|
16
17
|
|
|
17
18
|
// Debounced writeFile per path, flushed on reload -- the same save behavior the
|
|
@@ -64,12 +65,7 @@ export function SingleEditor({ path, editor }) {
|
|
|
64
65
|
const [selectedActorIds, setSelectedActorIds] = useState([]);
|
|
65
66
|
const [multiSelectMode, setMultiSelectMode] = useState(false);
|
|
66
67
|
const schedule = useFileSaver();
|
|
67
|
-
const
|
|
68
|
-
for (const [filePath, text] of Object.entries(files)) {
|
|
69
|
-
if (!filePath.endsWith('.drawing')) continue;
|
|
70
|
-
const parsed = parseJsonFile(filePath, text);
|
|
71
|
-
if (parsed.value) drawings[filePath] = parsed.value;
|
|
72
|
-
}
|
|
68
|
+
const { sprites } = collectAssets(files);
|
|
73
69
|
function onChange(nextText) {
|
|
74
70
|
setFiles((current) => ({ ...current, [path]: nextText }));
|
|
75
71
|
schedule(path, nextText);
|
|
@@ -83,21 +79,22 @@ export function SingleEditor({ path, editor }) {
|
|
|
83
79
|
path={path}
|
|
84
80
|
text={text}
|
|
85
81
|
files={files}
|
|
86
|
-
|
|
82
|
+
sprites={sprites}
|
|
87
83
|
onChange={onChange}
|
|
88
84
|
selectedActorIds={selectedActorIds}
|
|
89
85
|
onSelectActorIds={setSelectedActorIds}
|
|
90
86
|
multiSelectMode={multiSelectMode}
|
|
91
87
|
onSetMultiSelectMode={setMultiSelectMode}
|
|
92
|
-
bare
|
|
93
88
|
/>
|
|
94
89
|
);
|
|
95
|
-
} else if (kind === '
|
|
96
|
-
body = <
|
|
97
|
-
} else if (kind === 'text') {
|
|
98
|
-
body = <CodeEditor path={path} text={formatJson(text)} onChange={onChange} bare />;
|
|
90
|
+
} else if (kind === 'pxart') {
|
|
91
|
+
body = <PxArtEditor path={path} text={text} onChange={onChange} />;
|
|
99
92
|
} else {
|
|
100
|
-
|
|
93
|
+
// 'code' and 'text' both render raw. Note: do NOT run plain text through
|
|
94
|
+
// formatJson (JSON.stringify) -- getFileKind only routes non-JSON files
|
|
95
|
+
// here, so it would quote/escape the contents (a new file.txt would show
|
|
96
|
+
// and save back as "Hello I'm a file" with literal quotes).
|
|
97
|
+
body = <CodeEditor path={path} text={text} onChange={onChange} />;
|
|
101
98
|
}
|
|
102
99
|
return (
|
|
103
100
|
<div className={styles.panelBare}>
|
|
@@ -106,19 +103,40 @@ export function SingleEditor({ path, editor }) {
|
|
|
106
103
|
);
|
|
107
104
|
}
|
|
108
105
|
|
|
109
|
-
// File panel:
|
|
110
|
-
// file asks the shell to open / focus its editor panel.
|
|
106
|
+
// File panel: a read-only file browser (bare, no sidebar/sheet chrome).
|
|
107
|
+
// Clicking a file asks the shell to open / focus its editor panel. File
|
|
108
|
+
// mutation (add / duplicate / rename / delete) is intentionally NOT wired here:
|
|
109
|
+
// under the kit's static-glob content model those edits only persist within a
|
|
110
|
+
// session, so the browser is browse-and-open only until real persistence lands.
|
|
111
111
|
export function FileList({ selectedPath }) {
|
|
112
|
+
const [files] = useState(initialFiles);
|
|
113
|
+
const [selected, setSelected] = useState(selectedPath);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setSelected(selectedPath);
|
|
116
|
+
}, [selectedPath]);
|
|
117
|
+
|
|
112
118
|
function open(path) {
|
|
119
|
+
setSelected(path);
|
|
113
120
|
try {
|
|
114
121
|
window.parent.postMessage({ type: 'castle-open-file', path }, '*');
|
|
115
122
|
} catch {
|
|
116
123
|
/* not embedded */
|
|
117
124
|
}
|
|
118
125
|
}
|
|
126
|
+
// Safari won't paint this static panel inside its composited dockview iframe
|
|
127
|
+
// until an invalidation is forced (the rows only appear once devtools opens).
|
|
128
|
+
// translateZ(0) promotes it to its own compositing layer so Safari paints and
|
|
129
|
+
// keeps it. The container sits at the iframe's viewport origin and isn't the
|
|
130
|
+
// scrollport's scrolling box, so it doesn't shift the fixed context menu.
|
|
119
131
|
return (
|
|
120
|
-
<div
|
|
121
|
-
|
|
132
|
+
<div
|
|
133
|
+
style={{
|
|
134
|
+
height: '100vh',
|
|
135
|
+
overflow: 'auto',
|
|
136
|
+
background: 'var(--castle-sheet-bg, #0d0d0d)',
|
|
137
|
+
transform: 'translateZ(0)',
|
|
138
|
+
}}>
|
|
139
|
+
<FileBrowser bare files={files} selectedPath={selected} onSelect={open} />
|
|
122
140
|
</div>
|
|
123
141
|
);
|
|
124
142
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { HighlightStyle } from '@codemirror/language';
|
|
2
|
+
import { EditorView } from '@codemirror/view';
|
|
3
|
+
import { tags } from '@lezer/highlight';
|
|
4
|
+
|
|
5
|
+
// Dark theme for the in-kit CodeMirror editor. Surfaces reference the kit's
|
|
6
|
+
// shared `--castle-*` tokens (defined on `.editor-root` in ui.module.css) so
|
|
7
|
+
// the editor stays in sync with the rest of the dark inspector palette. Syntax
|
|
8
|
+
// token colors are literal hex chosen to read well on the #0d0d0d background.
|
|
9
|
+
const selectionBg = 'rgba(230, 230, 230, 0.22)';
|
|
10
|
+
|
|
11
|
+
export const castleCodeTheme = EditorView.theme(
|
|
12
|
+
{
|
|
13
|
+
'&': {
|
|
14
|
+
height: '100%',
|
|
15
|
+
fontSize: '9pt',
|
|
16
|
+
color: 'var(--castle-inspector-text)',
|
|
17
|
+
backgroundColor: 'var(--castle-inspector-bg)',
|
|
18
|
+
},
|
|
19
|
+
'&.cm-editor.cm-focused': {
|
|
20
|
+
outline: 'none',
|
|
21
|
+
},
|
|
22
|
+
'.cm-scroller': {
|
|
23
|
+
overflow: 'auto',
|
|
24
|
+
fontFamily: 'Menlo, Monaco, Lucida Console, monospace',
|
|
25
|
+
},
|
|
26
|
+
'.cm-content': {
|
|
27
|
+
minHeight: '100%',
|
|
28
|
+
caretColor: 'var(--castle-black)',
|
|
29
|
+
color: 'var(--castle-inspector-text)',
|
|
30
|
+
paddingBottom: '400px',
|
|
31
|
+
paddingRight: '80px',
|
|
32
|
+
},
|
|
33
|
+
'.cm-cursor, .cm-dropCursor': {
|
|
34
|
+
borderLeftColor: 'var(--castle-black)',
|
|
35
|
+
},
|
|
36
|
+
'&.cm-focused .cm-cursor': {
|
|
37
|
+
borderLeftColor: 'var(--castle-black)',
|
|
38
|
+
},
|
|
39
|
+
'.cm-selectionBackground, .cm-content ::selection': {
|
|
40
|
+
backgroundColor: selectionBg,
|
|
41
|
+
},
|
|
42
|
+
'&.cm-focused .cm-selectionBackground, &.cm-focused .cm-content ::selection': {
|
|
43
|
+
backgroundColor: selectionBg,
|
|
44
|
+
},
|
|
45
|
+
'.cm-gutters': {
|
|
46
|
+
// Gutters are hidden in this editor; keep them themed in case they are
|
|
47
|
+
// re-enabled so they don't flash a light surface.
|
|
48
|
+
display: 'none',
|
|
49
|
+
backgroundColor: 'var(--castle-inspector-bg)',
|
|
50
|
+
color: 'var(--castle-inspector-muted)',
|
|
51
|
+
borderRight: '1px solid var(--castle-inspector-divider)',
|
|
52
|
+
},
|
|
53
|
+
'.cm-activeLine': {
|
|
54
|
+
backgroundColor: 'var(--castle-inspector-input-bg)',
|
|
55
|
+
},
|
|
56
|
+
'.cm-activeLineGutter': {
|
|
57
|
+
color: 'var(--castle-inspector-text)',
|
|
58
|
+
backgroundColor: 'var(--castle-inspector-input-bg)',
|
|
59
|
+
},
|
|
60
|
+
'.cm-foldPlaceholder': {
|
|
61
|
+
backgroundColor: 'var(--castle-inspector-input-bg)',
|
|
62
|
+
border: '1px solid var(--castle-inspector-border)',
|
|
63
|
+
color: 'var(--castle-inspector-muted)',
|
|
64
|
+
},
|
|
65
|
+
'.cm-matchingBracket, &.cm-focused .cm-matchingBracket': {
|
|
66
|
+
backgroundColor: 'rgba(230, 230, 230, 0.18)',
|
|
67
|
+
outline: '1px solid var(--castle-black)',
|
|
68
|
+
color: 'inherit',
|
|
69
|
+
},
|
|
70
|
+
'.cm-nonmatchingBracket, &.cm-focused .cm-nonmatchingBracket': {
|
|
71
|
+
backgroundColor: 'rgba(232, 106, 115, 0.35)',
|
|
72
|
+
},
|
|
73
|
+
'.cm-searchMatch': {
|
|
74
|
+
backgroundColor: 'rgba(255, 203, 107, 0.25)',
|
|
75
|
+
outline: '1px solid rgba(255, 203, 107, 0.5)',
|
|
76
|
+
},
|
|
77
|
+
'.cm-searchMatch.cm-searchMatch-selected': {
|
|
78
|
+
backgroundColor: 'rgba(255, 203, 107, 0.45)',
|
|
79
|
+
},
|
|
80
|
+
'.cm-selectionMatch': {
|
|
81
|
+
backgroundColor: 'rgba(230, 230, 230, 0.14)',
|
|
82
|
+
},
|
|
83
|
+
'.cm-panels': {
|
|
84
|
+
backgroundColor: 'var(--castle-inspector-input-bg)',
|
|
85
|
+
color: 'var(--castle-inspector-text)',
|
|
86
|
+
},
|
|
87
|
+
'.cm-panels.cm-panels-top': {
|
|
88
|
+
borderBottom: '1px solid var(--castle-inspector-divider)',
|
|
89
|
+
},
|
|
90
|
+
'.cm-panels.cm-panels-bottom': {
|
|
91
|
+
borderTop: '1px solid var(--castle-inspector-divider)',
|
|
92
|
+
},
|
|
93
|
+
'.cm-panel.cm-search input, .cm-panel.cm-search button, .cm-panel.cm-search label': {
|
|
94
|
+
color: 'var(--castle-inspector-text)',
|
|
95
|
+
},
|
|
96
|
+
'.cm-panel.cm-search input': {
|
|
97
|
+
backgroundColor: 'var(--castle-inspector-bg)',
|
|
98
|
+
border: '1px solid var(--castle-inspector-border)',
|
|
99
|
+
},
|
|
100
|
+
'.cm-tooltip': {
|
|
101
|
+
backgroundColor: 'var(--castle-inspector-input-bg)',
|
|
102
|
+
border: '1px solid var(--castle-inspector-border)',
|
|
103
|
+
color: 'var(--castle-inspector-text)',
|
|
104
|
+
},
|
|
105
|
+
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
|
|
106
|
+
backgroundColor: 'var(--castle-black)',
|
|
107
|
+
color: 'var(--castle-inspector-text)',
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{ dark: true },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
export const castleHighlightStyle = HighlightStyle.define([
|
|
114
|
+
{ tag: [tags.keyword, tags.modifier, tags.operatorKeyword], color: '#b3b9d1' },
|
|
115
|
+
{ tag: [tags.controlKeyword, tags.moduleKeyword], color: '#b3b9d1' },
|
|
116
|
+
{ tag: [tags.definitionKeyword, tags.self], color: '#b3b9d1' },
|
|
117
|
+
{ tag: [tags.string, tags.special(tags.string), tags.regexp], color: '#c3e88d' },
|
|
118
|
+
{ tag: tags.escape, color: '#f78c6c' },
|
|
119
|
+
{ tag: [tags.number, tags.integer, tags.float, tags.bool, tags.null], color: '#f78c6c' },
|
|
120
|
+
{ tag: [tags.literal, tags.atom, tags.constant(tags.variableName)], color: '#f78c6c' },
|
|
121
|
+
{ tag: [tags.comment, tags.lineComment, tags.blockComment, tags.docComment], color: '#676e95', fontStyle: 'italic' },
|
|
122
|
+
{ tag: [tags.function(tags.variableName), tags.function(tags.propertyName)], color: '#82aaff' },
|
|
123
|
+
{ tag: tags.definition(tags.variableName), color: '#e6e6e6' },
|
|
124
|
+
{ tag: [tags.variableName, tags.labelName], color: '#e6e6e6' },
|
|
125
|
+
{ tag: tags.propertyName, color: '#b2ccd6' },
|
|
126
|
+
{ tag: [tags.typeName, tags.className, tags.namespace], color: '#ffcb6b' },
|
|
127
|
+
{ tag: [tags.operator, tags.punctuation, tags.bracket, tags.angleBracket, tags.brace, tags.squareBracket], color: '#89ddff' },
|
|
128
|
+
{ tag: [tags.tagName], color: '#f07178' },
|
|
129
|
+
{ tag: [tags.attributeName], color: '#b3b9d1' },
|
|
130
|
+
{ tag: [tags.attributeValue], color: '#c3e88d' },
|
|
131
|
+
{ tag: tags.link, color: '#82aaff', textDecoration: 'underline' },
|
|
132
|
+
{ tag: tags.strong, fontWeight: 'bold' },
|
|
133
|
+
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
134
|
+
{ tag: tags.invalid, color: '#e86a73' },
|
|
135
|
+
]);
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
const HISTORY_LIMIT = 50;
|
|
3
3
|
// Text-undo/redo for file-backed editors. `text` is the canonical current
|
|
4
4
|
// value; `onChange` writes the new value back. The hook owns the undo/redo
|
|
5
5
|
// stacks; it never mutates `text` directly.
|
|
6
6
|
export function useEditHistory(text, onChange) {
|
|
7
7
|
const [history, setHistory] = useState({ undo: [], redo: [] });
|
|
8
|
+
// Mirror the latest stacks so undo/redo can read them synchronously in the
|
|
9
|
+
// event handler. onChange writes the PARENT's state, so it must never run
|
|
10
|
+
// inside a setHistory updater -- updaters execute in React's render phase,
|
|
11
|
+
// which would update the parent while this editor renders (setState-in-render).
|
|
12
|
+
const historyRef = useRef(history);
|
|
13
|
+
historyRef.current = history;
|
|
8
14
|
function commit(nextText) {
|
|
9
15
|
if (nextText === text) return;
|
|
10
16
|
setHistory((current) => ({
|
|
@@ -20,26 +26,24 @@ export function useEditHistory(text, onChange) {
|
|
|
20
26
|
}));
|
|
21
27
|
}
|
|
22
28
|
function undo() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
|
|
30
|
-
};
|
|
29
|
+
const current = historyRef.current;
|
|
30
|
+
const previous = current.undo.at(-1);
|
|
31
|
+
if (previous === undefined) return;
|
|
32
|
+
setHistory({
|
|
33
|
+
undo: current.undo.slice(0, -1),
|
|
34
|
+
redo: [text, ...current.redo].slice(0, HISTORY_LIMIT),
|
|
31
35
|
});
|
|
36
|
+
onChange(previous);
|
|
32
37
|
}
|
|
33
38
|
function redo() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
redo: current.redo.slice(1),
|
|
41
|
-
};
|
|
39
|
+
const current = historyRef.current;
|
|
40
|
+
const next = current.redo[0];
|
|
41
|
+
if (next === undefined) return;
|
|
42
|
+
setHistory({
|
|
43
|
+
undo: [...current.undo, text].slice(-HISTORY_LIMIT),
|
|
44
|
+
redo: current.redo.slice(1),
|
|
42
45
|
});
|
|
46
|
+
onChange(next);
|
|
43
47
|
}
|
|
44
48
|
return {
|
|
45
49
|
commit,
|
|
@@ -50,3 +54,26 @@ export function useEditHistory(text, onChange) {
|
|
|
50
54
|
recordSnapshot,
|
|
51
55
|
};
|
|
52
56
|
}
|
|
57
|
+
|
|
58
|
+
export function useUndoRedoShortcuts(history) {
|
|
59
|
+
const historyRef = useRef(history);
|
|
60
|
+
historyRef.current = history;
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
function onKeyDown(event) {
|
|
63
|
+
if (event.defaultPrevented || isEditableTarget(event.target)) return;
|
|
64
|
+
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return;
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
if (event.shiftKey) historyRef.current.redo();
|
|
67
|
+
else historyRef.current.undo();
|
|
68
|
+
}
|
|
69
|
+
window.addEventListener('keydown', onKeyDown);
|
|
70
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
71
|
+
}, []);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isEditableTarget(target) {
|
|
75
|
+
return (
|
|
76
|
+
target instanceof Element &&
|
|
77
|
+
!!target.closest('input, textarea, select, [contenteditable="true"]')
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { styles, useMobileSheet } from '../engine/ui';
|
|
3
|
+
|
|
4
|
+
// Inspector panel as a bottom sheet on compact viewports, docked on desktop.
|
|
5
|
+
// Used by the pixel-art (PxArt) editor. `inspectorOpen` drives visibility;
|
|
6
|
+
// tapping the grab handle toggles between the high and low snaps.
|
|
7
|
+
export function useInspectorSheet(inspectorOpen) {
|
|
8
|
+
const [snap, setSnap] = useState('high');
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (inspectorOpen) setSnap('high');
|
|
11
|
+
}, [inspectorOpen]);
|
|
12
|
+
const effectiveSnap = inspectorOpen ? snap : 'hidden';
|
|
13
|
+
return useMobileSheet({
|
|
14
|
+
snap: effectiveSnap,
|
|
15
|
+
baseClassName: styles.inspector,
|
|
16
|
+
onTransition: (direction) => {
|
|
17
|
+
if (!inspectorOpen) return;
|
|
18
|
+
if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
|
|
19
|
+
else if (direction === 'down') setSnap('low');
|
|
20
|
+
else if (direction === 'up') setSnap('high');
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -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,55 @@
|
|
|
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) {
|
|
9
|
+
const history = useEditHistory(text, onChange);
|
|
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; `handlers` are the pointer callbacks the editor wires for tools.
|
|
40
|
+
export function PixelArtboard({ canvasRef, style, handlers }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className={styles.drawingArtboard}>
|
|
43
|
+
<canvas
|
|
44
|
+
ref={canvasRef}
|
|
45
|
+
className={styles.drawingCanvas}
|
|
46
|
+
style={style}
|
|
47
|
+
onPointerDown={handlers.onPointerDown}
|
|
48
|
+
onPointerMove={handlers.onPointerMove}
|
|
49
|
+
onPointerUp={handlers.onPointerUp}
|
|
50
|
+
onPointerCancel={handlers.onPointerCancel}
|
|
51
|
+
onPointerLeave={handlers.onPointerLeave}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|