zelune 1.6.2 → 1.6.4
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/CHANGELOG.md +16 -0
- package/bin/cli.mjs +13 -54
- package/dist/assets/CheckoutScreen-6IdrpKgm.js +229 -0
- package/dist/assets/HomeScreen-Dxe9L4I2.js +316 -0
- package/dist/assets/LoginScreen-Dp1awI4F.js +189 -0
- package/dist/assets/ProductDetailScreen-Wa5_nlRP.js +196 -0
- package/dist/assets/ProfileScreen-riAU6vVh.js +185 -0
- package/dist/assets/index-hEzd4JVy.js +44697 -0
- package/{index.html → dist/index.html} +3 -7
- package/mcp/server.mjs +1370 -34
- package/package.json +10 -17
- package/plugin.json +1 -1
- package/socket.yml +11 -0
- package/mcp/server.ts +0 -446
- package/plugins/viteReactNativeFallbackPlugin.ts +0 -519
- package/plugins/viteRnSourceInject.ts +0 -117
- package/plugins/viteVirtualScreensPlugin.ts +0 -216
- package/src/canvas/CanvasApp.tsx +0 -1655
- package/src/canvas/FlowArrowsLayer.tsx +0 -239
- package/src/canvas/InspectorPanel.tsx +0 -1494
- package/src/canvas/ScreenArtboard.tsx +0 -1491
- package/src/canvas/types.ts +0 -23
- package/src/main.tsx +0 -80
- package/src/runtime/MockNavigationContext.tsx +0 -1795
- package/src/runtime/ScreenErrorBoundary.tsx +0 -86
- package/src/runtime/nativewindShim.ts +0 -997
- package/src/runtime/reactNativeShim.ts +0 -240
- package/src/screens/CheckoutScreen.tsx +0 -229
- package/src/screens/HomeScreen.tsx +0 -288
- package/src/screens/LoginScreen.tsx +0 -176
- package/src/screens/ProductDetailScreen.tsx +0 -207
- package/src/screens/ProfileScreen.tsx +0 -172
- package/src/screens/index.ts +0 -1
- package/src/server/apiMiddleware.ts +0 -375
- package/src/server/astEngine.ts +0 -2553
- package/src/vite-env.d.ts +0 -7
- package/tsconfig.json +0 -23
- package/vite.config.ts +0 -340
- /package/{src/styles.css → dist/assets/index-C7nrS82N.css} +0 -0
package/src/canvas/CanvasApp.tsx
DELETED
|
@@ -1,1655 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
MousePointer,
|
|
4
|
-
Play,
|
|
5
|
-
GitCommit,
|
|
6
|
-
Undo2,
|
|
7
|
-
Redo2,
|
|
8
|
-
ZoomIn,
|
|
9
|
-
ZoomOut,
|
|
10
|
-
Maximize,
|
|
11
|
-
LayoutGrid,
|
|
12
|
-
Layers,
|
|
13
|
-
RefreshCw,
|
|
14
|
-
PanelLeftClose,
|
|
15
|
-
PanelLeftOpen,
|
|
16
|
-
PanelRightClose,
|
|
17
|
-
PanelRightOpen,
|
|
18
|
-
Sliders,
|
|
19
|
-
Sun,
|
|
20
|
-
Moon,
|
|
21
|
-
Plus,
|
|
22
|
-
} from 'lucide-react';
|
|
23
|
-
import { SCREEN_COMPONENTS, SCREEN_ORDER } from '../screens';
|
|
24
|
-
import { ScreenArtboard } from './ScreenArtboard';
|
|
25
|
-
import { FlowArrowsLayer } from './FlowArrowsLayer';
|
|
26
|
-
import { InspectorPanel } from './InspectorPanel';
|
|
27
|
-
import type {
|
|
28
|
-
ArtboardLayoutMap,
|
|
29
|
-
CanvasMode,
|
|
30
|
-
InspectedElement,
|
|
31
|
-
NavigationEdge,
|
|
32
|
-
ScreenMetadata,
|
|
33
|
-
SelectedElementRef,
|
|
34
|
-
} from './types';
|
|
35
|
-
import type {
|
|
36
|
-
PatchPayload,
|
|
37
|
-
ReorderPayload,
|
|
38
|
-
InsertElementPayload,
|
|
39
|
-
DuplicateElementPayload,
|
|
40
|
-
DeleteElementPayload,
|
|
41
|
-
} from '../server/astEngine';
|
|
42
|
-
|
|
43
|
-
const DEFAULT_WIDTH = 393;
|
|
44
|
-
const DEFAULT_HEIGHT = 852;
|
|
45
|
-
const ARTBOARD_GAP = 160;
|
|
46
|
-
const ARTBOARD_ROW_GAP = 220;
|
|
47
|
-
const INITIAL_OFFSET_X = 80;
|
|
48
|
-
const INITIAL_OFFSET_Y = 70;
|
|
49
|
-
const MIN_ZOOM = 0.08;
|
|
50
|
-
const MAX_ZOOM = 2.0;
|
|
51
|
-
|
|
52
|
-
function clampZoom(z: number): number {
|
|
53
|
-
return Math.min(Math.max(z, MIN_ZOOM), MAX_ZOOM);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function snapToDevicePixel(v: number): number {
|
|
57
|
-
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
58
|
-
return Math.round(v * dpr) / dpr;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function buildDefaultLayout(screens: ScreenMetadata[]): ArtboardLayoutMap {
|
|
62
|
-
const map: ArtboardLayoutMap = {};
|
|
63
|
-
const cols =
|
|
64
|
-
screens.length <= 5
|
|
65
|
-
? Math.max(1, screens.length)
|
|
66
|
-
: Math.min(7, Math.ceil(Math.sqrt(screens.length * 1.6)));
|
|
67
|
-
screens.forEach((screen, idx) => {
|
|
68
|
-
const col = idx % cols;
|
|
69
|
-
const row = Math.floor(idx / cols);
|
|
70
|
-
map[screen.id] = {
|
|
71
|
-
x: INITIAL_OFFSET_X + col * (DEFAULT_WIDTH + ARTBOARD_GAP),
|
|
72
|
-
y: INITIAL_OFFSET_Y + row * (DEFAULT_HEIGHT + ARTBOARD_ROW_GAP),
|
|
73
|
-
width: DEFAULT_WIDTH,
|
|
74
|
-
height: DEFAULT_HEIGHT,
|
|
75
|
-
};
|
|
76
|
-
});
|
|
77
|
-
return map;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export const CanvasApp: React.FC = () => {
|
|
81
|
-
const [screens, setScreens] = useState<ScreenMetadata[]>([]);
|
|
82
|
-
const [projectName, setProjectName] = useState<string>('Google Artifact');
|
|
83
|
-
const [layout, setLayout] = useState<ArtboardLayoutMap>({});
|
|
84
|
-
const [mode, setMode] = useState<CanvasMode>('inspect');
|
|
85
|
-
const [showFlows, setShowFlows] = useState<boolean>(true);
|
|
86
|
-
|
|
87
|
-
// Collapsible Left (Screen Rail) & Right (AST Inspector) Panels
|
|
88
|
-
const [isLeftPanelOpen, setIsLeftPanelOpen] = useState<boolean>(true);
|
|
89
|
-
const [isRightPanelOpen, setIsRightPanelOpen] = useState<boolean>(true);
|
|
90
|
-
|
|
91
|
-
// Studio color-scheme state ('dark' default, persisted in localStorage & <meta name="color-scheme">)
|
|
92
|
-
const [colorScheme, setColorScheme] = useState<'dark' | 'light'>(() => {
|
|
93
|
-
try {
|
|
94
|
-
return localStorage.getItem('color-scheme') === 'light' ? 'light' : 'dark';
|
|
95
|
-
} catch {
|
|
96
|
-
return 'dark';
|
|
97
|
-
}
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const applyColorScheme = useCallback((next: 'dark' | 'light') => {
|
|
101
|
-
setColorScheme(next);
|
|
102
|
-
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
|
|
103
|
-
if (meta) {
|
|
104
|
-
meta.content = next;
|
|
105
|
-
}
|
|
106
|
-
try {
|
|
107
|
-
localStorage.setItem('color-scheme', next);
|
|
108
|
-
} catch {
|
|
109
|
-
// Ignore storage write errors in restricted environments
|
|
110
|
-
}
|
|
111
|
-
}, []);
|
|
112
|
-
|
|
113
|
-
const toggleColorScheme = useCallback(() => {
|
|
114
|
-
applyColorScheme(colorScheme === 'dark' ? 'light' : 'dark');
|
|
115
|
-
}, [colorScheme, applyColorScheme]);
|
|
116
|
-
|
|
117
|
-
useEffect(() => {
|
|
118
|
-
const handleStorage = (e: StorageEvent) => {
|
|
119
|
-
if (e.key !== 'color-scheme') return;
|
|
120
|
-
const next = e.newValue === 'light' ? 'light' : 'dark';
|
|
121
|
-
setColorScheme(next);
|
|
122
|
-
const meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"]');
|
|
123
|
-
if (meta) {
|
|
124
|
-
meta.content = next;
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
window.addEventListener('storage', handleStorage);
|
|
128
|
-
return () => window.removeEventListener('storage', handleStorage);
|
|
129
|
-
}, []);
|
|
130
|
-
|
|
131
|
-
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 20, y: 10 });
|
|
132
|
-
const [zoom, setZoom] = useState<number>(0.68);
|
|
133
|
-
const [isPanning, setIsPanning] = useState<boolean>(false);
|
|
134
|
-
const [isCameraAnimating, setIsCameraAnimating] = useState<boolean>(false);
|
|
135
|
-
|
|
136
|
-
const [activeScreenId, setActiveScreenId] = useState<string | null>('Home');
|
|
137
|
-
const [highlightedScreenId, setHighlightedScreenId] = useState<string | null>(null);
|
|
138
|
-
const [selectedElement, setSelectedElement] = useState<SelectedElementRef | null>(null);
|
|
139
|
-
const [inspectedData, setInspectedData] = useState<InspectedElement | null>(null);
|
|
140
|
-
|
|
141
|
-
const [isInspecting, setIsInspecting] = useState<boolean>(false);
|
|
142
|
-
const [isSaving, setIsSaving] = useState<boolean>(false);
|
|
143
|
-
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
|
144
|
-
const [historyState, setHistoryState] = useState<{
|
|
145
|
-
undoAvailable: number;
|
|
146
|
-
redoAvailable: number;
|
|
147
|
-
}>({ undoAvailable: 0, redoAvailable: 0 });
|
|
148
|
-
|
|
149
|
-
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
|
150
|
-
const worldContainerRef = useRef<HTMLDivElement | null>(null);
|
|
151
|
-
const zoomLabelRef = useRef<HTMLSpanElement | null>(null);
|
|
152
|
-
const cameraTimerRef = useRef<number | null>(null);
|
|
153
|
-
const panSyncTimerRef = useRef<number | null>(null);
|
|
154
|
-
const hasInitialFitRef = useRef<boolean>(false);
|
|
155
|
-
|
|
156
|
-
// Refs for synchronous access inside non-passive native wheel/gesture listeners
|
|
157
|
-
const zoomRef = useRef<number>(zoom);
|
|
158
|
-
const panRef = useRef<{ x: number; y: number }>(pan);
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
zoomRef.current = zoom;
|
|
161
|
-
if (zoomLabelRef.current) {
|
|
162
|
-
zoomLabelRef.current.textContent = `${Math.round(zoom * 100)}%`;
|
|
163
|
-
}
|
|
164
|
-
}, [zoom]);
|
|
165
|
-
useEffect(() => {
|
|
166
|
-
panRef.current = pan;
|
|
167
|
-
}, [pan]);
|
|
168
|
-
|
|
169
|
-
const scheduleCameraStateSync = useCallback((commitImmediately = false) => {
|
|
170
|
-
if (panSyncTimerRef.current) {
|
|
171
|
-
window.clearTimeout(panSyncTimerRef.current);
|
|
172
|
-
panSyncTimerRef.current = null;
|
|
173
|
-
}
|
|
174
|
-
if (commitImmediately) {
|
|
175
|
-
setIsCameraAnimating(false);
|
|
176
|
-
setZoom(zoomRef.current);
|
|
177
|
-
setPan(panRef.current);
|
|
178
|
-
} else {
|
|
179
|
-
panSyncTimerRef.current = window.setTimeout(() => {
|
|
180
|
-
panSyncTimerRef.current = null;
|
|
181
|
-
setIsCameraAnimating(false);
|
|
182
|
-
setZoom(zoomRef.current);
|
|
183
|
-
setPan(panRef.current);
|
|
184
|
-
}, 140);
|
|
185
|
-
}
|
|
186
|
-
}, []);
|
|
187
|
-
|
|
188
|
-
const applyDirectPanTransform = useCallback(
|
|
189
|
-
(rawX: number, rawY: number, commitImmediately = false) => {
|
|
190
|
-
const nextPan = {
|
|
191
|
-
x: snapToDevicePixel(rawX),
|
|
192
|
-
y: snapToDevicePixel(rawY),
|
|
193
|
-
};
|
|
194
|
-
panRef.current = nextPan;
|
|
195
|
-
|
|
196
|
-
const worldEl = worldContainerRef.current;
|
|
197
|
-
if (worldEl) {
|
|
198
|
-
worldEl.style.transition = 'none';
|
|
199
|
-
worldEl.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${zoomRef.current})`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
scheduleCameraStateSync(commitImmediately);
|
|
203
|
-
},
|
|
204
|
-
[scheduleCameraStateSync]
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const triggerCameraTransition = useCallback(() => {
|
|
208
|
-
if (cameraTimerRef.current) {
|
|
209
|
-
window.clearTimeout(cameraTimerRef.current);
|
|
210
|
-
}
|
|
211
|
-
setIsCameraAnimating(true);
|
|
212
|
-
cameraTimerRef.current = window.setTimeout(() => {
|
|
213
|
-
setIsCameraAnimating(false);
|
|
214
|
-
}, 390);
|
|
215
|
-
}, []);
|
|
216
|
-
|
|
217
|
-
const sortScreensByOrder = useCallback((rawScreens: ScreenMetadata[]) => {
|
|
218
|
-
return [...rawScreens].sort((a, b) => {
|
|
219
|
-
const idxA = SCREEN_ORDER.indexOf(a.id);
|
|
220
|
-
const idxB = SCREEN_ORDER.indexOf(b.id);
|
|
221
|
-
if (idxA === -1 && idxB === -1) return a.id.localeCompare(b.id);
|
|
222
|
-
if (idxA === -1) return 1;
|
|
223
|
-
if (idxB === -1) return -1;
|
|
224
|
-
return idxA - idxB;
|
|
225
|
-
});
|
|
226
|
-
}, []);
|
|
227
|
-
|
|
228
|
-
const fetchScreens = useCallback(async () => {
|
|
229
|
-
try {
|
|
230
|
-
const [screensRes, layoutRes] = await Promise.all([
|
|
231
|
-
fetch('/api/screens'),
|
|
232
|
-
fetch('/api/layout'),
|
|
233
|
-
]);
|
|
234
|
-
const screensJson = (await screensRes.json()) as {
|
|
235
|
-
ok: boolean;
|
|
236
|
-
projectName?: string;
|
|
237
|
-
screens: ScreenMetadata[];
|
|
238
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
239
|
-
};
|
|
240
|
-
const layoutJson = (await layoutRes.json()) as {
|
|
241
|
-
ok: boolean;
|
|
242
|
-
layout: ArtboardLayoutMap | null;
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
if (screensJson.ok) {
|
|
246
|
-
if (screensJson.projectName) {
|
|
247
|
-
setProjectName(screensJson.projectName);
|
|
248
|
-
}
|
|
249
|
-
const sorted = sortScreensByOrder(screensJson.screens);
|
|
250
|
-
setScreens(sorted);
|
|
251
|
-
setActiveScreenId((prev) => {
|
|
252
|
-
if (prev && sorted.some((s) => s.id === prev)) return prev;
|
|
253
|
-
const home = sorted.find((s) => s.id.toLowerCase() === 'home');
|
|
254
|
-
return home ? home.id : (sorted[0]?.id ?? null);
|
|
255
|
-
});
|
|
256
|
-
if (screensJson.history) {
|
|
257
|
-
setHistoryState(screensJson.history);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
setLayout((prev) => {
|
|
261
|
-
const base = buildDefaultLayout(sorted);
|
|
262
|
-
const saved = layoutJson.ok && layoutJson.layout ? layoutJson.layout : {};
|
|
263
|
-
const merged = { ...base, ...saved, ...prev };
|
|
264
|
-
if (!hasInitialFitRef.current && sorted.length > 0 && canvasViewportRef.current) {
|
|
265
|
-
hasInitialFitRef.current = true;
|
|
266
|
-
const rect = canvasViewportRef.current.getBoundingClientRect();
|
|
267
|
-
const positions = Object.values(merged);
|
|
268
|
-
if (positions.length > 0 && rect.width > 0 && rect.height > 0) {
|
|
269
|
-
const minX = Math.min(...positions.map((p) => p.x));
|
|
270
|
-
const minY = Math.min(...positions.map((p) => p.y));
|
|
271
|
-
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
|
272
|
-
const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
|
|
273
|
-
const contentWidth = maxX - minX + 140;
|
|
274
|
-
const contentHeight = maxY - minY + 140;
|
|
275
|
-
const nextZoom = clampZoom(
|
|
276
|
-
Math.min(rect.width / contentWidth, rect.height / contentHeight, 0.85)
|
|
277
|
-
);
|
|
278
|
-
const centerX = (minX + maxX) / 2;
|
|
279
|
-
const centerY = (minY + maxY) / 2;
|
|
280
|
-
const nextPan = {
|
|
281
|
-
x: Math.round(rect.width / 2 - centerX * nextZoom),
|
|
282
|
-
y: Math.round(rect.height / 2 - centerY * nextZoom),
|
|
283
|
-
};
|
|
284
|
-
zoomRef.current = nextZoom;
|
|
285
|
-
panRef.current = nextPan;
|
|
286
|
-
setZoom(nextZoom);
|
|
287
|
-
setPan(nextPan);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return merged;
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
} catch (err) {
|
|
294
|
-
console.error('Failed to load screens metadata:', err);
|
|
295
|
-
}
|
|
296
|
-
}, [sortScreensByOrder]);
|
|
297
|
-
|
|
298
|
-
useEffect(() => {
|
|
299
|
-
void fetchScreens();
|
|
300
|
-
}, [fetchScreens]);
|
|
301
|
-
|
|
302
|
-
// Fetch AST inspection details when an element is selected
|
|
303
|
-
const inspectSelectedElement = useCallback(async (ref: SelectedElementRef) => {
|
|
304
|
-
setIsInspecting(true);
|
|
305
|
-
try {
|
|
306
|
-
const params = new URLSearchParams({
|
|
307
|
-
file: ref.file,
|
|
308
|
-
astPath: ref.astPath,
|
|
309
|
-
line: String(ref.line),
|
|
310
|
-
});
|
|
311
|
-
const res = await fetch(`/api/inspect?${params.toString()}`);
|
|
312
|
-
const data = (await res.json()) as {
|
|
313
|
-
ok: boolean;
|
|
314
|
-
element?: InspectedElement;
|
|
315
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
316
|
-
error?: string;
|
|
317
|
-
};
|
|
318
|
-
if (data.ok && data.element) {
|
|
319
|
-
setInspectedData(data.element);
|
|
320
|
-
if (data.history) setHistoryState(data.history);
|
|
321
|
-
}
|
|
322
|
-
} catch (err) {
|
|
323
|
-
console.error('Inspect error:', err);
|
|
324
|
-
} finally {
|
|
325
|
-
setIsInspecting(false);
|
|
326
|
-
}
|
|
327
|
-
}, []);
|
|
328
|
-
|
|
329
|
-
const handleSelectElement = useCallback(
|
|
330
|
-
(ref: SelectedElementRef) => {
|
|
331
|
-
setSelectedElement(ref);
|
|
332
|
-
setActiveScreenId(ref.screenId);
|
|
333
|
-
setIsRightPanelOpen(true);
|
|
334
|
-
void inspectSelectedElement(ref);
|
|
335
|
-
},
|
|
336
|
-
[inspectSelectedElement]
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// Surgical AST Patch handler
|
|
340
|
-
const handlePatchElement = useCallback(
|
|
341
|
-
async (payload: PatchPayload) => {
|
|
342
|
-
setIsSaving(true);
|
|
343
|
-
setStatusMessage(null);
|
|
344
|
-
try {
|
|
345
|
-
const res = await fetch('/api/patch', {
|
|
346
|
-
method: 'POST',
|
|
347
|
-
headers: { 'Content-Type': 'application/json' },
|
|
348
|
-
body: JSON.stringify(payload),
|
|
349
|
-
});
|
|
350
|
-
const data = (await res.json()) as {
|
|
351
|
-
ok: boolean;
|
|
352
|
-
element?: InspectedElement;
|
|
353
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
354
|
-
error?: string;
|
|
355
|
-
};
|
|
356
|
-
if (data.ok && data.element) {
|
|
357
|
-
setInspectedData(data.element);
|
|
358
|
-
if (data.history) setHistoryState(data.history);
|
|
359
|
-
setSelectedElement((prev) =>
|
|
360
|
-
prev
|
|
361
|
-
? {
|
|
362
|
-
...prev,
|
|
363
|
-
line: data.element!.line,
|
|
364
|
-
col: data.element!.col,
|
|
365
|
-
astPath: data.element!.astPath,
|
|
366
|
-
}
|
|
367
|
-
: null
|
|
368
|
-
);
|
|
369
|
-
setStatusMessage('Saved to .tsx');
|
|
370
|
-
void fetchScreens();
|
|
371
|
-
} else if (data.error) {
|
|
372
|
-
setStatusMessage(`Error: ${data.error}`);
|
|
373
|
-
}
|
|
374
|
-
} catch (err) {
|
|
375
|
-
setStatusMessage('Failed to write AST patch');
|
|
376
|
-
} finally {
|
|
377
|
-
setIsSaving(false);
|
|
378
|
-
}
|
|
379
|
-
},
|
|
380
|
-
[fetchScreens]
|
|
381
|
-
);
|
|
382
|
-
|
|
383
|
-
// Surgical AST Sibling Reorder handler
|
|
384
|
-
const handleReorderElement = useCallback(
|
|
385
|
-
async (payload: ReorderPayload) => {
|
|
386
|
-
setIsSaving(true);
|
|
387
|
-
setStatusMessage(null);
|
|
388
|
-
try {
|
|
389
|
-
const res = await fetch('/api/reorder', {
|
|
390
|
-
method: 'POST',
|
|
391
|
-
headers: { 'Content-Type': 'application/json' },
|
|
392
|
-
body: JSON.stringify(payload),
|
|
393
|
-
});
|
|
394
|
-
const data = (await res.json()) as {
|
|
395
|
-
ok: boolean;
|
|
396
|
-
element?: InspectedElement;
|
|
397
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
398
|
-
error?: string;
|
|
399
|
-
};
|
|
400
|
-
if (data.ok && data.element) {
|
|
401
|
-
setInspectedData(data.element);
|
|
402
|
-
if (data.history) setHistoryState(data.history);
|
|
403
|
-
setSelectedElement((prev) =>
|
|
404
|
-
prev
|
|
405
|
-
? {
|
|
406
|
-
...prev,
|
|
407
|
-
line: data.element!.line,
|
|
408
|
-
col: data.element!.col,
|
|
409
|
-
astPath: data.element!.astPath,
|
|
410
|
-
}
|
|
411
|
-
: null
|
|
412
|
-
);
|
|
413
|
-
setStatusMessage('Reordered in .tsx');
|
|
414
|
-
void fetchScreens();
|
|
415
|
-
} else if (data.error) {
|
|
416
|
-
setStatusMessage(`Error: ${data.error}`);
|
|
417
|
-
}
|
|
418
|
-
} catch {
|
|
419
|
-
setStatusMessage('Failed to reorder AST element');
|
|
420
|
-
} finally {
|
|
421
|
-
setIsSaving(false);
|
|
422
|
-
}
|
|
423
|
-
},
|
|
424
|
-
[fetchScreens]
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
// Surgical AST Component / Section Insertion handler
|
|
428
|
-
const handleInsertElement = useCallback(
|
|
429
|
-
async (payload: InsertElementPayload) => {
|
|
430
|
-
setIsSaving(true);
|
|
431
|
-
setStatusMessage(null);
|
|
432
|
-
try {
|
|
433
|
-
const res = await fetch('/api/insert', {
|
|
434
|
-
method: 'POST',
|
|
435
|
-
headers: { 'Content-Type': 'application/json' },
|
|
436
|
-
body: JSON.stringify(payload),
|
|
437
|
-
});
|
|
438
|
-
const data = (await res.json()) as {
|
|
439
|
-
ok: boolean;
|
|
440
|
-
element?: InspectedElement;
|
|
441
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
442
|
-
error?: string;
|
|
443
|
-
};
|
|
444
|
-
if (data.ok && data.element) {
|
|
445
|
-
setInspectedData(data.element);
|
|
446
|
-
if (data.history) setHistoryState(data.history);
|
|
447
|
-
const ownerScreen =
|
|
448
|
-
screens.find((s) => s.filePath === data.element!.file)?.id ??
|
|
449
|
-
activeScreenId ??
|
|
450
|
-
'Home';
|
|
451
|
-
setSelectedElement({
|
|
452
|
-
screenId: ownerScreen,
|
|
453
|
-
file: data.element.file,
|
|
454
|
-
astPath: data.element.astPath,
|
|
455
|
-
line: data.element.line,
|
|
456
|
-
col: data.element.col,
|
|
457
|
-
tagName: data.element.tagName,
|
|
458
|
-
});
|
|
459
|
-
setStatusMessage(`Added <${data.element.tagName}> to .tsx`);
|
|
460
|
-
void fetchScreens();
|
|
461
|
-
} else if (data.error) {
|
|
462
|
-
setStatusMessage(`Error: ${data.error}`);
|
|
463
|
-
}
|
|
464
|
-
} catch {
|
|
465
|
-
setStatusMessage('Failed to insert AST element');
|
|
466
|
-
} finally {
|
|
467
|
-
setIsSaving(false);
|
|
468
|
-
}
|
|
469
|
-
},
|
|
470
|
-
[fetchScreens, screens, activeScreenId]
|
|
471
|
-
);
|
|
472
|
-
|
|
473
|
-
// Surgical AST Duplicate Element handler
|
|
474
|
-
const handleDuplicateElement = useCallback(
|
|
475
|
-
async (payload: DuplicateElementPayload) => {
|
|
476
|
-
setIsSaving(true);
|
|
477
|
-
setStatusMessage(null);
|
|
478
|
-
try {
|
|
479
|
-
const res = await fetch('/api/duplicate', {
|
|
480
|
-
method: 'POST',
|
|
481
|
-
headers: { 'Content-Type': 'application/json' },
|
|
482
|
-
body: JSON.stringify(payload),
|
|
483
|
-
});
|
|
484
|
-
const data = (await res.json()) as {
|
|
485
|
-
ok: boolean;
|
|
486
|
-
element?: InspectedElement;
|
|
487
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
488
|
-
error?: string;
|
|
489
|
-
};
|
|
490
|
-
if (data.ok && data.element) {
|
|
491
|
-
setInspectedData(data.element);
|
|
492
|
-
if (data.history) setHistoryState(data.history);
|
|
493
|
-
setSelectedElement((prev) =>
|
|
494
|
-
prev
|
|
495
|
-
? {
|
|
496
|
-
...prev,
|
|
497
|
-
line: data.element!.line,
|
|
498
|
-
col: data.element!.col,
|
|
499
|
-
astPath: data.element!.astPath,
|
|
500
|
-
tagName: data.element!.tagName,
|
|
501
|
-
}
|
|
502
|
-
: null
|
|
503
|
-
);
|
|
504
|
-
setStatusMessage('Duplicated element in .tsx');
|
|
505
|
-
void fetchScreens();
|
|
506
|
-
} else if (data.error) {
|
|
507
|
-
setStatusMessage(`Error: ${data.error}`);
|
|
508
|
-
}
|
|
509
|
-
} catch {
|
|
510
|
-
setStatusMessage('Failed to duplicate AST element');
|
|
511
|
-
} finally {
|
|
512
|
-
setIsSaving(false);
|
|
513
|
-
}
|
|
514
|
-
},
|
|
515
|
-
[fetchScreens]
|
|
516
|
-
);
|
|
517
|
-
|
|
518
|
-
// Surgical AST Delete Element handler
|
|
519
|
-
const handleDeleteElement = useCallback(
|
|
520
|
-
async (payload: DeleteElementPayload) => {
|
|
521
|
-
setIsSaving(true);
|
|
522
|
-
setStatusMessage(null);
|
|
523
|
-
try {
|
|
524
|
-
const res = await fetch('/api/delete', {
|
|
525
|
-
method: 'POST',
|
|
526
|
-
headers: { 'Content-Type': 'application/json' },
|
|
527
|
-
body: JSON.stringify(payload),
|
|
528
|
-
});
|
|
529
|
-
const data = (await res.json()) as {
|
|
530
|
-
ok: boolean;
|
|
531
|
-
deletedAstPath?: string;
|
|
532
|
-
history?: { undoAvailable: number; redoAvailable: number };
|
|
533
|
-
error?: string;
|
|
534
|
-
};
|
|
535
|
-
if (data.ok) {
|
|
536
|
-
if (data.history) setHistoryState(data.history);
|
|
537
|
-
setSelectedElement(null);
|
|
538
|
-
setInspectedData(null);
|
|
539
|
-
setStatusMessage('Deleted element from .tsx');
|
|
540
|
-
void fetchScreens();
|
|
541
|
-
} else if (data.error) {
|
|
542
|
-
setStatusMessage(`Error: ${data.error}`);
|
|
543
|
-
}
|
|
544
|
-
} catch {
|
|
545
|
-
setStatusMessage('Failed to delete AST element');
|
|
546
|
-
} finally {
|
|
547
|
-
setIsSaving(false);
|
|
548
|
-
}
|
|
549
|
-
},
|
|
550
|
-
[fetchScreens]
|
|
551
|
-
);
|
|
552
|
-
|
|
553
|
-
// Undo & Redo handlers
|
|
554
|
-
const handleUndo = useCallback(async () => {
|
|
555
|
-
const res = await fetch('/api/undo', {
|
|
556
|
-
method: 'POST',
|
|
557
|
-
headers: { 'Content-Type': 'application/json' },
|
|
558
|
-
body: '{}',
|
|
559
|
-
});
|
|
560
|
-
const data = (await res.json()) as {
|
|
561
|
-
ok: boolean;
|
|
562
|
-
restoredFile: string | null;
|
|
563
|
-
undoAvailable: number;
|
|
564
|
-
redoAvailable: number;
|
|
565
|
-
};
|
|
566
|
-
if (data.ok) {
|
|
567
|
-
setHistoryState({
|
|
568
|
-
undoAvailable: data.undoAvailable,
|
|
569
|
-
redoAvailable: data.redoAvailable,
|
|
570
|
-
});
|
|
571
|
-
if (selectedElement) {
|
|
572
|
-
void inspectSelectedElement(selectedElement);
|
|
573
|
-
}
|
|
574
|
-
void fetchScreens();
|
|
575
|
-
setStatusMessage(data.restoredFile ? `Reverted ${data.restoredFile}` : 'Nothing to undo');
|
|
576
|
-
}
|
|
577
|
-
}, [selectedElement, inspectSelectedElement, fetchScreens]);
|
|
578
|
-
|
|
579
|
-
const handleRedo = useCallback(async () => {
|
|
580
|
-
const res = await fetch('/api/redo', {
|
|
581
|
-
method: 'POST',
|
|
582
|
-
headers: { 'Content-Type': 'application/json' },
|
|
583
|
-
body: '{}',
|
|
584
|
-
});
|
|
585
|
-
const data = (await res.json()) as {
|
|
586
|
-
ok: boolean;
|
|
587
|
-
restoredFile: string | null;
|
|
588
|
-
undoAvailable: number;
|
|
589
|
-
redoAvailable: number;
|
|
590
|
-
};
|
|
591
|
-
if (data.ok) {
|
|
592
|
-
setHistoryState({
|
|
593
|
-
undoAvailable: data.undoAvailable,
|
|
594
|
-
redoAvailable: data.redoAvailable,
|
|
595
|
-
});
|
|
596
|
-
if (selectedElement) {
|
|
597
|
-
void inspectSelectedElement(selectedElement);
|
|
598
|
-
}
|
|
599
|
-
void fetchScreens();
|
|
600
|
-
setStatusMessage(data.restoredFile ? `Restored ${data.restoredFile}` : 'Nothing to redo');
|
|
601
|
-
}
|
|
602
|
-
}, [selectedElement, inspectSelectedElement, fetchScreens]);
|
|
603
|
-
|
|
604
|
-
// Global keyboard shortcuts (Cmd+Z, Cmd+Shift+Z, [ and ] for sidebars, Escape)
|
|
605
|
-
useEffect(() => {
|
|
606
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
607
|
-
const target = e.target as HTMLElement | null;
|
|
608
|
-
const isInputFocused =
|
|
609
|
-
target &&
|
|
610
|
-
(target.tagName === 'INPUT' ||
|
|
611
|
-
target.tagName === 'TEXTAREA' ||
|
|
612
|
-
target.tagName === 'SELECT');
|
|
613
|
-
|
|
614
|
-
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'z' && !isInputFocused) {
|
|
615
|
-
e.preventDefault();
|
|
616
|
-
if (e.shiftKey) {
|
|
617
|
-
void handleRedo();
|
|
618
|
-
} else {
|
|
619
|
-
void handleUndo();
|
|
620
|
-
}
|
|
621
|
-
} else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === '[') {
|
|
622
|
-
e.preventDefault();
|
|
623
|
-
setIsLeftPanelOpen((prev) => !prev);
|
|
624
|
-
} else if (!isInputFocused && !e.metaKey && !e.ctrlKey && !e.altKey && e.key === ']') {
|
|
625
|
-
e.preventDefault();
|
|
626
|
-
setIsRightPanelOpen((prev) => !prev);
|
|
627
|
-
} else if (
|
|
628
|
-
!isInputFocused &&
|
|
629
|
-
selectedElement &&
|
|
630
|
-
inspectedData &&
|
|
631
|
-
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)
|
|
632
|
-
) {
|
|
633
|
-
e.preventDefault();
|
|
634
|
-
const step = e.shiftKey ? 8 : 1;
|
|
635
|
-
const isAbs = inspectedData.styles.position === 'absolute';
|
|
636
|
-
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
637
|
-
const prop = isAbs ? 'top' : 'marginTop';
|
|
638
|
-
const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
|
|
639
|
-
const nextVal = e.key === 'ArrowUp' ? currentVal - step : currentVal + step;
|
|
640
|
-
void handlePatchElement({
|
|
641
|
-
file: selectedElement.file,
|
|
642
|
-
astPath: selectedElement.astPath,
|
|
643
|
-
line: selectedElement.line,
|
|
644
|
-
styles: { [prop]: nextVal },
|
|
645
|
-
});
|
|
646
|
-
} else {
|
|
647
|
-
const prop = isAbs ? 'left' : 'marginLeft';
|
|
648
|
-
const currentVal = Number(inspectedData.styles[prop] ?? 0) || 0;
|
|
649
|
-
const nextVal = e.key === 'ArrowLeft' ? currentVal - step : currentVal + step;
|
|
650
|
-
void handlePatchElement({
|
|
651
|
-
file: selectedElement.file,
|
|
652
|
-
astPath: selectedElement.astPath,
|
|
653
|
-
line: selectedElement.line,
|
|
654
|
-
styles: { [prop]: nextVal },
|
|
655
|
-
});
|
|
656
|
-
}
|
|
657
|
-
} else if (e.key === 'Escape') {
|
|
658
|
-
setSelectedElement(null);
|
|
659
|
-
setInspectedData(null);
|
|
660
|
-
}
|
|
661
|
-
};
|
|
662
|
-
|
|
663
|
-
window.addEventListener('keydown', handleKeyDown);
|
|
664
|
-
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
665
|
-
}, [handleUndo, handleRedo, selectedElement, inspectedData, handlePatchElement]);
|
|
666
|
-
|
|
667
|
-
// Lock out whole-page browser pinch-to-zoom & route trackpad pinch to cursor-centered canvas zoom
|
|
668
|
-
useEffect(() => {
|
|
669
|
-
const viewport = canvasViewportRef.current;
|
|
670
|
-
|
|
671
|
-
const applyFocalZoom = (nextZoomRaw: number, clientX: number, clientY: number) => {
|
|
672
|
-
if (!viewport) return;
|
|
673
|
-
const rect = viewport.getBoundingClientRect();
|
|
674
|
-
const prevZoom = zoomRef.current;
|
|
675
|
-
const prevPan = panRef.current;
|
|
676
|
-
const nextZoom = clampZoom(nextZoomRaw);
|
|
677
|
-
|
|
678
|
-
if (nextZoom === prevZoom) return;
|
|
679
|
-
|
|
680
|
-
// Pin the world coordinate directly under the user's cursor during two-finger pinch
|
|
681
|
-
// Keep full floating-point precision during active zoom gesture so (cursorX - worldX * nextZoom) never oscillates
|
|
682
|
-
const cursorX = clientX - rect.left;
|
|
683
|
-
const cursorY = clientY - rect.top;
|
|
684
|
-
const worldX = (cursorX - prevPan.x) / prevZoom;
|
|
685
|
-
const worldY = (cursorY - prevPan.y) / prevZoom;
|
|
686
|
-
|
|
687
|
-
const nextPan = {
|
|
688
|
-
x: cursorX - worldX * nextZoom,
|
|
689
|
-
y: cursorY - worldY * nextZoom,
|
|
690
|
-
};
|
|
691
|
-
|
|
692
|
-
zoomRef.current = nextZoom;
|
|
693
|
-
panRef.current = nextPan;
|
|
694
|
-
if (worldContainerRef.current) {
|
|
695
|
-
worldContainerRef.current.style.transition = 'none';
|
|
696
|
-
worldContainerRef.current.style.transform = `translate3d(${nextPan.x}px, ${nextPan.y}px, 0) scale(${nextZoom})`;
|
|
697
|
-
}
|
|
698
|
-
if (zoomLabelRef.current) {
|
|
699
|
-
zoomLabelRef.current.textContent = `${Math.round(nextZoom * 100)}%`;
|
|
700
|
-
}
|
|
701
|
-
scheduleCameraStateSync(false);
|
|
702
|
-
};
|
|
703
|
-
|
|
704
|
-
// 1. Non-passive wheel listener on the canvas viewport for 0ms pan & focal pinch-zoom
|
|
705
|
-
const handleCanvasWheel = (e: WheelEvent) => {
|
|
706
|
-
e.preventDefault();
|
|
707
|
-
e.stopPropagation();
|
|
708
|
-
|
|
709
|
-
if (e.ctrlKey || e.metaKey) {
|
|
710
|
-
// Trackpad two-finger pinch or Cmd/Ctrl + wheel
|
|
711
|
-
const zoomFactor = Math.exp(-e.deltaY * 0.0085);
|
|
712
|
-
applyFocalZoom(zoomRef.current * zoomFactor, e.clientX, e.clientY);
|
|
713
|
-
} else {
|
|
714
|
-
applyDirectPanTransform(
|
|
715
|
-
panRef.current.x - e.deltaX,
|
|
716
|
-
panRef.current.y - e.deltaY,
|
|
717
|
-
false
|
|
718
|
-
);
|
|
719
|
-
}
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
// 2. Document-level non-passive guard so pinching over the top bar or sidebars NEVER zooms the browser page
|
|
723
|
-
const handleDocumentWheelGuard = (e: WheelEvent) => {
|
|
724
|
-
if (e.ctrlKey || e.metaKey) {
|
|
725
|
-
e.preventDefault();
|
|
726
|
-
}
|
|
727
|
-
};
|
|
728
|
-
|
|
729
|
-
// 3. WebKit/Safari proprietary trackpad gesture guard (gesturestart / gesturechange / gestureend)
|
|
730
|
-
let gestureStartZoom = zoomRef.current;
|
|
731
|
-
const handleGestureStart = (e: Event) => {
|
|
732
|
-
e.preventDefault();
|
|
733
|
-
gestureStartZoom = zoomRef.current;
|
|
734
|
-
};
|
|
735
|
-
const handleGestureChange = (e: Event) => {
|
|
736
|
-
e.preventDefault();
|
|
737
|
-
const gestureEvent = e as unknown as {
|
|
738
|
-
scale?: number;
|
|
739
|
-
clientX?: number;
|
|
740
|
-
clientY?: number;
|
|
741
|
-
};
|
|
742
|
-
if (typeof gestureEvent.scale === 'number' && viewport) {
|
|
743
|
-
const rect = viewport.getBoundingClientRect();
|
|
744
|
-
const cx = gestureEvent.clientX ?? rect.left + rect.width / 2;
|
|
745
|
-
const cy = gestureEvent.clientY ?? rect.top + rect.height / 2;
|
|
746
|
-
applyFocalZoom(gestureStartZoom * gestureEvent.scale, cx, cy);
|
|
747
|
-
}
|
|
748
|
-
};
|
|
749
|
-
const handleGestureEnd = (e: Event) => {
|
|
750
|
-
e.preventDefault();
|
|
751
|
-
};
|
|
752
|
-
|
|
753
|
-
viewport?.addEventListener('wheel', handleCanvasWheel, { passive: false });
|
|
754
|
-
document.addEventListener('wheel', handleDocumentWheelGuard, { passive: false });
|
|
755
|
-
document.addEventListener('gesturestart', handleGestureStart, { passive: false });
|
|
756
|
-
document.addEventListener('gesturechange', handleGestureChange, { passive: false });
|
|
757
|
-
document.addEventListener('gestureend', handleGestureEnd, { passive: false });
|
|
758
|
-
|
|
759
|
-
return () => {
|
|
760
|
-
viewport?.removeEventListener('wheel', handleCanvasWheel);
|
|
761
|
-
document.removeEventListener('wheel', handleDocumentWheelGuard);
|
|
762
|
-
document.removeEventListener('gesturestart', handleGestureStart);
|
|
763
|
-
document.removeEventListener('gesturechange', handleGestureChange);
|
|
764
|
-
document.removeEventListener('gestureend', handleGestureEnd);
|
|
765
|
-
};
|
|
766
|
-
}, [applyDirectPanTransform, scheduleCameraStateSync]);
|
|
767
|
-
|
|
768
|
-
// Center viewport on a specific screen artboard using Emil Kowalski ease-out-expo camera physics
|
|
769
|
-
const focusOnScreen = useCallback(
|
|
770
|
-
(screenId: string) => {
|
|
771
|
-
const pos = layout[screenId];
|
|
772
|
-
const viewport = canvasViewportRef.current;
|
|
773
|
-
if (!pos || !viewport) return;
|
|
774
|
-
|
|
775
|
-
const rect = viewport.getBoundingClientRect();
|
|
776
|
-
const targetZoom = Math.min(Math.max(zoom, 0.72), 0.95);
|
|
777
|
-
const nextPanX = rect.width / 2 - (pos.x + pos.width / 2) * targetZoom;
|
|
778
|
-
const nextPanY = rect.height / 2 - (pos.y + pos.height / 2) * targetZoom;
|
|
779
|
-
|
|
780
|
-
triggerCameraTransition();
|
|
781
|
-
setZoom(targetZoom);
|
|
782
|
-
setPan({ x: Math.round(nextPanX), y: Math.round(nextPanY) });
|
|
783
|
-
setActiveScreenId(screenId);
|
|
784
|
-
setHighlightedScreenId(screenId);
|
|
785
|
-
window.setTimeout(() => setHighlightedScreenId(null), 1600);
|
|
786
|
-
},
|
|
787
|
-
[layout, zoom, triggerCameraTransition]
|
|
788
|
-
);
|
|
789
|
-
|
|
790
|
-
// Fit all screens in viewport
|
|
791
|
-
const fitCameraToLayout = useCallback(
|
|
792
|
-
(targetLayout: ArtboardLayoutMap) => {
|
|
793
|
-
const viewport = canvasViewportRef.current;
|
|
794
|
-
if (!viewport) return;
|
|
795
|
-
const rect = viewport.getBoundingClientRect();
|
|
796
|
-
|
|
797
|
-
const positions = Object.values(targetLayout);
|
|
798
|
-
if (positions.length === 0) return;
|
|
799
|
-
|
|
800
|
-
const minX = Math.min(...positions.map((p) => p.x));
|
|
801
|
-
const minY = Math.min(...positions.map((p) => p.y));
|
|
802
|
-
const maxX = Math.max(...positions.map((p) => p.x + p.width));
|
|
803
|
-
const maxY = Math.max(...positions.map((p) => p.y + p.height + 50));
|
|
804
|
-
|
|
805
|
-
const contentWidth = maxX - minX + 140;
|
|
806
|
-
const contentHeight = maxY - minY + 140;
|
|
807
|
-
|
|
808
|
-
const scaleX = rect.width / contentWidth;
|
|
809
|
-
const scaleY = rect.height / contentHeight;
|
|
810
|
-
const nextZoom = clampZoom(Math.min(scaleX, scaleY, 1.0));
|
|
811
|
-
|
|
812
|
-
const centerX = (minX + maxX) / 2;
|
|
813
|
-
const centerY = (minY + maxY) / 2;
|
|
814
|
-
const nextPan = {
|
|
815
|
-
x: Math.round(rect.width / 2 - centerX * nextZoom),
|
|
816
|
-
y: Math.round(rect.height / 2 - centerY * nextZoom),
|
|
817
|
-
};
|
|
818
|
-
|
|
819
|
-
triggerCameraTransition();
|
|
820
|
-
zoomRef.current = nextZoom;
|
|
821
|
-
panRef.current = nextPan;
|
|
822
|
-
setZoom(nextZoom);
|
|
823
|
-
setPan(nextPan);
|
|
824
|
-
},
|
|
825
|
-
[triggerCameraTransition]
|
|
826
|
-
);
|
|
827
|
-
|
|
828
|
-
const handleFitAllScreens = useCallback(() => {
|
|
829
|
-
fitCameraToLayout(layout);
|
|
830
|
-
}, [fitCameraToLayout, layout]);
|
|
831
|
-
|
|
832
|
-
// Reset layout to clean multi-row grid and fit in viewport
|
|
833
|
-
const handleResetGridLayout = useCallback(() => {
|
|
834
|
-
const reset = buildDefaultLayout(screens);
|
|
835
|
-
setLayout(reset);
|
|
836
|
-
fitCameraToLayout(reset);
|
|
837
|
-
void fetch('/api/layout', {
|
|
838
|
-
method: 'POST',
|
|
839
|
-
headers: { 'Content-Type': 'application/json' },
|
|
840
|
-
body: JSON.stringify(reset),
|
|
841
|
-
});
|
|
842
|
-
}, [screens, fitCameraToLayout]);
|
|
843
|
-
|
|
844
|
-
// Move single artboard
|
|
845
|
-
const handleMoveArtboard = useCallback((screenId: string, nextX: number, nextY: number) => {
|
|
846
|
-
setLayout((prev) => {
|
|
847
|
-
const current = prev[screenId] ?? {
|
|
848
|
-
x: nextX,
|
|
849
|
-
y: nextY,
|
|
850
|
-
width: DEFAULT_WIDTH,
|
|
851
|
-
height: DEFAULT_HEIGHT,
|
|
852
|
-
};
|
|
853
|
-
const updated = {
|
|
854
|
-
...prev,
|
|
855
|
-
[screenId]: { ...current, x: nextX, y: nextY },
|
|
856
|
-
};
|
|
857
|
-
return updated;
|
|
858
|
-
});
|
|
859
|
-
}, []);
|
|
860
|
-
|
|
861
|
-
// Handle interactive navigation inside a React Native screen
|
|
862
|
-
const handleNavigateFlow = useCallback(
|
|
863
|
-
(_fromScreen: string, toScreen: string) => {
|
|
864
|
-
focusOnScreen(toScreen);
|
|
865
|
-
},
|
|
866
|
-
[focusOnScreen]
|
|
867
|
-
);
|
|
868
|
-
|
|
869
|
-
const handleSelectFlow = useCallback(
|
|
870
|
-
(edge: NavigationEdge) => {
|
|
871
|
-
focusOnScreen(edge.toScreen);
|
|
872
|
-
},
|
|
873
|
-
[focusOnScreen]
|
|
874
|
-
);
|
|
875
|
-
|
|
876
|
-
// Canvas background pointer pan (0ms direct DOM manipulation override)
|
|
877
|
-
const handleCanvasPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
878
|
-
if (e.button !== 0 && e.button !== 1) return;
|
|
879
|
-
setIsCameraAnimating(false);
|
|
880
|
-
setIsPanning(true);
|
|
881
|
-
|
|
882
|
-
const startX = e.clientX;
|
|
883
|
-
const startY = e.clientY;
|
|
884
|
-
const startPanX = panRef.current.x;
|
|
885
|
-
const startPanY = panRef.current.y;
|
|
886
|
-
|
|
887
|
-
const handleMove = (moveEvent: PointerEvent) => {
|
|
888
|
-
applyDirectPanTransform(
|
|
889
|
-
startPanX + (moveEvent.clientX - startX),
|
|
890
|
-
startPanY + (moveEvent.clientY - startY),
|
|
891
|
-
false
|
|
892
|
-
);
|
|
893
|
-
};
|
|
894
|
-
|
|
895
|
-
const handleUp = () => {
|
|
896
|
-
setIsPanning(false);
|
|
897
|
-
applyDirectPanTransform(panRef.current.x, panRef.current.y, true);
|
|
898
|
-
window.removeEventListener('pointermove', handleMove);
|
|
899
|
-
window.removeEventListener('pointerup', handleUp);
|
|
900
|
-
};
|
|
901
|
-
|
|
902
|
-
window.addEventListener('pointermove', handleMove);
|
|
903
|
-
window.addEventListener('pointerup', handleUp);
|
|
904
|
-
};
|
|
905
|
-
|
|
906
|
-
return (
|
|
907
|
-
<div
|
|
908
|
-
className="zelune-studio-root"
|
|
909
|
-
data-density="spacious"
|
|
910
|
-
style={{
|
|
911
|
-
display: 'flex',
|
|
912
|
-
flexDirection: 'column',
|
|
913
|
-
width: '100vw',
|
|
914
|
-
height: '100vh',
|
|
915
|
-
backgroundColor: 'var(--studio-canvas-bg)',
|
|
916
|
-
color: 'var(--studio-text-primary)',
|
|
917
|
-
overflow: 'hidden',
|
|
918
|
-
}}
|
|
919
|
-
>
|
|
920
|
-
{/* Top Studio Bar */}
|
|
921
|
-
<header
|
|
922
|
-
style={{
|
|
923
|
-
height: 52,
|
|
924
|
-
backgroundColor: 'var(--studio-surface-1)',
|
|
925
|
-
borderBottom: '1px solid var(--studio-border-hairline)',
|
|
926
|
-
display: 'flex',
|
|
927
|
-
alignItems: 'center',
|
|
928
|
-
justifyContent: 'space-between',
|
|
929
|
-
padding: '0 14px',
|
|
930
|
-
zIndex: 40,
|
|
931
|
-
}}
|
|
932
|
-
>
|
|
933
|
-
{/* Left: Sidebar Toggle + Architectural Brand Identity */}
|
|
934
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
935
|
-
<button
|
|
936
|
-
type="button"
|
|
937
|
-
className="zelune-interactive"
|
|
938
|
-
onClick={() => setIsLeftPanelOpen((prev) => !prev)}
|
|
939
|
-
title={`${isLeftPanelOpen ? 'Collapse' : 'Expand'} Screens Sidebar ([)`}
|
|
940
|
-
style={{
|
|
941
|
-
display: 'inline-flex',
|
|
942
|
-
alignItems: 'center',
|
|
943
|
-
justifyContent: 'center',
|
|
944
|
-
width: 30,
|
|
945
|
-
height: 30,
|
|
946
|
-
borderRadius: 7,
|
|
947
|
-
border: `1px solid ${
|
|
948
|
-
isLeftPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
|
|
949
|
-
}`,
|
|
950
|
-
backgroundColor: isLeftPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
|
|
951
|
-
color: isLeftPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
|
|
952
|
-
cursor: 'pointer',
|
|
953
|
-
}}
|
|
954
|
-
>
|
|
955
|
-
{isLeftPanelOpen ? <PanelLeftClose size={14} /> : <PanelLeftOpen size={14} />}
|
|
956
|
-
</button>
|
|
957
|
-
|
|
958
|
-
<div
|
|
959
|
-
style={{
|
|
960
|
-
width: 30,
|
|
961
|
-
height: 30,
|
|
962
|
-
borderRadius: 7,
|
|
963
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
964
|
-
border: '1px solid rgba(56, 189, 248, 0.35)',
|
|
965
|
-
boxShadow: 'inset 0 1px 0 rgba(255, 255, 255, 0.08)',
|
|
966
|
-
display: 'flex',
|
|
967
|
-
alignItems: 'center',
|
|
968
|
-
justifyContent: 'center',
|
|
969
|
-
fontFamily: 'var(--font-mono)',
|
|
970
|
-
fontWeight: 700,
|
|
971
|
-
fontSize: 12,
|
|
972
|
-
letterSpacing: '-0.03em',
|
|
973
|
-
color: '#38BDF8',
|
|
974
|
-
}}
|
|
975
|
-
>
|
|
976
|
-
ZL
|
|
977
|
-
</div>
|
|
978
|
-
<div>
|
|
979
|
-
<div
|
|
980
|
-
style={{
|
|
981
|
-
fontSize: 13.5,
|
|
982
|
-
fontWeight: 700,
|
|
983
|
-
letterSpacing: '-0.02em',
|
|
984
|
-
color: 'var(--studio-text-primary)',
|
|
985
|
-
}}
|
|
986
|
-
>
|
|
987
|
-
Zelune
|
|
988
|
-
</div>
|
|
989
|
-
<div
|
|
990
|
-
style={{
|
|
991
|
-
fontSize: 11,
|
|
992
|
-
color: 'var(--studio-text-secondary)',
|
|
993
|
-
fontFamily: 'var(--font-mono)',
|
|
994
|
-
marginTop: 1,
|
|
995
|
-
}}
|
|
996
|
-
>
|
|
997
|
-
{projectName}
|
|
998
|
-
</div>
|
|
999
|
-
</div>
|
|
1000
|
-
</div>
|
|
1001
|
-
|
|
1002
|
-
{/* Center: Icon-First Mode Switcher & Flow Toggle (Reveals Label on Hover) */}
|
|
1003
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
1004
|
-
<div
|
|
1005
|
-
style={{
|
|
1006
|
-
display: 'flex',
|
|
1007
|
-
alignItems: 'center',
|
|
1008
|
-
gap: 2,
|
|
1009
|
-
backgroundColor: 'var(--studio-canvas-bg)',
|
|
1010
|
-
padding: 3,
|
|
1011
|
-
borderRadius: 8,
|
|
1012
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1013
|
-
}}
|
|
1014
|
-
>
|
|
1015
|
-
<button
|
|
1016
|
-
type="button"
|
|
1017
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1018
|
-
onClick={() => setMode('inspect')}
|
|
1019
|
-
title="Inspect & Edit"
|
|
1020
|
-
aria-label="Inspect & Edit"
|
|
1021
|
-
style={{
|
|
1022
|
-
padding: '6px 9px',
|
|
1023
|
-
borderRadius: 5,
|
|
1024
|
-
border: mode === 'inspect' ? '1px solid rgba(56, 189, 248, 0.35)' : '1px solid transparent',
|
|
1025
|
-
backgroundColor: mode === 'inspect' ? 'var(--studio-surface-3)' : 'transparent',
|
|
1026
|
-
color: mode === 'inspect' ? '#0284C7' : 'var(--studio-text-secondary)',
|
|
1027
|
-
fontSize: 12,
|
|
1028
|
-
fontWeight: 600,
|
|
1029
|
-
cursor: 'pointer',
|
|
1030
|
-
}}
|
|
1031
|
-
>
|
|
1032
|
-
<MousePointer size={14} style={{ flexShrink: 0, color: mode === 'inspect' ? '#38BDF8' : 'currentColor' }} />
|
|
1033
|
-
<span className="zelune-reveal-label" style={{ color: mode === 'inspect' ? 'var(--studio-text-primary)' : 'inherit' }}>
|
|
1034
|
-
Inspect & Edit
|
|
1035
|
-
</span>
|
|
1036
|
-
</button>
|
|
1037
|
-
|
|
1038
|
-
<button
|
|
1039
|
-
type="button"
|
|
1040
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1041
|
-
onClick={() => setMode('interact')}
|
|
1042
|
-
title="Interact & Navigate"
|
|
1043
|
-
aria-label="Interact & Navigate"
|
|
1044
|
-
style={{
|
|
1045
|
-
padding: '6px 9px',
|
|
1046
|
-
borderRadius: 5,
|
|
1047
|
-
border: mode === 'interact' ? '1px solid rgba(16, 185, 129, 0.4)' : '1px solid transparent',
|
|
1048
|
-
backgroundColor: mode === 'interact' ? 'rgba(16, 185, 129, 0.14)' : 'transparent',
|
|
1049
|
-
color: mode === 'interact' ? (colorScheme === 'dark' ? '#34D399' : '#059669') : 'var(--studio-text-secondary)',
|
|
1050
|
-
fontSize: 12,
|
|
1051
|
-
fontWeight: 600,
|
|
1052
|
-
cursor: 'pointer',
|
|
1053
|
-
}}
|
|
1054
|
-
>
|
|
1055
|
-
<Play size={14} style={{ flexShrink: 0 }} />
|
|
1056
|
-
<span className="zelune-reveal-label">Interact & Navigate</span>
|
|
1057
|
-
</button>
|
|
1058
|
-
</div>
|
|
1059
|
-
|
|
1060
|
-
<button
|
|
1061
|
-
type="button"
|
|
1062
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1063
|
-
onClick={() => setShowFlows((prev) => !prev)}
|
|
1064
|
-
title="Toggle Flow Graph"
|
|
1065
|
-
aria-label="Toggle Flow Graph"
|
|
1066
|
-
style={{
|
|
1067
|
-
padding: '7px 9px',
|
|
1068
|
-
borderRadius: 7,
|
|
1069
|
-
border: `1px solid ${showFlows ? 'rgba(99, 102, 241, 0.45)' : 'var(--studio-border-subtle)'}`,
|
|
1070
|
-
backgroundColor: showFlows ? 'rgba(99, 102, 241, 0.14)' : 'var(--studio-surface-2)',
|
|
1071
|
-
color: showFlows ? (colorScheme === 'dark' ? '#C7D2FE' : '#4F46E5') : 'var(--studio-text-secondary)',
|
|
1072
|
-
fontSize: 12,
|
|
1073
|
-
fontWeight: 600,
|
|
1074
|
-
cursor: 'pointer',
|
|
1075
|
-
}}
|
|
1076
|
-
>
|
|
1077
|
-
<GitCommit size={14} style={{ flexShrink: 0 }} />
|
|
1078
|
-
<span className="zelune-reveal-label">Flow Graph</span>
|
|
1079
|
-
</button>
|
|
1080
|
-
|
|
1081
|
-
<button
|
|
1082
|
-
type="button"
|
|
1083
|
-
className="zelune-interactive"
|
|
1084
|
-
onClick={() => {
|
|
1085
|
-
setIsRightPanelOpen(true);
|
|
1086
|
-
window.dispatchEvent(new CustomEvent('zelune:open-insert-palette'));
|
|
1087
|
-
}}
|
|
1088
|
-
title="Add Button, Section, Input, or Component to Active Screen"
|
|
1089
|
-
style={{
|
|
1090
|
-
display: 'inline-flex',
|
|
1091
|
-
alignItems: 'center',
|
|
1092
|
-
gap: 5,
|
|
1093
|
-
padding: '6px 10px',
|
|
1094
|
-
borderRadius: 7,
|
|
1095
|
-
border: '1px solid rgba(56, 189, 248, 0.4)',
|
|
1096
|
-
backgroundColor: 'rgba(56, 189, 248, 0.12)',
|
|
1097
|
-
color: colorScheme === 'dark' ? '#38BDF8' : '#0284C7',
|
|
1098
|
-
fontSize: 12,
|
|
1099
|
-
fontWeight: 600,
|
|
1100
|
-
cursor: 'pointer',
|
|
1101
|
-
}}
|
|
1102
|
-
>
|
|
1103
|
-
<Plus size={14} style={{ flexShrink: 0 }} />
|
|
1104
|
-
<span>Add Element</span>
|
|
1105
|
-
</button>
|
|
1106
|
-
</div>
|
|
1107
|
-
|
|
1108
|
-
{/* Right: Undo/Redo, Viewport Controls, Theme Toggle & Right Inspector Toggle */}
|
|
1109
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
1110
|
-
<button
|
|
1111
|
-
type="button"
|
|
1112
|
-
className="zelune-interactive tabular-nums"
|
|
1113
|
-
onClick={handleUndo}
|
|
1114
|
-
disabled={historyState.undoAvailable === 0}
|
|
1115
|
-
title="Undo AST Patch (Cmd+Z)"
|
|
1116
|
-
style={{
|
|
1117
|
-
display: 'inline-flex',
|
|
1118
|
-
alignItems: 'center',
|
|
1119
|
-
gap: 4,
|
|
1120
|
-
padding: '6px 9px',
|
|
1121
|
-
borderRadius: 6,
|
|
1122
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1123
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1124
|
-
color:
|
|
1125
|
-
historyState.undoAvailable > 0
|
|
1126
|
-
? 'var(--studio-text-primary)'
|
|
1127
|
-
: 'var(--studio-text-muted)',
|
|
1128
|
-
fontSize: 11.5,
|
|
1129
|
-
fontFamily: 'var(--font-mono)',
|
|
1130
|
-
cursor: historyState.undoAvailable > 0 ? 'pointer' : 'not-allowed',
|
|
1131
|
-
}}
|
|
1132
|
-
>
|
|
1133
|
-
<Undo2 size={13} />
|
|
1134
|
-
<span>{historyState.undoAvailable}</span>
|
|
1135
|
-
</button>
|
|
1136
|
-
|
|
1137
|
-
<button
|
|
1138
|
-
type="button"
|
|
1139
|
-
className="zelune-interactive"
|
|
1140
|
-
onClick={handleRedo}
|
|
1141
|
-
disabled={historyState.redoAvailable === 0}
|
|
1142
|
-
title="Redo AST Patch (Cmd+Shift+Z)"
|
|
1143
|
-
style={{
|
|
1144
|
-
display: 'inline-flex',
|
|
1145
|
-
alignItems: 'center',
|
|
1146
|
-
gap: 4,
|
|
1147
|
-
padding: '6px 9px',
|
|
1148
|
-
borderRadius: 6,
|
|
1149
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1150
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1151
|
-
color:
|
|
1152
|
-
historyState.redoAvailable > 0
|
|
1153
|
-
? 'var(--studio-text-primary)'
|
|
1154
|
-
: 'var(--studio-text-muted)',
|
|
1155
|
-
fontSize: 11.5,
|
|
1156
|
-
cursor: historyState.redoAvailable > 0 ? 'pointer' : 'not-allowed',
|
|
1157
|
-
}}
|
|
1158
|
-
>
|
|
1159
|
-
<Redo2 size={13} />
|
|
1160
|
-
</button>
|
|
1161
|
-
|
|
1162
|
-
<div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
|
|
1163
|
-
|
|
1164
|
-
<button
|
|
1165
|
-
type="button"
|
|
1166
|
-
className="zelune-interactive"
|
|
1167
|
-
onClick={() => {
|
|
1168
|
-
triggerCameraTransition();
|
|
1169
|
-
setZoom((z) => clampZoom(z - 0.1));
|
|
1170
|
-
}}
|
|
1171
|
-
title="Zoom Out"
|
|
1172
|
-
style={{
|
|
1173
|
-
padding: 6,
|
|
1174
|
-
borderRadius: 6,
|
|
1175
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1176
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1177
|
-
color: 'var(--studio-text-secondary)',
|
|
1178
|
-
cursor: 'pointer',
|
|
1179
|
-
}}
|
|
1180
|
-
>
|
|
1181
|
-
<ZoomOut size={13} />
|
|
1182
|
-
</button>
|
|
1183
|
-
|
|
1184
|
-
<span
|
|
1185
|
-
ref={zoomLabelRef}
|
|
1186
|
-
className="tabular-nums"
|
|
1187
|
-
style={{
|
|
1188
|
-
minWidth: 46,
|
|
1189
|
-
textAlign: 'center',
|
|
1190
|
-
fontSize: 11.5,
|
|
1191
|
-
fontFamily: 'var(--font-mono)',
|
|
1192
|
-
color: 'var(--studio-text-secondary)',
|
|
1193
|
-
}}
|
|
1194
|
-
>
|
|
1195
|
-
{Math.round(zoom * 100)}%
|
|
1196
|
-
</span>
|
|
1197
|
-
|
|
1198
|
-
<button
|
|
1199
|
-
type="button"
|
|
1200
|
-
className="zelune-interactive"
|
|
1201
|
-
onClick={() => {
|
|
1202
|
-
triggerCameraTransition();
|
|
1203
|
-
setZoom((z) => clampZoom(z + 0.1));
|
|
1204
|
-
}}
|
|
1205
|
-
title="Zoom In"
|
|
1206
|
-
style={{
|
|
1207
|
-
padding: 6,
|
|
1208
|
-
borderRadius: 6,
|
|
1209
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1210
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1211
|
-
color: 'var(--studio-text-secondary)',
|
|
1212
|
-
cursor: 'pointer',
|
|
1213
|
-
}}
|
|
1214
|
-
>
|
|
1215
|
-
<ZoomIn size={13} />
|
|
1216
|
-
</button>
|
|
1217
|
-
|
|
1218
|
-
<button
|
|
1219
|
-
type="button"
|
|
1220
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1221
|
-
onClick={handleFitAllScreens}
|
|
1222
|
-
title="Fit All Screens in Viewport"
|
|
1223
|
-
aria-label="Fit All Screens in Viewport"
|
|
1224
|
-
style={{
|
|
1225
|
-
padding: '6px 8px',
|
|
1226
|
-
borderRadius: 6,
|
|
1227
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1228
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1229
|
-
color: 'var(--studio-text-secondary)',
|
|
1230
|
-
fontSize: 11.5,
|
|
1231
|
-
fontWeight: 500,
|
|
1232
|
-
cursor: 'pointer',
|
|
1233
|
-
}}
|
|
1234
|
-
>
|
|
1235
|
-
<Maximize size={13} style={{ flexShrink: 0 }} />
|
|
1236
|
-
<span className="zelune-reveal-label">Fit</span>
|
|
1237
|
-
</button>
|
|
1238
|
-
|
|
1239
|
-
<button
|
|
1240
|
-
type="button"
|
|
1241
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1242
|
-
onClick={handleResetGridLayout}
|
|
1243
|
-
title="Auto-Arrange Clean Artboard Grid"
|
|
1244
|
-
aria-label="Auto-Arrange Clean Artboard Grid"
|
|
1245
|
-
style={{
|
|
1246
|
-
padding: '6px 8px',
|
|
1247
|
-
borderRadius: 6,
|
|
1248
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1249
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1250
|
-
color: 'var(--studio-text-secondary)',
|
|
1251
|
-
fontSize: 11.5,
|
|
1252
|
-
fontWeight: 500,
|
|
1253
|
-
cursor: 'pointer',
|
|
1254
|
-
}}
|
|
1255
|
-
>
|
|
1256
|
-
<LayoutGrid size={13} style={{ flexShrink: 0 }} />
|
|
1257
|
-
<span className="zelune-reveal-label">Grid</span>
|
|
1258
|
-
</button>
|
|
1259
|
-
|
|
1260
|
-
<button
|
|
1261
|
-
type="button"
|
|
1262
|
-
className="zelune-interactive zelune-icon-reveal-btn"
|
|
1263
|
-
onClick={toggleColorScheme}
|
|
1264
|
-
title={
|
|
1265
|
-
colorScheme === 'dark' ? 'Switch Canvas to Light Mode' : 'Switch Canvas to Dark Mode'
|
|
1266
|
-
}
|
|
1267
|
-
aria-label={
|
|
1268
|
-
colorScheme === 'dark' ? 'Switch Canvas to Light Mode' : 'Switch Canvas to Dark Mode'
|
|
1269
|
-
}
|
|
1270
|
-
style={{
|
|
1271
|
-
padding: '6px 8px',
|
|
1272
|
-
borderRadius: 6,
|
|
1273
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1274
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1275
|
-
color: 'var(--studio-text-secondary)',
|
|
1276
|
-
fontSize: 11.5,
|
|
1277
|
-
fontWeight: 500,
|
|
1278
|
-
cursor: 'pointer',
|
|
1279
|
-
}}
|
|
1280
|
-
>
|
|
1281
|
-
{colorScheme === 'dark' ? (
|
|
1282
|
-
<Sun size={13} style={{ flexShrink: 0, color: '#F59E0B' }} />
|
|
1283
|
-
) : (
|
|
1284
|
-
<Moon size={13} style={{ flexShrink: 0, color: '#6366F1' }} />
|
|
1285
|
-
)}
|
|
1286
|
-
<span className="zelune-reveal-label">
|
|
1287
|
-
{colorScheme === 'dark' ? 'Light' : 'Dark'}
|
|
1288
|
-
</span>
|
|
1289
|
-
</button>
|
|
1290
|
-
|
|
1291
|
-
<div style={{ width: 1, height: 20, backgroundColor: 'var(--studio-border-subtle)', margin: '0 3px' }} />
|
|
1292
|
-
|
|
1293
|
-
<button
|
|
1294
|
-
type="button"
|
|
1295
|
-
className="zelune-interactive"
|
|
1296
|
-
onClick={() => setIsRightPanelOpen((prev) => !prev)}
|
|
1297
|
-
title={`${isRightPanelOpen ? 'Collapse' : 'Expand'} AST Inspector Panel (])`}
|
|
1298
|
-
style={{
|
|
1299
|
-
display: 'inline-flex',
|
|
1300
|
-
alignItems: 'center',
|
|
1301
|
-
justifyContent: 'center',
|
|
1302
|
-
width: 30,
|
|
1303
|
-
height: 30,
|
|
1304
|
-
borderRadius: 7,
|
|
1305
|
-
border: `1px solid ${
|
|
1306
|
-
isRightPanelOpen ? 'rgba(56, 189, 248, 0.35)' : 'var(--studio-border-subtle)'
|
|
1307
|
-
}`,
|
|
1308
|
-
backgroundColor: isRightPanelOpen ? 'var(--studio-surface-3)' : 'var(--studio-surface-2)',
|
|
1309
|
-
color: isRightPanelOpen ? '#38BDF8' : 'var(--studio-text-secondary)',
|
|
1310
|
-
cursor: 'pointer',
|
|
1311
|
-
}}
|
|
1312
|
-
>
|
|
1313
|
-
{isRightPanelOpen ? <PanelRightClose size={14} /> : <PanelRightOpen size={14} />}
|
|
1314
|
-
</button>
|
|
1315
|
-
</div>
|
|
1316
|
-
</header>
|
|
1317
|
-
|
|
1318
|
-
{/* Main Studio Body: Collapsible Left Screen Rail + Infinite Canvas + Collapsible Right AST Inspector */}
|
|
1319
|
-
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
|
1320
|
-
{/* Left Sidebar: Screen Artboard Navigator */}
|
|
1321
|
-
<aside
|
|
1322
|
-
aria-hidden={!isLeftPanelOpen}
|
|
1323
|
-
className="zelune-collapsible-panel"
|
|
1324
|
-
style={{
|
|
1325
|
-
width: isLeftPanelOpen ? 224 : 0,
|
|
1326
|
-
opacity: isLeftPanelOpen ? 1 : 0,
|
|
1327
|
-
pointerEvents: isLeftPanelOpen ? 'auto' : 'none',
|
|
1328
|
-
backgroundColor: 'var(--studio-surface-1)',
|
|
1329
|
-
borderRight: isLeftPanelOpen
|
|
1330
|
-
? '1px solid var(--studio-border-hairline)'
|
|
1331
|
-
: '0px solid transparent',
|
|
1332
|
-
display: 'flex',
|
|
1333
|
-
flexDirection: 'column',
|
|
1334
|
-
zIndex: 30,
|
|
1335
|
-
}}
|
|
1336
|
-
>
|
|
1337
|
-
<div
|
|
1338
|
-
className="zelune-scroller"
|
|
1339
|
-
style={{
|
|
1340
|
-
width: 224,
|
|
1341
|
-
minWidth: 224,
|
|
1342
|
-
height: '100%',
|
|
1343
|
-
display: 'flex',
|
|
1344
|
-
flexDirection: 'column',
|
|
1345
|
-
justifyContent: 'space-between',
|
|
1346
|
-
padding: 12,
|
|
1347
|
-
overflowY: 'auto',
|
|
1348
|
-
}}
|
|
1349
|
-
>
|
|
1350
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
1351
|
-
<div
|
|
1352
|
-
className="tabular-nums"
|
|
1353
|
-
style={{
|
|
1354
|
-
display: 'flex',
|
|
1355
|
-
alignItems: 'center',
|
|
1356
|
-
justifyContent: 'space-between',
|
|
1357
|
-
padding: '4px 6px 6px',
|
|
1358
|
-
fontSize: 10.5,
|
|
1359
|
-
fontFamily: 'var(--font-mono)',
|
|
1360
|
-
fontWeight: 600,
|
|
1361
|
-
color: 'var(--studio-text-muted)',
|
|
1362
|
-
letterSpacing: '0.06em',
|
|
1363
|
-
}}
|
|
1364
|
-
>
|
|
1365
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
1366
|
-
<Layers size={12} color="#38BDF8" />
|
|
1367
|
-
<span>SCREENS ({screens.length})</span>
|
|
1368
|
-
</span>
|
|
1369
|
-
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
1370
|
-
<button
|
|
1371
|
-
type="button"
|
|
1372
|
-
className="zelune-interactive"
|
|
1373
|
-
onClick={() => void fetchScreens()}
|
|
1374
|
-
title="Refresh Screen Discovery"
|
|
1375
|
-
style={{
|
|
1376
|
-
background: 'transparent',
|
|
1377
|
-
border: '1px solid transparent',
|
|
1378
|
-
borderRadius: 4,
|
|
1379
|
-
color: 'var(--studio-text-muted)',
|
|
1380
|
-
cursor: 'pointer',
|
|
1381
|
-
padding: 3,
|
|
1382
|
-
display: 'inline-flex',
|
|
1383
|
-
alignItems: 'center',
|
|
1384
|
-
}}
|
|
1385
|
-
>
|
|
1386
|
-
<RefreshCw size={11} />
|
|
1387
|
-
</button>
|
|
1388
|
-
<button
|
|
1389
|
-
type="button"
|
|
1390
|
-
className="zelune-interactive"
|
|
1391
|
-
onClick={() => setIsLeftPanelOpen(false)}
|
|
1392
|
-
title="Collapse Screens Sidebar ([)"
|
|
1393
|
-
style={{
|
|
1394
|
-
background: 'transparent',
|
|
1395
|
-
border: '1px solid transparent',
|
|
1396
|
-
borderRadius: 4,
|
|
1397
|
-
color: 'var(--studio-text-muted)',
|
|
1398
|
-
cursor: 'pointer',
|
|
1399
|
-
padding: 3,
|
|
1400
|
-
display: 'inline-flex',
|
|
1401
|
-
alignItems: 'center',
|
|
1402
|
-
}}
|
|
1403
|
-
>
|
|
1404
|
-
<PanelLeftClose size={12} />
|
|
1405
|
-
</button>
|
|
1406
|
-
</div>
|
|
1407
|
-
</div>
|
|
1408
|
-
|
|
1409
|
-
{screens.map((scr) => {
|
|
1410
|
-
const isSelected = activeScreenId === scr.id;
|
|
1411
|
-
return (
|
|
1412
|
-
<button
|
|
1413
|
-
key={scr.id}
|
|
1414
|
-
type="button"
|
|
1415
|
-
className="zelune-interactive zelune-rail-item"
|
|
1416
|
-
onClick={() => focusOnScreen(scr.id)}
|
|
1417
|
-
style={{
|
|
1418
|
-
display: 'flex',
|
|
1419
|
-
alignItems: 'center',
|
|
1420
|
-
justifyContent: 'space-between',
|
|
1421
|
-
width: '100%',
|
|
1422
|
-
paddingLeft: 10,
|
|
1423
|
-
paddingRight: 10,
|
|
1424
|
-
borderRadius: 7,
|
|
1425
|
-
border: `1px solid ${
|
|
1426
|
-
isSelected ? 'rgba(56, 189, 248, 0.35)' : 'transparent'
|
|
1427
|
-
}`,
|
|
1428
|
-
backgroundColor: isSelected ? 'var(--studio-surface-2)' : 'transparent',
|
|
1429
|
-
color: isSelected ? 'var(--studio-text-primary)' : 'var(--studio-text-secondary)',
|
|
1430
|
-
fontSize: 12.5,
|
|
1431
|
-
fontWeight: isSelected ? 600 : 500,
|
|
1432
|
-
cursor: 'pointer',
|
|
1433
|
-
textAlign: 'left',
|
|
1434
|
-
}}
|
|
1435
|
-
>
|
|
1436
|
-
<span>{scr.id}</span>
|
|
1437
|
-
<span
|
|
1438
|
-
className="tabular-nums"
|
|
1439
|
-
style={{
|
|
1440
|
-
fontSize: 10,
|
|
1441
|
-
fontFamily: 'var(--font-mono)',
|
|
1442
|
-
color: isSelected ? '#38BDF8' : 'var(--studio-text-muted)',
|
|
1443
|
-
}}
|
|
1444
|
-
>
|
|
1445
|
-
{scr.outgoingFlows.length}→
|
|
1446
|
-
</span>
|
|
1447
|
-
</button>
|
|
1448
|
-
);
|
|
1449
|
-
})}
|
|
1450
|
-
</div>
|
|
1451
|
-
|
|
1452
|
-
<div
|
|
1453
|
-
style={{
|
|
1454
|
-
backgroundColor: 'var(--studio-surface-2)',
|
|
1455
|
-
border: '1px solid var(--studio-border-hairline)',
|
|
1456
|
-
borderRadius: 9,
|
|
1457
|
-
padding: 11,
|
|
1458
|
-
fontSize: 11,
|
|
1459
|
-
color: 'var(--studio-text-secondary)',
|
|
1460
|
-
lineHeight: 1.55,
|
|
1461
|
-
}}
|
|
1462
|
-
>
|
|
1463
|
-
<div
|
|
1464
|
-
style={{
|
|
1465
|
-
fontFamily: 'var(--font-mono)',
|
|
1466
|
-
fontSize: 10,
|
|
1467
|
-
fontWeight: 600,
|
|
1468
|
-
letterSpacing: '0.05em',
|
|
1469
|
-
color: 'var(--studio-text-primary)',
|
|
1470
|
-
marginBottom: 4,
|
|
1471
|
-
}}
|
|
1472
|
-
>
|
|
1473
|
-
STUDIO ERGONOMICS
|
|
1474
|
-
</div>
|
|
1475
|
-
<div>• Two-finger scroll to pan</div>
|
|
1476
|
-
<div>• Two-finger pinch to zoom</div>
|
|
1477
|
-
<div>• Press [ or ] to toggle panels</div>
|
|
1478
|
-
<div>• Click any node to patch AST</div>
|
|
1479
|
-
</div>
|
|
1480
|
-
</div>
|
|
1481
|
-
</aside>
|
|
1482
|
-
|
|
1483
|
-
{/* Center: Infinite Zoom/Pan Canvas */}
|
|
1484
|
-
<main
|
|
1485
|
-
ref={canvasViewportRef}
|
|
1486
|
-
onPointerDown={handleCanvasPointerDown}
|
|
1487
|
-
className="infinite-canvas-surface"
|
|
1488
|
-
style={{
|
|
1489
|
-
flex: 1,
|
|
1490
|
-
position: 'relative',
|
|
1491
|
-
overflow: 'hidden',
|
|
1492
|
-
cursor: isPanning ? 'grabbing' : 'grab',
|
|
1493
|
-
}}
|
|
1494
|
-
>
|
|
1495
|
-
{/* Floating Edge Restore Pill for Collapsed Left Sidebar */}
|
|
1496
|
-
{!isLeftPanelOpen && (
|
|
1497
|
-
<button
|
|
1498
|
-
type="button"
|
|
1499
|
-
className="zelune-interactive tabular-nums"
|
|
1500
|
-
onClick={(e) => {
|
|
1501
|
-
e.stopPropagation();
|
|
1502
|
-
setIsLeftPanelOpen(true);
|
|
1503
|
-
}}
|
|
1504
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
1505
|
-
title="Expand Screens Navigator ([)"
|
|
1506
|
-
style={{
|
|
1507
|
-
position: 'absolute',
|
|
1508
|
-
top: 14,
|
|
1509
|
-
left: 14,
|
|
1510
|
-
zIndex: 35,
|
|
1511
|
-
display: 'inline-flex',
|
|
1512
|
-
alignItems: 'center',
|
|
1513
|
-
gap: 7,
|
|
1514
|
-
padding: '7px 11px',
|
|
1515
|
-
borderRadius: 8,
|
|
1516
|
-
backgroundColor: 'var(--studio-surface-1)',
|
|
1517
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1518
|
-
color: 'var(--studio-text-primary)',
|
|
1519
|
-
fontSize: 11.5,
|
|
1520
|
-
fontFamily: 'var(--font-mono)',
|
|
1521
|
-
fontWeight: 600,
|
|
1522
|
-
cursor: 'pointer',
|
|
1523
|
-
boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.28)',
|
|
1524
|
-
}}
|
|
1525
|
-
>
|
|
1526
|
-
<PanelLeftOpen size={13} color="#38BDF8" />
|
|
1527
|
-
<span>Screens ({screens.length})</span>
|
|
1528
|
-
</button>
|
|
1529
|
-
)}
|
|
1530
|
-
|
|
1531
|
-
{/* Floating Edge Restore Pill for Collapsed Right Inspector */}
|
|
1532
|
-
{!isRightPanelOpen && (
|
|
1533
|
-
<button
|
|
1534
|
-
type="button"
|
|
1535
|
-
className="zelune-interactive tabular-nums"
|
|
1536
|
-
onClick={(e) => {
|
|
1537
|
-
e.stopPropagation();
|
|
1538
|
-
setIsRightPanelOpen(true);
|
|
1539
|
-
}}
|
|
1540
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
1541
|
-
title="Expand AST Visual Inspector (])"
|
|
1542
|
-
style={{
|
|
1543
|
-
position: 'absolute',
|
|
1544
|
-
top: 14,
|
|
1545
|
-
right: 14,
|
|
1546
|
-
zIndex: 35,
|
|
1547
|
-
display: 'inline-flex',
|
|
1548
|
-
alignItems: 'center',
|
|
1549
|
-
gap: 7,
|
|
1550
|
-
padding: '7px 11px',
|
|
1551
|
-
borderRadius: 8,
|
|
1552
|
-
backgroundColor: 'var(--studio-surface-1)',
|
|
1553
|
-
border: '1px solid var(--studio-border-subtle)',
|
|
1554
|
-
color: 'var(--studio-text-primary)',
|
|
1555
|
-
fontSize: 11.5,
|
|
1556
|
-
fontFamily: 'var(--font-mono)',
|
|
1557
|
-
fontWeight: 600,
|
|
1558
|
-
cursor: 'pointer',
|
|
1559
|
-
boxShadow: '0 10px 24px -6px rgba(0, 0, 0, 0.28)',
|
|
1560
|
-
}}
|
|
1561
|
-
>
|
|
1562
|
-
<Sliders size={13} color="#38BDF8" />
|
|
1563
|
-
<span>
|
|
1564
|
-
{inspectedData ? `<${inspectedData.tagName}> L${inspectedData.line}` : 'AST Inspector'}
|
|
1565
|
-
</span>
|
|
1566
|
-
<PanelRightOpen size={13} color="var(--studio-text-secondary)" />
|
|
1567
|
-
</button>
|
|
1568
|
-
)}
|
|
1569
|
-
|
|
1570
|
-
{/* World Transform Container: Emil Kowalski ease-out-expo on camera focus, 0ms on direct drag/pinch */}
|
|
1571
|
-
<div
|
|
1572
|
-
ref={worldContainerRef}
|
|
1573
|
-
style={{
|
|
1574
|
-
position: 'absolute',
|
|
1575
|
-
top: 0,
|
|
1576
|
-
left: 0,
|
|
1577
|
-
transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom})`,
|
|
1578
|
-
transformOrigin: '0 0',
|
|
1579
|
-
transition: isCameraAnimating
|
|
1580
|
-
? 'transform var(--duration-camera) var(--ease-out-expo)'
|
|
1581
|
-
: 'none',
|
|
1582
|
-
}}
|
|
1583
|
-
>
|
|
1584
|
-
{showFlows && (
|
|
1585
|
-
<FlowArrowsLayer
|
|
1586
|
-
screens={screens}
|
|
1587
|
-
layout={layout}
|
|
1588
|
-
activeScreenId={activeScreenId}
|
|
1589
|
-
highlightedScreenId={highlightedScreenId}
|
|
1590
|
-
onSelectFlow={handleSelectFlow}
|
|
1591
|
-
/>
|
|
1592
|
-
)}
|
|
1593
|
-
|
|
1594
|
-
{screens.map((screen) => {
|
|
1595
|
-
const pos = layout[screen.id] ?? {
|
|
1596
|
-
x: INITIAL_OFFSET_X,
|
|
1597
|
-
y: INITIAL_OFFSET_Y,
|
|
1598
|
-
width: DEFAULT_WIDTH,
|
|
1599
|
-
height: DEFAULT_HEIGHT,
|
|
1600
|
-
};
|
|
1601
|
-
const ScreenComponent = SCREEN_COMPONENTS[screen.id];
|
|
1602
|
-
const isSelectedScreen = selectedElement?.screenId === screen.id;
|
|
1603
|
-
|
|
1604
|
-
return (
|
|
1605
|
-
<ScreenArtboard
|
|
1606
|
-
key={screen.id}
|
|
1607
|
-
screen={screen}
|
|
1608
|
-
ScreenComponent={ScreenComponent}
|
|
1609
|
-
position={pos}
|
|
1610
|
-
zoom={zoom}
|
|
1611
|
-
mode={mode}
|
|
1612
|
-
isActiveScreen={activeScreenId === screen.id}
|
|
1613
|
-
isHighlightedScreen={highlightedScreenId === screen.id}
|
|
1614
|
-
isCanvasMoving={isPanning}
|
|
1615
|
-
selectedElement={isSelectedScreen ? selectedElement : null}
|
|
1616
|
-
inspectedElement={isSelectedScreen ? inspectedData : null}
|
|
1617
|
-
onSelectElement={handleSelectElement}
|
|
1618
|
-
onSelectScreen={setActiveScreenId}
|
|
1619
|
-
onFocusScreen={focusOnScreen}
|
|
1620
|
-
onMoveArtboard={handleMoveArtboard}
|
|
1621
|
-
onNavigateFlow={handleNavigateFlow}
|
|
1622
|
-
onPatchElement={handlePatchElement}
|
|
1623
|
-
onReorderElement={handleReorderElement}
|
|
1624
|
-
/>
|
|
1625
|
-
);
|
|
1626
|
-
})}
|
|
1627
|
-
</div>
|
|
1628
|
-
</main>
|
|
1629
|
-
|
|
1630
|
-
{/* Right: AST Visual Inspector Panel */}
|
|
1631
|
-
<InspectorPanel
|
|
1632
|
-
inspected={inspectedData}
|
|
1633
|
-
screens={screens}
|
|
1634
|
-
activeScreenId={activeScreenId}
|
|
1635
|
-
isOpen={isRightPanelOpen}
|
|
1636
|
-
isLoading={isInspecting}
|
|
1637
|
-
isSaving={isSaving}
|
|
1638
|
-
statusMessage={statusMessage}
|
|
1639
|
-
onPatch={handlePatchElement}
|
|
1640
|
-
onInsert={handleInsertElement}
|
|
1641
|
-
onDuplicate={handleDuplicateElement}
|
|
1642
|
-
onDelete={handleDeleteElement}
|
|
1643
|
-
onReorder={handleReorderElement}
|
|
1644
|
-
onSelectScreen={setActiveScreenId}
|
|
1645
|
-
onClearSelection={() => {
|
|
1646
|
-
setSelectedElement(null);
|
|
1647
|
-
setInspectedData(null);
|
|
1648
|
-
}}
|
|
1649
|
-
onCollapse={() => setIsRightPanelOpen(false)}
|
|
1650
|
-
onJumpToScreen={focusOnScreen}
|
|
1651
|
-
/>
|
|
1652
|
-
</div>
|
|
1653
|
-
</div>
|
|
1654
|
-
);
|
|
1655
|
-
};
|