zelune 0.0.0-stage → 1.5.1

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