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.
Files changed (158) hide show
  1. package/dist/api.d.ts +53 -5
  2. package/dist/api.js +42 -15
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.js +25 -11
  5. package/dist/get-deck.d.ts +3 -0
  6. package/dist/get-deck.js +64 -0
  7. package/dist/ide-client.d.ts +1 -0
  8. package/dist/ide-client.js +537 -0
  9. package/dist/ide.d.ts +16 -0
  10. package/dist/ide.js +546 -0
  11. package/dist/index.js +36 -41
  12. package/dist/init.d.ts +3 -1
  13. package/dist/init.js +170 -24
  14. package/dist/localPaths.d.ts +6 -0
  15. package/dist/localPaths.js +33 -0
  16. package/dist/login.js +1 -1
  17. package/dist/preview.d.ts +3 -0
  18. package/dist/preview.js +53 -34
  19. package/dist/save-deck.d.ts +2 -0
  20. package/dist/{push.js → save-deck.js} +66 -5
  21. package/dist/serve.d.ts +2 -0
  22. package/dist/serve.js +290 -27
  23. package/kits/basic-2d/.prettierrc +8 -0
  24. package/kits/basic-2d/CLAUDE.md +131 -0
  25. package/kits/basic-2d/behaviors/Camera.jsx +43 -0
  26. package/kits/basic-2d/behaviors/Collider.jsx +71 -0
  27. package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
  28. package/kits/basic-2d/behaviors/Layout.jsx +16 -0
  29. package/kits/basic-2d/drawings/floor.drawing +70 -0
  30. package/kits/basic-2d/editors/App.jsx +152 -0
  31. package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
  32. package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
  33. package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
  34. package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
  35. package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
  36. package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
  37. package/kits/basic-2d/editors/editorHistory.js +52 -0
  38. package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
  39. package/kits/basic-2d/engine/SceneUI.jsx +67 -0
  40. package/kits/basic-2d/engine/TouchControls.jsx +136 -0
  41. package/kits/basic-2d/engine/autoInspector.jsx +51 -0
  42. package/kits/basic-2d/engine/files.js +62 -0
  43. package/kits/basic-2d/engine/scene.js +420 -0
  44. package/kits/basic-2d/engine/ui.jsx +344 -0
  45. package/kits/basic-2d/engine/ui.module.css +928 -0
  46. package/kits/basic-2d/eslint.config.js +50 -0
  47. package/kits/basic-2d/index.html +11 -0
  48. package/kits/basic-2d/main.jsx +10 -0
  49. package/kits/basic-2d/package-lock.json +2706 -0
  50. package/kits/basic-2d/package.json +41 -0
  51. package/kits/basic-2d/scenes/main.scene +108 -0
  52. package/kits/basic-2d/vite.config.js +1 -0
  53. package/kits/basic-2d-frozen/.prettierrc +8 -0
  54. package/kits/basic-2d-frozen/CLAUDE.md +131 -0
  55. package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
  56. package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
  57. package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
  58. package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
  59. package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
  60. package/kits/basic-2d-frozen/editors/App.jsx +152 -0
  61. package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
  62. package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
  63. package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
  64. package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
  65. package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
  66. package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
  67. package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
  68. package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
  69. package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
  70. package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
  71. package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
  72. package/kits/basic-2d-frozen/engine/files.js +62 -0
  73. package/kits/basic-2d-frozen/engine/scene.js +420 -0
  74. package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
  75. package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
  76. package/kits/basic-2d-frozen/eslint.config.js +50 -0
  77. package/kits/basic-2d-frozen/index.html +11 -0
  78. package/kits/basic-2d-frozen/main.jsx +10 -0
  79. package/kits/basic-2d-frozen/package-lock.json +2706 -0
  80. package/kits/basic-2d-frozen/package.json +41 -0
  81. package/kits/basic-2d-frozen/scenes/main.scene +108 -0
  82. package/kits/basic-2d-frozen/vite.config.js +1 -0
  83. package/kits/rpg-2d/.prettierrc +8 -0
  84. package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
  85. package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
  86. package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
  87. package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
  88. package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
  89. package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
  90. package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
  91. package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
  92. package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
  93. package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
  94. package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
  95. package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
  96. package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
  97. package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
  98. package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
  99. package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
  100. package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
  101. package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
  102. package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
  103. package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
  104. package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
  105. package/kits/rpg-2d/drawings/floor.drawing +70 -0
  106. package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
  107. package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
  108. package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
  109. package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
  110. package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
  111. package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
  112. package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
  113. package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
  114. package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
  115. package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
  116. package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
  117. package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
  118. package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
  119. package/kits/rpg-2d/editors/App.tsx +163 -0
  120. package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
  121. package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
  122. package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
  123. package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
  124. package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
  125. package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
  126. package/kits/rpg-2d/editors/editorHistory.ts +75 -0
  127. package/kits/rpg-2d/editors/editorProps.ts +10 -0
  128. package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
  129. package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
  130. package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
  131. package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
  132. package/kits/rpg-2d/engine/drawing.ts +81 -0
  133. package/kits/rpg-2d/engine/files.ts +215 -0
  134. package/kits/rpg-2d/engine/scene.ts +484 -0
  135. package/kits/rpg-2d/engine/ui.module.css +928 -0
  136. package/kits/rpg-2d/engine/ui.tsx +483 -0
  137. package/kits/rpg-2d/eslint.config.js +46 -0
  138. package/kits/rpg-2d/index.html +11 -0
  139. package/kits/rpg-2d/main.tsx +14 -0
  140. package/kits/rpg-2d/package-lock.json +3149 -0
  141. package/kits/rpg-2d/package.json +46 -0
  142. package/kits/rpg-2d/scenes/main.scene +203 -0
  143. package/kits/rpg-2d/tsconfig.json +17 -0
  144. package/kits/rpg-2d/vite-env.d.ts +7 -0
  145. package/kits/rpg-2d/vite.config.js +1 -0
  146. package/package.json +27 -5
  147. package/AGENTS.md +0 -25
  148. package/dist/push.d.ts +0 -1
  149. package/src/api.ts +0 -160
  150. package/src/bundle.ts +0 -28
  151. package/src/config.ts +0 -36
  152. package/src/index.ts +0 -143
  153. package/src/init.ts +0 -71
  154. package/src/login.ts +0 -24
  155. package/src/preview.ts +0 -94
  156. package/src/push.ts +0 -118
  157. package/src/serve.ts +0 -134
  158. 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
+ }