zelune 1.4.0 → 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.
- package/CHANGELOG.md +24 -0
- package/bin/cli.mjs +215 -31
- package/mcp/server.ts +57 -1
- package/package.json +1 -1
- package/src/canvas/CanvasApp.tsx +89 -4
- package/src/canvas/ScreenArtboard.tsx +1008 -43
- package/src/server/apiMiddleware.ts +185 -12
- package/src/server/astEngine.ts +428 -19
- package/vite.config.ts +91 -24
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
GripHorizontal,
|
|
4
|
+
Maximize2,
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
ArrowUp,
|
|
7
|
+
ArrowDown,
|
|
8
|
+
Pin,
|
|
9
|
+
PinOff,
|
|
10
|
+
Type,
|
|
11
|
+
Move,
|
|
12
|
+
} from 'lucide-react';
|
|
3
13
|
import { MockNavigationProvider } from '../runtime/MockNavigationContext';
|
|
4
14
|
import { ScreenErrorBoundary } from '../runtime/ScreenErrorBoundary';
|
|
5
15
|
import type {
|
|
6
16
|
ArtboardPosition,
|
|
7
17
|
CanvasMode,
|
|
18
|
+
InspectedElement,
|
|
8
19
|
ScreenMetadata,
|
|
9
20
|
SelectedElementRef,
|
|
10
21
|
} from './types';
|
|
22
|
+
import type { PatchPayload, ReorderPayload } from '../server/astEngine';
|
|
11
23
|
|
|
12
24
|
interface ScreenArtboardProps {
|
|
13
25
|
screen: ScreenMetadata;
|
|
@@ -18,11 +30,14 @@ interface ScreenArtboardProps {
|
|
|
18
30
|
isActiveScreen: boolean;
|
|
19
31
|
isHighlightedScreen: boolean;
|
|
20
32
|
selectedElement: SelectedElementRef | null;
|
|
33
|
+
inspectedElement: InspectedElement | null;
|
|
21
34
|
onSelectElement: (ref: SelectedElementRef) => void;
|
|
22
35
|
onSelectScreen: (screenId: string) => void;
|
|
23
36
|
onFocusScreen: (screenId: string) => void;
|
|
24
37
|
onMoveArtboard: (screenId: string, nextX: number, nextY: number) => void;
|
|
25
38
|
onNavigateFlow: (fromScreen: string, toScreen: string) => void;
|
|
39
|
+
onPatchElement: (payload: PatchPayload) => Promise<void>;
|
|
40
|
+
onReorderElement: (payload: ReorderPayload) => Promise<void>;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
interface BoxOverlay {
|
|
@@ -34,6 +49,19 @@ interface BoxOverlay {
|
|
|
34
49
|
astPath: string;
|
|
35
50
|
}
|
|
36
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
|
+
|
|
37
65
|
function findNearestAgyElement(
|
|
38
66
|
target: EventTarget | null,
|
|
39
67
|
container: HTMLElement | null
|
|
@@ -49,6 +77,12 @@ function findNearestAgyElement(
|
|
|
49
77
|
return null;
|
|
50
78
|
}
|
|
51
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
|
+
|
|
52
86
|
export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
53
87
|
screen,
|
|
54
88
|
ScreenComponent,
|
|
@@ -58,17 +92,54 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
58
92
|
isActiveScreen,
|
|
59
93
|
isHighlightedScreen,
|
|
60
94
|
selectedElement,
|
|
95
|
+
inspectedElement,
|
|
61
96
|
onSelectElement,
|
|
62
97
|
onSelectScreen,
|
|
63
98
|
onFocusScreen,
|
|
64
99
|
onMoveArtboard,
|
|
65
100
|
onNavigateFlow,
|
|
101
|
+
onPatchElement,
|
|
102
|
+
onReorderElement,
|
|
66
103
|
}) => {
|
|
67
104
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
105
|
+
const inlineInputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
106
|
+
|
|
68
107
|
const [hoverBox, setHoverBox] = useState<BoxOverlay | null>(null);
|
|
69
108
|
const [selectedBox, setSelectedBox] = useState<BoxOverlay | null>(null);
|
|
70
109
|
const [isDraggingHeader, setIsDraggingHeader] = useState(false);
|
|
71
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
|
+
|
|
72
143
|
const computeRelativeBox = useCallback(
|
|
73
144
|
(domEl: HTMLElement): BoxOverlay | null => {
|
|
74
145
|
const container = viewportRef.current;
|
|
@@ -90,20 +161,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
90
161
|
|
|
91
162
|
// Keep selected element bounding box updated when screen re-renders or styles change
|
|
92
163
|
useEffect(() => {
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
!container ||
|
|
96
|
-
!selectedElement ||
|
|
97
|
-
selectedElement.screenId !== screen.id
|
|
98
|
-
) {
|
|
164
|
+
setIsEditingInlineText(false);
|
|
165
|
+
if (!isSelectedOnThisScreen) {
|
|
99
166
|
setSelectedBox(null);
|
|
100
167
|
return;
|
|
101
168
|
}
|
|
102
169
|
|
|
103
170
|
const updateSelectionOverlay = () => {
|
|
104
|
-
const match =
|
|
105
|
-
`[data-agy-ast-path="${selectedElement.astPath}"]`
|
|
106
|
-
);
|
|
171
|
+
const match = getSelectedDomNode();
|
|
107
172
|
if (match) {
|
|
108
173
|
setSelectedBox(computeRelativeBox(match));
|
|
109
174
|
} else {
|
|
@@ -114,7 +179,44 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
114
179
|
updateSelectionOverlay();
|
|
115
180
|
const timer = window.setTimeout(updateSelectionOverlay, 90);
|
|
116
181
|
return () => window.clearTimeout(timer);
|
|
117
|
-
}, [
|
|
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]);
|
|
118
220
|
|
|
119
221
|
// Artboard header drag handler to reposition the screen on the infinite canvas
|
|
120
222
|
const handleHeaderPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
@@ -148,9 +250,451 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
148
250
|
window.addEventListener('pointerup', handlePointerUp);
|
|
149
251
|
};
|
|
150
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
|
+
|
|
151
695
|
// Inspect Mode pointer handlers
|
|
152
696
|
const handlePointerMoveCapture = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
153
|
-
if (mode !== 'inspect') return;
|
|
697
|
+
if (mode !== 'inspect' || activeGizmoAction) return;
|
|
154
698
|
const targetEl = findNearestAgyElement(e.target, viewportRef.current);
|
|
155
699
|
if (!targetEl) {
|
|
156
700
|
setHoverBox(null);
|
|
@@ -163,8 +707,23 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
163
707
|
setHoverBox(null);
|
|
164
708
|
};
|
|
165
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
|
+
|
|
166
725
|
const handleClickCapture = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
167
|
-
if (mode !== 'inspect') return;
|
|
726
|
+
if (mode !== 'inspect' || isEditingInlineText) return;
|
|
168
727
|
e.stopPropagation();
|
|
169
728
|
e.preventDefault();
|
|
170
729
|
|
|
@@ -189,12 +748,26 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
189
748
|
});
|
|
190
749
|
};
|
|
191
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
|
+
|
|
192
763
|
const borderColor = isHighlightedScreen
|
|
193
764
|
? '#10B981'
|
|
194
765
|
: isActiveScreen
|
|
195
766
|
? '#38BDF8'
|
|
196
767
|
: 'rgba(255, 255, 255, 0.09)';
|
|
197
768
|
|
|
769
|
+
const toolbarAbove = !selectedBox || selectedBox.top >= 30;
|
|
770
|
+
|
|
198
771
|
return (
|
|
199
772
|
<div
|
|
200
773
|
style={{
|
|
@@ -203,6 +776,7 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
203
776
|
top: position.y,
|
|
204
777
|
width: position.width,
|
|
205
778
|
userSelect: 'none',
|
|
779
|
+
WebkitUserSelect: 'none',
|
|
206
780
|
zIndex: isActiveScreen || isHighlightedScreen ? 20 : 10,
|
|
207
781
|
}}
|
|
208
782
|
onPointerDown={(e) => {
|
|
@@ -285,12 +859,14 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
285
859
|
</div>
|
|
286
860
|
</div>
|
|
287
861
|
|
|
288
|
-
{/* Pure Rectangular Screen Artboard
|
|
862
|
+
{/* Pure Rectangular Screen Artboard */}
|
|
289
863
|
<div
|
|
290
864
|
ref={viewportRef}
|
|
865
|
+
onPointerDownCapture={handleViewportPointerDownCapture}
|
|
291
866
|
onPointerMoveCapture={handlePointerMoveCapture}
|
|
292
867
|
onPointerLeave={handlePointerLeave}
|
|
293
868
|
onClickCapture={handleClickCapture}
|
|
869
|
+
onDoubleClickCapture={handleDoubleClickCapture}
|
|
294
870
|
style={{
|
|
295
871
|
position: 'relative',
|
|
296
872
|
width: position.width,
|
|
@@ -304,7 +880,8 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
304
880
|
: isActiveScreen
|
|
305
881
|
? '0 0 0 2px rgba(56, 189, 248, 0.22), 0 28px 56px -14px rgba(0, 0, 0, 0.78)'
|
|
306
882
|
: '0 22px 44px -15px rgba(0, 0, 0, 0.68)',
|
|
307
|
-
transition:
|
|
883
|
+
transition:
|
|
884
|
+
'border-color var(--duration-micro) var(--ease-out), box-shadow var(--duration-micro) var(--ease-out)',
|
|
308
885
|
display: 'flex',
|
|
309
886
|
flexDirection: 'column',
|
|
310
887
|
cursor: mode === 'inspect' ? 'crosshair' : 'default',
|
|
@@ -324,7 +901,16 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
324
901
|
fontFamily: 'var(--font-mono)',
|
|
325
902
|
}}
|
|
326
903
|
>
|
|
327
|
-
<div
|
|
904
|
+
<div
|
|
905
|
+
style={{
|
|
906
|
+
display: 'flex',
|
|
907
|
+
alignItems: 'center',
|
|
908
|
+
gap: 8,
|
|
909
|
+
color: '#F87171',
|
|
910
|
+
fontWeight: 700,
|
|
911
|
+
fontSize: 13,
|
|
912
|
+
}}
|
|
913
|
+
>
|
|
328
914
|
<AlertTriangle size={16} />
|
|
329
915
|
<span>AST Syntax Error in {screen.filePath}</span>
|
|
330
916
|
</div>
|
|
@@ -338,19 +924,93 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
338
924
|
routeName={screen.routeName}
|
|
339
925
|
onNavigate={onNavigateFlow}
|
|
340
926
|
>
|
|
341
|
-
<div
|
|
927
|
+
<div
|
|
928
|
+
style={{
|
|
929
|
+
flex: 1,
|
|
930
|
+
display: 'flex',
|
|
931
|
+
flexDirection: 'column',
|
|
932
|
+
height: '100%',
|
|
933
|
+
width: '100%',
|
|
934
|
+
}}
|
|
935
|
+
>
|
|
342
936
|
<ScreenComponent />
|
|
343
937
|
</div>
|
|
344
938
|
</MockNavigationProvider>
|
|
345
939
|
</ScreenErrorBoundary>
|
|
346
940
|
) : (
|
|
347
|
-
<div
|
|
941
|
+
<div
|
|
942
|
+
style={{
|
|
943
|
+
flex: 1,
|
|
944
|
+
display: 'flex',
|
|
945
|
+
alignItems: 'center',
|
|
946
|
+
justifyContent: 'center',
|
|
947
|
+
color: '#64748B',
|
|
948
|
+
fontSize: 13,
|
|
949
|
+
}}
|
|
950
|
+
>
|
|
348
951
|
Mounting {screen.name}...
|
|
349
952
|
</div>
|
|
350
953
|
)}
|
|
351
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
|
+
|
|
352
1011
|
{/* Figma-style Hover Bounding Box in Inspect Mode */}
|
|
353
1012
|
{mode === 'inspect' &&
|
|
1013
|
+
!activeGizmoAction &&
|
|
354
1014
|
hoverBox &&
|
|
355
1015
|
(!selectedBox || hoverBox.astPath !== selectedBox.astPath) && (
|
|
356
1016
|
<div
|
|
@@ -388,8 +1048,8 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
388
1048
|
</div>
|
|
389
1049
|
)}
|
|
390
1050
|
|
|
391
|
-
{/*
|
|
392
|
-
{selectedBox &&
|
|
1051
|
+
{/* Interactive Direct-Manipulation Selection Gizmo */}
|
|
1052
|
+
{mode === 'inspect' && selectedBox && isSelectedOnThisScreen && selectedElement && (
|
|
393
1053
|
<div
|
|
394
1054
|
className="zelune-box-overlay"
|
|
395
1055
|
style={{
|
|
@@ -398,51 +1058,356 @@ export const ScreenArtboard: React.FC<ScreenArtboardProps> = ({
|
|
|
398
1058
|
left: selectedBox.left,
|
|
399
1059
|
width: selectedBox.width,
|
|
400
1060
|
height: selectedBox.height,
|
|
401
|
-
border:
|
|
402
|
-
backgroundColor:
|
|
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)',
|
|
403
1065
|
pointerEvents: 'none',
|
|
404
1066
|
zIndex: 45,
|
|
405
1067
|
}}
|
|
406
1068
|
>
|
|
407
|
-
{/*
|
|
408
|
-
{
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 }) => (
|
|
414
1126
|
<span
|
|
415
|
-
key={
|
|
1127
|
+
key={dir}
|
|
1128
|
+
title={`Drag to resize (${dir.toUpperCase()})`}
|
|
1129
|
+
onPointerDown={(e) => handleResizePointerDown(e, dir)}
|
|
1130
|
+
onClick={(e) => e.stopPropagation()}
|
|
416
1131
|
style={{
|
|
417
1132
|
position: 'absolute',
|
|
418
|
-
...
|
|
419
|
-
width: 6,
|
|
420
|
-
height: 6,
|
|
1133
|
+
...style,
|
|
1134
|
+
width: dir.length === 2 ? 8 : 6,
|
|
1135
|
+
height: dir.length === 2 ? 8 : 6,
|
|
421
1136
|
backgroundColor: '#FFFFFF',
|
|
422
|
-
border:
|
|
423
|
-
borderRadius: 1,
|
|
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)',
|
|
424
1144
|
}}
|
|
425
1145
|
/>
|
|
426
1146
|
))}
|
|
427
|
-
|
|
428
|
-
|
|
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()}
|
|
429
1186
|
style={{
|
|
430
1187
|
position: 'absolute',
|
|
431
|
-
top: -
|
|
432
|
-
left: -1,
|
|
433
|
-
|
|
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',
|
|
434
1194
|
color: '#FFFFFF',
|
|
435
1195
|
fontSize: 10,
|
|
436
1196
|
fontWeight: 700,
|
|
437
1197
|
fontFamily: 'var(--font-mono)',
|
|
438
|
-
padding: '2px 6px',
|
|
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
|
+
<{selectedBox.tagName}> · 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',
|
|
439
1361
|
borderRadius: 3,
|
|
440
1362
|
whiteSpace: 'nowrap',
|
|
441
|
-
|
|
1363
|
+
pointerEvents: 'none',
|
|
1364
|
+
boxShadow: '0 2px 6px rgba(0, 0, 0, 0.5)',
|
|
442
1365
|
}}
|
|
443
1366
|
>
|
|
444
|
-
|
|
1367
|
+
{liveHudBadge ??
|
|
1368
|
+
`${Math.round(selectedBox.width)} × ${Math.round(selectedBox.height)}`}
|
|
445
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
|
+
)}
|
|
446
1411
|
</div>
|
|
447
1412
|
)}
|
|
448
1413
|
</div>
|