react-three-game 0.0.18 → 0.0.19

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.
@@ -2,34 +2,29 @@
2
2
 
3
3
  import GameCanvas from "../../shared/GameCanvas";
4
4
  import { useState, useRef, useEffect } from "react";
5
- import { Group, } from "three";
6
- import { Prefab, } from "./types";
5
+ import { Prefab } from "./types";
7
6
  import PrefabRoot from "./PrefabRoot";
8
7
  import { Physics } from "@react-three/rapier";
9
8
  import EditorUI from "./EditorUI";
9
+ import { base, toolbar } from "./styles";
10
10
 
11
11
  const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { basePath?: string, initialPrefab?: Prefab, onPrefabChange?: (prefab: Prefab) => void, children?: React.ReactNode }) => {
12
12
  const [editMode, setEditMode] = useState(true);
13
13
  const [loadedPrefab, setLoadedPrefab] = useState<Prefab>(initialPrefab ?? {
14
- "id": "prefab-default",
15
- "name": "New Prefab",
16
- "root": {
17
- "id": "root",
18
- "components": {
19
- "transform": {
20
- "type": "Transform",
21
- "properties": {
22
- "position": [0, 0, 0],
23
- "rotation": [0, 0, 0],
24
- "scale": [1, 1, 1]
25
- }
14
+ id: "prefab-default",
15
+ name: "New Prefab",
16
+ root: {
17
+ id: "root",
18
+ components: {
19
+ transform: {
20
+ type: "Transform",
21
+ properties: { position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1] }
26
22
  }
27
23
  }
28
24
  }
29
25
  });
30
26
  const [selectedId, setSelectedId] = useState<string | null>(null);
31
27
  const [transformMode, setTransformMode] = useState<"translate" | "rotate" | "scale">("translate");
32
- const prefabRef = useRef<Group>(null);
33
28
 
34
29
  // Sync internal state with external initialPrefab prop
35
30
  useEffect(() => {
@@ -52,15 +47,11 @@ const PrefabEditor = ({ basePath, initialPrefab, onPrefabChange, children }: { b
52
47
  <gridHelper args={[10, 10]} position={[0, -1, 0]} />
53
48
  <PrefabRoot
54
49
  data={loadedPrefab}
55
- ref={prefabRef}
56
-
57
- // props for edit mode
58
50
  editMode={editMode}
59
51
  onPrefabChange={updatePrefab}
60
52
  selectedId={selectedId}
61
53
  onSelect={setSelectedId}
62
54
  transformMode={transformMode}
63
- setTransformMode={setTransformMode}
64
55
  basePath={basePath}
65
56
  />
66
57
  {children}
@@ -98,189 +89,87 @@ const SaveDataPanel = ({
98
89
  }) => {
99
90
  const [history, setHistory] = useState<Prefab[]>([currentData]);
100
91
  const [historyIndex, setHistoryIndex] = useState(0);
101
- const throttleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
102
- const lastSavedDataRef = useRef<string>(JSON.stringify(currentData));
92
+ const throttleRef = useRef<NodeJS.Timeout | null>(null);
93
+ const lastDataRef = useRef<string>(JSON.stringify(currentData));
103
94
 
104
- // Define undo/redo handlers
105
- const handleUndo = () => {
95
+ const undo = () => {
106
96
  if (historyIndex > 0) {
107
97
  const newIndex = historyIndex - 1;
108
98
  setHistoryIndex(newIndex);
109
- lastSavedDataRef.current = JSON.stringify(history[newIndex]);
99
+ lastDataRef.current = JSON.stringify(history[newIndex]);
110
100
  onDataChange(history[newIndex]);
111
101
  }
112
102
  };
113
103
 
114
- const handleRedo = () => {
104
+ const redo = () => {
115
105
  if (historyIndex < history.length - 1) {
116
106
  const newIndex = historyIndex + 1;
117
107
  setHistoryIndex(newIndex);
118
- lastSavedDataRef.current = JSON.stringify(history[newIndex]);
108
+ lastDataRef.current = JSON.stringify(history[newIndex]);
119
109
  onDataChange(history[newIndex]);
120
110
  }
121
111
  };
122
112
 
123
- // Keyboard shortcuts for undo/redo
124
113
  useEffect(() => {
125
114
  const handleKeyDown = (e: KeyboardEvent) => {
126
- // Undo: Ctrl+Z (Cmd+Z on Mac)
127
115
  if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
128
116
  e.preventDefault();
129
- handleUndo();
130
- }
131
- // Redo: Ctrl+Shift+Z or Ctrl+Y (Cmd+Shift+Z or Cmd+Y on Mac)
132
- else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
117
+ undo();
118
+ } else if ((e.ctrlKey || e.metaKey) && (e.shiftKey && e.key === 'z' || e.key === 'y')) {
133
119
  e.preventDefault();
134
- handleRedo();
120
+ redo();
135
121
  }
136
122
  };
137
-
138
123
  window.addEventListener('keydown', handleKeyDown);
139
124
  return () => window.removeEventListener('keydown', handleKeyDown);
140
125
  }, [historyIndex, history]);
141
126
 
142
- // Throttled history update when currentData changes
143
127
  useEffect(() => {
144
- const currentDataStr = JSON.stringify(currentData);
145
-
146
- // Skip if data hasn't actually changed
147
- if (currentDataStr === lastSavedDataRef.current) {
148
- return;
149
- }
128
+ const currentStr = JSON.stringify(currentData);
129
+ if (currentStr === lastDataRef.current) return;
150
130
 
151
- // Clear existing throttle timeout
152
- if (throttleTimeoutRef.current) {
153
- clearTimeout(throttleTimeoutRef.current);
154
- }
155
-
156
- // Set new throttled update
157
- throttleTimeoutRef.current = setTimeout(() => {
158
- lastSavedDataRef.current = currentDataStr;
131
+ if (throttleRef.current) clearTimeout(throttleRef.current);
159
132
 
133
+ throttleRef.current = setTimeout(() => {
134
+ lastDataRef.current = currentStr;
160
135
  setHistory(prev => {
161
- // Slice history at current index (discard future states)
162
- const newHistory = prev.slice(0, historyIndex + 1);
163
- // Add new state
164
- newHistory.push(currentData);
165
- // Limit history size to 50 states
166
- if (newHistory.length > 50) {
167
- newHistory.shift();
168
- return newHistory;
169
- }
170
- return newHistory;
136
+ const newHistory = [...prev.slice(0, historyIndex + 1), currentData];
137
+ return newHistory.length > 50 ? newHistory.slice(1) : newHistory;
171
138
  });
172
-
173
- setHistoryIndex(prev => {
174
- const newHistory = history.slice(0, prev + 1);
175
- newHistory.push(currentData);
176
- return Math.min(newHistory.length - 1, 49);
177
- });
178
- }, 500); // 500ms throttle
139
+ setHistoryIndex(prev => Math.min(prev + 1, 49));
140
+ }, 500);
179
141
 
180
142
  return () => {
181
- if (throttleTimeoutRef.current) {
182
- clearTimeout(throttleTimeoutRef.current);
183
- }
143
+ if (throttleRef.current) clearTimeout(throttleRef.current);
184
144
  };
185
- }, [currentData, historyIndex, history]);
145
+ }, [currentData]);
186
146
 
187
147
  const handleLoad = async () => {
188
148
  const prefab = await loadJson();
189
149
  if (prefab) {
190
150
  onDataChange(prefab);
191
- // Reset history when loading new file
192
151
  setHistory([prefab]);
193
152
  setHistoryIndex(0);
194
- lastSavedDataRef.current = JSON.stringify(prefab);
153
+ lastDataRef.current = JSON.stringify(prefab);
195
154
  }
196
155
  };
197
156
 
198
157
  const canUndo = historyIndex > 0;
199
158
  const canRedo = historyIndex < history.length - 1;
200
159
 
201
- return <div style={{
202
- position: "absolute",
203
- top: 8,
204
- left: "50%",
205
- transform: "translateX(-50%)",
206
- display: "flex",
207
- alignItems: "center",
208
- gap: 6,
209
- padding: "2px 4px",
210
- background: "rgba(0,0,0,0.55)",
211
- border: "1px solid rgba(255,255,255,0.12)",
212
- borderRadius: 4,
213
- color: "rgba(255,255,255,0.9)",
214
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
215
- fontSize: 11,
216
- lineHeight: 1,
217
- WebkitUserSelect: "none",
218
- userSelect: "none",
219
- }}>
220
- <PanelButton onClick={() => onEditModeChange(!editMode)}>
160
+ return <div style={toolbar.panel}>
161
+ <button style={base.btn} onClick={() => onEditModeChange(!editMode)}>
221
162
  {editMode ? "▶" : "⏸"}
222
- </PanelButton>
223
-
224
- <span style={{ opacity: 0.35 }}>|</span>
225
-
226
- <PanelButton onClick={handleUndo} disabled={!canUndo} title="Undo (Ctrl+Z)">
227
-
228
- </PanelButton>
229
-
230
- <PanelButton onClick={handleRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)">
231
-
232
- </PanelButton>
233
-
234
- <span style={{ opacity: 0.35 }}>|</span>
235
-
236
- <PanelButton onClick={handleLoad} title="Load JSON">
237
- 📥
238
- </PanelButton>
239
-
240
- <PanelButton onClick={() => saveJson(currentData, "prefab")} title="Save JSON">
241
- 💾
242
- </PanelButton>
163
+ </button>
164
+ <div style={toolbar.divider} />
165
+ <button style={{ ...base.btn, ...(canUndo ? {} : toolbar.disabled) }} onClick={undo} disabled={!canUndo}>↶</button>
166
+ <button style={{ ...base.btn, ...(canRedo ? {} : toolbar.disabled) }} onClick={redo} disabled={!canRedo}>↷</button>
167
+ <div style={toolbar.divider} />
168
+ <button style={base.btn} onClick={handleLoad}>📥</button>
169
+ <button style={base.btn} onClick={() => saveJson(currentData, "prefab")}>💾</button>
243
170
  </div>;
244
171
  };
245
172
 
246
- const PanelButton = ({
247
- onClick,
248
- disabled,
249
- title,
250
- children
251
- }: {
252
- onClick: () => void;
253
- disabled?: boolean;
254
- title?: string;
255
- children: React.ReactNode;
256
- }) => {
257
- return <button
258
- style={{
259
- padding: "2px 6px",
260
- font: "inherit",
261
- background: "transparent",
262
- color: disabled ? "rgba(255,255,255,0.3)" : "inherit",
263
- border: "1px solid rgba(255,255,255,0.18)",
264
- borderRadius: 3,
265
- cursor: disabled ? "not-allowed" : "pointer",
266
- opacity: disabled ? 0.5 : 1,
267
- }}
268
- onClick={onClick}
269
- disabled={disabled}
270
- title={title}
271
- onPointerEnter={(e) => {
272
- if (!disabled) {
273
- (e.currentTarget as HTMLButtonElement).style.background = "rgba(255,255,255,0.08)";
274
- }
275
- }}
276
- onPointerLeave={(e) => {
277
- (e.currentTarget as HTMLButtonElement).style.background = "transparent";
278
- }}
279
- >
280
- {children}
281
- </button>;
282
- };
283
-
284
173
  const saveJson = (data: any, filename: string) => {
285
174
  const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(data, null, 2));
286
175
  const downloadAnchorNode = document.createElement('a');
@@ -1,31 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import { MapControls, TransformControls } from "@react-three/drei";
4
- import { useState, useRef, useEffect, forwardRef, useMemo, useCallback } from "react";
5
- import { Vector3, Euler, Quaternion, ClampToEdgeWrapping, DoubleSide, Group, Object3D, RepeatWrapping, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
4
+ import { useState, useRef, useEffect, forwardRef, useCallback } from "react";
5
+ import { Vector3, Euler, Quaternion, Group, Object3D, SRGBColorSpace, Texture, TextureLoader, Matrix4 } from "three";
6
6
  import { Prefab, GameObject as GameObjectType } from "./types";
7
- import { getComponent } from "./components/ComponentRegistry";
7
+ import { getComponent, registerComponent } from "./components/ComponentRegistry";
8
8
  import { ThreeEvent } from "@react-three/fiber";
9
9
  import { loadModel } from "../dragdrop/modelLoader";
10
10
  import { GameInstance, GameInstanceProvider } from "./InstanceProvider";
11
-
12
- // register all components
13
- import { registerComponent } from './components/ComponentRegistry';
11
+ import { updateNode } from "./utils";
14
12
  import components from './components/';
15
- components.forEach(registerComponent);
16
13
 
17
- function updatePrefabNode(root: GameObjectType, id: string, update: (node: GameObjectType) => GameObjectType): GameObjectType {
18
- if (root.id === id) {
19
- return update(root);
20
- }
21
- if (root.children) {
22
- return {
23
- ...root,
24
- children: root.children.map(child => updatePrefabNode(child, id, update))
25
- };
26
- }
27
- return root;
28
- }
14
+ // Register all components
15
+ components.forEach(registerComponent);
29
16
 
30
17
  export const PrefabRoot = forwardRef<Group, {
31
18
  editMode?: boolean;
@@ -34,29 +21,21 @@ export const PrefabRoot = forwardRef<Group, {
34
21
  selectedId?: string | null;
35
22
  onSelect?: (id: string | null) => void;
36
23
  transformMode?: "translate" | "rotate" | "scale";
37
- setTransformMode?: (mode: "translate" | "rotate" | "scale") => void;
38
24
  basePath?: string;
39
- }>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, setTransformMode, basePath = "" }, ref) => {
25
+ }>(({ editMode, data, onPrefabChange, selectedId, onSelect, transformMode, basePath = "" }, ref) => {
40
26
  const [loadedModels, setLoadedModels] = useState<Record<string, Object3D>>({});
41
27
  const [loadedTextures, setLoadedTextures] = useState<Record<string, Texture>>({});
42
- // const [prefabRoot, setPrefabRoot] = useState<Prefab>(data); // Removed local state
43
28
  const loadingRefs = useRef<Set<string>>(new Set());
44
29
  const objectRefs = useRef<Record<string, Object3D | null>>({});
45
30
  const [selectedObject, setSelectedObject] = useState<Object3D | null>(null);
46
31
 
47
32
  const registerRef = useCallback((id: string, obj: Object3D | null) => {
48
33
  objectRefs.current[id] = obj;
49
- if (id === selectedId) {
50
- setSelectedObject(obj);
51
- }
34
+ if (id === selectedId) setSelectedObject(obj);
52
35
  }, [selectedId]);
53
36
 
54
37
  useEffect(() => {
55
- if (selectedId) {
56
- setSelectedObject(objectRefs.current[selectedId] || null);
57
- } else {
58
- setSelectedObject(null);
59
- }
38
+ setSelectedObject(selectedId ? objectRefs.current[selectedId] || null : null);
60
39
  }, [selectedId]);
61
40
 
62
41
  const onTransformChange = () => {
@@ -82,10 +61,10 @@ export const PrefabRoot = forwardRef<Group, {
82
61
  const le = new Euler().setFromQuaternion(lq);
83
62
 
84
63
  // 4. Write back LOCAL transform into the prefab node
85
- const newRoot = updatePrefabNode(data.root, selectedId, (node) => ({
64
+ const newRoot = updateNode(data.root, selectedId, (node) => ({
86
65
  ...node,
87
66
  components: {
88
- ...node?.components,
67
+ ...node.components,
89
68
  transform: {
90
69
  type: "Transform",
91
70
  properties: {
@@ -193,37 +193,54 @@ function DirectionalLightView({ properties, editMode }: { properties: any; editM
193
193
  const { scene } = useThree();
194
194
  const directionalLightRef = useRef<DirectionalLight>(null);
195
195
  const targetRef = useRef<Object3D>(new Object3D());
196
- const lastUpdate = useRef(0);
197
196
  const cameraHelperRef = useRef<CameraHelper | null>(null);
198
- const lastPositionRef = useRef<Vector3>(new Vector3());
199
197
 
200
- // Add target to scene
198
+ // Add target to scene once
201
199
  useEffect(() => {
202
- if (targetRef.current) {
203
- scene.add(targetRef.current);
204
- return () => {
205
- scene.remove(targetRef.current);
206
- };
207
- }
200
+ const target = targetRef.current;
201
+ scene.add(target);
202
+ return () => {
203
+ scene.remove(target);
204
+ };
208
205
  }, [scene]);
209
206
 
210
- // Update target position when light position or offset changes
207
+ // Set up light target reference once
211
208
  useEffect(() => {
212
- if (directionalLightRef.current && targetRef.current) {
213
- const lightWorldPos = new Vector3();
214
- directionalLightRef.current.getWorldPosition(lightWorldPos);
215
- targetRef.current.position.set(
216
- lightWorldPos.x + targetOffset[0],
217
- lightWorldPos.y + targetOffset[1],
218
- lightWorldPos.z + targetOffset[2]
219
- );
209
+ if (directionalLightRef.current) {
220
210
  directionalLightRef.current.target = targetRef.current;
221
211
  }
212
+ }, []);
213
+
214
+ // Update target position and mark shadow for update when light moves or offset changes
215
+ useFrame(() => {
216
+ if (!directionalLightRef.current) return;
217
+
218
+ const lightWorldPos = new Vector3();
219
+ directionalLightRef.current.getWorldPosition(lightWorldPos);
220
+
221
+ const newTargetPos = new Vector3(
222
+ lightWorldPos.x + targetOffset[0],
223
+ lightWorldPos.y + targetOffset[1],
224
+ lightWorldPos.z + targetOffset[2]
225
+ );
226
+
227
+ // Only update if position actually changed
228
+ if (!targetRef.current.position.equals(newTargetPos)) {
229
+ targetRef.current.position.copy(newTargetPos);
230
+ if (directionalLightRef.current.shadow) {
231
+ directionalLightRef.current.shadow.needsUpdate = true;
232
+ }
233
+ }
234
+
235
+ // Update camera helper in edit mode
236
+ if (editMode && cameraHelperRef.current) {
237
+ cameraHelperRef.current.update();
238
+ }
222
239
  });
223
240
 
241
+ // Create/destroy camera helper for edit mode
224
242
  useEffect(() => {
225
- // Create camera helper for edit mode and add to scene
226
- if (editMode && directionalLightRef.current && directionalLightRef.current.shadow.camera) {
243
+ if (editMode && directionalLightRef.current?.shadow.camera) {
227
244
  const helper = new CameraHelper(directionalLightRef.current.shadow.camera);
228
245
  cameraHelperRef.current = helper;
229
246
  scene.add(helper);
@@ -232,44 +249,12 @@ function DirectionalLightView({ properties, editMode }: { properties: any; editM
232
249
  if (cameraHelperRef.current) {
233
250
  scene.remove(cameraHelperRef.current);
234
251
  cameraHelperRef.current.dispose();
252
+ cameraHelperRef.current = null;
235
253
  }
236
254
  };
237
255
  }
238
256
  }, [editMode, scene]);
239
257
 
240
- useFrame(({ clock }) => {
241
- if (!directionalLightRef.current || !directionalLightRef.current.shadow) return;
242
-
243
- // Disable auto-update for shadows
244
- if (directionalLightRef.current.shadow.autoUpdate) {
245
- directionalLightRef.current.shadow.autoUpdate = false;
246
- directionalLightRef.current.shadow.needsUpdate = true;
247
- }
248
-
249
- // Check if position has changed
250
- const currentPosition = new Vector3();
251
- directionalLightRef.current.getWorldPosition(currentPosition);
252
-
253
- const positionChanged = !currentPosition.equals(lastPositionRef.current);
254
-
255
- if (positionChanged) {
256
- lastPositionRef.current.copy(currentPosition);
257
- directionalLightRef.current.shadow.needsUpdate = true;
258
- lastUpdate.current = clock.elapsedTime; // Reset timer on position change
259
- }
260
-
261
- // Update shadow map infrequently (every 5 seconds) if position hasn't changed
262
- if (!editMode && !positionChanged && clock.elapsedTime - lastUpdate.current > 5) {
263
- lastUpdate.current = clock.elapsedTime;
264
- directionalLightRef.current.shadow.needsUpdate = true;
265
- }
266
-
267
- // Update camera helper in edit mode
268
- if (editMode && cameraHelperRef.current) {
269
- cameraHelperRef.current.update();
270
- }
271
- });
272
-
273
258
  return (
274
259
  <>
275
260
  <directionalLight
@@ -0,0 +1,195 @@
1
+ // Shared editor styles - single source of truth for all prefab editor UI
2
+
3
+ export const colors = {
4
+ bg: 'rgba(0,0,0,0.6)',
5
+ bgLight: 'rgba(255,255,255,0.06)',
6
+ bgHover: 'rgba(255,255,255,0.1)',
7
+ border: 'rgba(255,255,255,0.15)',
8
+ borderLight: 'rgba(255,255,255,0.1)',
9
+ borderFaint: 'rgba(255,255,255,0.05)',
10
+ text: '#fff',
11
+ textMuted: 'rgba(255,255,255,0.7)',
12
+ danger: '#ffaaaa',
13
+ dangerBg: 'rgba(255,80,80,0.2)',
14
+ dangerBorder: 'rgba(255,80,80,0.4)',
15
+ };
16
+
17
+ export const fonts = {
18
+ family: 'system-ui, sans-serif',
19
+ size: 11,
20
+ sizeSm: 10,
21
+ };
22
+
23
+ // Base component styles
24
+ export const base = {
25
+ panel: {
26
+ background: colors.bg,
27
+ color: colors.text,
28
+ border: `1px solid ${colors.border}`,
29
+ borderRadius: 4,
30
+ overflow: 'hidden',
31
+ backdropFilter: 'blur(8px)',
32
+ fontFamily: fonts.family,
33
+ fontSize: fonts.size,
34
+ } as React.CSSProperties,
35
+
36
+ header: {
37
+ padding: '6px 8px',
38
+ display: 'flex',
39
+ alignItems: 'center',
40
+ justifyContent: 'space-between',
41
+ cursor: 'pointer',
42
+ background: colors.bgLight,
43
+ borderBottom: `1px solid ${colors.borderLight}`,
44
+ fontSize: fonts.size,
45
+ fontWeight: 500,
46
+ textTransform: 'uppercase',
47
+ letterSpacing: 0.5,
48
+ } as React.CSSProperties,
49
+
50
+ input: {
51
+ width: '100%',
52
+ background: colors.bgHover,
53
+ border: `1px solid ${colors.border}`,
54
+ borderRadius: 3,
55
+ padding: '4px 6px',
56
+ color: colors.text,
57
+ fontSize: fonts.size,
58
+ outline: 'none',
59
+ } as React.CSSProperties,
60
+
61
+ btn: {
62
+ background: colors.bgHover,
63
+ border: `1px solid ${colors.border}`,
64
+ borderRadius: 3,
65
+ padding: '4px 8px',
66
+ color: colors.text,
67
+ fontSize: fonts.size,
68
+ cursor: 'pointer',
69
+ outline: 'none',
70
+ } as React.CSSProperties,
71
+
72
+ btnDanger: {
73
+ background: colors.dangerBg,
74
+ borderColor: colors.dangerBorder,
75
+ color: colors.danger,
76
+ } as React.CSSProperties,
77
+
78
+ label: {
79
+ fontSize: fonts.sizeSm,
80
+ opacity: 0.7,
81
+ marginBottom: 4,
82
+ textTransform: 'uppercase',
83
+ letterSpacing: 0.5,
84
+ } as React.CSSProperties,
85
+
86
+ row: {
87
+ display: 'flex',
88
+ gap: 6,
89
+ } as React.CSSProperties,
90
+
91
+ section: {
92
+ paddingBottom: 8,
93
+ borderBottom: `1px solid ${colors.borderLight}`,
94
+ } as React.CSSProperties,
95
+ };
96
+
97
+ // Specific panel styles
98
+ export const inspector = {
99
+ panel: {
100
+ ...base.panel,
101
+ position: 'absolute' as const,
102
+ top: 8,
103
+ right: 8,
104
+ zIndex: 20,
105
+ width: 260,
106
+ },
107
+ content: {
108
+ padding: 8,
109
+ maxHeight: '80vh',
110
+ overflowY: 'auto' as const,
111
+ display: 'flex',
112
+ flexDirection: 'column' as const,
113
+ gap: 8,
114
+ },
115
+ };
116
+
117
+ export const tree = {
118
+ panel: {
119
+ ...base.panel,
120
+ maxHeight: '85vh',
121
+ display: 'flex',
122
+ flexDirection: 'column' as const,
123
+ userSelect: 'none' as const,
124
+ },
125
+ scroll: {
126
+ overflowY: 'auto' as const,
127
+ padding: 4,
128
+ },
129
+ row: {
130
+ display: 'flex',
131
+ alignItems: 'center',
132
+ padding: '3px 6px',
133
+ borderBottom: `1px solid ${colors.borderFaint}`,
134
+ cursor: 'pointer',
135
+ whiteSpace: 'nowrap' as const,
136
+ } as React.CSSProperties,
137
+ selected: {
138
+ background: 'rgba(255,255,255,0.12)',
139
+ },
140
+ };
141
+
142
+ export const menu = {
143
+ container: {
144
+ position: 'fixed' as const,
145
+ zIndex: 50,
146
+ minWidth: 120,
147
+ background: 'rgba(0,0,0,0.85)',
148
+ border: `1px solid ${colors.border}`,
149
+ borderRadius: 4,
150
+ overflow: 'hidden',
151
+ boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
152
+ backdropFilter: 'blur(8px)',
153
+ },
154
+ item: {
155
+ width: '100%',
156
+ textAlign: 'left' as const,
157
+ padding: '6px 8px',
158
+ background: 'transparent',
159
+ border: 'none',
160
+ color: colors.text,
161
+ fontSize: fonts.size,
162
+ cursor: 'pointer',
163
+ outline: 'none',
164
+ } as React.CSSProperties,
165
+ danger: {
166
+ color: colors.danger,
167
+ },
168
+ };
169
+
170
+ export const toolbar = {
171
+ panel: {
172
+ position: 'absolute' as const,
173
+ top: 8,
174
+ left: '50%',
175
+ transform: 'translateX(-50%)',
176
+ display: 'flex',
177
+ gap: 6,
178
+ padding: '4px 6px',
179
+ background: colors.bg,
180
+ border: `1px solid ${colors.border}`,
181
+ borderRadius: 4,
182
+ color: colors.text,
183
+ fontFamily: fonts.family,
184
+ fontSize: fonts.size,
185
+ backdropFilter: 'blur(8px)',
186
+ },
187
+ divider: {
188
+ width: 1,
189
+ background: 'rgba(255,255,255,0.2)',
190
+ },
191
+ disabled: {
192
+ opacity: 0.4,
193
+ cursor: 'not-allowed',
194
+ },
195
+ };