castle-web-cli 0.4.3 → 0.4.5
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/index.js +8 -2
- package/dist/init.js +1 -1
- package/kits/basic-2d/CLAUDE.md +3 -3
- package/package.json +1 -1
- package/kits/basic-2d-frozen/.prettierrc +0 -8
- package/kits/basic-2d-frozen/CLAUDE.md +0 -131
- package/kits/basic-2d-frozen/behaviors/Camera.jsx +0 -43
- package/kits/basic-2d-frozen/behaviors/Collider.jsx +0 -71
- package/kits/basic-2d-frozen/behaviors/Drawing.jsx +0 -139
- package/kits/basic-2d-frozen/behaviors/Layout.jsx +0 -16
- package/kits/basic-2d-frozen/drawings/floor.drawing +0 -70
- package/kits/basic-2d-frozen/editors/App.jsx +0 -152
- package/kits/basic-2d-frozen/editors/CodeEditor.jsx +0 -112
- package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +0 -222
- package/kits/basic-2d-frozen/editors/FileBrowser.jsx +0 -143
- package/kits/basic-2d-frozen/editors/PlayOnly.jsx +0 -21
- package/kits/basic-2d-frozen/editors/SceneEditor.jsx +0 -1012
- package/kits/basic-2d-frozen/editors/behaviorRegistry.js +0 -24
- package/kits/basic-2d-frozen/editors/editorHistory.js +0 -52
- package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +0 -83
- package/kits/basic-2d-frozen/engine/SceneUI.jsx +0 -67
- package/kits/basic-2d-frozen/engine/TouchControls.jsx +0 -136
- package/kits/basic-2d-frozen/engine/autoInspector.jsx +0 -51
- package/kits/basic-2d-frozen/engine/files.js +0 -62
- package/kits/basic-2d-frozen/engine/scene.js +0 -420
- package/kits/basic-2d-frozen/engine/ui.jsx +0 -344
- package/kits/basic-2d-frozen/engine/ui.module.css +0 -928
- package/kits/basic-2d-frozen/eslint.config.js +0 -50
- package/kits/basic-2d-frozen/index.html +0 -11
- package/kits/basic-2d-frozen/main.jsx +0 -10
- package/kits/basic-2d-frozen/package-lock.json +0 -2706
- package/kits/basic-2d-frozen/package.json +0 -41
- package/kits/basic-2d-frozen/scenes/main.scene +0 -108
- package/kits/basic-2d-frozen/vite.config.js +0 -1
- package/kits/rpg-2d/.prettierrc +0 -8
- package/kits/rpg-2d/behaviors/Camera.tsx +0 -52
- package/kits/rpg-2d/behaviors/Collider.tsx +0 -98
- package/kits/rpg-2d/behaviors/Dialog.tsx +0 -184
- package/kits/rpg-2d/behaviors/Drawing.tsx +0 -161
- package/kits/rpg-2d/behaviors/Friend.tsx +0 -45
- package/kits/rpg-2d/behaviors/Layout.tsx +0 -29
- package/kits/rpg-2d/behaviors/PlayerController.tsx +0 -255
- package/kits/rpg-2d/behaviors/Portal.tsx +0 -60
- package/kits/rpg-2d/behaviors/QuestLog.tsx +0 -90
- package/kits/rpg-2d/behaviors/SaveMenu.tsx +0 -123
- package/kits/rpg-2d/behaviors/Tilemap.tsx +0 -90
- package/kits/rpg-2d/drawings/bld-home.drawing +0 -8136
- package/kits/rpg-2d/drawings/env-crate.drawing +0 -509
- package/kits/rpg-2d/drawings/env-fence.drawing +0 -536
- package/kits/rpg-2d/drawings/env-flower-bed.drawing +0 -607
- package/kits/rpg-2d/drawings/env-fountain.drawing +0 -2622
- package/kits/rpg-2d/drawings/env-hedge.drawing +0 -601
- package/kits/rpg-2d/drawings/env-house-blue.drawing +0 -1
- package/kits/rpg-2d/drawings/env-house-green.drawing +0 -1
- package/kits/rpg-2d/drawings/env-tree-oak.drawing +0 -1540
- package/kits/rpg-2d/drawings/env-tree-pine.drawing +0 -1315
- package/kits/rpg-2d/drawings/floor.drawing +0 -70
- package/kits/rpg-2d/drawings/fx-sparkle.drawing +0 -926
- package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +0 -1099
- package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +0 -4177
- package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +0 -1099
- package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +0 -4177
- package/kits/rpg-2d/drawings/player-idle-down.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-left.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-right.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-idle-up.drawing +0 -1070
- package/kits/rpg-2d/drawings/player-walk-down.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-left.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-right.drawing +0 -4148
- package/kits/rpg-2d/drawings/player-walk-up.drawing +0 -4148
- package/kits/rpg-2d/editors/App.tsx +0 -163
- package/kits/rpg-2d/editors/CodeEditor.tsx +0 -120
- package/kits/rpg-2d/editors/DrawingEditor.tsx +0 -278
- package/kits/rpg-2d/editors/FileBrowser.tsx +0 -191
- package/kits/rpg-2d/editors/PlayOnly.tsx +0 -26
- package/kits/rpg-2d/editors/SceneEditor.tsx +0 -1093
- package/kits/rpg-2d/editors/behaviorRegistry.ts +0 -33
- package/kits/rpg-2d/editors/editorHistory.ts +0 -75
- package/kits/rpg-2d/editors/editorProps.ts +0 -10
- package/kits/rpg-2d/engine/ScenePlayer.tsx +0 -130
- package/kits/rpg-2d/engine/SceneUI.tsx +0 -74
- package/kits/rpg-2d/engine/TouchControls.tsx +0 -157
- package/kits/rpg-2d/engine/autoInspector.tsx +0 -111
- package/kits/rpg-2d/engine/drawing.ts +0 -81
- package/kits/rpg-2d/engine/files.ts +0 -215
- package/kits/rpg-2d/engine/scene.ts +0 -484
- package/kits/rpg-2d/engine/ui.module.css +0 -928
- package/kits/rpg-2d/engine/ui.tsx +0 -483
- package/kits/rpg-2d/eslint.config.js +0 -46
- package/kits/rpg-2d/index.html +0 -11
- package/kits/rpg-2d/main.tsx +0 -14
- package/kits/rpg-2d/package-lock.json +0 -3149
- package/kits/rpg-2d/package.json +0 -46
- package/kits/rpg-2d/scenes/main.scene +0 -203
- package/kits/rpg-2d/tsconfig.json +0 -17
- package/kits/rpg-2d/vite-env.d.ts +0 -7
- package/kits/rpg-2d/vite.config.js +0 -1
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { writeFile } from 'castle-web-sdk';
|
|
3
|
-
import {
|
|
4
|
-
flatFileOrder,
|
|
5
|
-
formatJson,
|
|
6
|
-
getFileKind,
|
|
7
|
-
initialFiles,
|
|
8
|
-
normalizeDrawing,
|
|
9
|
-
parseJsonFile,
|
|
10
|
-
} from '../engine/files';
|
|
11
|
-
import type { DrawingData, FileMap } from '../engine/files';
|
|
12
|
-
import { AppShell, cx, MainEditor, styles } from '../engine/ui';
|
|
13
|
-
import { CodeEditor } from './CodeEditor';
|
|
14
|
-
import { DrawingEditor } from './DrawingEditor';
|
|
15
|
-
import { FileBrowser } from './FileBrowser';
|
|
16
|
-
import { SceneEditor } from './SceneEditor';
|
|
17
|
-
|
|
18
|
-
export function App() {
|
|
19
|
-
const [files, setFiles] = useState<FileMap>(initialFiles);
|
|
20
|
-
const [selectedPath, setSelectedPath] = useState('scenes/main.scene');
|
|
21
|
-
const [filesSheetOpen, setFilesSheetOpen] = useState(false);
|
|
22
|
-
const [selectedActorIds, setSelectedActorIds] = useState<string[]>([]);
|
|
23
|
-
const [multiSelectMode, setMultiSelectMode] = useState(false);
|
|
24
|
-
const saveTimersRef = useRef<Record<string, number>>({});
|
|
25
|
-
const saveVersionsRef = useRef<Record<string, number>>({});
|
|
26
|
-
|
|
27
|
-
const drawings: Record<string, DrawingData> = {};
|
|
28
|
-
for (const [path, text] of Object.entries(files)) {
|
|
29
|
-
if (!path.endsWith('.drawing')) continue;
|
|
30
|
-
const parsed = parseJsonFile<unknown>(path, text);
|
|
31
|
-
if (parsed.value) drawings[path] = normalizeDrawing(parsed.value);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function updateSelectedFile(nextText: string): void {
|
|
35
|
-
const path = selectedPath;
|
|
36
|
-
setFiles((current) => ({
|
|
37
|
-
...current,
|
|
38
|
-
[path]: nextText,
|
|
39
|
-
}));
|
|
40
|
-
scheduleFileWrite(path, nextText);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function scheduleFileWrite(path: string, nextText: string): void {
|
|
44
|
-
const version = (saveVersionsRef.current[path] ?? 0) + 1;
|
|
45
|
-
saveVersionsRef.current[path] = version;
|
|
46
|
-
const existingTimer = saveTimersRef.current[path];
|
|
47
|
-
if (existingTimer) window.clearTimeout(existingTimer);
|
|
48
|
-
saveTimersRef.current[path] = window.setTimeout(() => {
|
|
49
|
-
delete saveTimersRef.current[path];
|
|
50
|
-
writeFile(path, nextText).catch((error: unknown) => {
|
|
51
|
-
if (saveVersionsRef.current[path] !== version) return;
|
|
52
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
-
console.error(`Failed to save ${path}: ${message}`);
|
|
54
|
-
});
|
|
55
|
-
}, 1500);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const kind = getFileKind(selectedPath);
|
|
59
|
-
const text = files[selectedPath] ?? '';
|
|
60
|
-
|
|
61
|
-
function selectFile(path: string): void {
|
|
62
|
-
setSelectedPath(path);
|
|
63
|
-
setFilesSheetOpen(false);
|
|
64
|
-
setSelectedActorIds([]);
|
|
65
|
-
setMultiSelectMode(false);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const onToggleFiles = (): void => {
|
|
69
|
-
setFilesSheetOpen((previous) => !previous);
|
|
70
|
-
setSelectedActorIds([]);
|
|
71
|
-
setMultiSelectMode(false);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
// Alt+Up / Alt+Down steps through files in file-browser sidebar order.
|
|
75
|
-
useEffect(() => {
|
|
76
|
-
function onKeyDown(event: KeyboardEvent): void {
|
|
77
|
-
if (!event.altKey || event.metaKey || event.ctrlKey) return;
|
|
78
|
-
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
|
|
79
|
-
const active = document.activeElement as HTMLElement | null;
|
|
80
|
-
if (
|
|
81
|
-
active &&
|
|
82
|
-
(active.tagName === 'INPUT' ||
|
|
83
|
-
active.tagName === 'TEXTAREA' ||
|
|
84
|
-
active.tagName === 'SELECT' ||
|
|
85
|
-
active.isContentEditable ||
|
|
86
|
-
active.closest('.cm-editor'))
|
|
87
|
-
) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const order = flatFileOrder(Object.keys(files));
|
|
91
|
-
const index = order.indexOf(selectedPath);
|
|
92
|
-
if (index === -1) return;
|
|
93
|
-
const nextIndex = event.key === 'ArrowUp' ? index - 1 : index + 1;
|
|
94
|
-
if (nextIndex < 0 || nextIndex >= order.length) return;
|
|
95
|
-
event.preventDefault();
|
|
96
|
-
selectFile(order[nextIndex]);
|
|
97
|
-
}
|
|
98
|
-
window.addEventListener('keydown', onKeyDown);
|
|
99
|
-
return () => window.removeEventListener('keydown', onKeyDown);
|
|
100
|
-
}, [files, selectedPath]);
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<AppShell>
|
|
104
|
-
<FileBrowser
|
|
105
|
-
files={files}
|
|
106
|
-
selectedPath={selectedPath}
|
|
107
|
-
onSelect={selectFile}
|
|
108
|
-
sheetOpen={filesSheetOpen}
|
|
109
|
-
onSheetOpenChange={setFilesSheetOpen}
|
|
110
|
-
/>
|
|
111
|
-
{filesSheetOpen ? (
|
|
112
|
-
<div
|
|
113
|
-
className={cx(styles.sheetBackdrop, styles.mobileOnly)}
|
|
114
|
-
onClick={() => setFilesSheetOpen(false)}
|
|
115
|
-
/>
|
|
116
|
-
) : null}
|
|
117
|
-
<MainEditor>
|
|
118
|
-
{kind === 'scene' ? (
|
|
119
|
-
<SceneEditor
|
|
120
|
-
path={selectedPath}
|
|
121
|
-
text={text}
|
|
122
|
-
files={files}
|
|
123
|
-
drawings={drawings}
|
|
124
|
-
onChange={updateSelectedFile}
|
|
125
|
-
onToggleFiles={onToggleFiles}
|
|
126
|
-
filesOpen={filesSheetOpen}
|
|
127
|
-
selectedActorIds={selectedActorIds}
|
|
128
|
-
onSelectActorIds={setSelectedActorIds}
|
|
129
|
-
multiSelectMode={multiSelectMode}
|
|
130
|
-
onSetMultiSelectMode={setMultiSelectMode}
|
|
131
|
-
/>
|
|
132
|
-
) : null}
|
|
133
|
-
{kind === 'drawing' ? (
|
|
134
|
-
<DrawingEditor
|
|
135
|
-
path={selectedPath}
|
|
136
|
-
text={text}
|
|
137
|
-
onChange={updateSelectedFile}
|
|
138
|
-
onToggleFiles={onToggleFiles}
|
|
139
|
-
filesOpen={filesSheetOpen}
|
|
140
|
-
/>
|
|
141
|
-
) : null}
|
|
142
|
-
{kind === 'code' ? (
|
|
143
|
-
<CodeEditor
|
|
144
|
-
path={selectedPath}
|
|
145
|
-
text={text}
|
|
146
|
-
onChange={updateSelectedFile}
|
|
147
|
-
onToggleFiles={onToggleFiles}
|
|
148
|
-
filesOpen={filesSheetOpen}
|
|
149
|
-
/>
|
|
150
|
-
) : null}
|
|
151
|
-
{kind === 'text' ? (
|
|
152
|
-
<CodeEditor
|
|
153
|
-
path={selectedPath}
|
|
154
|
-
text={formatJson(text)}
|
|
155
|
-
onChange={updateSelectedFile}
|
|
156
|
-
onToggleFiles={onToggleFiles}
|
|
157
|
-
filesOpen={filesSheetOpen}
|
|
158
|
-
/>
|
|
159
|
-
) : null}
|
|
160
|
-
</MainEditor>
|
|
161
|
-
</AppShell>
|
|
162
|
-
);
|
|
163
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useRef } from 'react';
|
|
2
|
-
import { javascript } from '@codemirror/lang-javascript';
|
|
3
|
-
import { HighlightStyle, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
|
4
|
-
import { EditorState } from '@codemirror/state';
|
|
5
|
-
import { EditorView } from '@codemirror/view';
|
|
6
|
-
import { tags } from '@lezer/highlight';
|
|
7
|
-
import { basicSetup } from 'codemirror';
|
|
8
|
-
import { basename } from '../engine/files';
|
|
9
|
-
import { EditorBody, EditorHeader, styles } from '../engine/ui';
|
|
10
|
-
import type { FileEditorProps } from './editorProps';
|
|
11
|
-
|
|
12
|
-
const castleHighlightStyle = HighlightStyle.define([
|
|
13
|
-
{ tag: tags.strong, color: '#285CC4' },
|
|
14
|
-
{ tag: tags.namespace, color: '#BB7547' },
|
|
15
|
-
{ tag: tags.keyword, color: '#BC4A9B' },
|
|
16
|
-
{ tag: [tags.literal, tags.inserted], color: '#5DAF8D' },
|
|
17
|
-
{ tag: [tags.string, tags.deleted], color: '#E86A73' },
|
|
18
|
-
{ tag: tags.comment, color: '#8B93AF', fontStyle: 'italic' },
|
|
19
|
-
]);
|
|
20
|
-
|
|
21
|
-
const castleCodeTheme = EditorView.theme({
|
|
22
|
-
'&': {
|
|
23
|
-
height: '100%',
|
|
24
|
-
fontSize: '9pt',
|
|
25
|
-
backgroundColor: '#fff',
|
|
26
|
-
},
|
|
27
|
-
'&.cm-editor.cm-focused': {
|
|
28
|
-
outline: 'none',
|
|
29
|
-
},
|
|
30
|
-
'.cm-scroller': {
|
|
31
|
-
overflow: 'auto',
|
|
32
|
-
fontFamily: 'Menlo, Monaco, Lucida Console, monospace',
|
|
33
|
-
},
|
|
34
|
-
'.cm-content': {
|
|
35
|
-
minHeight: '100%',
|
|
36
|
-
color: '#322b28',
|
|
37
|
-
paddingBottom: '400px',
|
|
38
|
-
paddingRight: '80px',
|
|
39
|
-
},
|
|
40
|
-
'.cm-gutters': {
|
|
41
|
-
display: 'none',
|
|
42
|
-
},
|
|
43
|
-
'.cm-activeLine': {
|
|
44
|
-
backgroundColor: '#eeeeeea0',
|
|
45
|
-
},
|
|
46
|
-
'.cm-activeLineGutter': {
|
|
47
|
-
color: '#000',
|
|
48
|
-
backgroundColor: '#ddd',
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export function CodeEditor({ path, text, onChange, onToggleFiles, filesOpen }: FileEditorProps) {
|
|
53
|
-
const editorRef = useRef<HTMLDivElement | null>(null);
|
|
54
|
-
const viewRef = useRef<EditorView | null>(null);
|
|
55
|
-
const onChangeRef = useRef(onChange);
|
|
56
|
-
const initialTextRef = useRef(text);
|
|
57
|
-
const applyingExternalTextRef = useRef(false);
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
onChangeRef.current = onChange;
|
|
61
|
-
}, [onChange]);
|
|
62
|
-
|
|
63
|
-
// The editor is created once per `path`; the initial doc comes from a ref
|
|
64
|
-
// so `text` is not a dep of this effect (subsequent `text` updates flow
|
|
65
|
-
// through the second effect below). On `path` change we read whatever the
|
|
66
|
-
// latest `text` is at the moment of mount.
|
|
67
|
-
initialTextRef.current = text;
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (!editorRef.current) return undefined;
|
|
70
|
-
const view = new EditorView({
|
|
71
|
-
parent: editorRef.current,
|
|
72
|
-
state: EditorState.create({
|
|
73
|
-
doc: initialTextRef.current,
|
|
74
|
-
extensions: [
|
|
75
|
-
basicSetup,
|
|
76
|
-
javascript({ jsx: true, typescript: true }),
|
|
77
|
-
indentUnit.of(' '),
|
|
78
|
-
castleCodeTheme,
|
|
79
|
-
syntaxHighlighting(castleHighlightStyle),
|
|
80
|
-
EditorView.updateListener.of((update) => {
|
|
81
|
-
if (!update.docChanged || applyingExternalTextRef.current) return;
|
|
82
|
-
onChangeRef.current(update.state.doc.toString());
|
|
83
|
-
}),
|
|
84
|
-
],
|
|
85
|
-
}),
|
|
86
|
-
});
|
|
87
|
-
viewRef.current = view;
|
|
88
|
-
return () => {
|
|
89
|
-
view.destroy();
|
|
90
|
-
viewRef.current = null;
|
|
91
|
-
};
|
|
92
|
-
}, [path]);
|
|
93
|
-
|
|
94
|
-
useEffect(() => {
|
|
95
|
-
const view = viewRef.current;
|
|
96
|
-
if (!view) return;
|
|
97
|
-
const currentText = view.state.doc.toString();
|
|
98
|
-
if (currentText === text) return;
|
|
99
|
-
applyingExternalTextRef.current = true;
|
|
100
|
-
view.dispatch({
|
|
101
|
-
changes: {
|
|
102
|
-
from: 0,
|
|
103
|
-
to: currentText.length,
|
|
104
|
-
insert: text,
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
applyingExternalTextRef.current = false;
|
|
108
|
-
}, [text]);
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<>
|
|
112
|
-
<EditorHeader title={basename(path)} onToggleFiles={onToggleFiles} filesOpen={filesOpen} />
|
|
113
|
-
<EditorBody>
|
|
114
|
-
<div className={styles.codeEditor}>
|
|
115
|
-
<div ref={editorRef} className={styles.codeMirrorHost} />
|
|
116
|
-
</div>
|
|
117
|
-
</EditorBody>
|
|
118
|
-
</>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import type { CSSProperties, PointerEvent } from 'react';
|
|
2
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
3
|
-
import { basename, formatJson, normalizeDrawing, parseJsonFile } from '../engine/files';
|
|
4
|
-
import type { DrawingData } from '../engine/files';
|
|
5
|
-
|
|
6
|
-
// The editor treats layer 0, frame 0 as the editable surface -- multi-layer +
|
|
7
|
-
// multi-frame authoring is a future feature. Helpers below shim between that
|
|
8
|
-
// flat-pixel mental model and the layered DrawingData on disk.
|
|
9
|
-
function getEditPixels(drawing: DrawingData): string[] {
|
|
10
|
-
return drawing.layers[0]?.frames[0] ?? [];
|
|
11
|
-
}
|
|
12
|
-
function withEditPixels(drawing: DrawingData, pixels: string[]): DrawingData {
|
|
13
|
-
const layers = drawing.layers.map((layer, layerIndex) =>
|
|
14
|
-
layerIndex === 0
|
|
15
|
-
? { ...layer, frames: layer.frames.map((frame, frameIndex) => (frameIndex === 0 ? pixels : frame)) }
|
|
16
|
-
: layer
|
|
17
|
-
);
|
|
18
|
-
return { ...drawing, layers };
|
|
19
|
-
}
|
|
20
|
-
import {
|
|
21
|
-
cx,
|
|
22
|
-
EditorBody,
|
|
23
|
-
EditorHeader,
|
|
24
|
-
IconButton,
|
|
25
|
-
NumberField,
|
|
26
|
-
Panel,
|
|
27
|
-
SheetGrabHandle,
|
|
28
|
-
styles,
|
|
29
|
-
theme,
|
|
30
|
-
useMobileSheet,
|
|
31
|
-
} from '../engine/ui';
|
|
32
|
-
import type { SheetSnap } from '../engine/ui';
|
|
33
|
-
import type { FileEditorProps } from './editorProps';
|
|
34
|
-
import { useEditHistory } from './editorHistory';
|
|
35
|
-
|
|
36
|
-
const palette = [
|
|
37
|
-
'#00000000',
|
|
38
|
-
'#000000FF',
|
|
39
|
-
'#050505FF',
|
|
40
|
-
'#111111FF',
|
|
41
|
-
'#242234FF',
|
|
42
|
-
'#444444FF',
|
|
43
|
-
'#777777FF',
|
|
44
|
-
'#ffffffFF',
|
|
45
|
-
'#e3e6ffFF',
|
|
46
|
-
'#8db7ffFF',
|
|
47
|
-
'#ff5d5dFF',
|
|
48
|
-
'#ffe17aFF',
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
interface StrokeState {
|
|
52
|
-
active: boolean;
|
|
53
|
-
recorded: boolean;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function DrawingEditor({ path, text, onChange, onToggleFiles, filesOpen }: FileEditorProps) {
|
|
57
|
-
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
58
|
-
const strokeRef = useRef<StrokeState>({ active: false, recorded: false });
|
|
59
|
-
const [color, setColor] = useState('#ffffffFF');
|
|
60
|
-
const [inspectorOpen, setInspectorOpen] = useState(true);
|
|
61
|
-
const history = useEditHistory(text, onChange);
|
|
62
|
-
const inspectorSheet = useInspectorSheet(inspectorOpen);
|
|
63
|
-
const parsed = parseJsonFile<unknown>(path, text);
|
|
64
|
-
const drawing: DrawingData | null = parsed.value ? normalizeDrawing(parsed.value) : null;
|
|
65
|
-
const error = parsed.error;
|
|
66
|
-
const isWide = drawing ? drawing.width >= drawing.height : true;
|
|
67
|
-
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (!drawing || !canvasRef.current) return;
|
|
70
|
-
const canvas = canvasRef.current;
|
|
71
|
-
const ctx = canvas.getContext('2d');
|
|
72
|
-
if (!ctx) return;
|
|
73
|
-
canvas.width = drawing.width;
|
|
74
|
-
canvas.height = drawing.height;
|
|
75
|
-
ctx.clearRect(0, 0, drawing.width, drawing.height);
|
|
76
|
-
const editPixels = getEditPixels(drawing);
|
|
77
|
-
for (let y = 0; y < drawing.height; y++) {
|
|
78
|
-
for (let x = 0; x < drawing.width; x++) {
|
|
79
|
-
const pixel = editPixels[y * drawing.width + x];
|
|
80
|
-
if (!pixel || pixel.endsWith('00')) continue;
|
|
81
|
-
ctx.fillStyle = pixel;
|
|
82
|
-
ctx.fillRect(x, y, 1, 1);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}, [drawing, text]);
|
|
86
|
-
|
|
87
|
-
function commitDrawing(nextDrawing: DrawingData): void {
|
|
88
|
-
history.commit(formatJson(nextDrawing));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function resizeDrawing(nextWidth: number, nextHeight: number): void {
|
|
92
|
-
if (!drawing) return;
|
|
93
|
-
const width = clampInteger(nextWidth, 1, 128);
|
|
94
|
-
const height = clampInteger(nextHeight, 1, 128);
|
|
95
|
-
if (width === drawing.width && height === drawing.height) return;
|
|
96
|
-
const oldPixels = getEditPixels(drawing);
|
|
97
|
-
const pixels = Array<string>(width * height).fill('#00000000');
|
|
98
|
-
const copiedWidth = Math.min(width, drawing.width);
|
|
99
|
-
const copiedHeight = Math.min(height, drawing.height);
|
|
100
|
-
for (let y = 0; y < copiedHeight; y++) {
|
|
101
|
-
for (let x = 0; x < copiedWidth; x++) {
|
|
102
|
-
pixels[y * width + x] = oldPixels[y * drawing.width + x] ?? '#00000000';
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
commitDrawing(withEditPixels({ ...drawing, width, height }, pixels));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function paintAt(event: PointerEvent<HTMLCanvasElement>): void {
|
|
109
|
-
if (!drawing) return;
|
|
110
|
-
const rect = event.currentTarget.getBoundingClientRect();
|
|
111
|
-
const x = Math.floor(((event.clientX - rect.left) / rect.width) * drawing.width);
|
|
112
|
-
const y = Math.floor(((event.clientY - rect.top) / rect.height) * drawing.height);
|
|
113
|
-
if (x < 0 || y < 0 || x >= drawing.width || y >= drawing.height) return;
|
|
114
|
-
const editPixels = getEditPixels(drawing);
|
|
115
|
-
if (editPixels[y * drawing.width + x] === color) return;
|
|
116
|
-
const nextPixels = editPixels.slice();
|
|
117
|
-
nextPixels[y * drawing.width + x] = color;
|
|
118
|
-
const next = withEditPixels(drawing, nextPixels);
|
|
119
|
-
// Record one undo snapshot per pointer stroke (not per painted pixel).
|
|
120
|
-
if (!strokeRef.current.recorded) {
|
|
121
|
-
history.recordSnapshot();
|
|
122
|
-
strokeRef.current.recorded = true;
|
|
123
|
-
}
|
|
124
|
-
onChange(formatJson(next));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function startPaint(event: PointerEvent<HTMLCanvasElement>): void {
|
|
128
|
-
strokeRef.current = { active: true, recorded: false };
|
|
129
|
-
event.currentTarget.setPointerCapture(event.pointerId);
|
|
130
|
-
paintAt(event);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function finishPaint(): void {
|
|
134
|
-
strokeRef.current = { active: false, recorded: false };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const canvasStyle: CSSProperties | undefined = drawing
|
|
138
|
-
? {
|
|
139
|
-
width: isWide ? 'min(70vh, 520px)' : 'auto',
|
|
140
|
-
height: isWide ? 'auto' : 'min(70vh, 520px)',
|
|
141
|
-
}
|
|
142
|
-
: undefined;
|
|
143
|
-
|
|
144
|
-
const sharedActionButtons = (
|
|
145
|
-
<>
|
|
146
|
-
<IconButton icon="undo" label="Undo" onClick={history.undo} disabled={!history.canUndo} />
|
|
147
|
-
<IconButton icon="redo" label="Redo" onClick={history.redo} disabled={!history.canRedo} />
|
|
148
|
-
</>
|
|
149
|
-
);
|
|
150
|
-
|
|
151
|
-
return (
|
|
152
|
-
<>
|
|
153
|
-
<EditorHeader
|
|
154
|
-
title={basename(path)}
|
|
155
|
-
subtitle={drawing ? `${drawing.width} x ${drawing.height} pixels` : error}
|
|
156
|
-
right={
|
|
157
|
-
<span className={styles.mobileOnly}>
|
|
158
|
-
{sharedActionButtons}
|
|
159
|
-
<IconButton
|
|
160
|
-
icon="palette"
|
|
161
|
-
label="Tools"
|
|
162
|
-
active={inspectorOpen}
|
|
163
|
-
onClick={() => setInspectorOpen((previous) => !previous)}
|
|
164
|
-
/>
|
|
165
|
-
</span>
|
|
166
|
-
}
|
|
167
|
-
onToggleFiles={onToggleFiles}
|
|
168
|
-
filesOpen={filesOpen}
|
|
169
|
-
/>
|
|
170
|
-
<EditorBody>
|
|
171
|
-
<div className={styles.drawingEditor}>
|
|
172
|
-
<div className={styles.drawingTools}>{sharedActionButtons}</div>
|
|
173
|
-
<div className={styles.drawingCanvasWrap}>
|
|
174
|
-
<canvas
|
|
175
|
-
ref={canvasRef}
|
|
176
|
-
className={styles.drawingCanvas}
|
|
177
|
-
style={canvasStyle}
|
|
178
|
-
onPointerDown={startPaint}
|
|
179
|
-
onPointerMove={(event) => {
|
|
180
|
-
if (strokeRef.current.active && event.buttons === 1) paintAt(event);
|
|
181
|
-
}}
|
|
182
|
-
onPointerUp={finishPaint}
|
|
183
|
-
onPointerCancel={finishPaint}
|
|
184
|
-
/>
|
|
185
|
-
</div>
|
|
186
|
-
<DrawingInspector
|
|
187
|
-
sheet={inspectorSheet}
|
|
188
|
-
drawing={drawing}
|
|
189
|
-
color={color}
|
|
190
|
-
onSelectColor={setColor}
|
|
191
|
-
onResize={resizeDrawing}
|
|
192
|
-
/>
|
|
193
|
-
</div>
|
|
194
|
-
</EditorBody>
|
|
195
|
-
</>
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function useInspectorSheet(inspectorOpen: boolean): ReturnType<typeof useMobileSheet> {
|
|
200
|
-
const [snap, setSnap] = useState<'high' | 'low'>('high');
|
|
201
|
-
useEffect(() => {
|
|
202
|
-
if (inspectorOpen) setSnap('high');
|
|
203
|
-
}, [inspectorOpen]);
|
|
204
|
-
const effectiveSnap: SheetSnap = inspectorOpen ? snap : 'hidden';
|
|
205
|
-
return useMobileSheet({
|
|
206
|
-
snap: effectiveSnap,
|
|
207
|
-
baseClassName: styles.inspector,
|
|
208
|
-
onTransition: (direction) => {
|
|
209
|
-
if (!inspectorOpen) return;
|
|
210
|
-
if (direction === 'tap') setSnap((previous) => (previous === 'high' ? 'low' : 'high'));
|
|
211
|
-
else if (direction === 'down') setSnap('low');
|
|
212
|
-
else if (direction === 'up') setSnap('high');
|
|
213
|
-
},
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function DrawingInspector({
|
|
218
|
-
sheet,
|
|
219
|
-
drawing,
|
|
220
|
-
color,
|
|
221
|
-
onSelectColor,
|
|
222
|
-
onResize,
|
|
223
|
-
}: {
|
|
224
|
-
sheet: ReturnType<typeof useMobileSheet>;
|
|
225
|
-
drawing: DrawingData | null;
|
|
226
|
-
color: string;
|
|
227
|
-
onSelectColor: (color: string) => void;
|
|
228
|
-
onResize: (width: number, height: number) => void;
|
|
229
|
-
}) {
|
|
230
|
-
return (
|
|
231
|
-
<aside {...sheet.rootProps} className={cx(sheet.rootProps.className, styles.inspectorAuto)}>
|
|
232
|
-
<div {...sheet.grabProps}>
|
|
233
|
-
<SheetGrabHandle
|
|
234
|
-
label="Inspector"
|
|
235
|
-
hint={drawing ? `${drawing.width}x${drawing.height} · color` : 'drawing tools'}
|
|
236
|
-
/>
|
|
237
|
-
</div>
|
|
238
|
-
<div className={cx(styles.sheetBody, styles.inspectorBody)}>
|
|
239
|
-
<Panel title="Size">
|
|
240
|
-
<NumberField
|
|
241
|
-
label="Width"
|
|
242
|
-
value={drawing?.width ?? 1}
|
|
243
|
-
min={1}
|
|
244
|
-
max={128}
|
|
245
|
-
onChange={(width) => onResize(width, drawing?.height ?? 1)}
|
|
246
|
-
/>
|
|
247
|
-
<NumberField
|
|
248
|
-
label="Height"
|
|
249
|
-
value={drawing?.height ?? 1}
|
|
250
|
-
min={1}
|
|
251
|
-
max={128}
|
|
252
|
-
onChange={(height) => onResize(drawing?.width ?? 1, height)}
|
|
253
|
-
/>
|
|
254
|
-
</Panel>
|
|
255
|
-
<Panel title="Palette">
|
|
256
|
-
<div className={styles.palette}>
|
|
257
|
-
{palette.map((swatch) => (
|
|
258
|
-
<button
|
|
259
|
-
key={swatch}
|
|
260
|
-
className={cx(styles.swatch, color === swatch && styles.swatchSelected)}
|
|
261
|
-
title={swatch}
|
|
262
|
-
style={{
|
|
263
|
-
background: swatch.endsWith('00') ? theme.transparentChecker : swatch,
|
|
264
|
-
}}
|
|
265
|
-
onClick={() => onSelectColor(swatch)}
|
|
266
|
-
/>
|
|
267
|
-
))}
|
|
268
|
-
</div>
|
|
269
|
-
</Panel>
|
|
270
|
-
</div>
|
|
271
|
-
</aside>
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function clampInteger(value: number, min: number, max: number): number {
|
|
276
|
-
if (!Number.isFinite(value)) return min;
|
|
277
|
-
return Math.max(min, Math.min(max, Math.round(value)));
|
|
278
|
-
}
|