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.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bin/cli.mjs +13 -54
  3. package/dist/assets/CheckoutScreen-6IdrpKgm.js +229 -0
  4. package/dist/assets/HomeScreen-Dxe9L4I2.js +316 -0
  5. package/dist/assets/LoginScreen-Dp1awI4F.js +189 -0
  6. package/dist/assets/ProductDetailScreen-Wa5_nlRP.js +196 -0
  7. package/dist/assets/ProfileScreen-riAU6vVh.js +185 -0
  8. package/dist/assets/index-hEzd4JVy.js +44697 -0
  9. package/{index.html → dist/index.html} +3 -7
  10. package/mcp/server.mjs +1370 -34
  11. package/package.json +10 -17
  12. package/plugin.json +1 -1
  13. package/socket.yml +11 -0
  14. package/mcp/server.ts +0 -446
  15. package/plugins/viteReactNativeFallbackPlugin.ts +0 -519
  16. package/plugins/viteRnSourceInject.ts +0 -117
  17. package/plugins/viteVirtualScreensPlugin.ts +0 -216
  18. package/src/canvas/CanvasApp.tsx +0 -1655
  19. package/src/canvas/FlowArrowsLayer.tsx +0 -239
  20. package/src/canvas/InspectorPanel.tsx +0 -1494
  21. package/src/canvas/ScreenArtboard.tsx +0 -1491
  22. package/src/canvas/types.ts +0 -23
  23. package/src/main.tsx +0 -80
  24. package/src/runtime/MockNavigationContext.tsx +0 -1795
  25. package/src/runtime/ScreenErrorBoundary.tsx +0 -86
  26. package/src/runtime/nativewindShim.ts +0 -997
  27. package/src/runtime/reactNativeShim.ts +0 -240
  28. package/src/screens/CheckoutScreen.tsx +0 -229
  29. package/src/screens/HomeScreen.tsx +0 -288
  30. package/src/screens/LoginScreen.tsx +0 -176
  31. package/src/screens/ProductDetailScreen.tsx +0 -207
  32. package/src/screens/ProfileScreen.tsx +0 -172
  33. package/src/screens/index.ts +0 -1
  34. package/src/server/apiMiddleware.ts +0 -375
  35. package/src/server/astEngine.ts +0 -2553
  36. package/src/vite-env.d.ts +0 -7
  37. package/tsconfig.json +0 -23
  38. package/vite.config.ts +0 -340
  39. /package/{src/styles.css → dist/assets/index-C7nrS82N.css} +0 -0
@@ -1,1491 +0,0 @@
1
- import React, { useRef, useState, useEffect, useCallback } from 'react';
2
- import {
3
- GripHorizontal,
4
- Maximize2,
5
- AlertTriangle,
6
- ArrowUp,
7
- ArrowDown,
8
- Pin,
9
- PinOff,
10
- Type,
11
- Move,
12
- } from 'lucide-react';
13
- import { MockNavigationProvider } from '../runtime/MockNavigationContext';
14
- import { ScreenErrorBoundary } from '../runtime/ScreenErrorBoundary';
15
- import type {
16
- ArtboardPosition,
17
- CanvasMode,
18
- InspectedElement,
19
- ScreenMetadata,
20
- SelectedElementRef,
21
- } from './types';
22
- import type { PatchPayload, ReorderPayload } from '../server/astEngine';
23
-
24
- interface ScreenArtboardProps {
25
- screen: ScreenMetadata;
26
- ScreenComponent: React.ComponentType | undefined;
27
- position: ArtboardPosition;
28
- zoom: number;
29
- mode: CanvasMode;
30
- isActiveScreen: boolean;
31
- isHighlightedScreen: boolean;
32
- isCanvasMoving?: boolean;
33
- selectedElement: SelectedElementRef | null;
34
- inspectedElement: InspectedElement | null;
35
- onSelectElement: (ref: SelectedElementRef) => void;
36
- onSelectScreen: (screenId: string) => void;
37
- onFocusScreen: (screenId: string) => void;
38
- onMoveArtboard: (screenId: string, nextX: number, nextY: number) => void;
39
- onNavigateFlow: (fromScreen: string, toScreen: string) => void;
40
- onPatchElement: (payload: PatchPayload) => Promise<void>;
41
- onReorderElement: (payload: ReorderPayload) => Promise<void>;
42
- }
43
-
44
- interface BoxOverlay {
45
- top: number;
46
- left: number;
47
- width: number;
48
- height: number;
49
- tagName: string;
50
- astPath: string;
51
- }
52
-
53
- interface DropIndicator {
54
- targetAstPath: string;
55
- targetTagName: string;
56
- placement: 'before' | 'after';
57
- isRow: boolean;
58
- lineTop: number;
59
- lineLeft: number;
60
- lineWidth: number;
61
- lineHeight: number;
62
- }
63
-
64
- type ResizeDirection = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w';
65
-
66
- function findNearestAgyElement(
67
- target: EventTarget | null,
68
- container: HTMLElement | null
69
- ): HTMLElement | null {
70
- if (!target || !(target instanceof HTMLElement) || !container) return null;
71
- let curr: HTMLElement | null = target;
72
- while (curr && curr !== container && container.contains(curr)) {
73
- if (curr.dataset?.agyAstPath && curr.dataset?.agyFile) {
74
- return curr;
75
- }
76
- curr = curr.parentElement;
77
- }
78
- return null;
79
- }
80
-
81
- function escapeAstPathSelector(astPath: string): string {
82
- return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
83
- ? CSS.escape(astPath)
84
- : astPath.replace(/["\\]/g, '\\$&');
85
- }
86
-
87
- export const ScreenArtboard: React.FC<ScreenArtboardProps> = React.memo(({
88
- screen,
89
- ScreenComponent,
90
- position,
91
- zoom,
92
- mode,
93
- isActiveScreen,
94
- isHighlightedScreen,
95
- isCanvasMoving = false,
96
- selectedElement,
97
- inspectedElement,
98
- onSelectElement,
99
- onSelectScreen,
100
- onFocusScreen,
101
- onMoveArtboard,
102
- onNavigateFlow,
103
- onPatchElement,
104
- onReorderElement,
105
- }) => {
106
- const viewportRef = useRef<HTMLDivElement | null>(null);
107
- const inlineInputRef = useRef<HTMLTextAreaElement | null>(null);
108
-
109
- const [hoverBox, setHoverBox] = useState<BoxOverlay | null>(null);
110
- const [selectedBox, setSelectedBox] = useState<BoxOverlay | null>(null);
111
- const [isDraggingHeader, setIsDraggingHeader] = useState(false);
112
-
113
- // Direct On-Canvas Manipulation States
114
- const [activeGizmoAction, setActiveGizmoAction] = useState<
115
- 'resize' | 'radius' | 'move' | null
116
- >(null);
117
- const [liveHudBadge, setLiveHudBadge] = useState<string | null>(null);
118
- const [dropIndicator, setDropIndicator] = useState<DropIndicator | null>(null);
119
- const [showCenterGuideX, setShowCenterGuideX] = useState<boolean>(false);
120
-
121
- // Inline Double-Click Text Editing State
122
- const [isEditingInlineText, setIsEditingInlineText] = useState<boolean>(false);
123
- const [inlineTextDraft, setInlineTextDraft] = useState<string>('');
124
- const initialInlineTextRef = useRef<string>('');
125
- const prevSelectionKeyRef = useRef<string | null>(null);
126
-
127
- const isSelectedOnThisScreen =
128
- Boolean(selectedElement) && selectedElement?.screenId === screen.id;
129
- const activeInspected =
130
- isSelectedOnThisScreen &&
131
- inspectedElement &&
132
- inspectedElement.astPath === selectedElement?.astPath
133
- ? inspectedElement
134
- : null;
135
-
136
- const isAbsolutePositioned = activeInspected?.styles?.position === 'absolute';
137
-
138
- const getSelectedDomNode = useCallback((): HTMLElement | null => {
139
- const container = viewportRef.current;
140
- if (!container || !selectedElement || selectedElement.screenId !== screen.id) {
141
- return null;
142
- }
143
- const safeAstPath = escapeAstPathSelector(selectedElement.astPath);
144
- return container.querySelector<HTMLElement>(`[data-agy-ast-path="${safeAstPath}"]`);
145
- }, [selectedElement, screen.id]);
146
-
147
- const computeRelativeBox = useCallback(
148
- (domEl: HTMLElement): BoxOverlay | null => {
149
- const container = viewportRef.current;
150
- if (!container) return null;
151
- const containerRect = container.getBoundingClientRect();
152
- const elRect = domEl.getBoundingClientRect();
153
-
154
- return {
155
- top: (elRect.top - containerRect.top) / zoom,
156
- left: (elRect.left - containerRect.left) / zoom,
157
- width: elRect.width / zoom,
158
- height: elRect.height / zoom,
159
- tagName: domEl.dataset.agyTag || 'View',
160
- astPath: domEl.dataset.agyAstPath || '0',
161
- };
162
- },
163
- [zoom]
164
- );
165
-
166
- // Only close inline text editor when the selected element identity (screenId:astPath) actually changes
167
- const selectionIdentityKey =
168
- isSelectedOnThisScreen && selectedElement
169
- ? `${selectedElement.screenId}:${selectedElement.astPath}`
170
- : null;
171
-
172
- useEffect(() => {
173
- if (prevSelectionKeyRef.current !== selectionIdentityKey) {
174
- prevSelectionKeyRef.current = selectionIdentityKey;
175
- setIsEditingInlineText(false);
176
- }
177
- }, [selectionIdentityKey]);
178
-
179
- // Keep selected element bounding box updated when screen re-renders or styles change
180
- useEffect(() => {
181
- if (!isSelectedOnThisScreen) {
182
- setSelectedBox(null);
183
- return;
184
- }
185
-
186
- const updateSelectionOverlay = () => {
187
- const match = getSelectedDomNode();
188
- if (match) {
189
- setSelectedBox(computeRelativeBox(match));
190
- } else {
191
- setSelectedBox(null);
192
- }
193
- };
194
-
195
- updateSelectionOverlay();
196
- const timer = window.setTimeout(updateSelectionOverlay, 90);
197
- return () => window.clearTimeout(timer);
198
- }, [
199
- selectedElement,
200
- inspectedElement,
201
- screen,
202
- isSelectedOnThisScreen,
203
- getSelectedDomNode,
204
- computeRelativeBox,
205
- ]);
206
-
207
- // Focus inline text editor when activated
208
- useEffect(() => {
209
- if (isEditingInlineText && inlineInputRef.current) {
210
- inlineInputRef.current.focus();
211
- inlineInputRef.current.select();
212
- }
213
- }, [isEditingInlineText]);
214
-
215
- const startInlineTextEdit = useCallback(
216
- (explicitDomEl?: HTMLElement | null) => {
217
- const domEl = explicitDomEl ?? getSelectedDomNode();
218
- if (!domEl) return;
219
-
220
- const domTag = domEl.dataset.agyTag || '';
221
- const isDomTextNode =
222
- domTag === 'Text' ||
223
- domTag.endsWith('.Text') ||
224
- (domEl.querySelectorAll('[data-agy-ast-path]').length === 0 &&
225
- Boolean(domEl.textContent?.trim()));
226
-
227
- if (activeInspected && activeInspected.astPath === domEl.dataset.agyAstPath) {
228
- if (!activeInspected.isTextEditable && !isDomTextNode) return;
229
- const initialVal = activeInspected.textContent ?? domEl.textContent?.trim() ?? '';
230
- initialInlineTextRef.current = initialVal;
231
- setInlineTextDraft(initialVal);
232
- setIsEditingInlineText(true);
233
- return;
234
- }
235
-
236
- if (!isDomTextNode) return;
237
- const initialVal = domEl.textContent?.replace(/\s+/g, ' ').trim() ?? '';
238
- initialInlineTextRef.current = initialVal;
239
- setInlineTextDraft(initialVal);
240
- setIsEditingInlineText(true);
241
- },
242
- [activeInspected, getSelectedDomNode]
243
- );
244
-
245
- const commitInlineTextEdit = useCallback(() => {
246
- if (!isEditingInlineText || !selectedElement) {
247
- setIsEditingInlineText(false);
248
- return;
249
- }
250
- setIsEditingInlineText(false);
251
- const baselineText = activeInspected?.textContent ?? initialInlineTextRef.current;
252
- if (inlineTextDraft !== baselineText) {
253
- void onPatchElement({
254
- file: selectedElement.file,
255
- astPath: selectedElement.astPath,
256
- line: selectedElement.line,
257
- text: inlineTextDraft,
258
- });
259
- }
260
- }, [isEditingInlineText, selectedElement, activeInspected, inlineTextDraft, onPatchElement]);
261
-
262
- // Artboard header drag handler to reposition the screen on the infinite canvas
263
- const handleHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
264
- if (e.button !== 0) return;
265
- e.stopPropagation();
266
- onSelectScreen(screen.id);
267
- setIsDraggingHeader(true);
268
-
269
- const startClientX = e.clientX;
270
- const startClientY = e.clientY;
271
- const initialX = position.x;
272
- const initialY = position.y;
273
-
274
- const handlePointerMove = (moveEvent: PointerEvent) => {
275
- const dx = (moveEvent.clientX - startClientX) / zoom;
276
- const dy = (moveEvent.clientY - startClientY) / zoom;
277
- onMoveArtboard(
278
- screen.id,
279
- Math.round(initialX + dx),
280
- Math.round(initialY + dy)
281
- );
282
- };
283
-
284
- const handlePointerUp = () => {
285
- setIsDraggingHeader(false);
286
- window.removeEventListener('pointermove', handlePointerMove);
287
- window.removeEventListener('pointerup', handlePointerUp);
288
- };
289
-
290
- window.addEventListener('pointermove', handlePointerMove);
291
- window.addEventListener('pointerup', handlePointerUp);
292
- };
293
-
294
- // 8-Point Direct Resize Handler on Artboard
295
- const handleResizePointerDown = (
296
- e: React.PointerEvent<HTMLElement>,
297
- dir: ResizeDirection
298
- ) => {
299
- if (e.button !== 0 || !selectedBox || !selectedElement) return;
300
- e.stopPropagation();
301
- e.preventDefault();
302
-
303
- const targetDomEl = getSelectedDomNode();
304
- if (!targetDomEl) return;
305
-
306
- const startClientX = e.clientX;
307
- const startClientY = e.clientY;
308
- const initialBox = { ...selectedBox };
309
- const initialLeft = Number(activeInspected?.styles?.left ?? initialBox.left);
310
- const initialTop = Number(activeInspected?.styles?.top ?? initialBox.top);
311
-
312
- // Measure parent inner content width so dragging to parent edges can snap to '100%'
313
- let parentInnerWidth = position.width;
314
- if (targetDomEl.parentElement) {
315
- const parentRect = targetDomEl.parentElement.getBoundingClientRect();
316
- const parentComputed = window.getComputedStyle(targetDomEl.parentElement);
317
- const padL = Number.parseFloat(parentComputed.paddingLeft) || 0;
318
- const padR = Number.parseFloat(parentComputed.paddingRight) || 0;
319
- parentInnerWidth = parentRect.width / zoom - padL - padR;
320
- }
321
-
322
- setActiveGizmoAction('resize');
323
- let pendingStyles: Record<string, string | number> = {};
324
-
325
- const handlePointerMove = (moveEvent: PointerEvent) => {
326
- const dx = (moveEvent.clientX - startClientX) / zoom;
327
- const dy = (moveEvent.clientY - startClientY) / zoom;
328
-
329
- let nextW = initialBox.width;
330
- let nextH = initialBox.height;
331
- let nextL = initialLeft;
332
- let nextT = initialTop;
333
-
334
- const affectsWidth = dir.includes('e') || dir.includes('w');
335
- const affectsHeight = dir.includes('s') || dir.includes('n');
336
-
337
- if (dir.includes('e')) {
338
- nextW = Math.max(20, Math.round(initialBox.width + dx));
339
- } else if (dir.includes('w')) {
340
- nextW = Math.max(20, Math.round(initialBox.width - dx));
341
- if (isAbsolutePositioned) {
342
- nextL = Math.round(initialLeft + (initialBox.width - nextW));
343
- }
344
- }
345
-
346
- if (dir.includes('s')) {
347
- nextH = Math.max(18, Math.round(initialBox.height + dy));
348
- } else if (dir.includes('n')) {
349
- nextH = Math.max(18, Math.round(initialBox.height - dy));
350
- if (isAbsolutePositioned) {
351
- nextT = Math.round(initialTop + (initialBox.height - nextH));
352
- }
353
- }
354
-
355
- pendingStyles = {};
356
- let widthLabel = `${Math.round(nextW)}`;
357
-
358
- if (affectsWidth) {
359
- if (
360
- !isAbsolutePositioned &&
361
- !moveEvent.altKey &&
362
- Math.abs(nextW - parentInnerWidth) <= 7
363
- ) {
364
- targetDomEl.style.width = '100%';
365
- pendingStyles.width = '100%';
366
- widthLabel = '100%';
367
- } else {
368
- targetDomEl.style.width = `${nextW}px`;
369
- pendingStyles.width = nextW;
370
- }
371
- if (isAbsolutePositioned && dir.includes('w')) {
372
- targetDomEl.style.left = `${nextL}px`;
373
- pendingStyles.left = nextL;
374
- }
375
- }
376
-
377
- if (affectsHeight) {
378
- targetDomEl.style.height = `${nextH}px`;
379
- pendingStyles.height = nextH;
380
- if (isAbsolutePositioned && dir.includes('n')) {
381
- targetDomEl.style.top = `${nextT}px`;
382
- pendingStyles.top = nextT;
383
- }
384
- }
385
-
386
- const updatedBox = computeRelativeBox(targetDomEl);
387
- if (updatedBox) {
388
- setSelectedBox(updatedBox);
389
- }
390
- setLiveHudBadge(`${widthLabel} × ${Math.round(nextH)}`);
391
- };
392
-
393
- const handlePointerUp = () => {
394
- window.removeEventListener('pointermove', handlePointerMove);
395
- window.removeEventListener('pointerup', handlePointerUp);
396
- window.removeEventListener('pointercancel', handlePointerUp);
397
- setActiveGizmoAction(null);
398
- setLiveHudBadge(null);
399
-
400
- if (Object.keys(pendingStyles).length > 0) {
401
- void onPatchElement({
402
- file: selectedElement.file,
403
- astPath: selectedElement.astPath,
404
- line: selectedElement.line,
405
- styles: pendingStyles,
406
- });
407
- }
408
- };
409
-
410
- window.addEventListener('pointermove', handlePointerMove);
411
- window.addEventListener('pointerup', handlePointerUp);
412
- window.addEventListener('pointercancel', handlePointerUp);
413
- };
414
-
415
- // Inner Corner borderRadius Drag Handler
416
- const handleRadiusPointerDown = (
417
- e: React.PointerEvent<HTMLElement>,
418
- corner: 'nw' | 'ne' | 'sw' | 'se'
419
- ) => {
420
- if (e.button !== 0 || !selectedBox || !selectedElement) return;
421
- e.stopPropagation();
422
- e.preventDefault();
423
-
424
- const targetDomEl = getSelectedDomNode();
425
- if (!targetDomEl) return;
426
-
427
- const startClientX = e.clientX;
428
- const startClientY = e.clientY;
429
- const initialRadius = Number(activeInspected?.styles?.borderRadius ?? 0) || 0;
430
- const maxRadius = Math.max(0, Math.round(Math.min(selectedBox.width, selectedBox.height) / 2));
431
-
432
- setActiveGizmoAction('radius');
433
- let nextRadius = initialRadius;
434
- let didChange = false;
435
-
436
- const handlePointerMove = (moveEvent: PointerEvent) => {
437
- const dx = (moveEvent.clientX - startClientX) / zoom;
438
- const dy = (moveEvent.clientY - startClientY) / zoom;
439
- const signedX = corner.includes('w') ? dx : -dx;
440
- const signedY = corner.includes('n') ? dy : -dy;
441
- const delta = Math.round((signedX + signedY) / 2);
442
-
443
- nextRadius = Math.max(0, Math.min(maxRadius, initialRadius + delta));
444
- didChange = true;
445
- targetDomEl.style.borderRadius = `${nextRadius}px`;
446
- setLiveHudBadge(`Radius ${nextRadius}px`);
447
- };
448
-
449
- const handlePointerUp = () => {
450
- window.removeEventListener('pointermove', handlePointerMove);
451
- window.removeEventListener('pointerup', handlePointerUp);
452
- window.removeEventListener('pointercancel', handlePointerUp);
453
- setActiveGizmoAction(null);
454
- setLiveHudBadge(null);
455
-
456
- if (didChange && nextRadius !== initialRadius) {
457
- void onPatchElement({
458
- file: selectedElement.file,
459
- astPath: selectedElement.astPath,
460
- line: selectedElement.line,
461
- styles: { borderRadius: nextRadius },
462
- });
463
- }
464
- };
465
-
466
- window.addEventListener('pointermove', handlePointerMove);
467
- window.addEventListener('pointerup', handlePointerUp);
468
- window.addEventListener('pointercancel', handlePointerUp);
469
- };
470
-
471
- // Hybrid On-Canvas Move & Reorder Handler (Flexbox Reorder + Freeform Float + Shift Spacing Nudge)
472
- const startMoveElementInteraction = useCallback(
473
- (startClientX: number, startClientY: number, initialAltKey: boolean) => {
474
- if (!selectedBox || !selectedElement) return;
475
- const targetDomEl = getSelectedDomNode();
476
- const container = viewportRef.current;
477
- if (!targetDomEl || !container) return;
478
-
479
- const initialBox = { ...selectedBox };
480
- const initialLeft = Number(activeInspected?.styles?.left ?? initialBox.left);
481
- const initialTop = Number(activeInspected?.styles?.top ?? initialBox.top);
482
- const initialMarginTop = Number(activeInspected?.styles?.marginTop ?? 0) || 0;
483
- const prevTransform = targetDomEl.style.transform;
484
- const prevOpacity = targetDomEl.style.opacity;
485
- const prevZIndex = targetDomEl.style.zIndex;
486
-
487
- let hasExceededThreshold = false;
488
- let activeDrop: DropIndicator | null = null;
489
- let pendingStylePatch: Record<string, string | number> | null = null;
490
-
491
- const handlePointerMove = (moveEvent: PointerEvent) => {
492
- const rawDx = moveEvent.clientX - startClientX;
493
- const rawDy = moveEvent.clientY - startClientY;
494
-
495
- // Wait for 4px movement threshold before entering drag state (per modern-web-guidance)
496
- if (!hasExceededThreshold) {
497
- if (Math.hypot(rawDx, rawDy) < 4) return;
498
- hasExceededThreshold = true;
499
- setActiveGizmoAction('move');
500
- }
501
-
502
- const dx = rawDx / zoom;
503
- const dy = rawDy / zoom;
504
- const useFreeformFloat = isAbsolutePositioned || initialAltKey || moveEvent.altKey;
505
-
506
- if (useFreeformFloat) {
507
- setDropIndicator(null);
508
- activeDrop = null;
509
-
510
- let nextLeft = Math.round(initialLeft + dx);
511
- const nextTop = Math.round(initialTop + dy);
512
-
513
- // Snap to horizontal center of the 393px artboard within 4px
514
- const elementCenterX = nextLeft + initialBox.width / 2;
515
- const artboardCenterX = position.width / 2;
516
- if (Math.abs(elementCenterX - artboardCenterX) <= 4) {
517
- nextLeft = Math.round(artboardCenterX - initialBox.width / 2);
518
- setShowCenterGuideX(true);
519
- } else {
520
- setShowCenterGuideX(false);
521
- }
522
-
523
- targetDomEl.style.transform = prevTransform;
524
- targetDomEl.style.opacity = '0.95';
525
- targetDomEl.style.position = 'absolute';
526
- targetDomEl.style.left = `${nextLeft}px`;
527
- targetDomEl.style.top = `${nextTop}px`;
528
- targetDomEl.style.width = `${Math.round(initialBox.width)}px`;
529
- targetDomEl.style.zIndex = '20';
530
-
531
- pendingStylePatch = {
532
- position: 'absolute',
533
- left: nextLeft,
534
- top: nextTop,
535
- width: Math.round(initialBox.width),
536
- };
537
-
538
- const updatedBox = computeRelativeBox(targetDomEl);
539
- if (updatedBox) setSelectedBox(updatedBox);
540
- setLiveHudBadge(`Float X:${nextLeft} Y:${nextTop}`);
541
- return;
542
- }
543
-
544
- setShowCenterGuideX(false);
545
-
546
- // Shift + Drag in Flexbox mode = Nudge marginTop spacing
547
- if (moveEvent.shiftKey) {
548
- setDropIndicator(null);
549
- activeDrop = null;
550
- const nextMarginTop = Math.round(initialMarginTop + dy);
551
- targetDomEl.style.transform = prevTransform;
552
- targetDomEl.style.marginTop = `${nextMarginTop}px`;
553
- pendingStylePatch = { marginTop: nextMarginTop };
554
- const updatedBox = computeRelativeBox(targetDomEl);
555
- if (updatedBox) setSelectedBox(updatedBox);
556
- setLiveHudBadge(`marginTop: ${nextMarginTop}px`);
557
- return;
558
- }
559
-
560
- // Default Flexbox Drag = Reorder among sibling JSX elements inside parent container
561
- pendingStylePatch = null;
562
- targetDomEl.style.transform = `translate3d(${Math.round(dx)}px, ${Math.round(dy)}px, 0)`;
563
- targetDomEl.style.opacity = '0.84';
564
- targetDomEl.style.zIndex = '30';
565
-
566
- const parentEl = targetDomEl.parentElement;
567
- if (!parentEl) return;
568
-
569
- const parentStyle = window.getComputedStyle(parentEl);
570
- const isRow = parentStyle.flexDirection.startsWith('row');
571
- const containerRect = container.getBoundingClientRect();
572
- const pointerX = (moveEvent.clientX - containerRect.left) / zoom;
573
- const pointerY = (moveEvent.clientY - containerRect.top) / zoom;
574
-
575
- const siblings = Array.from(parentEl.children).filter(
576
- (child): child is HTMLElement =>
577
- child instanceof HTMLElement &&
578
- child !== targetDomEl &&
579
- Boolean(child.dataset?.agyAstPath)
580
- );
581
-
582
- if (siblings.length === 0) {
583
- // No siblings in parent container: fall back to marginTop nudge
584
- const nextMarginTop = Math.round(initialMarginTop + dy);
585
- pendingStylePatch = { marginTop: nextMarginTop };
586
- setLiveHudBadge(`Offset Y: ${nextMarginTop}px`);
587
- return;
588
- }
589
-
590
- let closestSibling: HTMLElement | null = null;
591
- let closestDist = Number.POSITIVE_INFINITY;
592
- let closestBox: BoxOverlay | null = null;
593
-
594
- for (const sib of siblings) {
595
- const box = computeRelativeBox(sib);
596
- if (!box) continue;
597
- const centerX = box.left + box.width / 2;
598
- const centerY = box.top + box.height / 2;
599
- const dist = isRow
600
- ? Math.abs(pointerX - centerX)
601
- : Math.abs(pointerY - centerY);
602
- if (dist < closestDist) {
603
- closestDist = dist;
604
- closestSibling = sib;
605
- closestBox = box;
606
- }
607
- }
608
-
609
- if (closestSibling && closestBox) {
610
- const placement: 'before' | 'after' = isRow
611
- ? pointerX < closestBox.left + closestBox.width / 2
612
- ? 'before'
613
- : 'after'
614
- : pointerY < closestBox.top + closestBox.height / 2
615
- ? 'before'
616
- : 'after';
617
-
618
- const indicator: DropIndicator = isRow
619
- ? {
620
- targetAstPath: closestBox.astPath,
621
- targetTagName: closestBox.tagName,
622
- placement,
623
- isRow: true,
624
- lineTop: closestBox.top,
625
- lineLeft:
626
- placement === 'before'
627
- ? closestBox.left - 3
628
- : closestBox.left + closestBox.width + 3,
629
- lineWidth: 2,
630
- lineHeight: closestBox.height,
631
- }
632
- : {
633
- targetAstPath: closestBox.astPath,
634
- targetTagName: closestBox.tagName,
635
- placement,
636
- isRow: false,
637
- lineTop:
638
- placement === 'before'
639
- ? closestBox.top - 3
640
- : closestBox.top + closestBox.height + 3,
641
- lineLeft: closestBox.left,
642
- lineWidth: closestBox.width,
643
- lineHeight: 2,
644
- };
645
-
646
- activeDrop = indicator;
647
- setDropIndicator(indicator);
648
- setLiveHudBadge(`Move ${placement} <${closestBox.tagName}>`);
649
- }
650
- };
651
-
652
- const handlePointerUp = () => {
653
- window.removeEventListener('pointermove', handlePointerMove);
654
- window.removeEventListener('pointerup', handlePointerUp);
655
- window.removeEventListener('pointercancel', handlePointerUp);
656
-
657
- targetDomEl.style.transform = prevTransform;
658
- targetDomEl.style.opacity = prevOpacity;
659
- targetDomEl.style.zIndex = prevZIndex;
660
-
661
- setActiveGizmoAction(null);
662
- setLiveHudBadge(null);
663
- setDropIndicator(null);
664
- setShowCenterGuideX(false);
665
-
666
- if (!hasExceededThreshold) return;
667
-
668
- if (activeDrop) {
669
- void onReorderElement({
670
- file: selectedElement.file,
671
- astPath: selectedElement.astPath,
672
- targetAstPath: activeDrop.targetAstPath,
673
- placement: activeDrop.placement,
674
- });
675
- } else if (pendingStylePatch) {
676
- void onPatchElement({
677
- file: selectedElement.file,
678
- astPath: selectedElement.astPath,
679
- line: selectedElement.line,
680
- styles: pendingStylePatch,
681
- });
682
- }
683
- };
684
-
685
- window.addEventListener('pointermove', handlePointerMove);
686
- window.addEventListener('pointerup', handlePointerUp);
687
- window.addEventListener('pointercancel', handlePointerUp);
688
- },
689
- [
690
- selectedBox,
691
- selectedElement,
692
- activeInspected,
693
- isAbsolutePositioned,
694
- zoom,
695
- position.width,
696
- getSelectedDomNode,
697
- computeRelativeBox,
698
- onPatchElement,
699
- onReorderElement,
700
- ]
701
- );
702
-
703
- // Toggle between Flexbox flow and Freeform Float (position: 'absolute')
704
- const handleToggleFloatMode = (e: React.MouseEvent<HTMLButtonElement>) => {
705
- e.stopPropagation();
706
- if (!selectedElement || !selectedBox) return;
707
-
708
- if (isAbsolutePositioned) {
709
- void onPatchElement({
710
- file: selectedElement.file,
711
- astPath: selectedElement.astPath,
712
- line: selectedElement.line,
713
- styles: {
714
- position: null,
715
- left: null,
716
- top: null,
717
- zIndex: null,
718
- },
719
- });
720
- } else {
721
- void onPatchElement({
722
- file: selectedElement.file,
723
- astPath: selectedElement.astPath,
724
- line: selectedElement.line,
725
- styles: {
726
- position: 'absolute',
727
- left: Math.round(selectedBox.left),
728
- top: Math.round(selectedBox.top),
729
- width: Math.round(selectedBox.width),
730
- zIndex: 10,
731
- },
732
- });
733
- }
734
- };
735
-
736
- // Inspect Mode pointer handlers
737
- useEffect(() => {
738
- if (isCanvasMoving) {
739
- setHoverBox(null);
740
- }
741
- }, [isCanvasMoving]);
742
-
743
- const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
744
- if (mode !== 'inspect' || activeGizmoAction || isCanvasMoving || e.buttons !== 0) return;
745
- const targetEl = findNearestAgyElement(e.target, viewportRef.current);
746
- if (!targetEl) {
747
- setHoverBox(null);
748
- return;
749
- }
750
- setHoverBox(computeRelativeBox(targetEl));
751
- };
752
-
753
- const handlePointerLeave = () => {
754
- setHoverBox(null);
755
- };
756
-
757
- const handleViewportPointerDownCapture = (e: React.PointerEvent<HTMLDivElement>) => {
758
- if (mode !== 'inspect' || e.button !== 0 || isEditingInlineText) return;
759
- const targetEl = findNearestAgyElement(e.target, viewportRef.current);
760
- if (!targetEl) return;
761
-
762
- // If user presses down on the already-selected element, allow dragging it after 4px threshold
763
- if (
764
- isSelectedOnThisScreen &&
765
- selectedElement &&
766
- targetEl.dataset.agyAstPath === selectedElement.astPath
767
- ) {
768
- startMoveElementInteraction(e.clientX, e.clientY, e.altKey);
769
- }
770
- };
771
-
772
- const resolveTextTargetForDoubleClick = (rawTarget: HTMLElement): HTMLElement => {
773
- const tag = rawTarget.dataset.agyTag || '';
774
- if (tag === 'Text' || tag.endsWith('.Text')) {
775
- return rawTarget;
776
- }
777
- const childTextNodes = rawTarget.querySelectorAll<HTMLElement>('[data-agy-tag="Text"]');
778
- if (childTextNodes.length === 1) {
779
- return childTextNodes[0];
780
- }
781
- return rawTarget;
782
- };
783
-
784
- const handleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
785
- if (mode !== 'inspect' || isEditingInlineText) return;
786
- e.stopPropagation();
787
- e.preventDefault();
788
-
789
- onSelectScreen(screen.id);
790
- const rawTargetEl = findNearestAgyElement(e.target, viewportRef.current);
791
- if (!rawTargetEl) return;
792
-
793
- const targetEl = e.detail >= 2 ? resolveTextTargetForDoubleClick(rawTargetEl) : rawTargetEl;
794
-
795
- const file = targetEl.dataset.agyFile || screen.filePath;
796
- const astPath = targetEl.dataset.agyAstPath || '0';
797
- const line = Number.parseInt(targetEl.dataset.agyLine || '1', 10);
798
- const col = Number.parseInt(targetEl.dataset.agyCol || '0', 10);
799
- const tagName = targetEl.dataset.agyTag || 'View';
800
-
801
- const isAlreadySelected =
802
- selectedElement?.screenId === screen.id && selectedElement?.astPath === astPath;
803
-
804
- setSelectedBox(computeRelativeBox(targetEl));
805
- if (!isAlreadySelected) {
806
- onSelectElement({
807
- screenId: screen.id,
808
- file,
809
- astPath,
810
- line,
811
- col,
812
- tagName,
813
- });
814
- }
815
-
816
- if (e.detail >= 2) {
817
- startInlineTextEdit(targetEl);
818
- }
819
- };
820
-
821
- const handleDoubleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
822
- if (mode !== 'inspect') return;
823
- const rawTargetEl = findNearestAgyElement(e.target, viewportRef.current);
824
- if (!rawTargetEl) return;
825
-
826
- e.stopPropagation();
827
- e.preventDefault();
828
- const targetEl = resolveTextTargetForDoubleClick(rawTargetEl);
829
- startInlineTextEdit(targetEl);
830
- };
831
-
832
- const borderColor = isHighlightedScreen
833
- ? '#10B981'
834
- : isActiveScreen
835
- ? '#38BDF8'
836
- : 'var(--studio-border-strong)';
837
-
838
- const toolbarAbove = !selectedBox || selectedBox.top >= 30;
839
-
840
- return (
841
- <div
842
- style={{
843
- position: 'absolute',
844
- left: position.x,
845
- top: position.y,
846
- width: position.width,
847
- userSelect: 'none',
848
- WebkitUserSelect: 'none',
849
- zIndex: isActiveScreen || isHighlightedScreen ? 20 : 10,
850
- }}
851
- onPointerDown={(e) => {
852
- // Prevent background canvas pan when interacting inside an artboard
853
- e.stopPropagation();
854
- onSelectScreen(screen.id);
855
- }}
856
- >
857
- {/* Minimalist Architectural Artboard Title Header */}
858
- <div
859
- onPointerDown={handleHeaderPointerDown}
860
- style={{
861
- height: 30,
862
- marginBottom: 8,
863
- display: 'flex',
864
- alignItems: 'center',
865
- justifyContent: 'space-between',
866
- padding: '0 4px',
867
- cursor: isDraggingHeader ? 'grabbing' : 'grab',
868
- color: isActiveScreen ? '#0284C7' : 'var(--studio-text-secondary)',
869
- }}
870
- >
871
- <div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
872
- <GripHorizontal size={14} style={{ opacity: 0.7 }} />
873
- <span
874
- style={{
875
- fontSize: 13,
876
- fontWeight: 600,
877
- letterSpacing: '-0.015em',
878
- color: isActiveScreen ? 'var(--studio-text-primary)' : 'var(--studio-text-secondary)',
879
- }}
880
- >
881
- {screen.name}
882
- </span>
883
- <span
884
- style={{
885
- fontSize: 11,
886
- fontFamily: 'var(--font-mono)',
887
- color: 'var(--studio-text-muted)',
888
- }}
889
- >
890
- /{screen.routeName}
891
- </span>
892
- </div>
893
-
894
- <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
895
- <span
896
- className="tabular-nums"
897
- style={{
898
- fontSize: 10.5,
899
- fontFamily: 'var(--font-mono)',
900
- color: 'var(--studio-text-muted)',
901
- }}
902
- >
903
- {position.width} × {position.height}
904
- </span>
905
- <button
906
- type="button"
907
- className="zelune-interactive"
908
- onClick={(e) => {
909
- e.stopPropagation();
910
- onFocusScreen(screen.id);
911
- }}
912
- title="Center screen in viewport"
913
- style={{
914
- display: 'inline-flex',
915
- alignItems: 'center',
916
- justifyContent: 'center',
917
- width: 22,
918
- height: 22,
919
- borderRadius: 5,
920
- border: '1px solid var(--studio-border-subtle)',
921
- backgroundColor: 'var(--studio-surface-2)',
922
- color: 'var(--studio-text-secondary)',
923
- cursor: 'pointer',
924
- }}
925
- >
926
- <Maximize2 size={11} />
927
- </button>
928
- </div>
929
- </div>
930
-
931
- {/* Pure Rectangular Screen Artboard */}
932
- <div
933
- ref={viewportRef}
934
- onPointerDownCapture={handleViewportPointerDownCapture}
935
- onPointerMoveCapture={handlePointerMoveCapture}
936
- onPointerLeave={handlePointerLeave}
937
- onClickCapture={handleClickCapture}
938
- onDoubleClickCapture={handleDoubleClickCapture}
939
- style={{
940
- position: 'relative',
941
- width: position.width,
942
- height: position.height,
943
- backgroundColor: '#0A0B0E',
944
- border: `1px solid ${borderColor}`,
945
- borderRadius: 6,
946
- overflow: 'hidden',
947
- boxShadow: isHighlightedScreen
948
- ? '0 0 0 3px rgba(16, 185, 129, 0.28), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
949
- : isActiveScreen
950
- ? '0 0 0 2px rgba(56, 189, 248, 0.22), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
951
- : '0 22px 44px -15px rgba(0, 0, 0, 0.68)',
952
- transition:
953
- 'border-color var(--duration-micro) var(--ease-out), box-shadow var(--duration-micro) var(--ease-out)',
954
- display: 'flex',
955
- flexDirection: 'column',
956
- cursor: mode === 'inspect' ? 'crosshair' : 'default',
957
- }}
958
- >
959
- {screen.parseError ? (
960
- <div
961
- style={{
962
- flex: 1,
963
- padding: 24,
964
- backgroundColor: '#18181B',
965
- color: '#FCA5A5',
966
- display: 'flex',
967
- flexDirection: 'column',
968
- justifyContent: 'center',
969
- gap: 10,
970
- fontFamily: 'var(--font-mono)',
971
- }}
972
- >
973
- <div
974
- style={{
975
- display: 'flex',
976
- alignItems: 'center',
977
- gap: 8,
978
- color: '#F87171',
979
- fontWeight: 700,
980
- fontSize: 13,
981
- }}
982
- >
983
- <AlertTriangle size={16} />
984
- <span>AST Syntax Error in {screen.filePath}</span>
985
- </div>
986
- <div style={{ fontSize: 11, color: '#FECACA', whiteSpace: 'pre-wrap' }}>
987
- {screen.parseError}
988
- </div>
989
- </div>
990
- ) : ScreenComponent ? (
991
- <ScreenErrorBoundary screenName={screen.name}>
992
- <MockNavigationProvider
993
- routeName={screen.routeName}
994
- onNavigate={onNavigateFlow}
995
- >
996
- <div
997
- style={{
998
- flex: 1,
999
- display: 'flex',
1000
- flexDirection: 'column',
1001
- height: '100%',
1002
- width: '100%',
1003
- minHeight: 0,
1004
- minWidth: 0,
1005
- backgroundColor: '#FFFFFF',
1006
- color: '#0F172A',
1007
- overflowY: 'auto',
1008
- overflowX: 'hidden',
1009
- }}
1010
- >
1011
- <ScreenComponent />
1012
- </div>
1013
- </MockNavigationProvider>
1014
- </ScreenErrorBoundary>
1015
- ) : (
1016
- <div
1017
- style={{
1018
- flex: 1,
1019
- display: 'flex',
1020
- alignItems: 'center',
1021
- justifyContent: 'center',
1022
- color: '#64748B',
1023
- fontSize: 13,
1024
- }}
1025
- >
1026
- Mounting {screen.name}...
1027
- </div>
1028
- )}
1029
-
1030
- {/* Smart Horizontal Center Snap Guide Line during Freeform Float Move */}
1031
- {showCenterGuideX && (
1032
- <div
1033
- style={{
1034
- position: 'absolute',
1035
- top: 0,
1036
- bottom: 0,
1037
- left: Math.round(position.width / 2),
1038
- width: 1,
1039
- borderLeft: '1px dashed #F43F5E',
1040
- pointerEvents: 'none',
1041
- zIndex: 55,
1042
- }}
1043
- />
1044
- )}
1045
-
1046
- {/* Smart Flexbox Drop Insertion Line during Sibling Reorder Drag */}
1047
- {dropIndicator && (
1048
- <div
1049
- style={{
1050
- position: 'absolute',
1051
- top: dropIndicator.lineTop,
1052
- left: dropIndicator.lineLeft,
1053
- width: dropIndicator.lineWidth,
1054
- height: dropIndicator.lineHeight,
1055
- backgroundColor: '#38BDF8',
1056
- boxShadow: '0 0 8px rgba(56, 189, 248, 0.9)',
1057
- pointerEvents: 'none',
1058
- zIndex: 55,
1059
- }}
1060
- >
1061
- <span
1062
- style={{
1063
- position: 'absolute',
1064
- top: dropIndicator.isRow ? -3 : -2,
1065
- left: dropIndicator.isRow ? -2 : -3,
1066
- width: 6,
1067
- height: 6,
1068
- borderRadius: '50%',
1069
- backgroundColor: '#38BDF8',
1070
- }}
1071
- />
1072
- <span
1073
- style={{
1074
- position: 'absolute',
1075
- bottom: dropIndicator.isRow ? -3 : -2,
1076
- right: dropIndicator.isRow ? -2 : -3,
1077
- width: 6,
1078
- height: 6,
1079
- borderRadius: '50%',
1080
- backgroundColor: '#38BDF8',
1081
- }}
1082
- />
1083
- </div>
1084
- )}
1085
-
1086
- {/* Figma-style Hover Bounding Box in Inspect Mode */}
1087
- {mode === 'inspect' &&
1088
- !activeGizmoAction &&
1089
- hoverBox &&
1090
- (!selectedBox || hoverBox.astPath !== selectedBox.astPath) && (
1091
- <div
1092
- className="zelune-box-overlay"
1093
- style={{
1094
- position: 'absolute',
1095
- top: hoverBox.top,
1096
- left: hoverBox.left,
1097
- width: hoverBox.width,
1098
- height: hoverBox.height,
1099
- border: '1px dashed #38BDF8',
1100
- backgroundColor: 'rgba(56, 189, 248, 0.06)',
1101
- pointerEvents: 'none',
1102
- zIndex: 40,
1103
- }}
1104
- >
1105
- <span
1106
- className="tabular-nums"
1107
- style={{
1108
- position: 'absolute',
1109
- top: -19,
1110
- left: -1,
1111
- backgroundColor: '#0284C7',
1112
- color: '#FFFFFF',
1113
- fontSize: 10,
1114
- fontWeight: 600,
1115
- fontFamily: 'var(--font-mono)',
1116
- padding: '1px 5px',
1117
- borderRadius: 3,
1118
- whiteSpace: 'nowrap',
1119
- }}
1120
- >
1121
- &lt;{hoverBox.tagName}&gt;
1122
- </span>
1123
- </div>
1124
- )}
1125
-
1126
- {/* Interactive Direct-Manipulation Selection Gizmo */}
1127
- {mode === 'inspect' && selectedBox && isSelectedOnThisScreen && selectedElement && (
1128
- <div
1129
- className="zelune-box-overlay"
1130
- style={{
1131
- position: 'absolute',
1132
- top: selectedBox.top,
1133
- left: selectedBox.left,
1134
- width: selectedBox.width,
1135
- height: selectedBox.height,
1136
- border: `1.5px solid ${isAbsolutePositioned ? '#F59E0B' : '#6366F1'}`,
1137
- backgroundColor: isAbsolutePositioned
1138
- ? 'rgba(245, 158, 11, 0.07)'
1139
- : 'rgba(99, 102, 241, 0.07)',
1140
- pointerEvents: 'none',
1141
- zIndex: 45,
1142
- }}
1143
- >
1144
- {/* 8-Point Interactive Resize Handles (4 Corners + 4 Edges) */}
1145
- {(
1146
- [
1147
- {
1148
- dir: 'nw' as ResizeDirection,
1149
- style: { top: -5, left: -5, cursor: 'nwse-resize' },
1150
- },
1151
- {
1152
- dir: 'n' as ResizeDirection,
1153
- style: {
1154
- top: -4,
1155
- left: '50%',
1156
- transform: 'translateX(-50%)',
1157
- cursor: 'ns-resize',
1158
- },
1159
- },
1160
- {
1161
- dir: 'ne' as ResizeDirection,
1162
- style: { top: -5, right: -5, cursor: 'nesw-resize' },
1163
- },
1164
- {
1165
- dir: 'e' as ResizeDirection,
1166
- style: {
1167
- top: '50%',
1168
- right: -4,
1169
- transform: 'translateY(-50%)',
1170
- cursor: 'ew-resize',
1171
- },
1172
- },
1173
- {
1174
- dir: 'se' as ResizeDirection,
1175
- style: { bottom: -5, right: -5, cursor: 'nwse-resize' },
1176
- },
1177
- {
1178
- dir: 's' as ResizeDirection,
1179
- style: {
1180
- bottom: -4,
1181
- left: '50%',
1182
- transform: 'translateX(-50%)',
1183
- cursor: 'ns-resize',
1184
- },
1185
- },
1186
- {
1187
- dir: 'sw' as ResizeDirection,
1188
- style: { bottom: -5, left: -5, cursor: 'nesw-resize' },
1189
- },
1190
- {
1191
- dir: 'w' as ResizeDirection,
1192
- style: {
1193
- top: '50%',
1194
- left: -4,
1195
- transform: 'translateY(-50%)',
1196
- cursor: 'ew-resize',
1197
- },
1198
- },
1199
- ] as const
1200
- ).map(({ dir, style }) => (
1201
- <span
1202
- key={dir}
1203
- title={`Drag to resize (${dir.toUpperCase()})`}
1204
- onPointerDown={(e) => handleResizePointerDown(e, dir)}
1205
- onClick={(e) => e.stopPropagation()}
1206
- style={{
1207
- position: 'absolute',
1208
- ...style,
1209
- width: dir.length === 2 ? 8 : 6,
1210
- height: dir.length === 2 ? 8 : 6,
1211
- backgroundColor: '#FFFFFF',
1212
- border: `1.5px solid ${isAbsolutePositioned ? '#D97706' : '#4F46E5'}`,
1213
- borderRadius: dir.length === 2 ? 2 : 1,
1214
- pointerEvents: 'auto',
1215
- touchAction: 'none',
1216
- WebkitUserSelect: 'none',
1217
- userSelect: 'none',
1218
- boxShadow: '0 1px 3px rgba(0,0,0,0.45)',
1219
- }}
1220
- />
1221
- ))}
1222
-
1223
- {/* 4 Inner Corner borderRadius Handles (Figma-style circular dots) */}
1224
- {selectedBox.width >= 44 &&
1225
- selectedBox.height >= 32 &&
1226
- !isEditingInlineText &&
1227
- (
1228
- [
1229
- { corner: 'nw' as const, style: { top: 6, left: 6 } },
1230
- { corner: 'ne' as const, style: { top: 6, right: 6 } },
1231
- { corner: 'sw' as const, style: { bottom: 6, left: 6 } },
1232
- { corner: 'se' as const, style: { bottom: 6, right: 6 } },
1233
- ] as const
1234
- ).map(({ corner, style }) => (
1235
- <span
1236
- key={corner}
1237
- title="Drag inward/outward to adjust borderRadius"
1238
- onPointerDown={(e) => handleRadiusPointerDown(e, corner)}
1239
- onClick={(e) => e.stopPropagation()}
1240
- style={{
1241
- position: 'absolute',
1242
- ...style,
1243
- width: 6,
1244
- height: 6,
1245
- borderRadius: '50%',
1246
- backgroundColor: 'rgba(56, 189, 248, 0.9)',
1247
- border: '1px solid #08090C',
1248
- cursor: 'crosshair',
1249
- pointerEvents: 'auto',
1250
- touchAction: 'none',
1251
- WebkitUserSelect: 'none',
1252
- userSelect: 'none',
1253
- opacity: 0.75,
1254
- }}
1255
- />
1256
- ))}
1257
-
1258
- {/* On-Canvas Floating Action & Move Toolbar Pill */}
1259
- <div
1260
- onClick={(e) => e.stopPropagation()}
1261
- style={{
1262
- position: 'absolute',
1263
- ...(toolbarAbove ? { top: -27 } : { bottom: -27 }),
1264
- left: -1.5,
1265
- display: 'inline-flex',
1266
- alignItems: 'center',
1267
- gap: 2,
1268
- backgroundColor: isAbsolutePositioned ? '#B45309' : '#4F46E5',
1269
- color: '#FFFFFF',
1270
- fontSize: 10,
1271
- fontWeight: 700,
1272
- fontFamily: 'var(--font-mono)',
1273
- padding: '2px 4px 2px 6px',
1274
- borderRadius: 4,
1275
- whiteSpace: 'nowrap',
1276
- boxShadow: '0 4px 10px rgba(0, 0, 0, 0.5)',
1277
- pointerEvents: 'auto',
1278
- touchAction: 'none',
1279
- WebkitUserSelect: 'none',
1280
- userSelect: 'none',
1281
- zIndex: 50,
1282
- }}
1283
- >
1284
- {/* Drag Grip Handle on the Tag Pill */}
1285
- <span
1286
- className="tabular-nums"
1287
- title="Drag to move or reorder element (Hold Option/Alt for freeform X/Y, Shift for margin offset)"
1288
- onPointerDown={(e) => {
1289
- if (e.button !== 0) return;
1290
- e.stopPropagation();
1291
- e.preventDefault();
1292
- startMoveElementInteraction(e.clientX, e.clientY, e.altKey);
1293
- }}
1294
- style={{
1295
- display: 'inline-flex',
1296
- alignItems: 'center',
1297
- gap: 4,
1298
- cursor: activeGizmoAction === 'move' ? 'grabbing' : 'grab',
1299
- paddingRight: 4,
1300
- }}
1301
- >
1302
- <Move size={10} />
1303
- <span>
1304
- &lt;{selectedBox.tagName}&gt; · L{selectedElement.line}
1305
- </span>
1306
- </span>
1307
-
1308
- {/* Quick Reorder Up inside Flexbox Parent */}
1309
- <button
1310
- type="button"
1311
- title="Move element earlier/up in Flexbox stack"
1312
- onClick={(e) => {
1313
- e.stopPropagation();
1314
- void onReorderElement({
1315
- file: selectedElement.file,
1316
- astPath: selectedElement.astPath,
1317
- direction: 'up',
1318
- });
1319
- }}
1320
- style={{
1321
- display: 'inline-flex',
1322
- alignItems: 'center',
1323
- justifyContent: 'center',
1324
- width: 17,
1325
- height: 17,
1326
- border: 'none',
1327
- borderRadius: 3,
1328
- backgroundColor: 'rgba(255, 255, 255, 0.14)',
1329
- color: '#FFFFFF',
1330
- cursor: 'pointer',
1331
- padding: 0,
1332
- }}
1333
- >
1334
- <ArrowUp size={10} />
1335
- </button>
1336
-
1337
- {/* Quick Reorder Down inside Flexbox Parent */}
1338
- <button
1339
- type="button"
1340
- title="Move element later/down in Flexbox stack"
1341
- onClick={(e) => {
1342
- e.stopPropagation();
1343
- void onReorderElement({
1344
- file: selectedElement.file,
1345
- astPath: selectedElement.astPath,
1346
- direction: 'down',
1347
- });
1348
- }}
1349
- style={{
1350
- display: 'inline-flex',
1351
- alignItems: 'center',
1352
- justifyContent: 'center',
1353
- width: 17,
1354
- height: 17,
1355
- border: 'none',
1356
- borderRadius: 3,
1357
- backgroundColor: 'rgba(255, 255, 255, 0.14)',
1358
- color: '#FFFFFF',
1359
- cursor: 'pointer',
1360
- padding: 0,
1361
- }}
1362
- >
1363
- <ArrowDown size={10} />
1364
- </button>
1365
-
1366
- {/* Toggle Freeform Float (position: 'absolute') vs Flexbox Flow */}
1367
- <button
1368
- type="button"
1369
- title={
1370
- isAbsolutePositioned
1371
- ? 'Unpin element back to Flexbox flow'
1372
- : 'Pin/Float element for freeform (X, Y) dragging'
1373
- }
1374
- onClick={handleToggleFloatMode}
1375
- style={{
1376
- display: 'inline-flex',
1377
- alignItems: 'center',
1378
- justifyContent: 'center',
1379
- width: 17,
1380
- height: 17,
1381
- border: 'none',
1382
- borderRadius: 3,
1383
- backgroundColor: isAbsolutePositioned
1384
- ? 'rgba(15, 23, 42, 0.45)'
1385
- : 'rgba(255, 255, 255, 0.14)',
1386
- color: '#FFFFFF',
1387
- cursor: 'pointer',
1388
- padding: 0,
1389
- }}
1390
- >
1391
- {isAbsolutePositioned ? <PinOff size={10} /> : <Pin size={10} />}
1392
- </button>
1393
-
1394
- {/* Inline Text Edit Trigger Button (for editable <Text> nodes) */}
1395
- {activeInspected?.isTextEditable && (
1396
- <button
1397
- type="button"
1398
- title="Edit text directly on artboard (or double-click element)"
1399
- onClick={(e) => {
1400
- e.stopPropagation();
1401
- startInlineTextEdit();
1402
- }}
1403
- style={{
1404
- display: 'inline-flex',
1405
- alignItems: 'center',
1406
- justifyContent: 'center',
1407
- width: 17,
1408
- height: 17,
1409
- border: 'none',
1410
- borderRadius: 3,
1411
- backgroundColor: 'rgba(255, 255, 255, 0.2)',
1412
- color: '#FFFFFF',
1413
- cursor: 'pointer',
1414
- padding: 0,
1415
- }}
1416
- >
1417
- <Type size={10} />
1418
- </button>
1419
- )}
1420
- </div>
1421
-
1422
- {/* Live HUD Readout Pill (Dimensions / Radius / Move Feedback) */}
1423
- <span
1424
- className="tabular-nums"
1425
- style={{
1426
- position: 'absolute',
1427
- ...(toolbarAbove ? { bottom: -20 } : { top: -20 }),
1428
- right: -1,
1429
- backgroundColor: '#0D0F14',
1430
- border: '1px solid rgba(255, 255, 255, 0.15)',
1431
- color: '#38BDF8',
1432
- fontSize: 9.5,
1433
- fontWeight: 600,
1434
- fontFamily: 'var(--font-mono)',
1435
- padding: '1px 5px',
1436
- borderRadius: 3,
1437
- whiteSpace: 'nowrap',
1438
- pointerEvents: 'none',
1439
- boxShadow: '0 2px 6px rgba(0, 0, 0, 0.5)',
1440
- }}
1441
- >
1442
- {liveHudBadge ??
1443
- `${Math.round(selectedBox.width)} × ${Math.round(selectedBox.height)}`}
1444
- </span>
1445
-
1446
- {/* Inline Double-Click On-Canvas <Text> Editor */}
1447
- {isEditingInlineText && (
1448
- <textarea
1449
- ref={inlineInputRef}
1450
- value={inlineTextDraft}
1451
- onChange={(e) => setInlineTextDraft(e.target.value)}
1452
- onBlur={commitInlineTextEdit}
1453
- onClick={(e) => e.stopPropagation()}
1454
- onPointerDown={(e) => e.stopPropagation()}
1455
- onKeyDown={(e) => {
1456
- e.stopPropagation();
1457
- if (e.key === 'Enter' && !e.shiftKey) {
1458
- e.preventDefault();
1459
- commitInlineTextEdit();
1460
- } else if (e.key === 'Escape') {
1461
- e.preventDefault();
1462
- setIsEditingInlineText(false);
1463
- }
1464
- }}
1465
- style={{
1466
- position: 'absolute',
1467
- inset: -2,
1468
- width: Math.max(selectedBox.width + 8, 90),
1469
- height: Math.max(selectedBox.height + 6, 28),
1470
- backgroundColor: 'rgba(13, 15, 20, 0.96)',
1471
- color: String(activeInspected?.styles?.color ?? '#F8FAFC'),
1472
- fontSize: Number(activeInspected?.styles?.fontSize ?? 14),
1473
- fontWeight: String(activeInspected?.styles?.fontWeight ?? '500'),
1474
- fontFamily: 'var(--font-sans)',
1475
- border: '1.5px solid #38BDF8',
1476
- borderRadius: 4,
1477
- padding: '2px 5px',
1478
- outline: 'none',
1479
- resize: 'none',
1480
- pointerEvents: 'auto',
1481
- zIndex: 60,
1482
- boxShadow: '0 8px 24px rgba(0, 0, 0, 0.65)',
1483
- }}
1484
- />
1485
- )}
1486
- </div>
1487
- )}
1488
- </div>
1489
- </div>
1490
- );
1491
- });