castle-web-cli 0.4.1 → 0.4.2
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/api.d.ts +53 -5
- package/dist/api.js +42 -15
- package/dist/config.d.ts +2 -0
- package/dist/config.js +25 -11
- package/dist/get-deck.d.ts +3 -0
- package/dist/get-deck.js +64 -0
- package/dist/ide-client.d.ts +1 -0
- package/dist/ide-client.js +537 -0
- package/dist/ide.d.ts +16 -0
- package/dist/ide.js +546 -0
- package/dist/index.js +36 -41
- package/dist/init.d.ts +3 -1
- package/dist/init.js +170 -24
- package/dist/localPaths.d.ts +6 -0
- package/dist/localPaths.js +33 -0
- package/dist/login.js +1 -1
- package/dist/preview.d.ts +3 -0
- package/dist/preview.js +53 -34
- package/dist/save-deck.d.ts +2 -0
- package/dist/{push.js → save-deck.js} +66 -5
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +290 -27
- package/kits/basic-2d/.prettierrc +8 -0
- package/kits/basic-2d/CLAUDE.md +131 -0
- package/kits/basic-2d/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d/drawings/floor.drawing +70 -0
- package/kits/basic-2d/editors/App.jsx +152 -0
- package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d/editors/editorHistory.js +52 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d/engine/files.js +62 -0
- package/kits/basic-2d/engine/scene.js +420 -0
- package/kits/basic-2d/engine/ui.jsx +344 -0
- package/kits/basic-2d/engine/ui.module.css +928 -0
- package/kits/basic-2d/eslint.config.js +50 -0
- package/kits/basic-2d/index.html +11 -0
- package/kits/basic-2d/main.jsx +10 -0
- package/kits/basic-2d/package-lock.json +2706 -0
- package/kits/basic-2d/package.json +41 -0
- package/kits/basic-2d/scenes/main.scene +108 -0
- package/kits/basic-2d/vite.config.js +1 -0
- package/kits/basic-2d-frozen/.prettierrc +8 -0
- package/kits/basic-2d-frozen/CLAUDE.md +131 -0
- package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
- package/kits/basic-2d-frozen/editors/App.jsx +152 -0
- package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
- package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d-frozen/engine/files.js +62 -0
- package/kits/basic-2d-frozen/engine/scene.js +420 -0
- package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
- package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
- package/kits/basic-2d-frozen/eslint.config.js +50 -0
- package/kits/basic-2d-frozen/index.html +11 -0
- package/kits/basic-2d-frozen/main.jsx +10 -0
- package/kits/basic-2d-frozen/package-lock.json +2706 -0
- package/kits/basic-2d-frozen/package.json +41 -0
- package/kits/basic-2d-frozen/scenes/main.scene +108 -0
- package/kits/basic-2d-frozen/vite.config.js +1 -0
- package/kits/rpg-2d/.prettierrc +8 -0
- package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
- package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
- package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
- package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
- package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
- package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
- package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
- package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
- package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
- package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
- package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
- package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
- package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
- package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
- package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
- package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
- package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
- package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
- package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
- package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
- package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
- package/kits/rpg-2d/drawings/floor.drawing +70 -0
- package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
- package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
- package/kits/rpg-2d/editors/App.tsx +163 -0
- package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
- package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
- package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
- package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
- package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
- package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
- package/kits/rpg-2d/editors/editorHistory.ts +75 -0
- package/kits/rpg-2d/editors/editorProps.ts +10 -0
- package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
- package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
- package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
- package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
- package/kits/rpg-2d/engine/drawing.ts +81 -0
- package/kits/rpg-2d/engine/files.ts +215 -0
- package/kits/rpg-2d/engine/scene.ts +484 -0
- package/kits/rpg-2d/engine/ui.module.css +928 -0
- package/kits/rpg-2d/engine/ui.tsx +483 -0
- package/kits/rpg-2d/eslint.config.js +46 -0
- package/kits/rpg-2d/index.html +11 -0
- package/kits/rpg-2d/main.tsx +14 -0
- package/kits/rpg-2d/package-lock.json +3149 -0
- package/kits/rpg-2d/package.json +46 -0
- package/kits/rpg-2d/scenes/main.scene +203 -0
- package/kits/rpg-2d/tsconfig.json +17 -0
- package/kits/rpg-2d/vite-env.d.ts +7 -0
- package/kits/rpg-2d/vite.config.js +1 -0
- package/package.json +27 -5
- package/AGENTS.md +0 -25
- package/dist/push.d.ts +0 -1
- package/src/api.ts +0 -160
- package/src/bundle.ts +0 -28
- package/src/config.ts +0 -36
- package/src/index.ts +0 -143
- package/src/init.ts +0 -71
- package/src/login.ts +0 -24
- package/src/preview.ts +0 -94
- package/src/push.ts +0 -118
- package/src/serve.ts +0 -134
- package/tsconfig.json +0 -13
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
}
|