@agent-native/core 0.80.9 → 0.80.10
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +7 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
- package/corpus/templates/design/actions/apply-design-state.ts +200 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +242 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +72 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +237 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +421 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
- package/corpus/templates/design/app/i18n-data.ts +269 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/helpers.ts +10 -4
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1016 -71
- package/corpus/templates/design/shared/component-model.ts +239 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/motion-compiler.ts +278 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +37 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +7 -7
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type PenNode,
|
|
33
33
|
type PenPath,
|
|
34
34
|
} from "@shared/pen-path";
|
|
35
|
-
import { IconCopy, IconMaximize } from "@tabler/icons-react";
|
|
35
|
+
import { IconCopy, IconMaximize, IconPlus } from "@tabler/icons-react";
|
|
36
36
|
import {
|
|
37
37
|
memo,
|
|
38
38
|
useRef,
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
import { prettyScreenName } from "@/lib/screen-names";
|
|
48
48
|
import { cn } from "@/lib/utils";
|
|
49
49
|
|
|
50
|
+
import { canvasPrimitiveReactStyle } from "./canvas-primitive-style";
|
|
50
51
|
import { DEVICE_FRAME_VIEWPORTS, type DeviceFrameType } from "./types";
|
|
51
52
|
|
|
52
53
|
interface ScreenFile {
|
|
@@ -64,6 +65,14 @@ interface ScreenFile {
|
|
|
64
65
|
height?: number;
|
|
65
66
|
url?: string;
|
|
66
67
|
previewUrl?: string;
|
|
68
|
+
/**
|
|
69
|
+
* When set, renders multiple side-by-side breakpoint frames (mobile-first,
|
|
70
|
+
* §6.4). Each entry is a pixel width; the active breakpoint determines the
|
|
71
|
+
* edit scope (Tailwind prefix: base / md: / lg: / xl:).
|
|
72
|
+
*/
|
|
73
|
+
breakpointWidths?: number[];
|
|
74
|
+
/** Id of the currently active breakpoint frame for this screen. */
|
|
75
|
+
activeBreakpointWidth?: number;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
type ScreenSourceType = "localhost" | "fusion" | "inline";
|
|
@@ -135,6 +144,7 @@ interface MultiScreenCanvasProps {
|
|
|
135
144
|
zoom: number;
|
|
136
145
|
activeId?: string | null;
|
|
137
146
|
selectedScreenIds?: string[];
|
|
147
|
+
fullViewScreenIds?: string[];
|
|
138
148
|
activeScreenHasHoveredChild?: boolean;
|
|
139
149
|
hoveredChildScreenId?: string | null;
|
|
140
150
|
directlyHoveredScreenId?: string | null;
|
|
@@ -158,6 +168,13 @@ interface MultiScreenCanvasProps {
|
|
|
158
168
|
primitive: CanvasPrimitiveInsert,
|
|
159
169
|
) => boolean | string;
|
|
160
170
|
onPrimitiveCreated?: (screenId: string, nodeId: string) => void;
|
|
171
|
+
onPrimitiveReparent?: (args: {
|
|
172
|
+
sourceNodeId: string;
|
|
173
|
+
sourceScreenId: string;
|
|
174
|
+
targetNodeId: string;
|
|
175
|
+
targetScreenId: string;
|
|
176
|
+
placement: "inside";
|
|
177
|
+
}) => void;
|
|
161
178
|
onCreateScreenFrame?: (geometry: FrameGeometry) => void;
|
|
162
179
|
onDeleteSelection?: (ids: string[]) => boolean | void;
|
|
163
180
|
onZoomChange?: (zoom: number) => void;
|
|
@@ -169,7 +186,33 @@ interface MultiScreenCanvasProps {
|
|
|
169
186
|
onScreenSelectionChange?: (ids: string[]) => void;
|
|
170
187
|
selectAllRequest?: number;
|
|
171
188
|
clearSelectionRequest?: number;
|
|
189
|
+
/**
|
|
190
|
+
* Called when the user clicks the + affordance on a screen's breakpoint
|
|
191
|
+
* row to add the next standard breakpoint width (390 / 768 / 1280).
|
|
192
|
+
*/
|
|
193
|
+
onAddBreakpoint?: (screenId: string, widthPx: number) => void;
|
|
194
|
+
/**
|
|
195
|
+
* Called when the user clicks a breakpoint frame header to make it the
|
|
196
|
+
* active edit scope.
|
|
197
|
+
*/
|
|
198
|
+
onActiveBreakpointChange?: (
|
|
199
|
+
screenId: string,
|
|
200
|
+
widthPx: number | undefined,
|
|
201
|
+
) => void;
|
|
172
202
|
onSelectionChange?: (selectedIds: string[]) => void;
|
|
203
|
+
/**
|
|
204
|
+
* Called when the user drags an element out of the active screen's iframe
|
|
205
|
+
* and drops it onto a different screen. The bridge in the source iframe
|
|
206
|
+
* posts { type:"agent-native:cross-screen-drag" } messages; the host
|
|
207
|
+
* translates them to board coords, finds the target frame, and calls this
|
|
208
|
+
* prop with the resolved ids.
|
|
209
|
+
*/
|
|
210
|
+
onCrossScreenElementDrop?: (args: {
|
|
211
|
+
sourceSelector: string;
|
|
212
|
+
sourceNodeId?: string;
|
|
213
|
+
sourceScreenId: string;
|
|
214
|
+
targetScreenId: string;
|
|
215
|
+
}) => void;
|
|
173
216
|
}
|
|
174
217
|
|
|
175
218
|
/**
|
|
@@ -482,6 +525,7 @@ export function MultiScreenCanvas({
|
|
|
482
525
|
zoom,
|
|
483
526
|
activeId,
|
|
484
527
|
selectedScreenIds,
|
|
528
|
+
fullViewScreenIds,
|
|
485
529
|
activeScreenHasHoveredChild = false,
|
|
486
530
|
hoveredChildScreenId,
|
|
487
531
|
directlyHoveredScreenId,
|
|
@@ -499,6 +543,7 @@ export function MultiScreenCanvas({
|
|
|
499
543
|
onGeometryCommit,
|
|
500
544
|
onCreatePrimitive,
|
|
501
545
|
onPrimitiveCreated,
|
|
546
|
+
onPrimitiveReparent,
|
|
502
547
|
onCreateScreenFrame,
|
|
503
548
|
onDeleteSelection,
|
|
504
549
|
onZoomChange,
|
|
@@ -506,7 +551,10 @@ export function MultiScreenCanvas({
|
|
|
506
551
|
onScreenSelectionChange,
|
|
507
552
|
selectAllRequest,
|
|
508
553
|
clearSelectionRequest,
|
|
554
|
+
onAddBreakpoint,
|
|
555
|
+
onActiveBreakpointChange,
|
|
509
556
|
onSelectionChange,
|
|
557
|
+
onCrossScreenElementDrop,
|
|
510
558
|
}: MultiScreenCanvasProps) {
|
|
511
559
|
const t = useT();
|
|
512
560
|
const surfaceRef = useRef<HTMLDivElement>(null);
|
|
@@ -518,6 +566,7 @@ export function MultiScreenCanvas({
|
|
|
518
566
|
const frameGeometryRef = useRef(frameGeometry);
|
|
519
567
|
const onGeometryChangeRef = useRef(onGeometryChange);
|
|
520
568
|
const onGeometryCommitRef = useRef(onGeometryCommit);
|
|
569
|
+
const screensRef = useRef(screens);
|
|
521
570
|
const [draftPrimitives, setDraftPrimitives] = useState<DraftPrimitive[]>([]);
|
|
522
571
|
const draftPrimitivesRef = useRef(draftPrimitives);
|
|
523
572
|
const [selectedDraftIds, setSelectedDraftIds] = useState<string[]>([]);
|
|
@@ -552,6 +601,34 @@ export function MultiScreenCanvas({
|
|
|
552
601
|
null,
|
|
553
602
|
);
|
|
554
603
|
const [dragCursor, setDragCursor] = useState<string | null>(null);
|
|
604
|
+
const [primitiveDropTarget, setPrimitiveDropTarget] =
|
|
605
|
+
useState<PrimitiveDropTarget | null>(null);
|
|
606
|
+
const primitiveDropTargetRef = useRef<PrimitiveDropTarget | null>(null);
|
|
607
|
+
const onPrimitiveReparentRef = useRef(onPrimitiveReparent);
|
|
608
|
+
|
|
609
|
+
// Cross-screen element drag state — driven by postMessage from the source iframe.
|
|
610
|
+
interface CrossScreenDragGhost {
|
|
611
|
+
/** Board-space point where the ghost is shown (follows the cursor). */
|
|
612
|
+
boardX: number;
|
|
613
|
+
boardY: number;
|
|
614
|
+
}
|
|
615
|
+
interface CrossScreenDragTarget {
|
|
616
|
+
/** The screen frame that is the candidate drop target. */
|
|
617
|
+
id: string;
|
|
618
|
+
geometry: FrameGeometry;
|
|
619
|
+
}
|
|
620
|
+
const [crossScreenGhost, setCrossScreenGhost] =
|
|
621
|
+
useState<CrossScreenDragGhost | null>(null);
|
|
622
|
+
const [crossScreenTarget, setCrossScreenTarget] =
|
|
623
|
+
useState<CrossScreenDragTarget | null>(null);
|
|
624
|
+
/** Ref kept in sync with state so the message handler can read without closures. */
|
|
625
|
+
const crossScreenTargetRef = useRef<CrossScreenDragTarget | null>(null);
|
|
626
|
+
/** The most-recent drag message payload — kept for use in the "end" handler. */
|
|
627
|
+
const crossScreenDragMsgRef = useRef<{
|
|
628
|
+
selector: string;
|
|
629
|
+
sourceId?: string;
|
|
630
|
+
} | null>(null);
|
|
631
|
+
const onCrossScreenElementDropRef = useRef(onCrossScreenElementDrop);
|
|
555
632
|
const suppressNextPick = useRef(false);
|
|
556
633
|
const feedbackTimerRef = useRef<number | null>(null);
|
|
557
634
|
const pendingWheelGestureRef = useRef<PendingWheelGesture | null>(null);
|
|
@@ -583,6 +660,18 @@ export function MultiScreenCanvas({
|
|
|
583
660
|
onGeometryCommitRef.current = onGeometryCommit;
|
|
584
661
|
}, [onGeometryCommit]);
|
|
585
662
|
|
|
663
|
+
useEffect(() => {
|
|
664
|
+
onPrimitiveReparentRef.current = onPrimitiveReparent;
|
|
665
|
+
}, [onPrimitiveReparent]);
|
|
666
|
+
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
onCrossScreenElementDropRef.current = onCrossScreenElementDrop;
|
|
669
|
+
}, [onCrossScreenElementDrop]);
|
|
670
|
+
|
|
671
|
+
useEffect(() => {
|
|
672
|
+
screensRef.current = screens;
|
|
673
|
+
}, [screens]);
|
|
674
|
+
|
|
586
675
|
useEffect(() => {
|
|
587
676
|
activePenPathRef.current = activePenPath;
|
|
588
677
|
}, [activePenPath]);
|
|
@@ -734,15 +823,13 @@ export function MultiScreenCanvas({
|
|
|
734
823
|
const metadata = getResolvedMetadata(screen);
|
|
735
824
|
const currentGeometry =
|
|
736
825
|
current[screen.id] ?? getInitialFrameGeometry(index, metadata);
|
|
737
|
-
const
|
|
738
|
-
currentGeometry
|
|
826
|
+
const nextGeometry = getPreviewDeviceFrameGeometry({
|
|
827
|
+
currentGeometry,
|
|
739
828
|
metadata,
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
height: nextHeight,
|
|
745
|
-
};
|
|
829
|
+
previewDeviceFrame,
|
|
830
|
+
});
|
|
831
|
+
if (sameFrameGeometry(currentGeometry, nextGeometry)) return;
|
|
832
|
+
next[screen.id] = nextGeometry;
|
|
746
833
|
changed = true;
|
|
747
834
|
});
|
|
748
835
|
|
|
@@ -787,9 +874,17 @@ export function MultiScreenCanvas({
|
|
|
787
874
|
if (!surfaceRef.current || screens.length === 0) return;
|
|
788
875
|
const rect = surfaceRef.current.getBoundingClientRect();
|
|
789
876
|
const scale = zoomRef.current / 100;
|
|
790
|
-
const frames = screens.map((screen, index) =>
|
|
791
|
-
|
|
792
|
-
|
|
877
|
+
const frames = screens.map((screen, index) => {
|
|
878
|
+
const metadata = getResolvedMetadata(screen);
|
|
879
|
+
const currentGeometry =
|
|
880
|
+
frameGeometryRef.current[screen.id] ??
|
|
881
|
+
getInitialFrameGeometry(index, metadata);
|
|
882
|
+
return getPreviewDeviceFrameGeometry({
|
|
883
|
+
currentGeometry,
|
|
884
|
+
metadata,
|
|
885
|
+
previewDeviceFrame,
|
|
886
|
+
});
|
|
887
|
+
});
|
|
793
888
|
const bounds = getFrameGroupBounds(
|
|
794
889
|
frames.map((geometry, index) => ({
|
|
795
890
|
id: screens[index]?.id ?? String(index),
|
|
@@ -908,6 +1003,155 @@ export function MultiScreenCanvas({
|
|
|
908
1003
|
[getCurrentFrameEntries],
|
|
909
1004
|
);
|
|
910
1005
|
|
|
1006
|
+
// ── Cross-screen element drag receiver ────────────────────────────────────
|
|
1007
|
+
// The source iframe (the active interactive screen) posts
|
|
1008
|
+
// { type: "agent-native:cross-screen-drag", phase, selector, sourceId,
|
|
1009
|
+
// iframeX, iframeY, viewportW, viewportH }
|
|
1010
|
+
// during element drags that the bridge wants the host to handle.
|
|
1011
|
+
useEffect(() => {
|
|
1012
|
+
if (!onCrossScreenElementDrop) return;
|
|
1013
|
+
|
|
1014
|
+
const clearCrossScreenDrag = () => {
|
|
1015
|
+
setCrossScreenGhost(null);
|
|
1016
|
+
setCrossScreenTarget(null);
|
|
1017
|
+
crossScreenTargetRef.current = null;
|
|
1018
|
+
crossScreenDragMsgRef.current = null;
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
const handleMessage = (event: MessageEvent) => {
|
|
1022
|
+
if (!event.data || event.data.type !== "agent-native:cross-screen-drag") {
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
const msg = event.data as {
|
|
1026
|
+
type: string;
|
|
1027
|
+
phase: "move" | "end" | "cancel";
|
|
1028
|
+
selector?: string;
|
|
1029
|
+
sourceId?: string;
|
|
1030
|
+
iframeX?: number;
|
|
1031
|
+
iframeY?: number;
|
|
1032
|
+
viewportW?: number;
|
|
1033
|
+
viewportH?: number;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
if (msg.phase === "cancel") {
|
|
1037
|
+
clearCrossScreenDrag();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// The drag originates from the active screen (only the active screen has
|
|
1042
|
+
// a live interactive iframe posting these messages).
|
|
1043
|
+
const sourceScreenId = activeId;
|
|
1044
|
+
if (!sourceScreenId) {
|
|
1045
|
+
// Always clear visual state (ghost + highlight) when we have no active
|
|
1046
|
+
// screen to attribute the drag to — regardless of the phase. Without
|
|
1047
|
+
// this, a "move" message arriving after activeId became null would leave
|
|
1048
|
+
// stale ghost/target state visible on the canvas.
|
|
1049
|
+
clearCrossScreenDrag();
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
if (msg.phase === "move") {
|
|
1054
|
+
const { iframeX, iframeY, viewportW, viewportH, selector, sourceId } =
|
|
1055
|
+
msg;
|
|
1056
|
+
if (
|
|
1057
|
+
iframeX === undefined ||
|
|
1058
|
+
iframeY === undefined ||
|
|
1059
|
+
viewportW === undefined ||
|
|
1060
|
+
viewportH === undefined
|
|
1061
|
+
) {
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Remember the latest drag payload for use on "end".
|
|
1066
|
+
crossScreenDragMsgRef.current = {
|
|
1067
|
+
selector: selector ?? "",
|
|
1068
|
+
sourceId,
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// Pointer is inside the source iframe — let the bridge handle it.
|
|
1072
|
+
if (
|
|
1073
|
+
iframeX >= 0 &&
|
|
1074
|
+
iframeY >= 0 &&
|
|
1075
|
+
iframeX <= viewportW &&
|
|
1076
|
+
iframeY <= viewportH
|
|
1077
|
+
) {
|
|
1078
|
+
clearCrossScreenDrag();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Translate iframe coords → board coords using source frame geometry.
|
|
1083
|
+
// Board math mirrors primitiveLocalToBoardRect:
|
|
1084
|
+
// boardX = frame.x + iframeX * (frame.width / metadata.width)
|
|
1085
|
+
// boardY = frame.y + iframeY * (frame.height / metadata.height)
|
|
1086
|
+
const sourceScreen = screensRef.current.find(
|
|
1087
|
+
(s) => s.id === sourceScreenId,
|
|
1088
|
+
);
|
|
1089
|
+
const sourceGeometry = frameGeometryRef.current[sourceScreenId];
|
|
1090
|
+
if (!sourceScreen || !sourceGeometry) {
|
|
1091
|
+
clearCrossScreenDrag();
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const sourceMetadata = getResolvedMetadata(sourceScreen);
|
|
1095
|
+
const scaleX = sourceGeometry.width / Math.max(1, sourceMetadata.width);
|
|
1096
|
+
const scaleY =
|
|
1097
|
+
sourceGeometry.height / Math.max(1, sourceMetadata.height);
|
|
1098
|
+
const boardX = sourceGeometry.x + iframeX * scaleX;
|
|
1099
|
+
const boardY = sourceGeometry.y + iframeY * scaleY;
|
|
1100
|
+
const boardPoint = { x: boardX, y: boardY };
|
|
1101
|
+
|
|
1102
|
+
setCrossScreenGhost({ boardX, boardY });
|
|
1103
|
+
|
|
1104
|
+
// Find which screen frame the board point falls in.
|
|
1105
|
+
const target = getFrameEntryAtPoint(boardPoint);
|
|
1106
|
+
if (target && target.id !== sourceScreenId) {
|
|
1107
|
+
const nextTarget = { id: target.id, geometry: target.geometry };
|
|
1108
|
+
crossScreenTargetRef.current = nextTarget;
|
|
1109
|
+
setCrossScreenTarget(nextTarget);
|
|
1110
|
+
} else {
|
|
1111
|
+
crossScreenTargetRef.current = null;
|
|
1112
|
+
setCrossScreenTarget(null);
|
|
1113
|
+
}
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (msg.phase === "end") {
|
|
1118
|
+
const candidate = crossScreenTargetRef.current;
|
|
1119
|
+
// Use the saved payload from the last "move" as the primary source of
|
|
1120
|
+
// truth; fall back to the "end" message's own fields in case the ref
|
|
1121
|
+
// was cleared (e.g. a brief re-entry into the source iframe nulled it
|
|
1122
|
+
// via clearCrossScreenDrag while pointerOutsideIframe remained true).
|
|
1123
|
+
const payload = crossScreenDragMsgRef.current ?? {
|
|
1124
|
+
selector: msg.selector ?? "",
|
|
1125
|
+
sourceId: msg.sourceId,
|
|
1126
|
+
};
|
|
1127
|
+
clearCrossScreenDrag();
|
|
1128
|
+
// Guard: require at least one stable identifier (selector or sourceId)
|
|
1129
|
+
// so the drop handler in DesignEditor can resolve the node. An empty
|
|
1130
|
+
// selector AND a missing sourceId would produce nodeAttrId="" and
|
|
1131
|
+
// moveNodeBetweenDocuments would fail with a generic error toast.
|
|
1132
|
+
const hasIdentifier = !!(payload.selector || payload.sourceId);
|
|
1133
|
+
if (candidate && hasIdentifier && sourceScreenId) {
|
|
1134
|
+
onCrossScreenElementDropRef.current?.({
|
|
1135
|
+
sourceSelector: payload.selector,
|
|
1136
|
+
sourceNodeId: payload.sourceId,
|
|
1137
|
+
sourceScreenId,
|
|
1138
|
+
targetScreenId: candidate.id,
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1144
|
+
window.addEventListener("message", handleMessage);
|
|
1145
|
+
return () => {
|
|
1146
|
+
window.removeEventListener("message", handleMessage);
|
|
1147
|
+
};
|
|
1148
|
+
}, [
|
|
1149
|
+
activeId,
|
|
1150
|
+
getFrameEntryAtPoint,
|
|
1151
|
+
getResolvedMetadata,
|
|
1152
|
+
onCrossScreenElementDrop,
|
|
1153
|
+
]);
|
|
1154
|
+
|
|
911
1155
|
const deleteSelectedItems = useCallback(() => {
|
|
912
1156
|
const frameIds = selectedIdsRef.current.filter(
|
|
913
1157
|
(id) => frameGeometryRef.current[id],
|
|
@@ -1036,6 +1280,31 @@ export function MultiScreenCanvas({
|
|
|
1036
1280
|
[],
|
|
1037
1281
|
);
|
|
1038
1282
|
|
|
1283
|
+
const updatePrimitiveDropTarget = useCallback(
|
|
1284
|
+
(target: PrimitiveDropTarget | null) => {
|
|
1285
|
+
primitiveDropTargetRef.current = target;
|
|
1286
|
+
setPrimitiveDropTarget(target);
|
|
1287
|
+
},
|
|
1288
|
+
[],
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
const findPrimitiveDropTarget = useCallback(
|
|
1292
|
+
(
|
|
1293
|
+
point: Point,
|
|
1294
|
+
draggedNodeId: string | null,
|
|
1295
|
+
): PrimitiveDropTarget | null => {
|
|
1296
|
+
if (!onPrimitiveReparentRef.current) return null;
|
|
1297
|
+
return getPrimitiveDropTargetForPoint(
|
|
1298
|
+
point,
|
|
1299
|
+
draggedNodeId,
|
|
1300
|
+
screensRef.current,
|
|
1301
|
+
frameGeometryRef.current,
|
|
1302
|
+
getResolvedMetadata,
|
|
1303
|
+
);
|
|
1304
|
+
},
|
|
1305
|
+
[getResolvedMetadata],
|
|
1306
|
+
);
|
|
1307
|
+
|
|
1039
1308
|
const finishDrag = useCallback(() => {
|
|
1040
1309
|
if (feedbackTimerRef.current !== null) {
|
|
1041
1310
|
window.clearTimeout(feedbackTimerRef.current);
|
|
@@ -1049,6 +1318,8 @@ export function MultiScreenCanvas({
|
|
|
1049
1318
|
setAlignmentGuides([]);
|
|
1050
1319
|
setTransformBadge(null);
|
|
1051
1320
|
setDragCursor(null);
|
|
1321
|
+
primitiveDropTargetRef.current = null;
|
|
1322
|
+
setPrimitiveDropTarget(null);
|
|
1052
1323
|
dragCleanup.current?.();
|
|
1053
1324
|
}, []);
|
|
1054
1325
|
|
|
@@ -1533,11 +1804,15 @@ export function MultiScreenCanvas({
|
|
|
1533
1804
|
|
|
1534
1805
|
const originCanvas = getCanvasPoint(e.clientX, e.clientY);
|
|
1535
1806
|
const originFrameId = getFrameEntryAtPoint(originCanvas)?.id;
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1807
|
+
// B1 fix: seed a zero-size preview at the click origin instead of
|
|
1808
|
+
// getDraftGeometryForTool(tool, origin, origin) which returns the
|
|
1809
|
+
// default 160x120 size and causes a visible flash before the drag delta
|
|
1810
|
+
// applies. The real size only matters at mouseup (createDraftPrimitive)
|
|
1811
|
+
// which already handles the "no drag" click-to-place case correctly.
|
|
1812
|
+
const initialGeometry =
|
|
1813
|
+
tool === "pen" || tool === "line" || tool === "arrow"
|
|
1814
|
+
? getDraftGeometryForTool(tool, originCanvas, originCanvas)
|
|
1815
|
+
: { x: originCanvas.x, y: originCanvas.y, width: 0, height: 0 };
|
|
1541
1816
|
const initialPoints =
|
|
1542
1817
|
tool === "pen"
|
|
1543
1818
|
? [originCanvas]
|
|
@@ -1773,40 +2048,94 @@ export function MultiScreenCanvas({
|
|
|
1773
2048
|
ev.clientX,
|
|
1774
2049
|
ev.clientY,
|
|
1775
2050
|
);
|
|
2051
|
+
|
|
2052
|
+
// Primitive drop-into-container detection: check if the dragged draft
|
|
2053
|
+
// is hovering over a committed container primitive on any screen.
|
|
2054
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
2055
|
+
const primitiveTarget = findPrimitiveDropTarget(canvasPoint, null);
|
|
2056
|
+
updatePrimitiveDropTarget(primitiveTarget);
|
|
1776
2057
|
};
|
|
1777
2058
|
|
|
1778
2059
|
const handleMouseUp = () => {
|
|
1779
2060
|
const state = dragState.current;
|
|
2061
|
+
const dropTarget = primitiveDropTargetRef.current;
|
|
1780
2062
|
if (state?.type === "draft-move" && state.hasMoved) {
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2063
|
+
if (dropTarget) {
|
|
2064
|
+
// Drop into a container primitive: persist the draft into the
|
|
2065
|
+
// target's screen, then call onPrimitiveReparent to nest it.
|
|
2066
|
+
const persisted: Array<{
|
|
2067
|
+
draftId: string;
|
|
2068
|
+
frameId: string;
|
|
2069
|
+
nodeId: string;
|
|
2070
|
+
}> = [];
|
|
2071
|
+
draftPrimitivesRef.current.forEach((draft) => {
|
|
2072
|
+
if (!state.targetIds.includes(draft.id)) return;
|
|
2073
|
+
// Persist into the target's screen (not just any containing frame)
|
|
2074
|
+
const result = persistDraftPrimitive(draft, dropTarget.screenId);
|
|
2075
|
+
if (result) {
|
|
2076
|
+
persisted.push({
|
|
2077
|
+
draftId: draft.id,
|
|
2078
|
+
frameId: result.frameId,
|
|
2079
|
+
nodeId: result.nodeId,
|
|
2080
|
+
});
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
|
|
2084
|
+
if (persisted.length > 0) {
|
|
2085
|
+
const persistedDraftIds = new Set(
|
|
2086
|
+
persisted.map((entry) => entry.draftId),
|
|
2087
|
+
);
|
|
2088
|
+
updateDraftPrimitives((current) =>
|
|
2089
|
+
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
2090
|
+
);
|
|
2091
|
+
updateSelectedDraftIds((current) =>
|
|
2092
|
+
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
2093
|
+
);
|
|
2094
|
+
// Reparent each persisted node into the container primitive.
|
|
2095
|
+
persisted.forEach((entry) => {
|
|
2096
|
+
onPrimitiveReparentRef.current?.({
|
|
2097
|
+
sourceNodeId: entry.nodeId,
|
|
2098
|
+
sourceScreenId: entry.frameId,
|
|
2099
|
+
targetNodeId: dropTarget.nodeId,
|
|
2100
|
+
targetScreenId: dropTarget.screenId,
|
|
2101
|
+
placement: "inside",
|
|
2102
|
+
});
|
|
1794
2103
|
});
|
|
2104
|
+
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
2105
|
+
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
2106
|
+
}
|
|
2107
|
+
} else {
|
|
2108
|
+
// Normal drop: persist into whichever screen contains the draft.
|
|
2109
|
+
const persisted: Array<{
|
|
2110
|
+
draftId: string;
|
|
2111
|
+
frameId: string;
|
|
2112
|
+
nodeId: string;
|
|
2113
|
+
}> = [];
|
|
2114
|
+
draftPrimitivesRef.current.forEach((draft) => {
|
|
2115
|
+
if (!state.targetIds.includes(draft.id)) return;
|
|
2116
|
+
const result = persistDraftPrimitive(draft);
|
|
2117
|
+
if (result) {
|
|
2118
|
+
persisted.push({
|
|
2119
|
+
draftId: draft.id,
|
|
2120
|
+
frameId: result.frameId,
|
|
2121
|
+
nodeId: result.nodeId,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
if (persisted.length > 0) {
|
|
2127
|
+
const persistedDraftIds = new Set(
|
|
2128
|
+
persisted.map((entry) => entry.draftId),
|
|
2129
|
+
);
|
|
2130
|
+
updateDraftPrimitives((current) =>
|
|
2131
|
+
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
2132
|
+
);
|
|
2133
|
+
updateSelectedDraftIds((current) =>
|
|
2134
|
+
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
2135
|
+
);
|
|
2136
|
+
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
2137
|
+
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
1795
2138
|
}
|
|
1796
|
-
});
|
|
1797
|
-
|
|
1798
|
-
if (persisted.length > 0) {
|
|
1799
|
-
const persistedDraftIds = new Set(
|
|
1800
|
-
persisted.map((entry) => entry.draftId),
|
|
1801
|
-
);
|
|
1802
|
-
updateDraftPrimitives((current) =>
|
|
1803
|
-
current.filter((draft) => !persistedDraftIds.has(draft.id)),
|
|
1804
|
-
);
|
|
1805
|
-
updateSelectedDraftIds((current) =>
|
|
1806
|
-
current.filter((draftId) => !persistedDraftIds.has(draftId)),
|
|
1807
|
-
);
|
|
1808
|
-
const lastNodeId = persisted[persisted.length - 1]?.nodeId;
|
|
1809
|
-
if (lastNodeId) updateSelectedIds(() => [lastNodeId]);
|
|
1810
2139
|
}
|
|
1811
2140
|
}
|
|
1812
2141
|
finishDrag();
|
|
@@ -1815,12 +2144,15 @@ export function MultiScreenCanvas({
|
|
|
1815
2144
|
installDragListeners(handleMouseMove, handleMouseUp);
|
|
1816
2145
|
},
|
|
1817
2146
|
[
|
|
2147
|
+
findPrimitiveDropTarget,
|
|
1818
2148
|
finishDrag,
|
|
2149
|
+
getCanvasPoint,
|
|
1819
2150
|
getCurrentCanvasEntries,
|
|
1820
2151
|
installDragListeners,
|
|
1821
2152
|
persistDraftPrimitive,
|
|
1822
2153
|
showTransformFeedback,
|
|
1823
2154
|
updateDraftPrimitives,
|
|
2155
|
+
updatePrimitiveDropTarget,
|
|
1824
2156
|
updateSelectedDraftIds,
|
|
1825
2157
|
updateSelectedIds,
|
|
1826
2158
|
],
|
|
@@ -2068,11 +2400,51 @@ export function MultiScreenCanvas({
|
|
|
2068
2400
|
ev.clientX,
|
|
2069
2401
|
ev.clientY,
|
|
2070
2402
|
);
|
|
2403
|
+
|
|
2404
|
+
// When all dragged ids are committed primitive nodeIds (not screen
|
|
2405
|
+
// frames), check for a container primitive drop target to highlight.
|
|
2406
|
+
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
2407
|
+
const allCommitted = state.targetIds.every(
|
|
2408
|
+
(targetId) => !currentFrameIds.includes(targetId),
|
|
2409
|
+
);
|
|
2410
|
+
if (allCommitted) {
|
|
2411
|
+
const canvasPoint = getCanvasPoint(ev.clientX, ev.clientY);
|
|
2412
|
+
updatePrimitiveDropTarget(
|
|
2413
|
+
findPrimitiveDropTarget(canvasPoint, state.primaryId),
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2071
2416
|
};
|
|
2072
2417
|
|
|
2073
2418
|
const handleMouseUp = () => {
|
|
2074
2419
|
const state = dragState.current;
|
|
2420
|
+
const dropTarget = primitiveDropTargetRef.current;
|
|
2075
2421
|
if (state?.type === "move" && state.hasMoved) {
|
|
2422
|
+
// If all dragged ids are committed primitive nodeIds (not screen
|
|
2423
|
+
// frames), attempt a primitive reparent on drop.
|
|
2424
|
+
const currentFrameIds = Object.keys(frameGeometryRef.current);
|
|
2425
|
+
const allCommitted = state.targetIds.every(
|
|
2426
|
+
(targetId) => !currentFrameIds.includes(targetId),
|
|
2427
|
+
);
|
|
2428
|
+
if (allCommitted && dropTarget) {
|
|
2429
|
+
const sourceScreenId = resolveNodeScreenId(
|
|
2430
|
+
state.primaryId,
|
|
2431
|
+
screensRef.current,
|
|
2432
|
+
);
|
|
2433
|
+
if (sourceScreenId) {
|
|
2434
|
+
onPrimitiveReparentRef.current?.({
|
|
2435
|
+
sourceNodeId: state.primaryId,
|
|
2436
|
+
sourceScreenId,
|
|
2437
|
+
targetNodeId: dropTarget.nodeId,
|
|
2438
|
+
targetScreenId: dropTarget.screenId,
|
|
2439
|
+
placement: "inside",
|
|
2440
|
+
});
|
|
2441
|
+
suppressNextPick.current = true;
|
|
2442
|
+
finishDrag();
|
|
2443
|
+
return;
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
// Normal screen-frame geometry commit.
|
|
2076
2448
|
const after = cloneFrameGeometryById(frameGeometryRef.current);
|
|
2077
2449
|
onGeometryCommitRef.current?.(
|
|
2078
2450
|
frameGeometryWithOverrides(after, state.originFrames),
|
|
@@ -2087,12 +2459,15 @@ export function MultiScreenCanvas({
|
|
|
2087
2459
|
},
|
|
2088
2460
|
[
|
|
2089
2461
|
activeId,
|
|
2462
|
+
findPrimitiveDropTarget,
|
|
2090
2463
|
finishDrag,
|
|
2464
|
+
getCanvasPoint,
|
|
2091
2465
|
getCurrentFrameEntries,
|
|
2092
2466
|
installDragListeners,
|
|
2093
2467
|
onPick,
|
|
2094
2468
|
showTransformFeedback,
|
|
2095
2469
|
updateFrameGeometry,
|
|
2470
|
+
updatePrimitiveDropTarget,
|
|
2096
2471
|
updateSelectedDraftIds,
|
|
2097
2472
|
updateSelectedIds,
|
|
2098
2473
|
],
|
|
@@ -2957,6 +3332,7 @@ export function MultiScreenCanvas({
|
|
|
2957
3332
|
: appendPenNode(activePenPath, createCornerNode(penPointer))
|
|
2958
3333
|
: activePenPath;
|
|
2959
3334
|
const selectedIdSet = new Set(selectedIds);
|
|
3335
|
+
const fullViewIdSet = new Set(fullViewScreenIds ?? []);
|
|
2960
3336
|
const selectedDraftIdSet = new Set(selectedDraftIds);
|
|
2961
3337
|
const surfaceCursor = isPanning
|
|
2962
3338
|
? "grabbing"
|
|
@@ -3063,6 +3439,7 @@ export function MultiScreenCanvas({
|
|
|
3063
3439
|
screenContent={screenContentById.get(screen.id)}
|
|
3064
3440
|
isActive={screen.id === activeId}
|
|
3065
3441
|
isSelected={selectedIdSet.has(screen.id)}
|
|
3442
|
+
showFullView={fullViewIdSet.has(screen.id)}
|
|
3066
3443
|
isDirectlyHovered={screen.id === directlyHoveredScreenId}
|
|
3067
3444
|
hasHoveredChild={
|
|
3068
3445
|
(screen.id === activeId && activeScreenHasHoveredChild) ||
|
|
@@ -3081,6 +3458,16 @@ export function MultiScreenCanvas({
|
|
|
3081
3458
|
onStartResize={beginResize}
|
|
3082
3459
|
onStartRotate={beginRotate}
|
|
3083
3460
|
onStartDuplicateGesture={beginDuplicateGesture}
|
|
3461
|
+
onAddBreakpoint={
|
|
3462
|
+
onAddBreakpoint
|
|
3463
|
+
? (widthPx) => onAddBreakpoint(screen.id, widthPx)
|
|
3464
|
+
: undefined
|
|
3465
|
+
}
|
|
3466
|
+
onActiveBreakpointChange={
|
|
3467
|
+
onActiveBreakpointChange
|
|
3468
|
+
? (widthPx) => onActiveBreakpointChange(screen.id, widthPx)
|
|
3469
|
+
: undefined
|
|
3470
|
+
}
|
|
3084
3471
|
/>
|
|
3085
3472
|
);
|
|
3086
3473
|
})}
|
|
@@ -3203,6 +3590,29 @@ export function MultiScreenCanvas({
|
|
|
3203
3590
|
/>
|
|
3204
3591
|
) : null}
|
|
3205
3592
|
|
|
3593
|
+
{primitiveDropTarget ? (
|
|
3594
|
+
<span
|
|
3595
|
+
data-primitive-drop-target
|
|
3596
|
+
className="pointer-events-none absolute z-40 rounded-sm"
|
|
3597
|
+
style={{
|
|
3598
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3599
|
+
left:
|
|
3600
|
+
pan.x +
|
|
3601
|
+
(SURFACE_PADDING + primitiveDropTarget.boardRect.x) * scale,
|
|
3602
|
+
top:
|
|
3603
|
+
pan.y +
|
|
3604
|
+
(SURFACE_PADDING + primitiveDropTarget.boardRect.y) * scale,
|
|
3605
|
+
width: Math.max(1, primitiveDropTarget.boardRect.width * scale),
|
|
3606
|
+
height: Math.max(1, primitiveDropTarget.boardRect.height * scale),
|
|
3607
|
+
// Match the in-screen inside-guide style: 2px accent border + 14%
|
|
3608
|
+
// accent fill. Uses the same CSS variable as the DesignCanvas guide.
|
|
3609
|
+
border: "2px solid var(--design-editor-accent-color)",
|
|
3610
|
+
background:
|
|
3611
|
+
"color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
|
|
3612
|
+
}}
|
|
3613
|
+
/>
|
|
3614
|
+
) : null}
|
|
3615
|
+
|
|
3206
3616
|
{duplicatePreview ? (
|
|
3207
3617
|
<div
|
|
3208
3618
|
className={cn(
|
|
@@ -3245,6 +3655,46 @@ export function MultiScreenCanvas({
|
|
|
3245
3655
|
{transformBadge.text}
|
|
3246
3656
|
</div>
|
|
3247
3657
|
) : null}
|
|
3658
|
+
|
|
3659
|
+
{/* Cross-screen element drag: highlight the candidate target screen frame.
|
|
3660
|
+
Uses the same style as the primitiveDropTarget overlay (2px accent border
|
|
3661
|
+
+ 14% accent fill) so it is visually consistent. */}
|
|
3662
|
+
{crossScreenTarget ? (
|
|
3663
|
+
<span
|
|
3664
|
+
data-cross-screen-drop-target
|
|
3665
|
+
className="pointer-events-none absolute z-40 rounded-sm"
|
|
3666
|
+
style={{
|
|
3667
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3668
|
+
left:
|
|
3669
|
+
pan.x + (SURFACE_PADDING + crossScreenTarget.geometry.x) * scale,
|
|
3670
|
+
top:
|
|
3671
|
+
pan.y + (SURFACE_PADDING + crossScreenTarget.geometry.y) * scale,
|
|
3672
|
+
width: Math.max(1, crossScreenTarget.geometry.width * scale),
|
|
3673
|
+
height: Math.max(1, crossScreenTarget.geometry.height * scale),
|
|
3674
|
+
border: "2px solid var(--design-editor-accent-color)",
|
|
3675
|
+
background:
|
|
3676
|
+
"color-mix(in srgb, var(--design-editor-accent-color) 14%, transparent)",
|
|
3677
|
+
}}
|
|
3678
|
+
/>
|
|
3679
|
+
) : null}
|
|
3680
|
+
|
|
3681
|
+
{/* Cross-screen element drag: small ghost following the board-space cursor. */}
|
|
3682
|
+
{crossScreenGhost ? (
|
|
3683
|
+
<span
|
|
3684
|
+
data-cross-screen-drag-ghost
|
|
3685
|
+
className="pointer-events-none absolute z-50 rounded border border-[var(--design-editor-accent-color)] bg-[var(--design-editor-accent-color)]/20 shadow"
|
|
3686
|
+
style={{
|
|
3687
|
+
// A fixed-size ghost (16×16 canvas-px) centred on the board point.
|
|
3688
|
+
// Surface position = pan + (SURFACE_PADDING + canvasCoord) * scale
|
|
3689
|
+
left:
|
|
3690
|
+
pan.x + (SURFACE_PADDING + crossScreenGhost.boardX) * scale - 8,
|
|
3691
|
+
top:
|
|
3692
|
+
pan.y + (SURFACE_PADDING + crossScreenGhost.boardY) * scale - 8,
|
|
3693
|
+
width: 16,
|
|
3694
|
+
height: 16,
|
|
3695
|
+
}}
|
|
3696
|
+
/>
|
|
3697
|
+
) : null}
|
|
3248
3698
|
</div>
|
|
3249
3699
|
);
|
|
3250
3700
|
}
|
|
@@ -3306,6 +3756,11 @@ function DraftPrimitiveLayer({
|
|
|
3306
3756
|
}}
|
|
3307
3757
|
>
|
|
3308
3758
|
<DraftPrimitiveContent draft={draft} preview={preview} />
|
|
3759
|
+
{/* B3 fix: for the creation preview the outline must sit flush with the
|
|
3760
|
+
geometry box (inset: 0) so the blue accent border lands exactly on the
|
|
3761
|
+
shape edge with no visible gap between the gray content border and the
|
|
3762
|
+
blue selection outline. For placed / hovered draft-primitives the
|
|
3763
|
+
existing -5px inset is intentional (matches the screen-frame chrome). */}
|
|
3309
3764
|
<span
|
|
3310
3765
|
className={cn(
|
|
3311
3766
|
"pointer-events-none absolute rounded-sm border transition-opacity",
|
|
@@ -3316,7 +3771,7 @@ function DraftPrimitiveLayer({
|
|
|
3316
3771
|
: "border-[var(--design-editor-accent-color)] opacity-0 group-hover/artboard:opacity-100",
|
|
3317
3772
|
)}
|
|
3318
3773
|
style={{
|
|
3319
|
-
inset: -5 * chromeScale,
|
|
3774
|
+
inset: preview ? 0 : -5 * chromeScale,
|
|
3320
3775
|
borderWidth: 1.5 * chromeScale,
|
|
3321
3776
|
transition: getChromeBorderTransition(chromeSettling),
|
|
3322
3777
|
}}
|
|
@@ -3389,12 +3844,19 @@ function DraftPrimitiveContent({
|
|
|
3389
3844
|
}
|
|
3390
3845
|
|
|
3391
3846
|
if (draft.kind === "text") {
|
|
3847
|
+
// B5 fix: use canonical style so preview matches the committed element.
|
|
3848
|
+
const textStyle = canvasPrimitiveReactStyle("text", {
|
|
3849
|
+
fill: draft.fill,
|
|
3850
|
+
stroke: draft.stroke,
|
|
3851
|
+
strokeWidth: draft.strokeWidth,
|
|
3852
|
+
});
|
|
3392
3853
|
return (
|
|
3393
3854
|
<div
|
|
3394
3855
|
className={cn(
|
|
3395
|
-
"flex size-full items-start
|
|
3856
|
+
"flex size-full items-start px-2 py-1 text-sm font-medium text-foreground",
|
|
3396
3857
|
muted,
|
|
3397
3858
|
)}
|
|
3859
|
+
style={textStyle}
|
|
3398
3860
|
>
|
|
3399
3861
|
<span className="truncate">{draft.text}</span>
|
|
3400
3862
|
</div>
|
|
@@ -3402,27 +3864,24 @@ function DraftPrimitiveContent({
|
|
|
3402
3864
|
}
|
|
3403
3865
|
|
|
3404
3866
|
if (draft.kind === "frame") {
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
);
|
|
3867
|
+
// B5 fix: use canonical style so preview matches the committed element.
|
|
3868
|
+
const frameStyle = canvasPrimitiveReactStyle("frame", {
|
|
3869
|
+
fill: draft.fill,
|
|
3870
|
+
stroke: draft.stroke,
|
|
3871
|
+
strokeWidth: draft.strokeWidth,
|
|
3872
|
+
});
|
|
3873
|
+
return <div className={cn("size-full", muted)} style={frameStyle} />;
|
|
3413
3874
|
}
|
|
3414
3875
|
|
|
3415
3876
|
if (draft.kind === "ellipse") {
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
/>
|
|
3425
|
-
);
|
|
3877
|
+
// B5/B6 fix: use canonical style — ellipse gets borderRadius:50% in both
|
|
3878
|
+
// the preview and the committed path, and the same calm neutral fill.
|
|
3879
|
+
const ellipseStyle = canvasPrimitiveReactStyle("ellipse", {
|
|
3880
|
+
fill: draft.fill,
|
|
3881
|
+
stroke: draft.stroke,
|
|
3882
|
+
strokeWidth: draft.strokeWidth,
|
|
3883
|
+
});
|
|
3884
|
+
return <div className={cn("size-full", muted)} style={ellipseStyle} />;
|
|
3426
3885
|
}
|
|
3427
3886
|
|
|
3428
3887
|
if (draft.kind === "polygon" || draft.kind === "star") {
|
|
@@ -3449,16 +3908,13 @@ function DraftPrimitiveContent({
|
|
|
3449
3908
|
);
|
|
3450
3909
|
}
|
|
3451
3910
|
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
}}
|
|
3460
|
-
/>
|
|
3461
|
-
);
|
|
3911
|
+
// B5 fix: rect/rectangle — use canonical style so preview matches committed.
|
|
3912
|
+
const rectStyle = canvasPrimitiveReactStyle("rect", {
|
|
3913
|
+
fill: draft.fill,
|
|
3914
|
+
stroke: draft.stroke,
|
|
3915
|
+
strokeWidth: draft.strokeWidth,
|
|
3916
|
+
});
|
|
3917
|
+
return <div className={cn("size-full", muted)} style={rectStyle} />;
|
|
3462
3918
|
}
|
|
3463
3919
|
|
|
3464
3920
|
function PenPathOverlay({
|
|
@@ -3567,12 +4023,28 @@ function isPoint(point: Point | undefined): point is Point {
|
|
|
3567
4023
|
return !!point;
|
|
3568
4024
|
}
|
|
3569
4025
|
|
|
4026
|
+
/** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */
|
|
4027
|
+
const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const;
|
|
4028
|
+
|
|
4029
|
+
/** Derive the Tailwind prefix for a given frame width. */
|
|
4030
|
+
function breakpointLabel(widthPx: number): string {
|
|
4031
|
+
if (widthPx <= 640) return "Mobile";
|
|
4032
|
+
if (widthPx <= 1024) return "Tablet";
|
|
4033
|
+
return "Desktop";
|
|
4034
|
+
}
|
|
4035
|
+
|
|
4036
|
+
/** Suggest the next standard breakpoint not yet in the set. */
|
|
4037
|
+
function nextBreakpointWidth(existing: number[]): number | undefined {
|
|
4038
|
+
return STANDARD_BREAKPOINT_WIDTHS.find((w) => !existing.includes(w));
|
|
4039
|
+
}
|
|
4040
|
+
|
|
3570
4041
|
interface ScreenProps {
|
|
3571
4042
|
screen: ScreenFile;
|
|
3572
4043
|
metadata: ResolvedScreenMetadata;
|
|
3573
4044
|
geometry: FrameGeometry;
|
|
3574
4045
|
isActive: boolean;
|
|
3575
4046
|
isSelected: boolean;
|
|
4047
|
+
showFullView: boolean;
|
|
3576
4048
|
isDirectlyHovered: boolean;
|
|
3577
4049
|
hasHoveredChild: boolean;
|
|
3578
4050
|
groupSelected: boolean;
|
|
@@ -3597,6 +4069,8 @@ interface ScreenProps {
|
|
|
3597
4069
|
display: string,
|
|
3598
4070
|
e: React.MouseEvent<HTMLElement>,
|
|
3599
4071
|
) => void;
|
|
4072
|
+
onAddBreakpoint?: (widthPx: number) => void;
|
|
4073
|
+
onActiveBreakpointChange?: (widthPx: number | undefined) => void;
|
|
3600
4074
|
}
|
|
3601
4075
|
|
|
3602
4076
|
const Screen = memo(function Screen({
|
|
@@ -3605,6 +4079,7 @@ const Screen = memo(function Screen({
|
|
|
3605
4079
|
geometry,
|
|
3606
4080
|
isActive,
|
|
3607
4081
|
isSelected,
|
|
4082
|
+
showFullView,
|
|
3608
4083
|
isDirectlyHovered,
|
|
3609
4084
|
hasHoveredChild,
|
|
3610
4085
|
groupSelected,
|
|
@@ -3621,6 +4096,8 @@ const Screen = memo(function Screen({
|
|
|
3621
4096
|
onStartRotate,
|
|
3622
4097
|
onStartDuplicateGesture,
|
|
3623
4098
|
screenContent,
|
|
4099
|
+
onAddBreakpoint,
|
|
4100
|
+
onActiveBreakpointChange,
|
|
3624
4101
|
}: ScreenProps) {
|
|
3625
4102
|
const t = useT();
|
|
3626
4103
|
const display = metadata.title ?? prettyScreenName(screen.filename);
|
|
@@ -3633,6 +4110,7 @@ const Screen = memo(function Screen({
|
|
|
3633
4110
|
!creationToolActive &&
|
|
3634
4111
|
!canvasGestureActive;
|
|
3635
4112
|
const emphasized = isSelected || frameDirectlyHovered;
|
|
4113
|
+
const fullViewVisible = emphasized || showFullView;
|
|
3636
4114
|
const activeOrEmphasized = isActive || emphasized;
|
|
3637
4115
|
const selectionOutlined = isSelected && !groupSelected;
|
|
3638
4116
|
const showHoverChrome =
|
|
@@ -3760,7 +4238,7 @@ const Screen = memo(function Screen({
|
|
|
3760
4238
|
className={cn(
|
|
3761
4239
|
"absolute top-1/2 z-40 flex h-5 shrink-0 items-center gap-1 overflow-hidden rounded-md border border-border bg-background/95 px-1.5 text-[10px] font-medium text-foreground opacity-0 shadow-sm transition-opacity",
|
|
3762
4240
|
"hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
3763
|
-
|
|
4241
|
+
fullViewVisible && "opacity-100",
|
|
3764
4242
|
fullViewOutsideFrame ? "left-full" : "right-1",
|
|
3765
4243
|
)}
|
|
3766
4244
|
style={{
|
|
@@ -3857,27 +4335,13 @@ const Screen = memo(function Screen({
|
|
|
3857
4335
|
touchAction: "none",
|
|
3858
4336
|
}}
|
|
3859
4337
|
>
|
|
3860
|
-
<span
|
|
3861
|
-
data-screen-hover-outline
|
|
3862
|
-
className={cn(
|
|
3863
|
-
"pointer-events-none absolute border transition-opacity",
|
|
3864
|
-
showHoverChrome
|
|
3865
|
-
? "border-[var(--design-editor-accent-color)] opacity-100"
|
|
3866
|
-
: "border-transparent opacity-0",
|
|
3867
|
-
)}
|
|
3868
|
-
style={{
|
|
3869
|
-
inset: -5 * chromeScale,
|
|
3870
|
-
borderRadius: 13 * chromeScale,
|
|
3871
|
-
borderWidth: 1.5 * chromeScale,
|
|
3872
|
-
transition: getChromeBorderTransition(chromeSettling),
|
|
3873
|
-
}}
|
|
3874
|
-
/>
|
|
3875
4338
|
<span
|
|
3876
4339
|
data-screen-content
|
|
3877
4340
|
className={cn(
|
|
3878
4341
|
"relative block h-full w-full overflow-hidden rounded-lg border bg-white shadow-2xl transition-colors",
|
|
3879
|
-
|
|
3880
|
-
|
|
4342
|
+
showHoverChrome
|
|
4343
|
+
? "border-[var(--design-editor-accent-color)]"
|
|
4344
|
+
: "border-border",
|
|
3881
4345
|
)}
|
|
3882
4346
|
style={{ pointerEvents: screenContentInteractive ? "auto" : "none" }}
|
|
3883
4347
|
>
|
|
@@ -3942,6 +4406,25 @@ const Screen = memo(function Screen({
|
|
|
3942
4406
|
onStartRotate={(e) => onStartRotate(screen.id, e)}
|
|
3943
4407
|
/>
|
|
3944
4408
|
</div>
|
|
4409
|
+
|
|
4410
|
+
{/* Multi-breakpoint preview row (§6.4 — Framer/Figma-Sites style).
|
|
4411
|
+
Rendered as a sibling row to the right of the primary frame when
|
|
4412
|
+
the screen has breakpointWidths set. Each frame shares the same
|
|
4413
|
+
srcdoc content at a different viewport width. The active breakpoint
|
|
4414
|
+
is highlighted and clicking a frame header sets the edit scope. */}
|
|
4415
|
+
{screen.breakpointWidths && screen.breakpointWidths.length > 0 ? (
|
|
4416
|
+
<BreakpointPreviewRow
|
|
4417
|
+
screen={screen}
|
|
4418
|
+
primaryGeometry={geometry}
|
|
4419
|
+
previewUrl={previewUrl}
|
|
4420
|
+
activeBreakpointWidth={screen.activeBreakpointWidth}
|
|
4421
|
+
penActive={penActive}
|
|
4422
|
+
creationToolActive={creationToolActive}
|
|
4423
|
+
chromeScale={chromeScale}
|
|
4424
|
+
onActiveBreakpointChange={onActiveBreakpointChange}
|
|
4425
|
+
onAddBreakpoint={onAddBreakpoint}
|
|
4426
|
+
/>
|
|
4427
|
+
) : null}
|
|
3945
4428
|
</div>
|
|
3946
4429
|
);
|
|
3947
4430
|
}, areScreenPropsEqual);
|
|
@@ -3954,6 +4437,7 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
3954
4437
|
sameFrameGeometry(prev.geometry, next.geometry) &&
|
|
3955
4438
|
prev.isActive === next.isActive &&
|
|
3956
4439
|
prev.isSelected === next.isSelected &&
|
|
4440
|
+
prev.showFullView === next.showFullView &&
|
|
3957
4441
|
prev.isDirectlyHovered === next.isDirectlyHovered &&
|
|
3958
4442
|
prev.hasHoveredChild === next.hasHoveredChild &&
|
|
3959
4443
|
prev.groupSelected === next.groupSelected &&
|
|
@@ -3972,6 +4456,174 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) {
|
|
|
3972
4456
|
);
|
|
3973
4457
|
}
|
|
3974
4458
|
|
|
4459
|
+
// ── Breakpoint preview row (§6.4) ────────────────────────────────────────────
|
|
4460
|
+
|
|
4461
|
+
/** Gap between adjacent breakpoint frames in canvas pixels. */
|
|
4462
|
+
const BREAKPOINT_FRAME_GAP = 24;
|
|
4463
|
+
|
|
4464
|
+
function BreakpointPreviewRow({
|
|
4465
|
+
screen,
|
|
4466
|
+
primaryGeometry,
|
|
4467
|
+
previewUrl,
|
|
4468
|
+
activeBreakpointWidth,
|
|
4469
|
+
penActive,
|
|
4470
|
+
creationToolActive,
|
|
4471
|
+
chromeScale,
|
|
4472
|
+
onActiveBreakpointChange,
|
|
4473
|
+
onAddBreakpoint,
|
|
4474
|
+
}: {
|
|
4475
|
+
screen: ScreenFile;
|
|
4476
|
+
primaryGeometry: FrameGeometry;
|
|
4477
|
+
previewUrl: string | undefined;
|
|
4478
|
+
activeBreakpointWidth: number | undefined;
|
|
4479
|
+
penActive: boolean;
|
|
4480
|
+
creationToolActive: boolean;
|
|
4481
|
+
chromeScale: number;
|
|
4482
|
+
onActiveBreakpointChange?: (widthPx: number | undefined) => void;
|
|
4483
|
+
onAddBreakpoint?: (widthPx: number) => void;
|
|
4484
|
+
}) {
|
|
4485
|
+
const breakpointWidths = screen.breakpointWidths ?? [];
|
|
4486
|
+
// Scale factor used to shrink additional frames to match the primary frame height
|
|
4487
|
+
const scaleY = primaryGeometry.height / (primaryGeometry.width || 1);
|
|
4488
|
+
// Place additional frames to the right of the primary, starting after the gap
|
|
4489
|
+
let offsetX = primaryGeometry.width + BREAKPOINT_FRAME_GAP;
|
|
4490
|
+
|
|
4491
|
+
const nextWidth = nextBreakpointWidth(breakpointWidths);
|
|
4492
|
+
|
|
4493
|
+
return (
|
|
4494
|
+
<>
|
|
4495
|
+
{breakpointWidths.map((widthPx) => {
|
|
4496
|
+
// Scale the additional frame proportionally (same height as primary)
|
|
4497
|
+
const frameWidth = Math.round(widthPx * scaleY);
|
|
4498
|
+
const frameHeight = primaryGeometry.height;
|
|
4499
|
+
const isActive = activeBreakpointWidth === widthPx;
|
|
4500
|
+
const currentOffsetX = offsetX;
|
|
4501
|
+
offsetX += frameWidth + BREAKPOINT_FRAME_GAP;
|
|
4502
|
+
|
|
4503
|
+
return (
|
|
4504
|
+
<div
|
|
4505
|
+
key={widthPx}
|
|
4506
|
+
data-frame-shell
|
|
4507
|
+
data-breakpoint-frame
|
|
4508
|
+
className="pointer-events-auto absolute"
|
|
4509
|
+
// Positioned relative to the parent Screen wrapper, which is already
|
|
4510
|
+
// absolute at left: SURFACE_PADDING + geometry.x / top:
|
|
4511
|
+
// SURFACE_PADDING + geometry.y - FRAME_LABEL_HEIGHT. Re-adding those
|
|
4512
|
+
// surface/primary terms here would double-offset every breakpoint
|
|
4513
|
+
// frame (~240px+ down-right), so we offset only within the wrapper.
|
|
4514
|
+
style={{
|
|
4515
|
+
left: currentOffsetX,
|
|
4516
|
+
top: -FRAME_LABEL_HEIGHT,
|
|
4517
|
+
width: frameWidth,
|
|
4518
|
+
zIndex: primaryGeometry.z,
|
|
4519
|
+
}}
|
|
4520
|
+
>
|
|
4521
|
+
{/* Frame label row */}
|
|
4522
|
+
<div
|
|
4523
|
+
className={cn(
|
|
4524
|
+
"flex h-7 w-full items-center justify-between gap-1 px-1 cursor-pointer select-none",
|
|
4525
|
+
)}
|
|
4526
|
+
onClick={(e) => {
|
|
4527
|
+
e.stopPropagation();
|
|
4528
|
+
onActiveBreakpointChange?.(isActive ? undefined : widthPx);
|
|
4529
|
+
}}
|
|
4530
|
+
>
|
|
4531
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
4532
|
+
<span
|
|
4533
|
+
className={cn(
|
|
4534
|
+
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
4535
|
+
isActive
|
|
4536
|
+
? "bg-[var(--design-editor-accent-color)]"
|
|
4537
|
+
: "bg-muted-foreground/40",
|
|
4538
|
+
)}
|
|
4539
|
+
/>
|
|
4540
|
+
<span
|
|
4541
|
+
className={cn(
|
|
4542
|
+
"truncate text-[11px] font-medium",
|
|
4543
|
+
isActive
|
|
4544
|
+
? "text-[var(--design-editor-accent-color)]"
|
|
4545
|
+
: "text-muted-foreground",
|
|
4546
|
+
)}
|
|
4547
|
+
>
|
|
4548
|
+
{breakpointLabel(widthPx)}
|
|
4549
|
+
</span>
|
|
4550
|
+
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/50">
|
|
4551
|
+
{widthPx}px
|
|
4552
|
+
</span>
|
|
4553
|
+
</div>
|
|
4554
|
+
</div>
|
|
4555
|
+
{/* Frame card */}
|
|
4556
|
+
<div
|
|
4557
|
+
className={cn(
|
|
4558
|
+
"relative block overflow-hidden rounded-lg border bg-white shadow-lg transition-colors",
|
|
4559
|
+
isActive
|
|
4560
|
+
? "border-[var(--design-editor-accent-color)]"
|
|
4561
|
+
: "border-border",
|
|
4562
|
+
)}
|
|
4563
|
+
style={{ width: frameWidth, height: frameHeight }}
|
|
4564
|
+
>
|
|
4565
|
+
<iframe
|
|
4566
|
+
src={previewUrl}
|
|
4567
|
+
srcDoc={previewUrl ? undefined : screen.content}
|
|
4568
|
+
sandbox="allow-scripts"
|
|
4569
|
+
loading="lazy"
|
|
4570
|
+
className="pointer-events-none border-0"
|
|
4571
|
+
style={{
|
|
4572
|
+
width: widthPx,
|
|
4573
|
+
height: Math.round(
|
|
4574
|
+
widthPx * (primaryGeometry.height / primaryGeometry.width),
|
|
4575
|
+
),
|
|
4576
|
+
transform: `scale(${frameWidth / widthPx}, ${frameHeight / Math.round(widthPx * (primaryGeometry.height / primaryGeometry.width))})`,
|
|
4577
|
+
transformOrigin: "top left",
|
|
4578
|
+
}}
|
|
4579
|
+
title={`${screen.filename} — ${breakpointLabel(widthPx)}`}
|
|
4580
|
+
/>
|
|
4581
|
+
{creationToolActive || penActive ? (
|
|
4582
|
+
<span className="absolute inset-0 z-20 cursor-crosshair" />
|
|
4583
|
+
) : null}
|
|
4584
|
+
<span className="pointer-events-none absolute inset-0 rounded-[7px] border border-black/5" />
|
|
4585
|
+
</div>
|
|
4586
|
+
</div>
|
|
4587
|
+
);
|
|
4588
|
+
})}
|
|
4589
|
+
|
|
4590
|
+
{/* + affordance: add the next standard breakpoint */}
|
|
4591
|
+
{onAddBreakpoint && nextWidth !== undefined ? (
|
|
4592
|
+
<div
|
|
4593
|
+
className="pointer-events-auto absolute flex items-center"
|
|
4594
|
+
// Same wrapper-relative coordinate space as the breakpoint frames
|
|
4595
|
+
// above — no SURFACE_PADDING / primaryGeometry.x/y terms or it would
|
|
4596
|
+
// double-offset.
|
|
4597
|
+
style={{
|
|
4598
|
+
left: offsetX,
|
|
4599
|
+
top: -FRAME_LABEL_HEIGHT + primaryGeometry.height / 2,
|
|
4600
|
+
zIndex: primaryGeometry.z,
|
|
4601
|
+
}}
|
|
4602
|
+
>
|
|
4603
|
+
<button
|
|
4604
|
+
type="button"
|
|
4605
|
+
className={cn(
|
|
4606
|
+
"flex size-7 items-center justify-center rounded-full border border-border bg-background/95 text-muted-foreground shadow-sm transition-colors",
|
|
4607
|
+
"hover:border-[var(--design-editor-accent-color)] hover:text-[var(--design-editor-accent-color)]",
|
|
4608
|
+
)}
|
|
4609
|
+
style={{
|
|
4610
|
+
transform: `scale(${chromeScale})`,
|
|
4611
|
+
transformOrigin: "center",
|
|
4612
|
+
}}
|
|
4613
|
+
title={`Add ${breakpointLabel(nextWidth)} breakpoint (${nextWidth}px)`}
|
|
4614
|
+
onClick={(e) => {
|
|
4615
|
+
e.stopPropagation();
|
|
4616
|
+
onAddBreakpoint(nextWidth);
|
|
4617
|
+
}}
|
|
4618
|
+
>
|
|
4619
|
+
<IconPlus className="size-3.5" />
|
|
4620
|
+
</button>
|
|
4621
|
+
</div>
|
|
4622
|
+
) : null}
|
|
4623
|
+
</>
|
|
4624
|
+
);
|
|
4625
|
+
}
|
|
4626
|
+
|
|
3975
4627
|
function GroupSelectionBox({
|
|
3976
4628
|
bounds,
|
|
3977
4629
|
chromeScale,
|
|
@@ -4278,6 +4930,30 @@ function getOverviewFrameHeight(width: number, metadata?: ScreenViewportSize) {
|
|
|
4278
4930
|
return Math.max(80, Math.round((width * sourceHeight) / sourceWidth));
|
|
4279
4931
|
}
|
|
4280
4932
|
|
|
4933
|
+
export function getPreviewDeviceFrameGeometry({
|
|
4934
|
+
currentGeometry,
|
|
4935
|
+
metadata,
|
|
4936
|
+
previewDeviceFrame,
|
|
4937
|
+
}: {
|
|
4938
|
+
currentGeometry: FrameGeometry;
|
|
4939
|
+
metadata?: { width: number; height: number };
|
|
4940
|
+
previewDeviceFrame: DeviceFrameType;
|
|
4941
|
+
}): FrameGeometry {
|
|
4942
|
+
if (previewDeviceFrame === "none") {
|
|
4943
|
+
return {
|
|
4944
|
+
...currentGeometry,
|
|
4945
|
+
height: getOverviewFrameHeight(currentGeometry.width, metadata),
|
|
4946
|
+
};
|
|
4947
|
+
}
|
|
4948
|
+
|
|
4949
|
+
const viewport = DEVICE_FRAME_VIEWPORTS[previewDeviceFrame];
|
|
4950
|
+
return {
|
|
4951
|
+
...currentGeometry,
|
|
4952
|
+
width: Math.max(1, Math.round(metadata?.width ?? viewport.width)),
|
|
4953
|
+
height: Math.max(1, Math.round(metadata?.height ?? viewport.height)),
|
|
4954
|
+
};
|
|
4955
|
+
}
|
|
4956
|
+
|
|
4281
4957
|
function getScreenPreviewViewport(
|
|
4282
4958
|
metadata: ScreenViewportSize,
|
|
4283
4959
|
geometry: ScreenViewportSize,
|
|
@@ -4968,12 +5644,17 @@ function resolveScreenMetadata(
|
|
|
4968
5644
|
getterMetadata?: ScreenMetadata,
|
|
4969
5645
|
previewDeviceFrame: DeviceFrameType = "none",
|
|
4970
5646
|
): ResolvedScreenMetadata {
|
|
4971
|
-
|
|
5647
|
+
// Normalize content to a string up front: a screen whose content has not yet
|
|
5648
|
+
// loaded (or is otherwise not a plain string) must not crash the overview
|
|
5649
|
+
// render via the content.trim()/slice() helpers below.
|
|
5650
|
+
const safeScreen: ScreenFile =
|
|
5651
|
+
typeof screen.content === "string" ? screen : { ...screen, content: "" };
|
|
5652
|
+
const metadata = { ...safeScreen, ...keyedMetadata, ...getterMetadata };
|
|
4972
5653
|
const previewUrl =
|
|
4973
5654
|
metadata.url ??
|
|
4974
5655
|
metadata.previewUrl ??
|
|
4975
|
-
|
|
4976
|
-
getPreviewUrl(
|
|
5656
|
+
safeScreen.previewUrl ??
|
|
5657
|
+
getPreviewUrl(safeScreen.content);
|
|
4977
5658
|
const deviceViewport =
|
|
4978
5659
|
previewDeviceFrame === "none"
|
|
4979
5660
|
? undefined
|
|
@@ -4987,11 +5668,11 @@ function resolveScreenMetadata(
|
|
|
4987
5668
|
return {
|
|
4988
5669
|
source:
|
|
4989
5670
|
normalizeSource(metadata.sourceType ?? metadata.source) ??
|
|
4990
|
-
deriveSource(
|
|
5671
|
+
deriveSource(safeScreen, previewUrl),
|
|
4991
5672
|
previewState:
|
|
4992
5673
|
normalizePreviewState(
|
|
4993
5674
|
metadata.lod ?? metadata.previewState ?? metadata.status,
|
|
4994
|
-
) ?? derivePreviewState(
|
|
5675
|
+
) ?? derivePreviewState(safeScreen, previewUrl),
|
|
4995
5676
|
title: metadata.title,
|
|
4996
5677
|
width,
|
|
4997
5678
|
height,
|
|
@@ -5067,7 +5748,11 @@ function derivePreviewState(
|
|
|
5067
5748
|
}
|
|
5068
5749
|
|
|
5069
5750
|
function getPreviewUrl(content: string) {
|
|
5070
|
-
|
|
5751
|
+
// Tolerate non-string content (e.g. a screen whose content has not loaded):
|
|
5752
|
+
// a missing URL is correct here, a crash is not.
|
|
5753
|
+
return getUrl(
|
|
5754
|
+
typeof content === "string" ? content.trim() : undefined,
|
|
5755
|
+
)?.toString();
|
|
5071
5756
|
}
|
|
5072
5757
|
|
|
5073
5758
|
function getUrl(value: string | undefined) {
|
|
@@ -5085,3 +5770,272 @@ function getUrl(value: string | undefined) {
|
|
|
5085
5770
|
function clampNumber(value: number, min: number, max: number) {
|
|
5086
5771
|
return Math.min(max, Math.max(min, value));
|
|
5087
5772
|
}
|
|
5773
|
+
|
|
5774
|
+
// ---------------------------------------------------------------------------
|
|
5775
|
+
// Primitive-into-primitive drop target detection
|
|
5776
|
+
// ---------------------------------------------------------------------------
|
|
5777
|
+
|
|
5778
|
+
/** A committed container primitive that can accept a dropped primitive. */
|
|
5779
|
+
export interface PrimitiveDropTarget {
|
|
5780
|
+
/** The node's data-agent-native-node-id value. */
|
|
5781
|
+
nodeId: string;
|
|
5782
|
+
/** The screen frame id (ScreenFile.id) that owns this primitive. */
|
|
5783
|
+
screenId: string;
|
|
5784
|
+
/** The primitive's bounding rect in board/canvas space. */
|
|
5785
|
+
boardRect: FrameGeometry;
|
|
5786
|
+
}
|
|
5787
|
+
|
|
5788
|
+
/**
|
|
5789
|
+
* Parsed representation of a committed primitive found in a screen's HTML.
|
|
5790
|
+
* Geometry is in screen-local CSS pixels (as written by appendCanvasPrimitiveToHtml).
|
|
5791
|
+
*/
|
|
5792
|
+
export interface ParsedScreenPrimitive {
|
|
5793
|
+
nodeId: string;
|
|
5794
|
+
screenId: string;
|
|
5795
|
+
/** Position relative to screen body (CSS px). */
|
|
5796
|
+
localLeft: number;
|
|
5797
|
+
localTop: number;
|
|
5798
|
+
localWidth: number;
|
|
5799
|
+
localHeight: number;
|
|
5800
|
+
isContainer: boolean;
|
|
5801
|
+
}
|
|
5802
|
+
|
|
5803
|
+
/** Fast djb2-variant hash of a string. Runs in O(n) but is inlined for the
|
|
5804
|
+
* JIT — no allocations, no imports. Produces a 32-bit unsigned integer as a
|
|
5805
|
+
* hex string. Used only for cache-key disambiguation, not cryptography. */
|
|
5806
|
+
function hashString(s: string): string {
|
|
5807
|
+
let h = 5381;
|
|
5808
|
+
for (let i = 0; i < s.length; i++) {
|
|
5809
|
+
// h = h * 33 ^ charCode (djb2 xor variant)
|
|
5810
|
+
h = ((h << 5) + h) ^ s.charCodeAt(i);
|
|
5811
|
+
h = h >>> 0; // keep as unsigned 32-bit
|
|
5812
|
+
}
|
|
5813
|
+
return h.toString(16);
|
|
5814
|
+
}
|
|
5815
|
+
|
|
5816
|
+
/** Simple LRU-style cache to avoid re-parsing the same screen HTML on every
|
|
5817
|
+
* mousemove frame. Keyed by `screenId:contentLength:hash` so that ANY edit
|
|
5818
|
+
* (including changes deep in the middle of a large HTML document) invalidates
|
|
5819
|
+
* the entry. A length:hash pair eliminates the collision zone that existed
|
|
5820
|
+
* when only prefix48+suffix48 were used — edits in `content[48..len-49]` would
|
|
5821
|
+
* produce the same key even though the content differed. */
|
|
5822
|
+
export const primitiveParseCache = new Map<string, ParsedScreenPrimitive[]>();
|
|
5823
|
+
const PRIMITIVE_PARSE_CACHE_MAX = 64;
|
|
5824
|
+
|
|
5825
|
+
export function parsePrimitivesFromScreen(
|
|
5826
|
+
screen: ScreenFile,
|
|
5827
|
+
): ParsedScreenPrimitive[] {
|
|
5828
|
+
const cacheKey = `${screen.id}:${screen.content.length}:${hashString(screen.content)}`;
|
|
5829
|
+
const cached = primitiveParseCache.get(cacheKey);
|
|
5830
|
+
if (cached) return cached;
|
|
5831
|
+
|
|
5832
|
+
const result: ParsedScreenPrimitive[] = [];
|
|
5833
|
+
if (typeof DOMParser === "undefined" || !screen.content) {
|
|
5834
|
+
return result;
|
|
5835
|
+
}
|
|
5836
|
+
|
|
5837
|
+
try {
|
|
5838
|
+
const doc = new DOMParser().parseFromString(screen.content, "text/html");
|
|
5839
|
+
const nodes = doc.querySelectorAll("[data-agent-native-node-id]");
|
|
5840
|
+
nodes.forEach((el) => {
|
|
5841
|
+
const nodeId = el.getAttribute("data-agent-native-node-id");
|
|
5842
|
+
if (!nodeId) return;
|
|
5843
|
+
|
|
5844
|
+
const htmlEl = el as HTMLElement;
|
|
5845
|
+
const style = htmlEl.style;
|
|
5846
|
+
const tag = el.tagName.toLowerCase();
|
|
5847
|
+
|
|
5848
|
+
// Only block elements with explicit absolute positioning are considered
|
|
5849
|
+
// positioned primitives drawn by appendCanvasPrimitiveToHtml.
|
|
5850
|
+
if (style.position !== "absolute") return;
|
|
5851
|
+
|
|
5852
|
+
const left = parseFloat(style.left) || 0;
|
|
5853
|
+
const top = parseFloat(style.top) || 0;
|
|
5854
|
+
const width = parseFloat(style.width) || 0;
|
|
5855
|
+
const height = parseFloat(style.height) || 0;
|
|
5856
|
+
|
|
5857
|
+
// Validity: must have non-zero size
|
|
5858
|
+
if (width <= 0 || height <= 0) return;
|
|
5859
|
+
|
|
5860
|
+
// Container check:
|
|
5861
|
+
// - Must be a div (not svg/path/circle/polygon/etc.)
|
|
5862
|
+
// - Must NOT be an ellipse (border-radius:50%)
|
|
5863
|
+
// - NOT a text primitive (but text divs could be containers in principle;
|
|
5864
|
+
// we exclude them by checking for display:inline-block autoSize pattern)
|
|
5865
|
+
const isDiv = tag === "div";
|
|
5866
|
+
const isEllipse =
|
|
5867
|
+
style.borderRadius === "50%" ||
|
|
5868
|
+
style.borderRadius === "50% 50% 50% 50%";
|
|
5869
|
+
const isTextAutoSize = style.display === "inline-block";
|
|
5870
|
+
const isContainer = isDiv && !isEllipse && !isTextAutoSize;
|
|
5871
|
+
|
|
5872
|
+
result.push({
|
|
5873
|
+
nodeId,
|
|
5874
|
+
screenId: screen.id,
|
|
5875
|
+
localLeft: left,
|
|
5876
|
+
localTop: top,
|
|
5877
|
+
localWidth: width,
|
|
5878
|
+
localHeight: height,
|
|
5879
|
+
isContainer,
|
|
5880
|
+
});
|
|
5881
|
+
});
|
|
5882
|
+
} catch {
|
|
5883
|
+
// Silently ignore parse errors
|
|
5884
|
+
}
|
|
5885
|
+
|
|
5886
|
+
if (primitiveParseCache.size >= PRIMITIVE_PARSE_CACHE_MAX) {
|
|
5887
|
+
// Evict the oldest entry
|
|
5888
|
+
const firstKey = primitiveParseCache.keys().next().value;
|
|
5889
|
+
if (firstKey !== undefined) primitiveParseCache.delete(firstKey);
|
|
5890
|
+
}
|
|
5891
|
+
primitiveParseCache.set(cacheKey, result);
|
|
5892
|
+
return result;
|
|
5893
|
+
}
|
|
5894
|
+
|
|
5895
|
+
/**
|
|
5896
|
+
* Convert a screen-local primitive rect to board/canvas coordinates.
|
|
5897
|
+
*
|
|
5898
|
+
* appendCanvasPrimitiveToHtml stores positions in screen-local CSS pixels
|
|
5899
|
+
* scaled from the board draft geometry:
|
|
5900
|
+
* localX = (boardX - frame.x) * (metadata.width / frame.width)
|
|
5901
|
+
* Inverting:
|
|
5902
|
+
* boardX = frame.x + localX * (frame.width / metadata.width)
|
|
5903
|
+
*/
|
|
5904
|
+
export function primitiveLocalToBoardRect(
|
|
5905
|
+
localLeft: number,
|
|
5906
|
+
localTop: number,
|
|
5907
|
+
localWidth: number,
|
|
5908
|
+
localHeight: number,
|
|
5909
|
+
frameGeometry: FrameGeometry,
|
|
5910
|
+
metadata: { width: number; height: number },
|
|
5911
|
+
): FrameGeometry {
|
|
5912
|
+
const scaleX = frameGeometry.width / Math.max(1, metadata.width);
|
|
5913
|
+
const scaleY = frameGeometry.height / Math.max(1, metadata.height);
|
|
5914
|
+
return {
|
|
5915
|
+
x: frameGeometry.x + localLeft * scaleX,
|
|
5916
|
+
y: frameGeometry.y + localTop * scaleY,
|
|
5917
|
+
width: Math.max(1, localWidth * scaleX),
|
|
5918
|
+
height: Math.max(1, localHeight * scaleY),
|
|
5919
|
+
};
|
|
5920
|
+
}
|
|
5921
|
+
|
|
5922
|
+
/**
|
|
5923
|
+
* Find the topmost committed container primitive at `point` (canvas coords),
|
|
5924
|
+
* excluding `draggedNodeId` and any of its descendants.
|
|
5925
|
+
*
|
|
5926
|
+
* Descendants are detected geometrically: a primitive whose board rect is
|
|
5927
|
+
* fully enclosed by the dragged node's board rect is treated as a descendant
|
|
5928
|
+
* and excluded. This avoids a circular parent-child relationship on drop.
|
|
5929
|
+
*
|
|
5930
|
+
* Returns null if no valid target found.
|
|
5931
|
+
*/
|
|
5932
|
+
export function getPrimitiveDropTargetForPoint(
|
|
5933
|
+
point: Point,
|
|
5934
|
+
draggedNodeId: string | null,
|
|
5935
|
+
screens: ScreenFile[],
|
|
5936
|
+
frameGeometryById: FrameGeometryById,
|
|
5937
|
+
getMetadata: (screen: ScreenFile) => { width: number; height: number },
|
|
5938
|
+
): PrimitiveDropTarget | null {
|
|
5939
|
+
// Pre-compute the dragged node's board rect so we can exclude its descendants.
|
|
5940
|
+
let draggedBoardRect: FrameGeometry | null = null;
|
|
5941
|
+
if (draggedNodeId) {
|
|
5942
|
+
outer: for (const screen of screens) {
|
|
5943
|
+
const frameGeometry = frameGeometryById[screen.id];
|
|
5944
|
+
if (!frameGeometry) continue;
|
|
5945
|
+
const metadata = getMetadata(screen);
|
|
5946
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
5947
|
+
for (const prim of primitives) {
|
|
5948
|
+
if (prim.nodeId === draggedNodeId) {
|
|
5949
|
+
draggedBoardRect = primitiveLocalToBoardRect(
|
|
5950
|
+
prim.localLeft,
|
|
5951
|
+
prim.localTop,
|
|
5952
|
+
prim.localWidth,
|
|
5953
|
+
prim.localHeight,
|
|
5954
|
+
frameGeometry,
|
|
5955
|
+
metadata,
|
|
5956
|
+
);
|
|
5957
|
+
break outer;
|
|
5958
|
+
}
|
|
5959
|
+
}
|
|
5960
|
+
}
|
|
5961
|
+
}
|
|
5962
|
+
|
|
5963
|
+
let best: PrimitiveDropTarget | null = null;
|
|
5964
|
+
|
|
5965
|
+
for (const screen of screens) {
|
|
5966
|
+
const frameGeometry = frameGeometryById[screen.id];
|
|
5967
|
+
if (!frameGeometry) continue;
|
|
5968
|
+
|
|
5969
|
+
const frameBounds = {
|
|
5970
|
+
left: frameGeometry.x,
|
|
5971
|
+
top: frameGeometry.y,
|
|
5972
|
+
right: frameGeometry.x + frameGeometry.width,
|
|
5973
|
+
bottom: frameGeometry.y + frameGeometry.height,
|
|
5974
|
+
};
|
|
5975
|
+
if (!rectContainsPoint(frameBounds, point)) {
|
|
5976
|
+
continue;
|
|
5977
|
+
}
|
|
5978
|
+
|
|
5979
|
+
const metadata = getMetadata(screen);
|
|
5980
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
5981
|
+
|
|
5982
|
+
for (const prim of primitives) {
|
|
5983
|
+
if (!prim.isContainer) continue;
|
|
5984
|
+
if (draggedNodeId && prim.nodeId === draggedNodeId) continue;
|
|
5985
|
+
|
|
5986
|
+
const boardRect = primitiveLocalToBoardRect(
|
|
5987
|
+
prim.localLeft,
|
|
5988
|
+
prim.localTop,
|
|
5989
|
+
prim.localWidth,
|
|
5990
|
+
prim.localHeight,
|
|
5991
|
+
frameGeometry,
|
|
5992
|
+
metadata,
|
|
5993
|
+
);
|
|
5994
|
+
|
|
5995
|
+
// Exclude geometric descendants: a primitive whose board rect is fully
|
|
5996
|
+
// contained within the dragged node's board rect is a child/descendant
|
|
5997
|
+
// and cannot be a valid reparent target (would create a cycle).
|
|
5998
|
+
if (
|
|
5999
|
+
draggedBoardRect &&
|
|
6000
|
+
boardRect.x >= draggedBoardRect.x &&
|
|
6001
|
+
boardRect.y >= draggedBoardRect.y &&
|
|
6002
|
+
boardRect.x + boardRect.width <=
|
|
6003
|
+
draggedBoardRect.x + draggedBoardRect.width &&
|
|
6004
|
+
boardRect.y + boardRect.height <=
|
|
6005
|
+
draggedBoardRect.y + draggedBoardRect.height
|
|
6006
|
+
) {
|
|
6007
|
+
continue;
|
|
6008
|
+
}
|
|
6009
|
+
|
|
6010
|
+
if (
|
|
6011
|
+
point.x >= boardRect.x &&
|
|
6012
|
+
point.x <= boardRect.x + boardRect.width &&
|
|
6013
|
+
point.y >= boardRect.y &&
|
|
6014
|
+
point.y <= boardRect.y + boardRect.height
|
|
6015
|
+
) {
|
|
6016
|
+
// Later in the DOM = higher paint order = topmost visually.
|
|
6017
|
+
// We take the last match within each screen (DOM order).
|
|
6018
|
+
best = { nodeId: prim.nodeId, screenId: screen.id, boardRect };
|
|
6019
|
+
}
|
|
6020
|
+
}
|
|
6021
|
+
}
|
|
6022
|
+
|
|
6023
|
+
return best;
|
|
6024
|
+
}
|
|
6025
|
+
|
|
6026
|
+
/**
|
|
6027
|
+
* Resolve which screen (ScreenFile.id) owns a committed primitive nodeId by
|
|
6028
|
+
* scanning all screen HTML for the given data-agent-native-node-id value.
|
|
6029
|
+
*/
|
|
6030
|
+
export function resolveNodeScreenId(
|
|
6031
|
+
nodeId: string,
|
|
6032
|
+
screens: ScreenFile[],
|
|
6033
|
+
): string | null {
|
|
6034
|
+
for (const screen of screens) {
|
|
6035
|
+
const primitives = parsePrimitivesFromScreen(screen);
|
|
6036
|
+
if (primitives.some((p) => p.nodeId === nodeId)) {
|
|
6037
|
+
return screen.id;
|
|
6038
|
+
}
|
|
6039
|
+
}
|
|
6040
|
+
return null;
|
|
6041
|
+
}
|