@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
|
@@ -38,20 +38,30 @@ import {
|
|
|
38
38
|
type CanvasFrameGeometry,
|
|
39
39
|
type CanvasFrameGeometryById,
|
|
40
40
|
} from "@shared/canvas-frames";
|
|
41
|
+
import { resolveSourceCapabilities } from "@shared/capability-resolver";
|
|
41
42
|
import {
|
|
42
43
|
applyVisualEdit,
|
|
43
44
|
buildCodeLayerProjection,
|
|
44
45
|
buildCodeLayerTree,
|
|
45
46
|
ensureCodeLayerNodeIdsInHtml,
|
|
47
|
+
moveNodeBetweenDocuments,
|
|
46
48
|
removeCodeLayerNodeFromHtml,
|
|
47
49
|
type CodeLayerNode,
|
|
50
|
+
type CodeLayerProjection,
|
|
48
51
|
type CodeLayerTreeNode,
|
|
49
52
|
} from "@shared/code-layer";
|
|
53
|
+
import { componentNameFor, isComponentInstance } from "@shared/component-model";
|
|
54
|
+
import {
|
|
55
|
+
DESIGN_CAPABILITY_NAMES,
|
|
56
|
+
hasCapability,
|
|
57
|
+
} from "@shared/design-source-capabilities";
|
|
50
58
|
import { shouldUseLiveFileContent } from "@shared/html-content";
|
|
51
59
|
import {
|
|
52
60
|
resolveTweaksToCssVars,
|
|
53
61
|
type TweakSelections,
|
|
54
62
|
} from "@shared/resolve-tweaks";
|
|
63
|
+
import { widthToPrefix } from "@shared/responsive-classes";
|
|
64
|
+
import { normalizeDesignSourceType } from "@shared/source-mode";
|
|
55
65
|
import {
|
|
56
66
|
IconArrowLeft,
|
|
57
67
|
IconArrowUpRight,
|
|
@@ -94,6 +104,9 @@ import {
|
|
|
94
104
|
IconFileExport,
|
|
95
105
|
IconPlayerPlay,
|
|
96
106
|
IconDeviceFloppy,
|
|
107
|
+
IconRocket,
|
|
108
|
+
IconExternalLink,
|
|
109
|
+
IconCircleCheck,
|
|
97
110
|
IconTerminal2,
|
|
98
111
|
} from "@tabler/icons-react";
|
|
99
112
|
import { useQueryClient } from "@tanstack/react-query";
|
|
@@ -113,6 +126,7 @@ import { useParams, useNavigate, Link, useLocation } from "react-router";
|
|
|
113
126
|
import { toast } from "sonner";
|
|
114
127
|
import * as Y from "yjs";
|
|
115
128
|
|
|
129
|
+
import { canvasPrimitiveVisual } from "@/components/design/canvas-primitive-style";
|
|
116
130
|
import {
|
|
117
131
|
CanvasContextMenu,
|
|
118
132
|
type CanvasContextMenuHandle,
|
|
@@ -121,10 +135,15 @@ import {
|
|
|
121
135
|
DesignCanvas,
|
|
122
136
|
type IframeContextMenuPayload,
|
|
123
137
|
type IframeHotkeyPayload,
|
|
138
|
+
type MotionTrackWire,
|
|
124
139
|
} from "@/components/design/DesignCanvas";
|
|
125
140
|
import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton";
|
|
126
141
|
import type { DesignExtensionSlotContext } from "@/components/design/DesignExtensionsPanel";
|
|
127
|
-
import {
|
|
142
|
+
import {
|
|
143
|
+
EditPanel,
|
|
144
|
+
type InspectCodeData,
|
|
145
|
+
type InspectorTab,
|
|
146
|
+
} from "@/components/design/EditPanel";
|
|
128
147
|
import type { ExportSettingsValue } from "@/components/design/inspector";
|
|
129
148
|
import {
|
|
130
149
|
LayersPanel,
|
|
@@ -132,12 +151,18 @@ import {
|
|
|
132
151
|
type LayersPanelMoveIntent,
|
|
133
152
|
type LayersPanelNode,
|
|
134
153
|
} from "@/components/design/LayersPanel";
|
|
154
|
+
import { LocalSourceEditBanner } from "@/components/design/LocalSourceEditBanner";
|
|
155
|
+
import {
|
|
156
|
+
MotionDock,
|
|
157
|
+
type MotionDockTrack,
|
|
158
|
+
} from "@/components/design/MotionDock";
|
|
135
159
|
import {
|
|
136
160
|
MultiScreenCanvas,
|
|
137
161
|
OVERVIEW_FRAME_WIDTH,
|
|
138
162
|
type CanvasPrimitiveInsert,
|
|
139
163
|
} from "@/components/design/MultiScreenCanvas";
|
|
140
164
|
import { QuestionFlow } from "@/components/design/QuestionFlow";
|
|
165
|
+
import type { ReviewPanelProps } from "@/components/design/ReviewPanel";
|
|
141
166
|
import type { ElementInfo, DeviceFrameType } from "@/components/design/types";
|
|
142
167
|
import {
|
|
143
168
|
DEVICE_FRAME_VIEWPORTS,
|
|
@@ -148,6 +173,14 @@ import type { UploadedFile } from "@/components/editor/PromptDialog";
|
|
|
148
173
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
149
174
|
import { Button } from "@/components/ui/button";
|
|
150
175
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
176
|
+
import {
|
|
177
|
+
Dialog,
|
|
178
|
+
DialogContent,
|
|
179
|
+
DialogDescription,
|
|
180
|
+
DialogFooter,
|
|
181
|
+
DialogHeader,
|
|
182
|
+
DialogTitle,
|
|
183
|
+
} from "@/components/ui/dialog";
|
|
151
184
|
import {
|
|
152
185
|
DropdownMenu,
|
|
153
186
|
DropdownMenuContent,
|
|
@@ -190,6 +223,8 @@ import {
|
|
|
190
223
|
import { prettyScreenName } from "@/lib/screen-names";
|
|
191
224
|
import { cn } from "@/lib/utils";
|
|
192
225
|
|
|
226
|
+
import type { A11yFinding } from "../../shared/design-review.js";
|
|
227
|
+
|
|
193
228
|
const TAB_ID = generateTabId();
|
|
194
229
|
|
|
195
230
|
// Selection is tab-scoped (like navigation) so a second editor tab cannot
|
|
@@ -263,6 +298,32 @@ export function getSelectedScreenIdsForEditorState(args: {
|
|
|
263
298
|
return activeFileId ? [activeFileId] : [];
|
|
264
299
|
}
|
|
265
300
|
|
|
301
|
+
function fileIdFromLayerSelectionId(
|
|
302
|
+
layerId: string,
|
|
303
|
+
fileIds: Set<string>,
|
|
304
|
+
): string | null {
|
|
305
|
+
const normalized = layerId.startsWith("code:")
|
|
306
|
+
? layerId.slice("code:".length)
|
|
307
|
+
: layerId;
|
|
308
|
+
return fileIds.has(normalized) ? normalized : null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function getOverviewScreenIdsFromLayerSelection(args: {
|
|
312
|
+
fileIds: string[];
|
|
313
|
+
layerIds: string[];
|
|
314
|
+
}) {
|
|
315
|
+
const fileIds = new Set(args.fileIds);
|
|
316
|
+
const seen = new Set<string>();
|
|
317
|
+
const selectedScreenIds: string[] = [];
|
|
318
|
+
args.layerIds.forEach((layerId) => {
|
|
319
|
+
const fileId = fileIdFromLayerSelectionId(layerId, fileIds);
|
|
320
|
+
if (!fileId || seen.has(fileId)) return;
|
|
321
|
+
seen.add(fileId);
|
|
322
|
+
selectedScreenIds.push(fileId);
|
|
323
|
+
});
|
|
324
|
+
return selectedScreenIds;
|
|
325
|
+
}
|
|
326
|
+
|
|
266
327
|
export function getOverviewEnterTarget(args: {
|
|
267
328
|
activeFileId: string | null | undefined;
|
|
268
329
|
overviewSelectedScreenIds: string[];
|
|
@@ -334,6 +395,45 @@ export function getDesignEditorShareUrl(
|
|
|
334
395
|
return new URL(pathname, origin).toString();
|
|
335
396
|
}
|
|
336
397
|
|
|
398
|
+
export function getLocalhostRouteSourceFile(args: {
|
|
399
|
+
sourceFile?: string;
|
|
400
|
+
source?: string;
|
|
401
|
+
}): string | undefined {
|
|
402
|
+
if (args.sourceFile?.trim()) return args.sourceFile;
|
|
403
|
+
const raw = args.source;
|
|
404
|
+
if (!raw) return undefined;
|
|
405
|
+
try {
|
|
406
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
407
|
+
if (
|
|
408
|
+
parsed &&
|
|
409
|
+
typeof parsed === "object" &&
|
|
410
|
+
"file" in parsed &&
|
|
411
|
+
typeof (parsed as Record<string, unknown>).file === "string"
|
|
412
|
+
) {
|
|
413
|
+
return (parsed as Record<string, string>).file;
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
if (raw.length > 0) return raw;
|
|
417
|
+
}
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function getLayerMoveSourceContent(args: {
|
|
422
|
+
sourceFileId: string;
|
|
423
|
+
activeFileId?: string | null;
|
|
424
|
+
activeContent: string;
|
|
425
|
+
sourceFileContent?: string;
|
|
426
|
+
sourceContentMap: ReadonlyMap<string, string>;
|
|
427
|
+
}) {
|
|
428
|
+
return (
|
|
429
|
+
args.sourceContentMap.get(args.sourceFileId) ??
|
|
430
|
+
(args.sourceFileId === args.activeFileId
|
|
431
|
+
? args.activeContent
|
|
432
|
+
: args.sourceFileContent) ??
|
|
433
|
+
""
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
337
437
|
function resolveZoomUpdate(update: SetStateAction<number>, current: number) {
|
|
338
438
|
return typeof update === "function" ? update(current) : update;
|
|
339
439
|
}
|
|
@@ -367,6 +467,27 @@ export function shouldEscapeToOverview(args: {
|
|
|
367
467
|
);
|
|
368
468
|
}
|
|
369
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Build the set of all node ids (both projection ids and data-agent-native-node-id
|
|
472
|
+
* attribute values) that exist in the given projection. Used by handleGroupSelection
|
|
473
|
+
* and handleUngroupSelection to filter selectedLayerIdsState to the active file's
|
|
474
|
+
* nodes before passing them to wrapNodes / unwrap, preventing cross-file stale ids
|
|
475
|
+
* from causing spurious "conflict" errors.
|
|
476
|
+
*
|
|
477
|
+
* Exported for unit testing.
|
|
478
|
+
*/
|
|
479
|
+
export function buildActiveFileNodeIdSet(
|
|
480
|
+
projection: CodeLayerProjection,
|
|
481
|
+
): Set<string> {
|
|
482
|
+
const ids = new Set<string>();
|
|
483
|
+
for (const n of projection.nodes) {
|
|
484
|
+
ids.add(n.id);
|
|
485
|
+
const attrId = n.dataAttributes["data-agent-native-node-id"];
|
|
486
|
+
if (attrId) ids.add(attrId);
|
|
487
|
+
}
|
|
488
|
+
return ids;
|
|
489
|
+
}
|
|
490
|
+
|
|
370
491
|
let html2CanvasColorContext: CanvasRenderingContext2D | null | undefined;
|
|
371
492
|
|
|
372
493
|
interface FileContentSaveRequest {
|
|
@@ -603,6 +724,12 @@ interface GeometryHistoryEntry {
|
|
|
603
724
|
after: CanvasFrameGeometryById;
|
|
604
725
|
}
|
|
605
726
|
|
|
727
|
+
interface ContentHistoryEntry {
|
|
728
|
+
fileId: string;
|
|
729
|
+
before: string;
|
|
730
|
+
after: string;
|
|
731
|
+
}
|
|
732
|
+
|
|
606
733
|
type PatchProofStatus =
|
|
607
734
|
| "runtime"
|
|
608
735
|
| "queued"
|
|
@@ -820,6 +947,44 @@ function escapeHtmlAttributeValue(value: string): string {
|
|
|
820
947
|
.replace(/>/g, ">");
|
|
821
948
|
}
|
|
822
949
|
|
|
950
|
+
const ABS_POSITION_PROPS = [
|
|
951
|
+
"position",
|
|
952
|
+
"left",
|
|
953
|
+
"top",
|
|
954
|
+
"right",
|
|
955
|
+
"bottom",
|
|
956
|
+
] as const;
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Remove absolute-positioning style properties from the element identified by
|
|
960
|
+
* `data-agent-native-node-id` so that it becomes a flow child after being
|
|
961
|
+
* reparented into a container. Returns the updated HTML, or the original HTML
|
|
962
|
+
* if the node cannot be found or parsing is unavailable.
|
|
963
|
+
*
|
|
964
|
+
* Uses DOMParser + CSSStyleDeclaration.removeProperty() rather than
|
|
965
|
+
* applyVisualEdit({kind:"style",value:""}) because the substrate rejects
|
|
966
|
+
* empty-string values in isSafeStyleValue, making that approach a silent no-op.
|
|
967
|
+
*/
|
|
968
|
+
function removeAbsolutePositioningFromNodeInHtml(
|
|
969
|
+
content: string,
|
|
970
|
+
nodeAttrId: string,
|
|
971
|
+
): string {
|
|
972
|
+
if (typeof window === "undefined") return content;
|
|
973
|
+
try {
|
|
974
|
+
const doc = new DOMParser().parseFromString(content, "text/html");
|
|
975
|
+
const element = doc.querySelector(
|
|
976
|
+
`[data-agent-native-node-id="${CSS.escape(nodeAttrId)}"]`,
|
|
977
|
+
) as HTMLElement | null;
|
|
978
|
+
if (!element) return content;
|
|
979
|
+
for (const prop of ABS_POSITION_PROPS) {
|
|
980
|
+
element.style.removeProperty(prop);
|
|
981
|
+
}
|
|
982
|
+
return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
|
|
983
|
+
} catch {
|
|
984
|
+
return content;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
823
988
|
function escapeHtmlText(value: string): string {
|
|
824
989
|
return value
|
|
825
990
|
.replace(/&/g, "&")
|
|
@@ -1147,12 +1312,20 @@ function appendCanvasPrimitiveToHtml(
|
|
|
1147
1312
|
element.style.transform = `rotate(${geometry.rotation}deg)`;
|
|
1148
1313
|
}
|
|
1149
1314
|
|
|
1315
|
+
// Use the shared canvas-primitive-style module so committed output is
|
|
1316
|
+
// pixel-identical to the draft preview (fixes B5 color jump, B6 ellipse
|
|
1317
|
+
// radius jump). User-supplied fill/stroke/strokeWidth override the
|
|
1318
|
+
// canonical defaults so hand-chosen colours are preserved.
|
|
1319
|
+
const canonical = canvasPrimitiveVisual(
|
|
1320
|
+
primitive.kind === "rectangle" ? "rect" : primitive.kind,
|
|
1321
|
+
);
|
|
1150
1322
|
if (primitive.kind === "frame") {
|
|
1151
|
-
element.style.background = primitive.fill ??
|
|
1152
|
-
element.style.border =
|
|
1153
|
-
primitive.stroke
|
|
1154
|
-
|
|
1155
|
-
|
|
1323
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1324
|
+
element.style.border =
|
|
1325
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1326
|
+
? `${primitive.strokeWidth ?? 1}px dashed ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1327
|
+
: canonical.border;
|
|
1328
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1156
1329
|
element.style.overflow = "hidden";
|
|
1157
1330
|
} else if (primitive.kind === "text") {
|
|
1158
1331
|
element.textContent = primitive.text ?? "Text";
|
|
@@ -1164,18 +1337,23 @@ function appendCanvasPrimitiveToHtml(
|
|
|
1164
1337
|
element.style.fontSize = "16px";
|
|
1165
1338
|
element.style.lineHeight = "1.2";
|
|
1166
1339
|
element.style.whiteSpace = "pre-wrap";
|
|
1340
|
+
element.style.border = canonical.border;
|
|
1341
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1167
1342
|
} else if (primitive.kind === "ellipse") {
|
|
1168
|
-
element.style.background = primitive.fill ??
|
|
1169
|
-
element.style.border =
|
|
1170
|
-
primitive.stroke
|
|
1171
|
-
|
|
1172
|
-
|
|
1343
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1344
|
+
element.style.border =
|
|
1345
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1346
|
+
? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1347
|
+
: canonical.border;
|
|
1348
|
+
element.style.borderRadius = canonical.borderRadius; // "50%"
|
|
1173
1349
|
} else {
|
|
1174
|
-
|
|
1175
|
-
element.style.
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1350
|
+
// rect / rectangle / frame fallthrough
|
|
1351
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1352
|
+
element.style.border =
|
|
1353
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1354
|
+
? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1355
|
+
: canonical.border;
|
|
1356
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1179
1357
|
}
|
|
1180
1358
|
|
|
1181
1359
|
doc.body.appendChild(element);
|
|
@@ -1918,6 +2096,48 @@ function collectCodeLayerAncestors(
|
|
|
1918
2096
|
return [];
|
|
1919
2097
|
}
|
|
1920
2098
|
|
|
2099
|
+
function findCodeLayerNodeInProjection(
|
|
2100
|
+
projection: CodeLayerProjection,
|
|
2101
|
+
previousNode: CodeLayerNode,
|
|
2102
|
+
): CodeLayerNode | null {
|
|
2103
|
+
const stableSourceIds = [
|
|
2104
|
+
previousNode.dataAttributes["data-agent-native-node-id"],
|
|
2105
|
+
previousNode.dataAttributes["data-code-layer-id"],
|
|
2106
|
+
previousNode.dataAttributes["data-layer-id"],
|
|
2107
|
+
previousNode.dataAttributes["data-builder-id"],
|
|
2108
|
+
previousNode.dataAttributes["data-loc"],
|
|
2109
|
+
typeof previousNode.attributes.id === "string"
|
|
2110
|
+
? previousNode.attributes.id
|
|
2111
|
+
: undefined,
|
|
2112
|
+
].filter((id): id is string => Boolean(id));
|
|
2113
|
+
|
|
2114
|
+
for (const sourceId of stableSourceIds) {
|
|
2115
|
+
const stableMatch = projection.nodes.find(
|
|
2116
|
+
(node) =>
|
|
2117
|
+
node.dataAttributes["data-agent-native-node-id"] === sourceId ||
|
|
2118
|
+
node.dataAttributes["data-code-layer-id"] === sourceId ||
|
|
2119
|
+
node.dataAttributes["data-layer-id"] === sourceId ||
|
|
2120
|
+
node.dataAttributes["data-builder-id"] === sourceId ||
|
|
2121
|
+
node.dataAttributes["data-loc"] === sourceId ||
|
|
2122
|
+
node.attributes.id === sourceId,
|
|
2123
|
+
);
|
|
2124
|
+
if (stableMatch) return stableMatch;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
const exactMatch = projection.nodes.find(
|
|
2128
|
+
(node) => node.id === previousNode.id,
|
|
2129
|
+
);
|
|
2130
|
+
if (exactMatch) return exactMatch;
|
|
2131
|
+
|
|
2132
|
+
const fallbackMatches = projection.nodes.filter(
|
|
2133
|
+
(node) =>
|
|
2134
|
+
node.tag === previousNode.tag &&
|
|
2135
|
+
node.layerName === previousNode.layerName &&
|
|
2136
|
+
(node.textSnippet ?? "") === (previousNode.textSnippet ?? ""),
|
|
2137
|
+
);
|
|
2138
|
+
return fallbackMatches.length === 1 ? (fallbackMatches[0] ?? null) : null;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
1921
2141
|
function AgentNativeMenuMark({ className }: { className?: string }) {
|
|
1922
2142
|
return (
|
|
1923
2143
|
<svg
|
|
@@ -2828,6 +3048,24 @@ export default function DesignEditor() {
|
|
|
2828
3048
|
const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
|
|
2829
3049
|
const hasSelectedElement = Boolean(selectedElement);
|
|
2830
3050
|
|
|
3051
|
+
// ── Motion dock state (§6.3) ────────────────────────────────────────────────
|
|
3052
|
+
// The MotionDock is mounted below the canvas and shown when motionDockOpen.
|
|
3053
|
+
// Tracks and durationMs are local state; "Write to CSS" calls applyMotionEdit.
|
|
3054
|
+
const [motionDockOpen, setMotionDockOpen] = useState(false);
|
|
3055
|
+
const [motionTracks, setMotionTracks] = useState<MotionDockTrack[]>([]);
|
|
3056
|
+
const [motionDurationMs, setMotionDurationMs] = useState(1000);
|
|
3057
|
+
|
|
3058
|
+
// ── Breakpoint preview state (§6.4) ─────────────────────────────────────────
|
|
3059
|
+
// Active breakpoint width for the current design (pixels). Controls which
|
|
3060
|
+
// side-by-side frame is focused. undefined = no frame selected (overview mode).
|
|
3061
|
+
const [activeBreakpointWidthState, setActiveBreakpointWidthState] = useState<
|
|
3062
|
+
number | undefined
|
|
3063
|
+
>(undefined);
|
|
3064
|
+
|
|
3065
|
+
// ── Design state selection (§6.4 / §8) ───────────────────────────────────────
|
|
3066
|
+
// null = Default (live) view; a string id = one of the design_state rows.
|
|
3067
|
+
const [selectedStateId, setSelectedStateId] = useState<string | null>(null);
|
|
3068
|
+
|
|
2831
3069
|
useEffect(() => {
|
|
2832
3070
|
if (!isBuilderDesignEmbed) return;
|
|
2833
3071
|
// Announce ready to Builder. The trusted origin is not yet known at this
|
|
@@ -2955,6 +3193,9 @@ export default function DesignEditor() {
|
|
|
2955
3193
|
const [canUndo, setCanUndo] = useState(false);
|
|
2956
3194
|
const [canRedo, setCanRedo] = useState(false);
|
|
2957
3195
|
const undoManagerRef = useRef<Y.UndoManager | null>(null);
|
|
3196
|
+
const contentUndoStackRef = useRef<ContentHistoryEntry[]>([]);
|
|
3197
|
+
const contentRedoStackRef = useRef<ContentHistoryEntry[]>([]);
|
|
3198
|
+
const suppressContentHistoryRef = useRef(false);
|
|
2958
3199
|
const geometryUndoStackRef = useRef<GeometryHistoryEntry[]>([]);
|
|
2959
3200
|
const geometryRedoStackRef = useRef<GeometryHistoryEntry[]>([]);
|
|
2960
3201
|
const historyOrderRef = useRef<Array<"content" | "geometry">>([]);
|
|
@@ -2963,10 +3204,12 @@ export default function DesignEditor() {
|
|
|
2963
3204
|
const undoManager = undoManagerRef.current;
|
|
2964
3205
|
setCanUndo(
|
|
2965
3206
|
Boolean(undoManager?.canUndo()) ||
|
|
3207
|
+
contentUndoStackRef.current.length > 0 ||
|
|
2966
3208
|
geometryUndoStackRef.current.length > 0,
|
|
2967
3209
|
);
|
|
2968
3210
|
setCanRedo(
|
|
2969
3211
|
Boolean(undoManager?.canRedo()) ||
|
|
3212
|
+
contentRedoStackRef.current.length > 0 ||
|
|
2970
3213
|
geometryRedoStackRef.current.length > 0,
|
|
2971
3214
|
);
|
|
2972
3215
|
}, []);
|
|
@@ -2974,6 +3217,9 @@ export default function DesignEditor() {
|
|
|
2974
3217
|
const designSelectionOwnerIdRef = useRef(`${TAB_ID}:${generateTabId()}`);
|
|
2975
3218
|
const frameGeometrySaveTimerRef = useRef<number | null>(null);
|
|
2976
3219
|
const [tweakSaveActive, setTweakSaveActive] = useState(false);
|
|
3220
|
+
// Dismissible localhost-source banner (reset per session).
|
|
3221
|
+
const [localSourceBannerDismissed, setLocalSourceBannerDismissed] =
|
|
3222
|
+
useState(false);
|
|
2977
3223
|
// Shared visual-editor annotate overlays. drawMode owns the send toolbar,
|
|
2978
3224
|
// while pinMode temporarily routes canvas clicks to comment pins that queue
|
|
2979
3225
|
// into the same agent submission.
|
|
@@ -3292,6 +3538,58 @@ export default function DesignEditor() {
|
|
|
3292
3538
|
designAccessRole === "owner" || designAccessRole === "admin";
|
|
3293
3539
|
const canEditDesign = canShareDesign || designAccessRole === "editor";
|
|
3294
3540
|
const canEditDesignRef = useRef(canEditDesign);
|
|
3541
|
+
const pendingLocalFileContentsRef = useRef<
|
|
3542
|
+
Map<
|
|
3543
|
+
string,
|
|
3544
|
+
{ content: string; startedAt: number; baseUpdatedAt?: string | null }
|
|
3545
|
+
>
|
|
3546
|
+
>(new Map());
|
|
3547
|
+
const [
|
|
3548
|
+
pendingLocalFileContentsRevision,
|
|
3549
|
+
setPendingLocalFileContentsRevision,
|
|
3550
|
+
] = useState(0);
|
|
3551
|
+
|
|
3552
|
+
const markPendingLocalFileContent = useCallback(
|
|
3553
|
+
(fileId: string, content: string, baseUpdatedAt?: string | null) => {
|
|
3554
|
+
const current = pendingLocalFileContentsRef.current.get(fileId);
|
|
3555
|
+
if (current?.content === content) {
|
|
3556
|
+
if (
|
|
3557
|
+
baseUpdatedAt !== undefined &&
|
|
3558
|
+
current.baseUpdatedAt === undefined
|
|
3559
|
+
) {
|
|
3560
|
+
pendingLocalFileContentsRef.current.set(fileId, {
|
|
3561
|
+
...current,
|
|
3562
|
+
baseUpdatedAt,
|
|
3563
|
+
});
|
|
3564
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3565
|
+
}
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
pendingLocalFileContentsRef.current.set(fileId, {
|
|
3569
|
+
content,
|
|
3570
|
+
startedAt: Date.now(),
|
|
3571
|
+
baseUpdatedAt,
|
|
3572
|
+
});
|
|
3573
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3574
|
+
},
|
|
3575
|
+
[],
|
|
3576
|
+
);
|
|
3577
|
+
|
|
3578
|
+
const clearPendingLocalFileContent = useCallback(
|
|
3579
|
+
(fileId: string, expectedContent?: string) => {
|
|
3580
|
+
const current = pendingLocalFileContentsRef.current.get(fileId);
|
|
3581
|
+
if (!current) return;
|
|
3582
|
+
if (
|
|
3583
|
+
expectedContent !== undefined &&
|
|
3584
|
+
current.content !== expectedContent
|
|
3585
|
+
) {
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
pendingLocalFileContentsRef.current.delete(fileId);
|
|
3589
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3590
|
+
},
|
|
3591
|
+
[],
|
|
3592
|
+
);
|
|
3295
3593
|
|
|
3296
3594
|
useEffect(() => {
|
|
3297
3595
|
canEditDesignRef.current = canEditDesign;
|
|
@@ -3332,6 +3630,45 @@ export default function DesignEditor() {
|
|
|
3332
3630
|
const duplicateDesignMutation = useActionMutation("duplicate-design");
|
|
3333
3631
|
const exportHtmlMutation = useActionMutation("export-html");
|
|
3334
3632
|
const exportZipMutation = useActionMutation("export-zip");
|
|
3633
|
+
const applyMotionEditMutation = useActionMutation("apply-motion-edit");
|
|
3634
|
+
// §6.4 breakpoint mutations — wired to MultiScreenCanvas + affordance
|
|
3635
|
+
const addBreakpointMutation = useActionMutation("add-breakpoint");
|
|
3636
|
+
const setActiveBreakpointMutation = useActionMutation(
|
|
3637
|
+
"set-active-breakpoint",
|
|
3638
|
+
);
|
|
3639
|
+
|
|
3640
|
+
// §6.1 — promote a selection into a reusable component instance.
|
|
3641
|
+
const createComponentMutation = useActionMutation("create-component");
|
|
3642
|
+
// §6.1 — jump to a component instance's source (selects the root + navigates).
|
|
3643
|
+
const openComponentSourceMutation = useActionMutation(
|
|
3644
|
+
"open-component-source",
|
|
3645
|
+
);
|
|
3646
|
+
|
|
3647
|
+
// §6.6 — "Make it real" migration flow (migrate-inline-design-to-app).
|
|
3648
|
+
// The mutation stays unconditional; the dialog gates on isSignedIn.
|
|
3649
|
+
const migrateMutation = useActionMutation("migrate-inline-design-to-app");
|
|
3650
|
+
|
|
3651
|
+
// Dialog open/close state for the "Make this a real app" flow.
|
|
3652
|
+
const [makeRealDialogOpen, setMakeRealDialogOpen] = useState(false);
|
|
3653
|
+
|
|
3654
|
+
// Result payload returned by migrate-inline-design-to-app on success.
|
|
3655
|
+
// `null` = not yet migrated; populated once the Builder agent accepts the job.
|
|
3656
|
+
const [migrationResult, setMigrationResult] = useState<{
|
|
3657
|
+
branchName?: string;
|
|
3658
|
+
url?: string;
|
|
3659
|
+
versionId?: string;
|
|
3660
|
+
seedFileCount?: number;
|
|
3661
|
+
status?: string;
|
|
3662
|
+
projectId?: string;
|
|
3663
|
+
cta?: {
|
|
3664
|
+
kind: string;
|
|
3665
|
+
label: string;
|
|
3666
|
+
description: string;
|
|
3667
|
+
connectUrl?: string;
|
|
3668
|
+
primaryAction: string;
|
|
3669
|
+
};
|
|
3670
|
+
} | null>(null);
|
|
3671
|
+
|
|
3335
3672
|
const [shareExportFormat, setShareExportFormat] =
|
|
3336
3673
|
useState<ShareExportFormat>("html");
|
|
3337
3674
|
const [codingHandoffResult, setCodingHandoffResult] =
|
|
@@ -3356,6 +3693,7 @@ export default function DesignEditor() {
|
|
|
3356
3693
|
const saveFileContent = useCallback(
|
|
3357
3694
|
(pending: FileContentSaveRequest) => {
|
|
3358
3695
|
if (!canEditDesignRef.current) return;
|
|
3696
|
+
markPendingLocalFileContent(pending.id, pending.content);
|
|
3359
3697
|
latestFileSaveForUnloadRef.current[pending.id] = pending;
|
|
3360
3698
|
const previous =
|
|
3361
3699
|
fileSaveChainsRef.current[pending.id] ?? Promise.resolve();
|
|
@@ -3374,6 +3712,7 @@ export default function DesignEditor() {
|
|
|
3374
3712
|
: prev,
|
|
3375
3713
|
);
|
|
3376
3714
|
} catch (error) {
|
|
3715
|
+
clearPendingLocalFileContent(pending.id, pending.content);
|
|
3377
3716
|
setPatchProof((prev) =>
|
|
3378
3717
|
prev && prev.fileId === pending.id && prev.status === "queued"
|
|
3379
3718
|
? {
|
|
@@ -3395,7 +3734,12 @@ export default function DesignEditor() {
|
|
|
3395
3734
|
}
|
|
3396
3735
|
});
|
|
3397
3736
|
},
|
|
3398
|
-
[
|
|
3737
|
+
[
|
|
3738
|
+
clearPendingLocalFileContent,
|
|
3739
|
+
markPendingLocalFileContent,
|
|
3740
|
+
t,
|
|
3741
|
+
updateFileMutation,
|
|
3742
|
+
],
|
|
3399
3743
|
);
|
|
3400
3744
|
|
|
3401
3745
|
const queueFileContentSave = useCallback(
|
|
@@ -3410,6 +3754,7 @@ export default function DesignEditor() {
|
|
|
3410
3754
|
content,
|
|
3411
3755
|
syncCollab: options.syncCollab ?? true,
|
|
3412
3756
|
};
|
|
3757
|
+
markPendingLocalFileContent(fileId, content);
|
|
3413
3758
|
latestFileSaveForUnloadRef.current[fileId] = pending;
|
|
3414
3759
|
if (options.immediate) {
|
|
3415
3760
|
const timer = fileSaveTimersRef.current[fileId];
|
|
@@ -3434,7 +3779,7 @@ export default function DesignEditor() {
|
|
|
3434
3779
|
saveFileContent(pending);
|
|
3435
3780
|
}, 400);
|
|
3436
3781
|
},
|
|
3437
|
-
[saveFileContent],
|
|
3782
|
+
[markPendingLocalFileContent, saveFileContent],
|
|
3438
3783
|
);
|
|
3439
3784
|
|
|
3440
3785
|
useEffect(() => {
|
|
@@ -3679,7 +4024,38 @@ export default function DesignEditor() {
|
|
|
3679
4024
|
updateDesignMutation,
|
|
3680
4025
|
]);
|
|
3681
4026
|
|
|
3682
|
-
const
|
|
4027
|
+
const serverFiles = design?.files ?? [];
|
|
4028
|
+
useEffect(() => {
|
|
4029
|
+
if (pendingLocalFileContentsRef.current.size === 0) return;
|
|
4030
|
+
let changed = false;
|
|
4031
|
+
for (const file of serverFiles) {
|
|
4032
|
+
const pending = pendingLocalFileContentsRef.current.get(file.id);
|
|
4033
|
+
if (pending && (file.content ?? "") === pending.content) {
|
|
4034
|
+
if (
|
|
4035
|
+
pending.baseUpdatedAt !== undefined &&
|
|
4036
|
+
file.updatedAt === pending.baseUpdatedAt
|
|
4037
|
+
) {
|
|
4038
|
+
continue;
|
|
4039
|
+
}
|
|
4040
|
+
pendingLocalFileContentsRef.current.delete(file.id);
|
|
4041
|
+
changed = true;
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
if (changed) {
|
|
4045
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
4046
|
+
}
|
|
4047
|
+
}, [serverFiles]);
|
|
4048
|
+
const pendingLocalFileContentsSnapshot = useMemo(
|
|
4049
|
+
() => new Map(pendingLocalFileContentsRef.current),
|
|
4050
|
+
[pendingLocalFileContentsRevision],
|
|
4051
|
+
);
|
|
4052
|
+
const files = useMemo(() => {
|
|
4053
|
+
if (pendingLocalFileContentsSnapshot.size === 0) return serverFiles;
|
|
4054
|
+
return serverFiles.map((file) => {
|
|
4055
|
+
const pending = pendingLocalFileContentsSnapshot.get(file.id);
|
|
4056
|
+
return pending ? { ...file, content: pending.content } : file;
|
|
4057
|
+
});
|
|
4058
|
+
}, [pendingLocalFileContentsSnapshot, serverFiles]);
|
|
3683
4059
|
const designDataJson = useMemo(
|
|
3684
4060
|
() => parseDesignDataJson(design?.data),
|
|
3685
4061
|
[design?.data],
|
|
@@ -3699,6 +4075,38 @@ export default function DesignEditor() {
|
|
|
3699
4075
|
designDataJson,
|
|
3700
4076
|
"screenMetadata",
|
|
3701
4077
|
);
|
|
4078
|
+
// §6.4 — breakpoint set stored in designs.data.breakpointSet as a
|
|
4079
|
+
// BreakpointSet { id, breakpoints: BreakpointDefinition[] }.
|
|
4080
|
+
// Each BreakpointDefinition has { id, label, widthPx, prefix }.
|
|
4081
|
+
const breakpointSet = (() => {
|
|
4082
|
+
try {
|
|
4083
|
+
const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
|
|
4084
|
+
if (
|
|
4085
|
+
raw &&
|
|
4086
|
+
typeof raw === "object" &&
|
|
4087
|
+
!Array.isArray(raw) &&
|
|
4088
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
4089
|
+
) {
|
|
4090
|
+
return raw as {
|
|
4091
|
+
id: string;
|
|
4092
|
+
breakpoints: Array<{
|
|
4093
|
+
id: string;
|
|
4094
|
+
widthPx: number;
|
|
4095
|
+
label?: string;
|
|
4096
|
+
prefix?: string;
|
|
4097
|
+
}>;
|
|
4098
|
+
};
|
|
4099
|
+
}
|
|
4100
|
+
} catch {
|
|
4101
|
+
// ignore
|
|
4102
|
+
}
|
|
4103
|
+
return undefined;
|
|
4104
|
+
})();
|
|
4105
|
+
const bpWidths =
|
|
4106
|
+
breakpointSet && breakpointSet.breakpoints.length > 0
|
|
4107
|
+
? breakpointSet.breakpoints.map((bp) => bp.widthPx)
|
|
4108
|
+
: undefined;
|
|
4109
|
+
|
|
3702
4110
|
return files.map((file) => {
|
|
3703
4111
|
const metadata = getDesignDataRecord(metadataByFileId, file.id);
|
|
3704
4112
|
const stringValue = (key: string) =>
|
|
@@ -3716,6 +4124,7 @@ export default function DesignEditor() {
|
|
|
3716
4124
|
updatedAt: file.updatedAt,
|
|
3717
4125
|
sourceType: stringValue("sourceType"),
|
|
3718
4126
|
source: stringValue("source"),
|
|
4127
|
+
sourceFile: stringValue("sourceFile"),
|
|
3719
4128
|
lod: stringValue("lod"),
|
|
3720
4129
|
previewState: stringValue("previewState"),
|
|
3721
4130
|
status: stringValue("status"),
|
|
@@ -3724,9 +4133,19 @@ export default function DesignEditor() {
|
|
|
3724
4133
|
height: numberValue("height"),
|
|
3725
4134
|
url: stringValue("url"),
|
|
3726
4135
|
previewUrl: stringValue("previewUrl"),
|
|
4136
|
+
// Breakpoint preview widths (§6.4). When non-empty, MultiScreenCanvas
|
|
4137
|
+
// renders one iframe per width to the right of the primary frame.
|
|
4138
|
+
breakpointWidths: bpWidths,
|
|
4139
|
+
// Active breakpoint width tracked in component state; shared across all
|
|
4140
|
+
// screens (a design has one active breakpoint set at a time in v1).
|
|
4141
|
+
activeBreakpointWidth: bpWidths?.includes(
|
|
4142
|
+
activeBreakpointWidthState ?? -1,
|
|
4143
|
+
)
|
|
4144
|
+
? activeBreakpointWidthState
|
|
4145
|
+
: undefined,
|
|
3727
4146
|
};
|
|
3728
4147
|
});
|
|
3729
|
-
}, [designDataJson, files]);
|
|
4148
|
+
}, [designDataJson, files, activeBreakpointWidthState]);
|
|
3730
4149
|
const queueFrameGeometrySave = useCallback(
|
|
3731
4150
|
(geometryById: CanvasFrameGeometryById) => {
|
|
3732
4151
|
if (!id || !canEditDesignRef.current) return;
|
|
@@ -3838,6 +4257,61 @@ export default function DesignEditor() {
|
|
|
3838
4257
|
[syncUndoRedoState, writeFrameGeometrySnapshot],
|
|
3839
4258
|
);
|
|
3840
4259
|
|
|
4260
|
+
// §6.6 — "Make this a real app" handler.
|
|
4261
|
+
// Opens the dialog; actual migration fires when the user confirms.
|
|
4262
|
+
const handleOpenMakeReal = useCallback(() => {
|
|
4263
|
+
setMigrationResult(null);
|
|
4264
|
+
setMakeRealDialogOpen(true);
|
|
4265
|
+
}, []);
|
|
4266
|
+
|
|
4267
|
+
// Fires when the user clicks "Start migration" in the dialog.
|
|
4268
|
+
// Calls migrate-inline-design-to-app, then on success flips sourceType to
|
|
4269
|
+
// "fusion" in the design data blob so gated panels light up.
|
|
4270
|
+
const handleConfirmMakeReal = useCallback(async () => {
|
|
4271
|
+
if (!id) return;
|
|
4272
|
+
try {
|
|
4273
|
+
const result = await migrateMutation.mutateAsync({ designId: id } as any);
|
|
4274
|
+
const r = result as any;
|
|
4275
|
+
setMigrationResult({
|
|
4276
|
+
branchName: r?.branchName,
|
|
4277
|
+
url: r?.url,
|
|
4278
|
+
versionId: r?.versionId,
|
|
4279
|
+
seedFileCount: r?.seedFileCount,
|
|
4280
|
+
status: r?.status,
|
|
4281
|
+
projectId: r?.projectId,
|
|
4282
|
+
cta: r?.cta,
|
|
4283
|
+
});
|
|
4284
|
+
|
|
4285
|
+
// When the Builder agent accepted the job (status = "processing"),
|
|
4286
|
+
// flip the design data to sourceType "fusion" so capability-gated
|
|
4287
|
+
// panels (branches, deploy) light up on refresh.
|
|
4288
|
+
if (r?.status === "processing" && r?.url) {
|
|
4289
|
+
const nextData = {
|
|
4290
|
+
...designDataJsonRef.current,
|
|
4291
|
+
sourceType: "fusion",
|
|
4292
|
+
fusionBranchName: r.branchName,
|
|
4293
|
+
fusionUrl: r.url,
|
|
4294
|
+
fusionProjectId: r.projectId,
|
|
4295
|
+
};
|
|
4296
|
+
updateDesignMutation.mutate(
|
|
4297
|
+
{ id, data: JSON.stringify(nextData) } as any,
|
|
4298
|
+
{
|
|
4299
|
+
onSuccess: () => {
|
|
4300
|
+
queryClient.invalidateQueries({
|
|
4301
|
+
queryKey: ["action", "get-design"],
|
|
4302
|
+
});
|
|
4303
|
+
},
|
|
4304
|
+
},
|
|
4305
|
+
);
|
|
4306
|
+
} else if (r?.status === "not-configured") {
|
|
4307
|
+
// Builder not connected — leave dialog open to show the CTA.
|
|
4308
|
+
}
|
|
4309
|
+
} catch (err) {
|
|
4310
|
+
const message = err instanceof Error ? err.message : "Migration failed";
|
|
4311
|
+
toast.error(message);
|
|
4312
|
+
}
|
|
4313
|
+
}, [id, migrateMutation, updateDesignMutation, queryClient]);
|
|
4314
|
+
|
|
3841
4315
|
generationOutputReadyRef.current = files.length > 0;
|
|
3842
4316
|
|
|
3843
4317
|
useEffect(() => {
|
|
@@ -3952,6 +4426,65 @@ export default function DesignEditor() {
|
|
|
3952
4426
|
generating,
|
|
3953
4427
|
pendingGenerationActive,
|
|
3954
4428
|
});
|
|
4429
|
+
|
|
4430
|
+
// §6.5 Review panel — lazy, on-demand accessibility audit for the active
|
|
4431
|
+
// file. The audit walks the file's rendered HTML, so it must never run on
|
|
4432
|
+
// editor load: it is gated behind `auditRequested` (flipped by the panel's
|
|
4433
|
+
// "Run" button) and reset whenever the active file changes so each file is
|
|
4434
|
+
// audited explicitly. The ReviewPanel itself owns the apply-a11y-fix mutation
|
|
4435
|
+
// behind its inline "Fix" button; we just supply the active design source and
|
|
4436
|
+
// refetch on apply so a resolved finding drops out of the list.
|
|
4437
|
+
const [auditRequested, setAuditRequested] = useState(false);
|
|
4438
|
+
useEffect(() => {
|
|
4439
|
+
setAuditRequested(false);
|
|
4440
|
+
}, [activeFile?.id]);
|
|
4441
|
+
|
|
4442
|
+
const auditQuery = useActionQuery<{
|
|
4443
|
+
auditedAt: string;
|
|
4444
|
+
findings: A11yFinding[];
|
|
4445
|
+
}>(
|
|
4446
|
+
"run-design-audit",
|
|
4447
|
+
{ designId: id ?? "", fileId: activeFile?.id },
|
|
4448
|
+
{
|
|
4449
|
+
enabled: auditRequested && !!id && !!activeFile?.id,
|
|
4450
|
+
refetchOnWindowFocus: false,
|
|
4451
|
+
},
|
|
4452
|
+
);
|
|
4453
|
+
|
|
4454
|
+
const reviewPanelProps = useMemo<
|
|
4455
|
+
Omit<ReviewPanelProps, "className"> | undefined
|
|
4456
|
+
>(() => {
|
|
4457
|
+
if (!id || !activeFile) return undefined;
|
|
4458
|
+
const audit = auditRequested ? auditQuery.data : undefined;
|
|
4459
|
+
return {
|
|
4460
|
+
findings: audit?.findings ?? [],
|
|
4461
|
+
auditLoading: auditRequested && auditQuery.isFetching,
|
|
4462
|
+
auditedAt: audit?.auditedAt ?? null,
|
|
4463
|
+
auditError:
|
|
4464
|
+
auditRequested && auditQuery.isError
|
|
4465
|
+
? auditQuery.error?.message ||
|
|
4466
|
+
"Audit failed. Try again." /* i18n-ignore design review fallback */
|
|
4467
|
+
: null,
|
|
4468
|
+
onRunAudit: () => {
|
|
4469
|
+
// First run flips the gate (the query auto-fetches); later runs refetch.
|
|
4470
|
+
if (auditRequested) {
|
|
4471
|
+
void auditQuery.refetch();
|
|
4472
|
+
} else {
|
|
4473
|
+
setAuditRequested(true);
|
|
4474
|
+
}
|
|
4475
|
+
},
|
|
4476
|
+
fixSource: {
|
|
4477
|
+
designId: id,
|
|
4478
|
+
fileId: activeFile.id,
|
|
4479
|
+
filename: activeFile.filename,
|
|
4480
|
+
},
|
|
4481
|
+
onFixApplied: () => {
|
|
4482
|
+
// Re-run the audit so the resolved finding drops out of the list.
|
|
4483
|
+
void auditQuery.refetch();
|
|
4484
|
+
},
|
|
4485
|
+
};
|
|
4486
|
+
}, [id, activeFile, auditRequested, auditQuery]);
|
|
4487
|
+
|
|
3955
4488
|
const selectedScreenIds = useMemo(
|
|
3956
4489
|
() =>
|
|
3957
4490
|
getSelectedScreenIdsForEditorState({
|
|
@@ -4383,7 +4916,10 @@ export default function DesignEditor() {
|
|
|
4383
4916
|
setCollabContentFileId(null);
|
|
4384
4917
|
lastAppliedFileUpdatedAtRef.current = null;
|
|
4385
4918
|
lastLocalContentRef.current = null;
|
|
4919
|
+
contentUndoStackRef.current = [];
|
|
4920
|
+
contentRedoStackRef.current = [];
|
|
4386
4921
|
clearStaleAgentCollabRecovery();
|
|
4922
|
+
syncUndoRedoState();
|
|
4387
4923
|
return;
|
|
4388
4924
|
}
|
|
4389
4925
|
if (activeFileId !== prevActiveFileIdRef.current) {
|
|
@@ -4392,9 +4928,17 @@ export default function DesignEditor() {
|
|
|
4392
4928
|
setCollabContentFileId(null);
|
|
4393
4929
|
lastAppliedFileUpdatedAtRef.current = null;
|
|
4394
4930
|
lastLocalContentRef.current = null;
|
|
4931
|
+
contentUndoStackRef.current = [];
|
|
4932
|
+
contentRedoStackRef.current = [];
|
|
4395
4933
|
clearStaleAgentCollabRecovery();
|
|
4934
|
+
syncUndoRedoState();
|
|
4396
4935
|
}
|
|
4397
|
-
}, [
|
|
4936
|
+
}, [
|
|
4937
|
+
activeFileId,
|
|
4938
|
+
clearStaleAgentCollabRecovery,
|
|
4939
|
+
syncUndoRedoState,
|
|
4940
|
+
viewMode,
|
|
4941
|
+
]);
|
|
4398
4942
|
|
|
4399
4943
|
useEffect(() => {
|
|
4400
4944
|
return clearStaleAgentCollabRecovery;
|
|
@@ -4406,6 +4950,19 @@ export default function DesignEditor() {
|
|
|
4406
4950
|
const fileId = activeFileId;
|
|
4407
4951
|
const ytext = ydoc.getText("content");
|
|
4408
4952
|
const text = ytext.toString();
|
|
4953
|
+
const pendingLocalContent =
|
|
4954
|
+
pendingLocalFileContentsRef.current.get(fileId)?.content;
|
|
4955
|
+
if (pendingLocalContent && text !== pendingLocalContent) {
|
|
4956
|
+
setCollabContent(pendingLocalContent);
|
|
4957
|
+
setCollabContentFileId(fileId);
|
|
4958
|
+
lastLocalContentRef.current = pendingLocalContent;
|
|
4959
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
4960
|
+
ydoc.transact(() => {
|
|
4961
|
+
ytext.delete(0, ytext.length);
|
|
4962
|
+
ytext.insert(0, pendingLocalContent);
|
|
4963
|
+
}, TAB_ID);
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4409
4966
|
if (text.length > 0) {
|
|
4410
4967
|
const storedContent = activeFile?.content ?? "";
|
|
4411
4968
|
if (
|
|
@@ -4432,7 +4989,14 @@ export default function DesignEditor() {
|
|
|
4432
4989
|
setCollabContentFileId(fileId);
|
|
4433
4990
|
setContentRenderRevision((revision) => revision + 1);
|
|
4434
4991
|
}
|
|
4435
|
-
}, [
|
|
4992
|
+
}, [
|
|
4993
|
+
ydoc,
|
|
4994
|
+
isSynced,
|
|
4995
|
+
activeFileId,
|
|
4996
|
+
activeFile?.content,
|
|
4997
|
+
activeFile?.fileType,
|
|
4998
|
+
pendingLocalFileContentsRevision,
|
|
4999
|
+
]);
|
|
4436
5000
|
|
|
4437
5001
|
// Keep the freshest DB `updatedAt` in a ref the observe handler can read.
|
|
4438
5002
|
useEffect(() => {
|
|
@@ -4453,14 +5017,27 @@ export default function DesignEditor() {
|
|
|
4453
5017
|
const ytext = ydoc.getText("content");
|
|
4454
5018
|
const handler = (_event: unknown, transaction?: { origin?: unknown }) => {
|
|
4455
5019
|
const next = ytext.toString();
|
|
4456
|
-
setCollabContent(next);
|
|
4457
|
-
setCollabContentFileId(fileId);
|
|
4458
5020
|
// UndoManager fires with itself as the origin; treat those as local too
|
|
4459
5021
|
// so the reconcile watermark and stale-selection fix are consistent.
|
|
4460
5022
|
const isLocalEdit =
|
|
4461
5023
|
transaction?.origin === TAB_ID ||
|
|
4462
5024
|
transaction?.origin === LOCAL_EDIT_ORIGIN ||
|
|
4463
5025
|
transaction?.origin === undoManagerRef.current;
|
|
5026
|
+
const pendingLocalContent =
|
|
5027
|
+
pendingLocalFileContentsRef.current.get(fileId)?.content;
|
|
5028
|
+
if (pendingLocalContent && next !== pendingLocalContent && !isLocalEdit) {
|
|
5029
|
+
setCollabContent(pendingLocalContent);
|
|
5030
|
+
setCollabContentFileId(fileId);
|
|
5031
|
+
lastLocalContentRef.current = pendingLocalContent;
|
|
5032
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
5033
|
+
ydoc.transact(() => {
|
|
5034
|
+
ytext.delete(0, ytext.length);
|
|
5035
|
+
ytext.insert(0, pendingLocalContent);
|
|
5036
|
+
}, TAB_ID);
|
|
5037
|
+
return;
|
|
5038
|
+
}
|
|
5039
|
+
setCollabContent(next);
|
|
5040
|
+
setCollabContentFileId(fileId);
|
|
4464
5041
|
if (isLocalEdit) {
|
|
4465
5042
|
lastLocalContentRef.current = next;
|
|
4466
5043
|
} else {
|
|
@@ -4688,6 +5265,22 @@ export default function DesignEditor() {
|
|
|
4688
5265
|
const canvasContextMenuRef = useRef<CanvasContextMenuHandle | null>(null);
|
|
4689
5266
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
|
4690
5267
|
|
|
5268
|
+
// Live handle to the active DesignCanvas preview iframe. DesignCanvas owns the
|
|
5269
|
+
// <iframe> internally (tagged data-design-preview-iframe) and does not forward
|
|
5270
|
+
// its ref, so we resolve the element lazily from the DOM at read time. The
|
|
5271
|
+
// MotionDock reads `.current` only when scrubbing, so this always returns the
|
|
5272
|
+
// currently-mounted iframe even after content swaps recreate the element.
|
|
5273
|
+
const canvasIframeRef = useMemo<React.RefObject<HTMLIFrameElement | null>>(
|
|
5274
|
+
() => ({
|
|
5275
|
+
get current() {
|
|
5276
|
+
return document.querySelector<HTMLIFrameElement>(
|
|
5277
|
+
"iframe[data-design-preview-iframe]",
|
|
5278
|
+
);
|
|
5279
|
+
},
|
|
5280
|
+
}),
|
|
5281
|
+
[],
|
|
5282
|
+
);
|
|
5283
|
+
|
|
4691
5284
|
// Broadcast pointer position (normalized to canvas container) and
|
|
4692
5285
|
// selected element selector so peers can see where the user is working.
|
|
4693
5286
|
const handleCanvasPointerMove = useCallback(
|
|
@@ -4801,18 +5394,27 @@ export default function DesignEditor() {
|
|
|
4801
5394
|
// Resolve the content to render: prefer collab content only after the
|
|
4802
5395
|
// per-file reconcile state has reset for the current active file. Otherwise a
|
|
4803
5396
|
// file switch can render one frame with the previous file's Yjs text.
|
|
5397
|
+
// Always resolve to a string — a non-string source (e.g. a collab value that
|
|
5398
|
+
// is not yet a plain string, or a not-yet-loaded file) must never reach the
|
|
5399
|
+
// many `content.trim()` / projection callers below, which would crash render.
|
|
4804
5400
|
const activeCollabFileReady =
|
|
4805
5401
|
viewMode === "single" && activeFileId === prevActiveFileIdRef.current;
|
|
4806
|
-
const
|
|
4807
|
-
|
|
5402
|
+
const pendingActiveFileContent = activeFile?.id
|
|
5403
|
+
? pendingLocalFileContentsSnapshot.get(activeFile.id)?.content
|
|
5404
|
+
: undefined;
|
|
5405
|
+
const activeContentSource =
|
|
5406
|
+
pendingActiveFileContent ??
|
|
5407
|
+
(activeCollabFileReady &&
|
|
4808
5408
|
collabContentFileId === activeFile?.id &&
|
|
4809
5409
|
collabContent !== null
|
|
4810
5410
|
? collabContent
|
|
4811
|
-
: (activeFile?.content ?? "");
|
|
5411
|
+
: (activeFile?.content ?? ""));
|
|
5412
|
+
const activeContent =
|
|
5413
|
+
typeof activeContentSource === "string" ? activeContentSource : "";
|
|
4812
5414
|
const fileContentById = useMemo(() => {
|
|
4813
5415
|
const map = new Map<string, string>();
|
|
4814
5416
|
for (const file of files) {
|
|
4815
|
-
map.set(file.id, file.content
|
|
5417
|
+
map.set(file.id, typeof file.content === "string" ? file.content : "");
|
|
4816
5418
|
}
|
|
4817
5419
|
return map;
|
|
4818
5420
|
}, [files]);
|
|
@@ -4846,6 +5448,173 @@ export default function DesignEditor() {
|
|
|
4846
5448
|
return selectedElement?.selector ? [selectedElement.selector] : [];
|
|
4847
5449
|
}, [selectedCodeLayerNode, selectedElement?.selector]);
|
|
4848
5450
|
const selectedCanvasSelector = selectedCanvasSelectorCandidates[0] ?? null;
|
|
5451
|
+
|
|
5452
|
+
// ── Inspector header quick actions (Create component / Inspect code) ───────
|
|
5453
|
+
// Resolve the design-level source type + capability map so the inspector can
|
|
5454
|
+
// gate the real-app affordances (jump-to-source, prop write-back).
|
|
5455
|
+
const designSourceType = useMemo(
|
|
5456
|
+
() =>
|
|
5457
|
+
normalizeDesignSourceType(designDataJson.sourceType as unknown) ??
|
|
5458
|
+
"inline",
|
|
5459
|
+
[designDataJson.sourceType],
|
|
5460
|
+
);
|
|
5461
|
+
const sourceCapabilities = useMemo(() => {
|
|
5462
|
+
const caps = resolveSourceCapabilities(designSourceType);
|
|
5463
|
+
return DESIGN_CAPABILITY_NAMES.filter((name) => hasCapability(caps, name));
|
|
5464
|
+
}, [designSourceType]);
|
|
5465
|
+
|
|
5466
|
+
// Builder-hosted preview URL for fusion-source designs, written into the
|
|
5467
|
+
// design data blob by the "Make it real" migration. Threaded into DesignCanvas
|
|
5468
|
+
// so the fusion preview renders (and so the bridge trust check can validate
|
|
5469
|
+
// the frame's origin against it).
|
|
5470
|
+
const designFusionUrl = useMemo(() => {
|
|
5471
|
+
const raw = (designDataJson as { fusionUrl?: unknown }).fusionUrl;
|
|
5472
|
+
return typeof raw === "string" && raw ? raw : undefined;
|
|
5473
|
+
}, [designDataJson]);
|
|
5474
|
+
|
|
5475
|
+
// §6.1 — open a component instance's source. open-component-source selects the
|
|
5476
|
+
// component root in the editor and emits a navigate app-state; for real-app
|
|
5477
|
+
// (localhost / fusion) sources it also resolves the external file location.
|
|
5478
|
+
const handleComponentSourceJump = useCallback(
|
|
5479
|
+
({ nodeId }: { nodeId: string; componentName: string }) => {
|
|
5480
|
+
if (!id || !nodeId) return;
|
|
5481
|
+
openComponentSourceMutation.mutate(
|
|
5482
|
+
{ designId: id, nodeId, fileId: activeFileId ?? undefined } as any,
|
|
5483
|
+
{
|
|
5484
|
+
onError: () => {
|
|
5485
|
+
toast.error(
|
|
5486
|
+
"Could not open component source" /* i18n-ignore edge-case jump failure */,
|
|
5487
|
+
);
|
|
5488
|
+
},
|
|
5489
|
+
},
|
|
5490
|
+
);
|
|
5491
|
+
},
|
|
5492
|
+
[id, activeFileId, openComponentSourceMutation],
|
|
5493
|
+
);
|
|
5494
|
+
|
|
5495
|
+
// The selected node id, when it already is a recognised component instance —
|
|
5496
|
+
// unlocks the contextual Component section at the top of the Design tab.
|
|
5497
|
+
const selectedComponentNodeId = useMemo(() => {
|
|
5498
|
+
if (!selectedCodeLayerNode) return undefined;
|
|
5499
|
+
return isComponentInstance(selectedCodeLayerNode)
|
|
5500
|
+
? selectedCodeLayerNode.id
|
|
5501
|
+
: undefined;
|
|
5502
|
+
}, [selectedCodeLayerNode]);
|
|
5503
|
+
|
|
5504
|
+
// A friendly default name for the create-component dialog, derived from the
|
|
5505
|
+
// selected element's layer name / tag.
|
|
5506
|
+
const defaultComponentName = useMemo(() => {
|
|
5507
|
+
if (selectedCodeLayerNode?.layerName)
|
|
5508
|
+
return selectedCodeLayerNode.layerName;
|
|
5509
|
+
if (selectedElement?.tagName) {
|
|
5510
|
+
const tag = selectedElement.tagName;
|
|
5511
|
+
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
|
5512
|
+
}
|
|
5513
|
+
return "Component";
|
|
5514
|
+
}, [selectedCodeLayerNode?.layerName, selectedElement?.tagName]);
|
|
5515
|
+
|
|
5516
|
+
// Outer HTML of the selection — backs the inline/Alpine "Inspect code" view.
|
|
5517
|
+
const selectedElementOuterHtml = useMemo(() => {
|
|
5518
|
+
if (!selectedElement?.selector) return null;
|
|
5519
|
+
return (
|
|
5520
|
+
selectedElement.htmlContent ??
|
|
5521
|
+
getElementOuterHtml(activeContent, selectedElement.selector)
|
|
5522
|
+
);
|
|
5523
|
+
}, [activeContent, selectedElement?.selector, selectedElement?.htmlContent]);
|
|
5524
|
+
|
|
5525
|
+
// §6.3 — the motion-dock target: the selected element's literal
|
|
5526
|
+
// `data-agent-native-node-id` (the value the motion compiler + preview bridge
|
|
5527
|
+
// match on, NOT the hashed projection id) plus a friendly label. Single-screen
|
|
5528
|
+
// mode auto-stamps every selectable node with this attribute (see the
|
|
5529
|
+
// ensureCodeLayerNodeIdsInHtml effect), so a selection reliably resolves to a
|
|
5530
|
+
// stable node id here. `null` when nothing animatable is selected — the dock
|
|
5531
|
+
// then disables its "Add track" affordance.
|
|
5532
|
+
const motionSelectedTarget = useMemo<{
|
|
5533
|
+
nodeId: string;
|
|
5534
|
+
label: string;
|
|
5535
|
+
} | null>(() => {
|
|
5536
|
+
if (!selectedCodeLayerNode) return null;
|
|
5537
|
+
const nodeId =
|
|
5538
|
+
selectedCodeLayerNode.dataAttributes["data-agent-native-node-id"]?.trim();
|
|
5539
|
+
if (!nodeId) return null;
|
|
5540
|
+
const label =
|
|
5541
|
+
selectedCodeLayerNode.layerName ||
|
|
5542
|
+
selectedElement?.tagName ||
|
|
5543
|
+
"Selected element";
|
|
5544
|
+
return { nodeId, label };
|
|
5545
|
+
}, [selectedCodeLayerNode, selectedElement?.tagName]);
|
|
5546
|
+
|
|
5547
|
+
// Serialisable subset of the dock's tracks for the DesignCanvas motion-preview
|
|
5548
|
+
// bridge. Strips the UI-only `label` field. Only populated while the dock is
|
|
5549
|
+
// open so a closed dock never leaves preview overrides on the canvas; an empty
|
|
5550
|
+
// array makes DesignCanvas send `motion-preview-clear`. Scrubbing previews
|
|
5551
|
+
// these tracks live in the iframe — it never writes (that is "Write to CSS").
|
|
5552
|
+
const motionTracksWire = useMemo<MotionTrackWire[]>(() => {
|
|
5553
|
+
if (!motionDockOpen || motionTracks.length === 0) return [];
|
|
5554
|
+
return motionTracks.map(({ label: _label, ...track }) => track);
|
|
5555
|
+
}, [motionDockOpen, motionTracks]);
|
|
5556
|
+
|
|
5557
|
+
const inspectCodeData = useMemo<InspectCodeData | undefined>(() => {
|
|
5558
|
+
if (!selectedElement) return undefined;
|
|
5559
|
+
// Inline/Alpine: the design HTML is the source — show the element's HTML.
|
|
5560
|
+
// Real-app source resolution (vscode:// deep link) requires the
|
|
5561
|
+
// resolveNodeToFile bridge op, which is wired through open-component-source
|
|
5562
|
+
// when an external file path is available; until that round-trip is hooked
|
|
5563
|
+
// up here the popover shows the projected HTML for all source types.
|
|
5564
|
+
return { html: selectedElementOuterHtml, sourceLocation: null };
|
|
5565
|
+
}, [selectedElement, selectedElementOuterHtml]);
|
|
5566
|
+
|
|
5567
|
+
const handleCreateComponent = useCallback(
|
|
5568
|
+
(name: string) => {
|
|
5569
|
+
if (!id || !selectedElement) return;
|
|
5570
|
+
const nodeId = selectedElementLayerId ?? undefined;
|
|
5571
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
5572
|
+
createComponentMutation.mutate(
|
|
5573
|
+
{ designId: id, nodeId, selector, name } as any,
|
|
5574
|
+
{
|
|
5575
|
+
onSuccess: () => {
|
|
5576
|
+
queryClient.invalidateQueries({
|
|
5577
|
+
queryKey: ["action", "get-design"],
|
|
5578
|
+
});
|
|
5579
|
+
toast.success(t("designEditor.toasts.componentCreated"));
|
|
5580
|
+
},
|
|
5581
|
+
onError: () => {
|
|
5582
|
+
toast.error(t("designEditor.toasts.componentCreateFailed"));
|
|
5583
|
+
},
|
|
5584
|
+
},
|
|
5585
|
+
);
|
|
5586
|
+
|
|
5587
|
+
// Follow-up: ask the Design agent to extract props and replace repeated
|
|
5588
|
+
// instances with this component. The deterministic annotate above is the
|
|
5589
|
+
// core; this is an enhancement that runs in the agent chat.
|
|
5590
|
+
sendToAgentChat({
|
|
5591
|
+
message: `Extract props for the "${name}" component and replace repeated instances on this design with it.`,
|
|
5592
|
+
context: [
|
|
5593
|
+
`Design id: "${id}".`,
|
|
5594
|
+
selectedElement.selector
|
|
5595
|
+
? `Component root selector: ${selectedElement.selector}.`
|
|
5596
|
+
: "",
|
|
5597
|
+
nodeId ? `Component root node id: ${nodeId}.` : "",
|
|
5598
|
+
`The element was just annotated with data-agent-native-component="${name}".`,
|
|
5599
|
+
"Call view-screen first, then use get-code-layer-projection to find repeated instances, and apply-visual-edit / apply-component-prop-edit to converge them on this component with data-agent-native-prop-* props.",
|
|
5600
|
+
]
|
|
5601
|
+
.filter(Boolean)
|
|
5602
|
+
.join("\n"),
|
|
5603
|
+
submit: true,
|
|
5604
|
+
openSidebar: true,
|
|
5605
|
+
});
|
|
5606
|
+
},
|
|
5607
|
+
[
|
|
5608
|
+
id,
|
|
5609
|
+
selectedElement,
|
|
5610
|
+
selectedElementLayerId,
|
|
5611
|
+
selectedCanvasSelector,
|
|
5612
|
+
createComponentMutation,
|
|
5613
|
+
queryClient,
|
|
5614
|
+
t,
|
|
5615
|
+
],
|
|
5616
|
+
);
|
|
5617
|
+
|
|
4849
5618
|
const hoveredCodeLayerNode = useMemo(() => {
|
|
4850
5619
|
if (!hoveredElement) return null;
|
|
4851
5620
|
if (isScreenRootElementInfo(hoveredElement)) return null;
|
|
@@ -4923,6 +5692,40 @@ export default function DesignEditor() {
|
|
|
4923
5692
|
} = {},
|
|
4924
5693
|
) => {
|
|
4925
5694
|
if (!activeFile || !canEditDesignRef.current) return;
|
|
5695
|
+
const previousContent =
|
|
5696
|
+
collabContentFileIdRef.current === activeFile.id &&
|
|
5697
|
+
typeof collabContentRef.current === "string"
|
|
5698
|
+
? collabContentRef.current
|
|
5699
|
+
: (activeFile.content ?? "");
|
|
5700
|
+
const yjsHistoryAvailable = Boolean(
|
|
5701
|
+
ydoc && isSynced && undoManagerRef.current,
|
|
5702
|
+
);
|
|
5703
|
+
if (
|
|
5704
|
+
!suppressContentHistoryRef.current &&
|
|
5705
|
+
!yjsHistoryAvailable &&
|
|
5706
|
+
previousContent !== nextContent
|
|
5707
|
+
) {
|
|
5708
|
+
contentUndoStackRef.current = [
|
|
5709
|
+
...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
5710
|
+
{
|
|
5711
|
+
fileId: activeFile.id,
|
|
5712
|
+
before: previousContent,
|
|
5713
|
+
after: nextContent,
|
|
5714
|
+
},
|
|
5715
|
+
];
|
|
5716
|
+
contentRedoStackRef.current = [];
|
|
5717
|
+
historyOrderRef.current = [
|
|
5718
|
+
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
5719
|
+
"content",
|
|
5720
|
+
];
|
|
5721
|
+
redoOrderRef.current = [];
|
|
5722
|
+
syncUndoRedoState();
|
|
5723
|
+
}
|
|
5724
|
+
markPendingLocalFileContent(
|
|
5725
|
+
activeFile.id,
|
|
5726
|
+
nextContent,
|
|
5727
|
+
activeFile.updatedAt,
|
|
5728
|
+
);
|
|
4926
5729
|
setCollabContent(nextContent);
|
|
4927
5730
|
setCollabContentFileId(activeFile.id);
|
|
4928
5731
|
lastLocalContentRef.current = nextContent;
|
|
@@ -4976,9 +5779,11 @@ export default function DesignEditor() {
|
|
|
4976
5779
|
activeFile,
|
|
4977
5780
|
id,
|
|
4978
5781
|
isSynced,
|
|
5782
|
+
markPendingLocalFileContent,
|
|
4979
5783
|
queryClient,
|
|
4980
5784
|
queueFileContentSave,
|
|
4981
5785
|
replacePreviewContent,
|
|
5786
|
+
syncUndoRedoState,
|
|
4982
5787
|
ydoc,
|
|
4983
5788
|
],
|
|
4984
5789
|
);
|
|
@@ -4994,6 +5799,8 @@ export default function DesignEditor() {
|
|
|
4994
5799
|
applyLocalContentUpdate(nextContent, options);
|
|
4995
5800
|
return;
|
|
4996
5801
|
}
|
|
5802
|
+
const previousFile = files.find((file) => file.id === fileId);
|
|
5803
|
+
markPendingLocalFileContent(fileId, nextContent, previousFile?.updatedAt);
|
|
4997
5804
|
queryClient.setQueryData(["action", "get-design", { id }], (old: any) => {
|
|
4998
5805
|
if (!old || typeof old !== "object" || !Array.isArray(old.files)) {
|
|
4999
5806
|
return old;
|
|
@@ -5011,7 +5818,15 @@ export default function DesignEditor() {
|
|
|
5011
5818
|
syncCollab: true,
|
|
5012
5819
|
});
|
|
5013
5820
|
},
|
|
5014
|
-
[
|
|
5821
|
+
[
|
|
5822
|
+
activeFile?.id,
|
|
5823
|
+
applyLocalContentUpdate,
|
|
5824
|
+
files,
|
|
5825
|
+
id,
|
|
5826
|
+
markPendingLocalFileContent,
|
|
5827
|
+
queryClient,
|
|
5828
|
+
saveFileContent,
|
|
5829
|
+
],
|
|
5015
5830
|
);
|
|
5016
5831
|
|
|
5017
5832
|
const handleCreatePrimitive = useCallback(
|
|
@@ -5085,8 +5900,12 @@ export default function DesignEditor() {
|
|
|
5085
5900
|
|
|
5086
5901
|
const handlePrimitiveCreated = useCallback(
|
|
5087
5902
|
(screenId: string, nodeId: string) => {
|
|
5903
|
+
// B2/B4 fix: stay in overview mode after drawing a primitive. The user
|
|
5904
|
+
// drew a shape on the board — they should remain on the board with the
|
|
5905
|
+
// new primitive selected, matching Figma behaviour. We activate the
|
|
5906
|
+
// target screen (so the layers panel shows its content) and select the
|
|
5907
|
+
// new node, but do NOT switch to single/full view.
|
|
5088
5908
|
pendingOverviewScreenSelectionRef.current = null;
|
|
5089
|
-
viewModeRef.current = "single";
|
|
5090
5909
|
setActiveFileId(screenId);
|
|
5091
5910
|
setSelectedElement(null);
|
|
5092
5911
|
setHoveredElement(null);
|
|
@@ -5094,7 +5913,7 @@ export default function DesignEditor() {
|
|
|
5094
5913
|
setOverviewSelectedScreenIds([]);
|
|
5095
5914
|
setActiveTool("move");
|
|
5096
5915
|
setMode("edit");
|
|
5097
|
-
setViewMode("single")
|
|
5916
|
+
// viewMode stays at "overview" — no setViewMode("single") call here.
|
|
5098
5917
|
},
|
|
5099
5918
|
[],
|
|
5100
5919
|
);
|
|
@@ -5329,6 +6148,58 @@ export default function DesignEditor() {
|
|
|
5329
6148
|
mode,
|
|
5330
6149
|
activeTool,
|
|
5331
6150
|
inspectorTab: activeInspectorTab,
|
|
6151
|
+
// §8 DesignNavigationState additions — dock + breakpoint context
|
|
6152
|
+
dock: { kind: "motion" as const, open: motionDockOpen },
|
|
6153
|
+
motion: {
|
|
6154
|
+
previewing: false,
|
|
6155
|
+
playheadMs: 0,
|
|
6156
|
+
timelineId: undefined as string | undefined,
|
|
6157
|
+
selectedTrackId: undefined as string | undefined,
|
|
6158
|
+
selectedKeyframeId: undefined as string | undefined,
|
|
6159
|
+
},
|
|
6160
|
+
// §8 breakpoint fields — "auto" = no specific breakpoint focused.
|
|
6161
|
+
breakpoint: (activeBreakpointWidthState != null
|
|
6162
|
+
? activeBreakpointWidthState < 500
|
|
6163
|
+
? "mobile"
|
|
6164
|
+
: activeBreakpointWidthState < 1024
|
|
6165
|
+
? "tablet"
|
|
6166
|
+
: "desktop"
|
|
6167
|
+
: "auto") as "auto" | "mobile" | "tablet" | "desktop",
|
|
6168
|
+
activeBreakpointId: (() => {
|
|
6169
|
+
if (activeBreakpointWidthState == null) return undefined;
|
|
6170
|
+
try {
|
|
6171
|
+
const raw = (designDataJson as Record<string, unknown>)
|
|
6172
|
+
?.breakpointSet;
|
|
6173
|
+
if (
|
|
6174
|
+
raw &&
|
|
6175
|
+
typeof raw === "object" &&
|
|
6176
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
6177
|
+
) {
|
|
6178
|
+
const bps = (
|
|
6179
|
+
raw as { breakpoints: Array<{ id: string; widthPx: number }> }
|
|
6180
|
+
).breakpoints;
|
|
6181
|
+
return bps.find((b) => b.widthPx === activeBreakpointWidthState)
|
|
6182
|
+
?.id;
|
|
6183
|
+
}
|
|
6184
|
+
} catch {
|
|
6185
|
+
// ignore
|
|
6186
|
+
}
|
|
6187
|
+
return undefined;
|
|
6188
|
+
})(),
|
|
6189
|
+
breakpointSetId: (() => {
|
|
6190
|
+
try {
|
|
6191
|
+
const raw = (designDataJson as Record<string, unknown>)
|
|
6192
|
+
?.breakpointSet;
|
|
6193
|
+
if (raw && typeof raw === "object") {
|
|
6194
|
+
return (raw as Record<string, unknown>).id as string | undefined;
|
|
6195
|
+
}
|
|
6196
|
+
} catch {
|
|
6197
|
+
// ignore
|
|
6198
|
+
}
|
|
6199
|
+
return undefined;
|
|
6200
|
+
})(),
|
|
6201
|
+
// §8 — active design state (null = Default / live view)
|
|
6202
|
+
selectedStateId,
|
|
5332
6203
|
};
|
|
5333
6204
|
(window as any).__designSelection = selection;
|
|
5334
6205
|
const persistedSelection = {
|
|
@@ -5344,6 +6215,12 @@ export default function DesignEditor() {
|
|
|
5344
6215
|
mode: selection.mode,
|
|
5345
6216
|
activeTool: selection.activeTool,
|
|
5346
6217
|
inspectorTab: selection.inspectorTab,
|
|
6218
|
+
dock: selection.dock,
|
|
6219
|
+
motion: selection.motion,
|
|
6220
|
+
breakpoint: selection.breakpoint,
|
|
6221
|
+
activeBreakpointId: selection.activeBreakpointId,
|
|
6222
|
+
breakpointSetId: selection.breakpointSetId,
|
|
6223
|
+
selectedStateId: selection.selectedStateId,
|
|
5347
6224
|
ownerId: designSelectionOwnerIdRef.current,
|
|
5348
6225
|
};
|
|
5349
6226
|
const persistedKey = JSON.stringify(persistedSelection);
|
|
@@ -5381,6 +6258,10 @@ export default function DesignEditor() {
|
|
|
5381
6258
|
overviewSelectedScreenIds,
|
|
5382
6259
|
viewMode,
|
|
5383
6260
|
zoom,
|
|
6261
|
+
motionDockOpen,
|
|
6262
|
+
activeBreakpointWidthState,
|
|
6263
|
+
designDataJson,
|
|
6264
|
+
selectedStateId,
|
|
5384
6265
|
]);
|
|
5385
6266
|
|
|
5386
6267
|
useEffect(() => {
|
|
@@ -5751,12 +6632,49 @@ export default function DesignEditor() {
|
|
|
5751
6632
|
const nextContent = applyInlineStylesToHtml(baseContent, selector, {
|
|
5752
6633
|
...Object.fromEntries(entries),
|
|
5753
6634
|
});
|
|
6635
|
+
// §6.4 — Breakpoint-scoped class editing. Reuses the `projection` and
|
|
6636
|
+
// `targetNode` resolved above for the patch-proof block (same baseContent).
|
|
6637
|
+
// When an active non-base breakpoint frame is set, attempt to route class
|
|
6638
|
+
// edits through `kind: "responsive-class"` so the write targets only that
|
|
6639
|
+
// breakpoint prefix (e.g. "md:text-lg" instead of "text-lg"). This fires
|
|
6640
|
+
// when the element has a `responsive-class` EditCapability, which signals
|
|
6641
|
+
// that its values come from Tailwind class tokens and can carry a prefix.
|
|
6642
|
+
// Falls back to `kind: "style"` (inline attribute) for any entry that
|
|
6643
|
+
// fails the responsive path (e.g. raw CSS values with no Tailwind utility).
|
|
6644
|
+
const activeBreakpointPrefix =
|
|
6645
|
+
activeBreakpointWidthState != null
|
|
6646
|
+
? widthToPrefix(activeBreakpointWidthState)
|
|
6647
|
+
: null;
|
|
6648
|
+
// `responsive-class` is a code-layer EditCapability kind not yet reflected
|
|
6649
|
+
// in the ElementInfo type union (types.ts); cast to string for the check.
|
|
6650
|
+
const hasResponsiveCapability =
|
|
6651
|
+
activeBreakpointPrefix != null &&
|
|
6652
|
+
activeBreakpointPrefix !== "base" &&
|
|
6653
|
+
selectedElement?.editCapabilities?.some(
|
|
6654
|
+
(cap) => (cap.kind as string) === "responsive-class",
|
|
6655
|
+
);
|
|
5754
6656
|
const stylePatch = entries.reduce<{
|
|
5755
6657
|
content: string;
|
|
5756
6658
|
failed: string | null;
|
|
5757
6659
|
}>(
|
|
5758
6660
|
(current, [property, value]) => {
|
|
5759
6661
|
if (current.failed) return current;
|
|
6662
|
+
// Try responsive-class path first when appropriate.
|
|
6663
|
+
if (hasResponsiveCapability && activeBreakpointPrefix) {
|
|
6664
|
+
const rcPatch = applyVisualEdit(current.content, {
|
|
6665
|
+
kind: "responsive-class",
|
|
6666
|
+
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
6667
|
+
prefix: activeBreakpointPrefix,
|
|
6668
|
+
operation: "replace",
|
|
6669
|
+
utility: value,
|
|
6670
|
+
stem: property,
|
|
6671
|
+
});
|
|
6672
|
+
if (rcPatch.result.status === "applied") {
|
|
6673
|
+
return { content: rcPatch.content, failed: null };
|
|
6674
|
+
}
|
|
6675
|
+
// Responsive-class path didn't apply (e.g. value is a raw CSS value,
|
|
6676
|
+
// not a Tailwind utility); fall through to the inline-style path.
|
|
6677
|
+
}
|
|
5760
6678
|
const patch = applyVisualEdit(current.content, {
|
|
5761
6679
|
kind: "style",
|
|
5762
6680
|
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
@@ -5865,6 +6783,7 @@ export default function DesignEditor() {
|
|
|
5865
6783
|
[
|
|
5866
6784
|
activeContent,
|
|
5867
6785
|
activeFile,
|
|
6786
|
+
activeBreakpointWidthState,
|
|
5868
6787
|
canEditDesign,
|
|
5869
6788
|
queueFileContentSave,
|
|
5870
6789
|
selectedElement,
|
|
@@ -6490,7 +7409,6 @@ export default function DesignEditor() {
|
|
|
6490
7409
|
setHasCanvasClipboard(true);
|
|
6491
7410
|
try {
|
|
6492
7411
|
await navigator.clipboard.writeText(html);
|
|
6493
|
-
toast.success(t("designEditor.toasts.copied"));
|
|
6494
7412
|
} catch {
|
|
6495
7413
|
toast.error(t("designEditor.toasts.clipboardBlocked"));
|
|
6496
7414
|
}
|
|
@@ -6499,7 +7417,53 @@ export default function DesignEditor() {
|
|
|
6499
7417
|
const handlePasteSelection = useCallback(
|
|
6500
7418
|
(position?: { x: number; y: number }) => {
|
|
6501
7419
|
if (!activeFile || !canEditDesign || !copiedLayerHtmlRef.current) return;
|
|
6502
|
-
|
|
7420
|
+
|
|
7421
|
+
// B7 fix: when an element is selected and no explicit canvas position was
|
|
7422
|
+
// given, insert the clone as an in-flow sibling right AFTER the selected
|
|
7423
|
+
// element. Strip any position/left/top from the clone so it participates
|
|
7424
|
+
// in normal document flow instead of being an absolutely-positioned body
|
|
7425
|
+
// child. Fall back to the old position-based clone when nothing is
|
|
7426
|
+
// selected or a "Paste here" position is provided.
|
|
7427
|
+
if (!position && selectedElement?.selector) {
|
|
7428
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
7429
|
+
// Strip position properties from the pasted clone so it becomes an
|
|
7430
|
+
// in-flow sibling (not absolute).
|
|
7431
|
+
const strippedHtml = (() => {
|
|
7432
|
+
try {
|
|
7433
|
+
const parser = new DOMParser();
|
|
7434
|
+
const tmp = parser.parseFromString(
|
|
7435
|
+
`<template>${copiedLayerHtmlRef.current!}</template>`,
|
|
7436
|
+
"text/html",
|
|
7437
|
+
);
|
|
7438
|
+
const root =
|
|
7439
|
+
tmp.querySelector("template")?.content.firstElementChild ??
|
|
7440
|
+
tmp.body.firstElementChild;
|
|
7441
|
+
if (root && root instanceof HTMLElement) {
|
|
7442
|
+
root.style.position = "";
|
|
7443
|
+
root.style.left = "";
|
|
7444
|
+
root.style.top = "";
|
|
7445
|
+
root.style.right = "";
|
|
7446
|
+
root.style.bottom = "";
|
|
7447
|
+
}
|
|
7448
|
+
return root?.outerHTML ?? copiedLayerHtmlRef.current!;
|
|
7449
|
+
} catch {
|
|
7450
|
+
return copiedLayerHtmlRef.current!;
|
|
7451
|
+
}
|
|
7452
|
+
})();
|
|
7453
|
+
|
|
7454
|
+
const nextContent = insertClonedHtmlLayer(activeContent, strippedHtml, {
|
|
7455
|
+
targetSelectors: [selector],
|
|
7456
|
+
placement: "after",
|
|
7457
|
+
});
|
|
7458
|
+
if (nextContent) {
|
|
7459
|
+
pasteCascadeRef.current += 1;
|
|
7460
|
+
applyLocalContentUpdate(nextContent);
|
|
7461
|
+
return;
|
|
7462
|
+
}
|
|
7463
|
+
// Fall through to position-based clone if insert failed.
|
|
7464
|
+
}
|
|
7465
|
+
|
|
7466
|
+
// Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
|
|
6503
7467
|
// Keyboard pastes land near the source layer and cascade so repeats don't
|
|
6504
7468
|
// stack exactly.
|
|
6505
7469
|
const targetPosition =
|
|
@@ -6519,9 +7483,15 @@ export default function DesignEditor() {
|
|
|
6519
7483
|
if (!nextContent) return;
|
|
6520
7484
|
if (!position) pasteCascadeRef.current += 1;
|
|
6521
7485
|
applyLocalContentUpdate(nextContent);
|
|
6522
|
-
toast.success(t("designEditor.toasts.pasted"), { duration: 3000 });
|
|
6523
7486
|
},
|
|
6524
|
-
[
|
|
7487
|
+
[
|
|
7488
|
+
activeContent,
|
|
7489
|
+
activeFile,
|
|
7490
|
+
applyLocalContentUpdate,
|
|
7491
|
+
canEditDesign,
|
|
7492
|
+
selectedCanvasSelector,
|
|
7493
|
+
selectedElement,
|
|
7494
|
+
],
|
|
6525
7495
|
);
|
|
6526
7496
|
|
|
6527
7497
|
const handlePasteOverSelection = useCallback(() => {
|
|
@@ -6535,7 +7505,6 @@ export default function DesignEditor() {
|
|
|
6535
7505
|
);
|
|
6536
7506
|
if (!nextContent) return;
|
|
6537
7507
|
applyLocalContentUpdate(nextContent);
|
|
6538
|
-
toast.success(t("designEditor.toasts.pasted"));
|
|
6539
7508
|
} else {
|
|
6540
7509
|
handlePasteSelection();
|
|
6541
7510
|
}
|
|
@@ -6545,20 +7514,46 @@ export default function DesignEditor() {
|
|
|
6545
7514
|
applyLocalContentUpdate,
|
|
6546
7515
|
handlePasteSelection,
|
|
6547
7516
|
selectedElement,
|
|
6548
|
-
t,
|
|
6549
7517
|
]);
|
|
6550
7518
|
|
|
6551
7519
|
const handleDuplicateSelection = useCallback(() => {
|
|
6552
7520
|
if (!canEditDesign) return;
|
|
6553
7521
|
if (selectedElement?.selector) {
|
|
6554
7522
|
const html = getElementOuterHtml(activeContent, selectedElement.selector);
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
7523
|
+
if (!html) {
|
|
7524
|
+
toast.error(t("designEditor.toasts.duplicateElementFailed"));
|
|
7525
|
+
return;
|
|
7526
|
+
}
|
|
7527
|
+
// B7 fix: duplicate inserts the clone as an in-flow sibling right AFTER
|
|
7528
|
+
// the original — not as an absolutely-positioned body child. Strip
|
|
7529
|
+
// position/left/top so it joins normal document flow.
|
|
7530
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
7531
|
+
const strippedHtml = (() => {
|
|
7532
|
+
try {
|
|
7533
|
+
const parser = new DOMParser();
|
|
7534
|
+
const tmp = parser.parseFromString(
|
|
7535
|
+
`<template>${html}</template>`,
|
|
7536
|
+
"text/html",
|
|
7537
|
+
);
|
|
7538
|
+
const root =
|
|
7539
|
+
tmp.querySelector("template")?.content.firstElementChild ??
|
|
7540
|
+
tmp.body.firstElementChild;
|
|
7541
|
+
if (root && root instanceof HTMLElement) {
|
|
7542
|
+
root.style.position = "";
|
|
7543
|
+
root.style.left = "";
|
|
7544
|
+
root.style.top = "";
|
|
7545
|
+
root.style.right = "";
|
|
7546
|
+
root.style.bottom = "";
|
|
7547
|
+
}
|
|
7548
|
+
return root?.outerHTML ?? html;
|
|
7549
|
+
} catch {
|
|
7550
|
+
return html;
|
|
7551
|
+
}
|
|
7552
|
+
})();
|
|
7553
|
+
const nextContent = insertClonedHtmlLayer(activeContent, strippedHtml, {
|
|
7554
|
+
targetSelectors: [selector],
|
|
7555
|
+
placement: "after",
|
|
7556
|
+
});
|
|
6562
7557
|
if (nextContent) {
|
|
6563
7558
|
applyLocalContentUpdate(nextContent);
|
|
6564
7559
|
} else {
|
|
@@ -6573,7 +7568,9 @@ export default function DesignEditor() {
|
|
|
6573
7568
|
applyLocalContentUpdate,
|
|
6574
7569
|
canEditDesign,
|
|
6575
7570
|
handleDuplicateScreen,
|
|
7571
|
+
selectedCanvasSelector,
|
|
6576
7572
|
selectedElement,
|
|
7573
|
+
t,
|
|
6577
7574
|
]);
|
|
6578
7575
|
|
|
6579
7576
|
const handleDeleteSelection = useCallback(() => {
|
|
@@ -6639,6 +7636,402 @@ export default function DesignEditor() {
|
|
|
6639
7636
|
selectedLayerIdsState,
|
|
6640
7637
|
]);
|
|
6641
7638
|
|
|
7639
|
+
// Wrap the current multi-layer selection into a new group container.
|
|
7640
|
+
const handleGroupSelection = useCallback(() => {
|
|
7641
|
+
if (!canEditDesign || !activeFile) return;
|
|
7642
|
+
// Collect the DOM-node layer ids that belong to the active screen.
|
|
7643
|
+
// Build a set of ids present in the active content so stale ids from
|
|
7644
|
+
// other files (which can persist in selectedLayerIdsState after a
|
|
7645
|
+
// cross-screen layers-panel selection) are excluded before wrapNodes
|
|
7646
|
+
// runs against activeContent. Without this filter, cross-file ids
|
|
7647
|
+
// cause wrapNodes to return "conflict" even for a valid same-file
|
|
7648
|
+
// selection.
|
|
7649
|
+
const fileIds = new Set(files.map((f) => f.id));
|
|
7650
|
+
const activeNodeIdSet = buildActiveFileNodeIdSet(
|
|
7651
|
+
buildCodeLayerProjection(activeContent),
|
|
7652
|
+
);
|
|
7653
|
+
const nodeIds = selectedLayerIdsState.filter(
|
|
7654
|
+
(id) =>
|
|
7655
|
+
!id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
|
|
7656
|
+
);
|
|
7657
|
+
if (nodeIds.length < 2) return;
|
|
7658
|
+
const patch = applyVisualEdit(activeContent, {
|
|
7659
|
+
kind: "wrapNodes",
|
|
7660
|
+
targetIds: nodeIds,
|
|
7661
|
+
autoLayout: false,
|
|
7662
|
+
});
|
|
7663
|
+
if (patch.result.status !== "applied") {
|
|
7664
|
+
toast.error(
|
|
7665
|
+
codeLayerPatchMessage(
|
|
7666
|
+
patch.result.message,
|
|
7667
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7668
|
+
),
|
|
7669
|
+
{ duration: 4000 },
|
|
7670
|
+
);
|
|
7671
|
+
return;
|
|
7672
|
+
}
|
|
7673
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
7674
|
+
// Select the new wrapper node if the substrate reported its id.
|
|
7675
|
+
const wrapperId = patch.result.wrapperNodeId;
|
|
7676
|
+
if (wrapperId) {
|
|
7677
|
+
// Find the projection node whose data-agent-native-node-id matches.
|
|
7678
|
+
const wrapperNode = patch.projection.nodes.find(
|
|
7679
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === wrapperId,
|
|
7680
|
+
);
|
|
7681
|
+
if (wrapperNode) {
|
|
7682
|
+
setSelectedLayerIdsState([wrapperNode.id]);
|
|
7683
|
+
setSelectedElement(elementInfoFromCodeLayerNode(wrapperNode));
|
|
7684
|
+
}
|
|
7685
|
+
}
|
|
7686
|
+
}, [
|
|
7687
|
+
activeContent,
|
|
7688
|
+
activeFile,
|
|
7689
|
+
applyLocalContentUpdate,
|
|
7690
|
+
canEditDesign,
|
|
7691
|
+
files,
|
|
7692
|
+
selectedLayerIdsState,
|
|
7693
|
+
t,
|
|
7694
|
+
]);
|
|
7695
|
+
|
|
7696
|
+
// Unwrap the currently selected single-container layer.
|
|
7697
|
+
const handleUngroupSelection = useCallback(() => {
|
|
7698
|
+
if (!canEditDesign || !activeFile) return;
|
|
7699
|
+
// Filter to active-file nodes only (mirrors handleGroupSelection fix).
|
|
7700
|
+
// A stale id from another file must not be passed to unwrap or it will
|
|
7701
|
+
// fail with "conflict" even though the actual selection is valid.
|
|
7702
|
+
const fileIds = new Set(files.map((f) => f.id));
|
|
7703
|
+
const activeNodeIdSet = buildActiveFileNodeIdSet(
|
|
7704
|
+
buildCodeLayerProjection(activeContent),
|
|
7705
|
+
);
|
|
7706
|
+
const nodeIds = selectedLayerIdsState.filter(
|
|
7707
|
+
(id) =>
|
|
7708
|
+
!id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
|
|
7709
|
+
);
|
|
7710
|
+
const targetId = nodeIds[0];
|
|
7711
|
+
if (!targetId) return;
|
|
7712
|
+
const patch = applyVisualEdit(activeContent, {
|
|
7713
|
+
kind: "unwrap",
|
|
7714
|
+
targetId,
|
|
7715
|
+
});
|
|
7716
|
+
if (patch.result.status !== "applied") {
|
|
7717
|
+
toast.error(
|
|
7718
|
+
codeLayerPatchMessage(
|
|
7719
|
+
patch.result.message,
|
|
7720
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7721
|
+
),
|
|
7722
|
+
{ duration: 4000 },
|
|
7723
|
+
);
|
|
7724
|
+
return;
|
|
7725
|
+
}
|
|
7726
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
7727
|
+
setSelectedElement(null);
|
|
7728
|
+
setSelectedLayerIdsState([]);
|
|
7729
|
+
}, [
|
|
7730
|
+
activeContent,
|
|
7731
|
+
activeFile,
|
|
7732
|
+
applyLocalContentUpdate,
|
|
7733
|
+
canEditDesign,
|
|
7734
|
+
files,
|
|
7735
|
+
selectedLayerIdsState,
|
|
7736
|
+
t,
|
|
7737
|
+
]);
|
|
7738
|
+
|
|
7739
|
+
/**
|
|
7740
|
+
* Convert the selected container to full auto-layout. Applies the
|
|
7741
|
+
* { kind: "autoLayout", enabled: true } substrate intent which sets
|
|
7742
|
+
* display:flex on the target AND strips position:absolute/left/top/right/bottom
|
|
7743
|
+
* from its direct children so they become flow children.
|
|
7744
|
+
*/
|
|
7745
|
+
const handleAutoLayoutConvert = useCallback(
|
|
7746
|
+
(
|
|
7747
|
+
targetNodeId: string,
|
|
7748
|
+
opts?: { direction?: "row" | "column"; gap?: string },
|
|
7749
|
+
) => {
|
|
7750
|
+
if (!canEditDesign || !activeFile) return;
|
|
7751
|
+
const patch = applyVisualEdit(activeContent, {
|
|
7752
|
+
kind: "autoLayout",
|
|
7753
|
+
targetId: targetNodeId,
|
|
7754
|
+
enabled: true,
|
|
7755
|
+
direction: opts?.direction ?? "row",
|
|
7756
|
+
gap: opts?.gap ?? "8px",
|
|
7757
|
+
});
|
|
7758
|
+
if (patch.result.status !== "applied") {
|
|
7759
|
+
toast.error(
|
|
7760
|
+
codeLayerPatchMessage(
|
|
7761
|
+
patch.result.message,
|
|
7762
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7763
|
+
),
|
|
7764
|
+
{ duration: 4000 },
|
|
7765
|
+
);
|
|
7766
|
+
return;
|
|
7767
|
+
}
|
|
7768
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
7769
|
+
// Re-select the container so the inspector refreshes its layout state.
|
|
7770
|
+
const containerNode = patch.projection.nodes.find(
|
|
7771
|
+
(n) =>
|
|
7772
|
+
n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
|
|
7773
|
+
n.id === targetNodeId,
|
|
7774
|
+
);
|
|
7775
|
+
if (containerNode) {
|
|
7776
|
+
setSelectedLayerIdsState([containerNode.id]);
|
|
7777
|
+
setSelectedElement(elementInfoFromCodeLayerNode(containerNode));
|
|
7778
|
+
}
|
|
7779
|
+
},
|
|
7780
|
+
[activeContent, activeFile, applyLocalContentUpdate, canEditDesign, t],
|
|
7781
|
+
);
|
|
7782
|
+
|
|
7783
|
+
/**
|
|
7784
|
+
* Handle a primitive being drag-dropped onto another primitive in the
|
|
7785
|
+
* MultiScreenCanvas overview (CONTRACT: onPrimitiveReparent prop).
|
|
7786
|
+
*
|
|
7787
|
+
* Same-screen: applies a moveNode intent then strips the moved node's
|
|
7788
|
+
* absolute positioning so it becomes a flow child of the container.
|
|
7789
|
+
* Cross-screen: uses moveNodeBetweenDocuments and persists both files.
|
|
7790
|
+
*/
|
|
7791
|
+
const handleOverviewPrimitiveReparent = useCallback(
|
|
7792
|
+
({
|
|
7793
|
+
sourceNodeId,
|
|
7794
|
+
sourceScreenId,
|
|
7795
|
+
targetNodeId,
|
|
7796
|
+
targetScreenId,
|
|
7797
|
+
}: {
|
|
7798
|
+
sourceNodeId: string;
|
|
7799
|
+
sourceScreenId: string;
|
|
7800
|
+
targetNodeId: string;
|
|
7801
|
+
targetScreenId: string;
|
|
7802
|
+
placement: "inside";
|
|
7803
|
+
}) => {
|
|
7804
|
+
if (!canEditDesign) return;
|
|
7805
|
+
|
|
7806
|
+
if (sourceScreenId === targetScreenId) {
|
|
7807
|
+
// --- Same-screen reparent ---
|
|
7808
|
+
const baseContent = getScreenContent(sourceScreenId);
|
|
7809
|
+
if (!baseContent) return;
|
|
7810
|
+
|
|
7811
|
+
// 1. Move the node inside the target container.
|
|
7812
|
+
const movePatch = applyVisualEdit(baseContent, {
|
|
7813
|
+
kind: "moveNode",
|
|
7814
|
+
target: { nodeId: sourceNodeId },
|
|
7815
|
+
anchor: { nodeId: targetNodeId },
|
|
7816
|
+
placement: "inside",
|
|
7817
|
+
});
|
|
7818
|
+
if (movePatch.result.status !== "applied") {
|
|
7819
|
+
toast.error(
|
|
7820
|
+
codeLayerPatchMessage(
|
|
7821
|
+
movePatch.result.message,
|
|
7822
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7823
|
+
),
|
|
7824
|
+
{ duration: 4000 },
|
|
7825
|
+
);
|
|
7826
|
+
return;
|
|
7827
|
+
}
|
|
7828
|
+
|
|
7829
|
+
// 2. Strip absolute positioning from the moved node so it flows naturally.
|
|
7830
|
+
// Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because the
|
|
7831
|
+
// applyVisualEdit substrate rejects empty-string values in isSafeStyleValue,
|
|
7832
|
+
// making applyVisualEdit({kind:"style",value:""}) a silent no-op.
|
|
7833
|
+
const movedNodeAttrId =
|
|
7834
|
+
movePatch.projection.nodes.find(
|
|
7835
|
+
(n) =>
|
|
7836
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
7837
|
+
n.id === sourceNodeId,
|
|
7838
|
+
)?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
|
|
7839
|
+
const strippedContent = removeAbsolutePositioningFromNodeInHtml(
|
|
7840
|
+
movePatch.content,
|
|
7841
|
+
movedNodeAttrId,
|
|
7842
|
+
);
|
|
7843
|
+
|
|
7844
|
+
applyFileContentUpdate(sourceScreenId, strippedContent, {
|
|
7845
|
+
skipPreview: true,
|
|
7846
|
+
});
|
|
7847
|
+
|
|
7848
|
+
// Re-select the moved node.
|
|
7849
|
+
const nextProjection = buildCodeLayerProjection(strippedContent);
|
|
7850
|
+
const movedNodeAfter = nextProjection.nodes.find(
|
|
7851
|
+
(n) =>
|
|
7852
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
7853
|
+
n.id === sourceNodeId,
|
|
7854
|
+
);
|
|
7855
|
+
if (movedNodeAfter) {
|
|
7856
|
+
setSelectedLayerIdsState([movedNodeAfter.id]);
|
|
7857
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeAfter));
|
|
7858
|
+
}
|
|
7859
|
+
return;
|
|
7860
|
+
}
|
|
7861
|
+
|
|
7862
|
+
// --- Cross-screen reparent ---
|
|
7863
|
+
const sourceContent = getScreenContent(sourceScreenId);
|
|
7864
|
+
const destContent = getScreenContent(targetScreenId);
|
|
7865
|
+
if (!sourceContent || !destContent) return;
|
|
7866
|
+
|
|
7867
|
+
// Resolve data-agent-native-node-id attributes for moveNodeBetweenDocuments.
|
|
7868
|
+
const sourceProjection = buildCodeLayerProjection(sourceContent);
|
|
7869
|
+
const destProjection = buildCodeLayerProjection(destContent);
|
|
7870
|
+
const sourceNode = sourceProjection.nodes.find(
|
|
7871
|
+
(n) =>
|
|
7872
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
7873
|
+
n.id === sourceNodeId,
|
|
7874
|
+
);
|
|
7875
|
+
const anchorNode = destProjection.nodes.find(
|
|
7876
|
+
(n) =>
|
|
7877
|
+
n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
|
|
7878
|
+
n.id === targetNodeId,
|
|
7879
|
+
);
|
|
7880
|
+
const nodeAttrId =
|
|
7881
|
+
sourceNode?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
|
|
7882
|
+
const anchorAttrId =
|
|
7883
|
+
anchorNode?.dataAttributes["data-agent-native-node-id"] ?? targetNodeId;
|
|
7884
|
+
|
|
7885
|
+
const result = moveNodeBetweenDocuments(sourceContent, destContent, {
|
|
7886
|
+
nodeId: nodeAttrId,
|
|
7887
|
+
anchorNodeId: anchorAttrId,
|
|
7888
|
+
placement: "inside",
|
|
7889
|
+
});
|
|
7890
|
+
if (result.status !== "applied") {
|
|
7891
|
+
toast.error(
|
|
7892
|
+
codeLayerPatchMessage(
|
|
7893
|
+
result.message,
|
|
7894
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7895
|
+
),
|
|
7896
|
+
{ duration: 4000 },
|
|
7897
|
+
);
|
|
7898
|
+
return;
|
|
7899
|
+
}
|
|
7900
|
+
|
|
7901
|
+
// Strip absolute positioning from the moved node in the destination.
|
|
7902
|
+
// Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because
|
|
7903
|
+
// applyVisualEdit({kind:"style",value:""}) is a silent no-op (the
|
|
7904
|
+
// substrate rejects empty-string values in isSafeStyleValue). Use
|
|
7905
|
+
// result.movedNodeId (the final id in destHtml, which may differ from
|
|
7906
|
+
// nodeAttrId when a collision triggered an id re-stamp) so the strip and
|
|
7907
|
+
// re-selection always find the correct element.
|
|
7908
|
+
const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
|
|
7909
|
+
const strippedDest = removeAbsolutePositioningFromNodeInHtml(
|
|
7910
|
+
result.destHtml,
|
|
7911
|
+
destNodeAttrId,
|
|
7912
|
+
);
|
|
7913
|
+
|
|
7914
|
+
applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
|
|
7915
|
+
refreshPreview: true,
|
|
7916
|
+
});
|
|
7917
|
+
applyFileContentUpdate(targetScreenId, strippedDest, {
|
|
7918
|
+
refreshPreview: true,
|
|
7919
|
+
});
|
|
7920
|
+
|
|
7921
|
+
// Re-select the moved node in the destination.
|
|
7922
|
+
const finalProjection = buildCodeLayerProjection(strippedDest);
|
|
7923
|
+
const movedNodeFinal = finalProjection.nodes.find(
|
|
7924
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
|
|
7925
|
+
);
|
|
7926
|
+
if (movedNodeFinal) {
|
|
7927
|
+
setSelectedLayerIdsState([movedNodeFinal.id]);
|
|
7928
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
|
|
7929
|
+
}
|
|
7930
|
+
},
|
|
7931
|
+
[applyFileContentUpdate, canEditDesign, getScreenContent, t],
|
|
7932
|
+
);
|
|
7933
|
+
|
|
7934
|
+
/**
|
|
7935
|
+
* Cross-screen element drag-drop handler (CONTRACT: onCrossScreenElementDrop
|
|
7936
|
+
* prop on MultiScreenCanvas).
|
|
7937
|
+
*
|
|
7938
|
+
* The bridge in the source screen's iframe posts phase:"end" with the
|
|
7939
|
+
* selector / sourceNodeId of the dragged element. MultiScreenCanvas maps
|
|
7940
|
+
* the board point to a target screen and calls this handler. We resolve
|
|
7941
|
+
* both screens' content, identify the node by its data-agent-native-node-id
|
|
7942
|
+
* (falling back to a projection lookup by selector when only the selector is
|
|
7943
|
+
* available), call moveNodeBetweenDocuments to move it into the target
|
|
7944
|
+
* screen's <body>, persist both files, switch the active screen to the
|
|
7945
|
+
* target, and select the moved node — keeping viewMode "overview" throughout.
|
|
7946
|
+
*/
|
|
7947
|
+
const handleCrossScreenElementDrop = useCallback(
|
|
7948
|
+
({
|
|
7949
|
+
sourceSelector,
|
|
7950
|
+
sourceNodeId,
|
|
7951
|
+
sourceScreenId,
|
|
7952
|
+
targetScreenId,
|
|
7953
|
+
}: {
|
|
7954
|
+
sourceSelector: string;
|
|
7955
|
+
sourceNodeId?: string;
|
|
7956
|
+
sourceScreenId: string;
|
|
7957
|
+
targetScreenId: string;
|
|
7958
|
+
}) => {
|
|
7959
|
+
if (!canEditDesign) return;
|
|
7960
|
+
if (sourceScreenId === targetScreenId) return;
|
|
7961
|
+
|
|
7962
|
+
const sourceContent = getScreenContent(sourceScreenId);
|
|
7963
|
+
const destContent = getScreenContent(targetScreenId);
|
|
7964
|
+
if (!sourceContent || !destContent) return;
|
|
7965
|
+
|
|
7966
|
+
// Resolve the data-agent-native-node-id that moveNodeBetweenDocuments
|
|
7967
|
+
// uses as a stable key. Prefer the bridge-supplied sourceNodeId when it
|
|
7968
|
+
// looks like a node-attr id; otherwise look up via selector projection.
|
|
7969
|
+
const sourceProjection = buildCodeLayerProjection(sourceContent);
|
|
7970
|
+
const resolvedSourceNode = sourceNodeId
|
|
7971
|
+
? (sourceProjection.nodes.find(
|
|
7972
|
+
(n) =>
|
|
7973
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
7974
|
+
n.id === sourceNodeId,
|
|
7975
|
+
) ??
|
|
7976
|
+
resolveCodeLayerNodeFromBridge(
|
|
7977
|
+
sourceProjection,
|
|
7978
|
+
sourceSelector,
|
|
7979
|
+
sourceNodeId,
|
|
7980
|
+
))
|
|
7981
|
+
: resolveCodeLayerNodeFromBridge(sourceProjection, sourceSelector);
|
|
7982
|
+
const nodeAttrId =
|
|
7983
|
+
resolvedSourceNode?.dataAttributes["data-agent-native-node-id"] ??
|
|
7984
|
+
sourceNodeId ??
|
|
7985
|
+
sourceSelector;
|
|
7986
|
+
|
|
7987
|
+
const result = moveNodeBetweenDocuments(sourceContent, destContent, {
|
|
7988
|
+
nodeId: nodeAttrId,
|
|
7989
|
+
placement: "inside",
|
|
7990
|
+
});
|
|
7991
|
+
if (result.status !== "applied") {
|
|
7992
|
+
toast.error(
|
|
7993
|
+
codeLayerPatchMessage(
|
|
7994
|
+
result.message,
|
|
7995
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
7996
|
+
),
|
|
7997
|
+
{ duration: 4000 },
|
|
7998
|
+
);
|
|
7999
|
+
return;
|
|
8000
|
+
}
|
|
8001
|
+
|
|
8002
|
+
// Strip absolute positioning from the moved node in the destination so
|
|
8003
|
+
// it flows naturally as a body child (mirrors handleOverviewPrimitiveReparent).
|
|
8004
|
+
// Use result.movedNodeId (the final id in destHtml, which may differ from
|
|
8005
|
+
// nodeAttrId when a collision triggered an id re-stamp) so the strip and
|
|
8006
|
+
// re-selection always find the correct element.
|
|
8007
|
+
const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
|
|
8008
|
+
const strippedDest = removeAbsolutePositioningFromNodeInHtml(
|
|
8009
|
+
result.destHtml,
|
|
8010
|
+
destNodeAttrId,
|
|
8011
|
+
);
|
|
8012
|
+
|
|
8013
|
+
applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
|
|
8014
|
+
refreshPreview: true,
|
|
8015
|
+
});
|
|
8016
|
+
applyFileContentUpdate(targetScreenId, strippedDest, {
|
|
8017
|
+
refreshPreview: true,
|
|
8018
|
+
});
|
|
8019
|
+
|
|
8020
|
+
// Switch active screen to the target and select the moved node; viewMode
|
|
8021
|
+
// stays "overview" (no setViewMode call).
|
|
8022
|
+
setActiveFileId(targetScreenId);
|
|
8023
|
+
const finalProjection = buildCodeLayerProjection(strippedDest);
|
|
8024
|
+
const movedNodeFinal = finalProjection.nodes.find(
|
|
8025
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
|
|
8026
|
+
);
|
|
8027
|
+
if (movedNodeFinal) {
|
|
8028
|
+
setSelectedLayerIdsState([movedNodeFinal.id]);
|
|
8029
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
|
|
8030
|
+
}
|
|
8031
|
+
},
|
|
8032
|
+
[applyFileContentUpdate, canEditDesign, getScreenContent, t],
|
|
8033
|
+
);
|
|
8034
|
+
|
|
6642
8035
|
const handleCutSelection = useCallback(async () => {
|
|
6643
8036
|
if (!selectedElement?.selector) return;
|
|
6644
8037
|
// Copy first (populates the internal clipboard ref even if the async
|
|
@@ -6737,8 +8130,7 @@ export default function DesignEditor() {
|
|
|
6737
8130
|
textAlign: selectedElement.computedStyles.textAlign,
|
|
6738
8131
|
};
|
|
6739
8132
|
setHasPropsClipboard(true);
|
|
6740
|
-
|
|
6741
|
-
}, [selectedElement, t]);
|
|
8133
|
+
}, [selectedElement]);
|
|
6742
8134
|
|
|
6743
8135
|
const handlePasteProps = useCallback(() => {
|
|
6744
8136
|
if (!canEditDesign) return;
|
|
@@ -6749,8 +8141,7 @@ export default function DesignEditor() {
|
|
|
6749
8141
|
),
|
|
6750
8142
|
);
|
|
6751
8143
|
commitVisualStyles(selectedElement.selector, styles);
|
|
6752
|
-
|
|
6753
|
-
}, [canEditDesign, commitVisualStyles, selectedElement, t]);
|
|
8144
|
+
}, [canEditDesign, commitVisualStyles, selectedElement]);
|
|
6754
8145
|
|
|
6755
8146
|
const changeSelectedZIndex = useCallback(
|
|
6756
8147
|
(mode: "forward" | "front" | "backward" | "back") => {
|
|
@@ -6810,31 +8201,67 @@ export default function DesignEditor() {
|
|
|
6810
8201
|
if (!canEditDesign) return;
|
|
6811
8202
|
const um = undoManagerRef.current;
|
|
6812
8203
|
const undoContent = () => {
|
|
6813
|
-
if (
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
8204
|
+
if (um?.canUndo()) {
|
|
8205
|
+
um.undo();
|
|
8206
|
+
if (ydoc && activeFile) {
|
|
8207
|
+
const next = ydoc.getText("content").toString();
|
|
8208
|
+
markPendingLocalFileContent(
|
|
8209
|
+
activeFile.id,
|
|
8210
|
+
next,
|
|
8211
|
+
activeFile.updatedAt,
|
|
8212
|
+
);
|
|
8213
|
+
lastLocalContentRef.current = next;
|
|
8214
|
+
queueFileContentSave(activeFile.id, next, {
|
|
8215
|
+
syncCollab: !(ydoc && isSynced),
|
|
8216
|
+
});
|
|
8217
|
+
if (!replacePreviewContent(next)) {
|
|
8218
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
8219
|
+
}
|
|
8220
|
+
// Clear stale selection if the undo removed the selected element.
|
|
8221
|
+
setSelectedElement((prev) => {
|
|
8222
|
+
if (!prev) return prev;
|
|
8223
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8224
|
+
});
|
|
8225
|
+
setHoveredElement((prev) => {
|
|
8226
|
+
if (!prev) return prev;
|
|
8227
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8228
|
+
});
|
|
6823
8229
|
}
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
setHoveredElement((prev) => {
|
|
6830
|
-
if (!prev) return prev;
|
|
6831
|
-
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
6832
|
-
});
|
|
8230
|
+
redoOrderRef.current = [
|
|
8231
|
+
...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8232
|
+
"content",
|
|
8233
|
+
];
|
|
8234
|
+
return true;
|
|
6833
8235
|
}
|
|
8236
|
+
|
|
8237
|
+
if (!activeFile) return false;
|
|
8238
|
+
const entry = contentUndoStackRef.current.pop();
|
|
8239
|
+
if (!entry || entry.fileId !== activeFile.id) return false;
|
|
8240
|
+
contentRedoStackRef.current = [
|
|
8241
|
+
...contentRedoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8242
|
+
entry,
|
|
8243
|
+
];
|
|
6834
8244
|
redoOrderRef.current = [
|
|
6835
8245
|
...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
6836
8246
|
"content",
|
|
6837
8247
|
];
|
|
8248
|
+
suppressContentHistoryRef.current = true;
|
|
8249
|
+
try {
|
|
8250
|
+
applyLocalContentUpdate(entry.before, {
|
|
8251
|
+
refreshPreview: false,
|
|
8252
|
+
immediateSave: true,
|
|
8253
|
+
});
|
|
8254
|
+
} finally {
|
|
8255
|
+
suppressContentHistoryRef.current = false;
|
|
8256
|
+
}
|
|
8257
|
+
setSelectedElement((prev) => {
|
|
8258
|
+
if (!prev) return prev;
|
|
8259
|
+
return elementInfoExistsInContent(entry.before, prev) ? prev : null;
|
|
8260
|
+
});
|
|
8261
|
+
setHoveredElement((prev) => {
|
|
8262
|
+
if (!prev) return prev;
|
|
8263
|
+
return elementInfoExistsInContent(entry.before, prev) ? prev : null;
|
|
8264
|
+
});
|
|
6838
8265
|
return true;
|
|
6839
8266
|
};
|
|
6840
8267
|
const undoGeometry = () => {
|
|
@@ -6870,8 +8297,10 @@ export default function DesignEditor() {
|
|
|
6870
8297
|
}, [
|
|
6871
8298
|
ydoc,
|
|
6872
8299
|
activeFile,
|
|
8300
|
+
applyLocalContentUpdate,
|
|
6873
8301
|
canEditDesign,
|
|
6874
8302
|
isSynced,
|
|
8303
|
+
markPendingLocalFileContent,
|
|
6875
8304
|
queueFileContentSave,
|
|
6876
8305
|
replacePreviewContent,
|
|
6877
8306
|
syncUndoRedoState,
|
|
@@ -6882,31 +8311,67 @@ export default function DesignEditor() {
|
|
|
6882
8311
|
if (!canEditDesign) return;
|
|
6883
8312
|
const um = undoManagerRef.current;
|
|
6884
8313
|
const redoContent = () => {
|
|
6885
|
-
if (
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
8314
|
+
if (um?.canRedo()) {
|
|
8315
|
+
um.redo();
|
|
8316
|
+
if (ydoc && activeFile) {
|
|
8317
|
+
const next = ydoc.getText("content").toString();
|
|
8318
|
+
markPendingLocalFileContent(
|
|
8319
|
+
activeFile.id,
|
|
8320
|
+
next,
|
|
8321
|
+
activeFile.updatedAt,
|
|
8322
|
+
);
|
|
8323
|
+
lastLocalContentRef.current = next;
|
|
8324
|
+
queueFileContentSave(activeFile.id, next, {
|
|
8325
|
+
syncCollab: !(ydoc && isSynced),
|
|
8326
|
+
});
|
|
8327
|
+
if (!replacePreviewContent(next)) {
|
|
8328
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
8329
|
+
}
|
|
8330
|
+
// Clear stale selection if the redo removed the selected element.
|
|
8331
|
+
setSelectedElement((prev) => {
|
|
8332
|
+
if (!prev) return prev;
|
|
8333
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8334
|
+
});
|
|
8335
|
+
setHoveredElement((prev) => {
|
|
8336
|
+
if (!prev) return prev;
|
|
8337
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8338
|
+
});
|
|
6895
8339
|
}
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
setHoveredElement((prev) => {
|
|
6902
|
-
if (!prev) return prev;
|
|
6903
|
-
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
6904
|
-
});
|
|
8340
|
+
historyOrderRef.current = [
|
|
8341
|
+
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8342
|
+
"content",
|
|
8343
|
+
];
|
|
8344
|
+
return true;
|
|
6905
8345
|
}
|
|
8346
|
+
|
|
8347
|
+
if (!activeFile) return false;
|
|
8348
|
+
const entry = contentRedoStackRef.current.pop();
|
|
8349
|
+
if (!entry || entry.fileId !== activeFile.id) return false;
|
|
8350
|
+
contentUndoStackRef.current = [
|
|
8351
|
+
...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8352
|
+
entry,
|
|
8353
|
+
];
|
|
6906
8354
|
historyOrderRef.current = [
|
|
6907
8355
|
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
6908
8356
|
"content",
|
|
6909
8357
|
];
|
|
8358
|
+
suppressContentHistoryRef.current = true;
|
|
8359
|
+
try {
|
|
8360
|
+
applyLocalContentUpdate(entry.after, {
|
|
8361
|
+
refreshPreview: false,
|
|
8362
|
+
immediateSave: true,
|
|
8363
|
+
});
|
|
8364
|
+
} finally {
|
|
8365
|
+
suppressContentHistoryRef.current = false;
|
|
8366
|
+
}
|
|
8367
|
+
setSelectedElement((prev) => {
|
|
8368
|
+
if (!prev) return prev;
|
|
8369
|
+
return elementInfoExistsInContent(entry.after, prev) ? prev : null;
|
|
8370
|
+
});
|
|
8371
|
+
setHoveredElement((prev) => {
|
|
8372
|
+
if (!prev) return prev;
|
|
8373
|
+
return elementInfoExistsInContent(entry.after, prev) ? prev : null;
|
|
8374
|
+
});
|
|
6910
8375
|
return true;
|
|
6911
8376
|
};
|
|
6912
8377
|
const redoGeometry = () => {
|
|
@@ -6942,8 +8407,10 @@ export default function DesignEditor() {
|
|
|
6942
8407
|
}, [
|
|
6943
8408
|
ydoc,
|
|
6944
8409
|
activeFile,
|
|
8410
|
+
applyLocalContentUpdate,
|
|
6945
8411
|
canEditDesign,
|
|
6946
8412
|
isSynced,
|
|
8413
|
+
markPendingLocalFileContent,
|
|
6947
8414
|
queueFileContentSave,
|
|
6948
8415
|
replacePreviewContent,
|
|
6949
8416
|
syncUndoRedoState,
|
|
@@ -7382,6 +8849,8 @@ export default function DesignEditor() {
|
|
|
7382
8849
|
setTitleDraft(design?.title ?? "");
|
|
7383
8850
|
setTitleEditing(true);
|
|
7384
8851
|
},
|
|
8852
|
+
onGroup: canEditDesign ? handleGroupSelection : undefined,
|
|
8853
|
+
onUngroup: canEditDesign ? handleUngroupSelection : undefined,
|
|
7385
8854
|
onSelectAll: handleSelectAllFrames,
|
|
7386
8855
|
onUndo: canEditDesign ? handleUndo : undefined,
|
|
7387
8856
|
onRedo: canEditDesign ? handleRedo : undefined,
|
|
@@ -8458,6 +9927,55 @@ ${serializedHtml}
|
|
|
8458
9927
|
);
|
|
8459
9928
|
}, [activeContent, activeFile?.id, builderPreviewUrl, overviewScreens]);
|
|
8460
9929
|
|
|
9930
|
+
// §6.4 / §8 — Breakpoints list for the StatesPanel, derived from
|
|
9931
|
+
// designs.data.breakpointSet. Returns a stable empty array when none are set.
|
|
9932
|
+
const statesPanelBreakpoints = useMemo<
|
|
9933
|
+
Array<{ id: string; label: string; widthPx: number }>
|
|
9934
|
+
>(() => {
|
|
9935
|
+
try {
|
|
9936
|
+
const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
|
|
9937
|
+
if (
|
|
9938
|
+
raw &&
|
|
9939
|
+
typeof raw === "object" &&
|
|
9940
|
+
!Array.isArray(raw) &&
|
|
9941
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
9942
|
+
) {
|
|
9943
|
+
const bps = (
|
|
9944
|
+
raw as {
|
|
9945
|
+
breakpoints: Array<{
|
|
9946
|
+
id: string;
|
|
9947
|
+
widthPx: number;
|
|
9948
|
+
label?: string;
|
|
9949
|
+
}>;
|
|
9950
|
+
}
|
|
9951
|
+
).breakpoints;
|
|
9952
|
+
return bps.map((bp) => ({
|
|
9953
|
+
id: bp.id,
|
|
9954
|
+
widthPx: bp.widthPx,
|
|
9955
|
+
label:
|
|
9956
|
+
bp.label ??
|
|
9957
|
+
(bp.widthPx >= 1024
|
|
9958
|
+
? "Desktop"
|
|
9959
|
+
: bp.widthPx >= 600
|
|
9960
|
+
? "Tablet"
|
|
9961
|
+
: "Mobile"),
|
|
9962
|
+
}));
|
|
9963
|
+
}
|
|
9964
|
+
} catch {
|
|
9965
|
+
// ignore
|
|
9966
|
+
}
|
|
9967
|
+
return [];
|
|
9968
|
+
}, [designDataJson]);
|
|
9969
|
+
|
|
9970
|
+
// Active breakpoint id for the StatesPanel — "auto" when no frame is focused.
|
|
9971
|
+
const statesPanelActiveBreakpointId = useMemo<string>(() => {
|
|
9972
|
+
if (activeBreakpointWidthState == null) return "auto";
|
|
9973
|
+
const match = statesPanelBreakpoints.find(
|
|
9974
|
+
(bp) => bp.widthPx === activeBreakpointWidthState,
|
|
9975
|
+
);
|
|
9976
|
+
return match?.id ?? "auto";
|
|
9977
|
+
}, [activeBreakpointWidthState, statesPanelBreakpoints]);
|
|
9978
|
+
|
|
8461
9979
|
const handleOpenDesignPreview = useCallback(() => {
|
|
8462
9980
|
if (activeScreenPreviewUrl) {
|
|
8463
9981
|
window.open(activeScreenPreviewUrl, "_blank", "noopener,noreferrer");
|
|
@@ -8479,6 +9997,16 @@ ${serializedHtml}
|
|
|
8479
9997
|
selectedElementLayerId ??
|
|
8480
9998
|
activeFile?.id ??
|
|
8481
9999
|
"";
|
|
10000
|
+
const selectedElementFullViewScreenId =
|
|
10001
|
+
viewMode === "overview" && selectedElement
|
|
10002
|
+
? selectedElementLayerId
|
|
10003
|
+
? (codeLayerOwnerByNodeId.get(selectedElementLayerId)?.fileId ??
|
|
10004
|
+
activeFileId)
|
|
10005
|
+
: activeFileId
|
|
10006
|
+
: null;
|
|
10007
|
+
const fullViewScreenIds = selectedElementFullViewScreenId
|
|
10008
|
+
? [selectedElementFullViewScreenId]
|
|
10009
|
+
: [];
|
|
8482
10010
|
const activeLayerLocked = Boolean(
|
|
8483
10011
|
activeLayerId && effectiveCodeLayerState.lockedIds.has(activeLayerId),
|
|
8484
10012
|
);
|
|
@@ -8486,6 +10014,35 @@ ${serializedHtml}
|
|
|
8486
10014
|
activeLayerId && effectiveCodeLayerState.hiddenIds.has(activeLayerId),
|
|
8487
10015
|
);
|
|
8488
10016
|
|
|
10017
|
+
// Detect if the active screen is a localhost/local source so we can show a banner.
|
|
10018
|
+
const activeScreenIsLocalSource =
|
|
10019
|
+
viewMode === "single" &&
|
|
10020
|
+
Boolean(activeFile) &&
|
|
10021
|
+
activeOverviewScreen?.sourceType === "localhost";
|
|
10022
|
+
const activeScreenRouteSourceFile = activeScreenIsLocalSource
|
|
10023
|
+
? getLocalhostRouteSourceFile({
|
|
10024
|
+
sourceFile: activeOverviewScreen?.sourceFile,
|
|
10025
|
+
source: activeOverviewScreen?.source,
|
|
10026
|
+
})
|
|
10027
|
+
: undefined;
|
|
10028
|
+
|
|
10029
|
+
// canGroup: 2+ DOM-node layers selected in the active screen (not file rows).
|
|
10030
|
+
const fileIdSet = new Set(files.map((f) => f.id));
|
|
10031
|
+
const selectedDomLayerIds = selectedLayerIds.filter(
|
|
10032
|
+
(id) => !id.startsWith("__") && !fileIdSet.has(id),
|
|
10033
|
+
);
|
|
10034
|
+
const canGroup =
|
|
10035
|
+
canEditDesign &&
|
|
10036
|
+
viewMode === "single" &&
|
|
10037
|
+
Boolean(activeFile) &&
|
|
10038
|
+
selectedDomLayerIds.length >= 2;
|
|
10039
|
+
// canUngroup: exactly one DOM-node layer selected.
|
|
10040
|
+
const canUngroup =
|
|
10041
|
+
canEditDesign &&
|
|
10042
|
+
viewMode === "single" &&
|
|
10043
|
+
Boolean(activeFile) &&
|
|
10044
|
+
selectedDomLayerIds.length === 1;
|
|
10045
|
+
|
|
8489
10046
|
const canMoveLayer = useCallback(
|
|
8490
10047
|
(intent: LayersPanelMoveIntent) => {
|
|
8491
10048
|
const targetOwner = codeLayerOwnerByNodeId.get(intent.targetId);
|
|
@@ -8498,17 +10055,24 @@ ${serializedHtml}
|
|
|
8498
10055
|
}
|
|
8499
10056
|
return intent.draggedIds.some((draggedId) => {
|
|
8500
10057
|
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
8501
|
-
|
|
8502
|
-
draggedId
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
10058
|
+
if (
|
|
10059
|
+
draggedId === intent.targetId ||
|
|
10060
|
+
!draggedOwner ||
|
|
10061
|
+
effectiveCodeLayerState.lockedIds.has(draggedId) ||
|
|
10062
|
+
effectiveCodeLayerState.hiddenIds.has(draggedId)
|
|
10063
|
+
) {
|
|
10064
|
+
return false;
|
|
10065
|
+
}
|
|
10066
|
+
// Same-file move: also exclude ancestor drags (would orphan the node).
|
|
10067
|
+
if (draggedOwner.fileId === targetOwner.fileId) {
|
|
10068
|
+
return !collectCodeLayerAncestors(
|
|
8506
10069
|
targetOwner.tree,
|
|
8507
10070
|
intent.targetId,
|
|
8508
|
-
).includes(draggedId)
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
)
|
|
10071
|
+
).includes(draggedId);
|
|
10072
|
+
}
|
|
10073
|
+
// Cross-file move: allowed as long as neither side is locked/hidden
|
|
10074
|
+
// (already checked above). File-row ids are excluded by the owner check.
|
|
10075
|
+
return true;
|
|
8512
10076
|
});
|
|
8513
10077
|
},
|
|
8514
10078
|
[codeLayerOwnerByNodeId, effectiveCodeLayerState],
|
|
@@ -8526,26 +10090,45 @@ ${serializedHtml}
|
|
|
8526
10090
|
) {
|
|
8527
10091
|
return;
|
|
8528
10092
|
}
|
|
8529
|
-
const
|
|
8530
|
-
const
|
|
10093
|
+
const destFile = files.find((file) => file.id === targetOwner.fileId);
|
|
10094
|
+
const destContent =
|
|
8531
10095
|
targetOwner.fileId === activeFile?.id
|
|
8532
10096
|
? activeContent
|
|
8533
|
-
: (
|
|
8534
|
-
if (!
|
|
8535
|
-
|
|
8536
|
-
|
|
10097
|
+
: (destFile?.content ?? "");
|
|
10098
|
+
if (!destContent) return;
|
|
10099
|
+
|
|
10100
|
+
// Group dragged ids by source file so we can handle same-file and
|
|
10101
|
+
// cross-file moves independently.
|
|
10102
|
+
const sameFileDragIds: string[] = [];
|
|
10103
|
+
const crossFileDrags: Array<{ draggedId: string; sourceFileId: string }> =
|
|
10104
|
+
[];
|
|
10105
|
+
const movedNodeSnapshots = new Map<string, CodeLayerNode>();
|
|
8537
10106
|
for (const draggedId of intent.draggedIds) {
|
|
8538
10107
|
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
8539
10108
|
if (
|
|
8540
10109
|
draggedId === intent.targetId ||
|
|
8541
10110
|
!draggedOwner ||
|
|
8542
|
-
draggedOwner.fileId !== targetOwner.fileId ||
|
|
8543
10111
|
effectiveCodeLayerState.lockedIds.has(draggedId) ||
|
|
8544
10112
|
effectiveCodeLayerState.hiddenIds.has(draggedId)
|
|
8545
10113
|
) {
|
|
8546
10114
|
continue;
|
|
8547
10115
|
}
|
|
8548
|
-
|
|
10116
|
+
movedNodeSnapshots.set(draggedId, draggedOwner.node);
|
|
10117
|
+
if (draggedOwner.fileId === targetOwner.fileId) {
|
|
10118
|
+
sameFileDragIds.push(draggedId);
|
|
10119
|
+
} else {
|
|
10120
|
+
crossFileDrags.push({
|
|
10121
|
+
draggedId,
|
|
10122
|
+
sourceFileId: draggedOwner.fileId,
|
|
10123
|
+
});
|
|
10124
|
+
}
|
|
10125
|
+
}
|
|
10126
|
+
|
|
10127
|
+
// --- Same-file moves (existing path) ---
|
|
10128
|
+
let nextDestContent = destContent;
|
|
10129
|
+
let moved = false;
|
|
10130
|
+
for (const draggedId of sameFileDragIds) {
|
|
10131
|
+
const patch = applyVisualEdit(nextDestContent, {
|
|
8549
10132
|
kind: "moveNode",
|
|
8550
10133
|
target: { nodeId: draggedId },
|
|
8551
10134
|
anchor: { nodeId: intent.targetId },
|
|
@@ -8561,13 +10144,109 @@ ${serializedHtml}
|
|
|
8561
10144
|
);
|
|
8562
10145
|
continue;
|
|
8563
10146
|
}
|
|
8564
|
-
|
|
10147
|
+
nextDestContent = patch.content;
|
|
8565
10148
|
moved = true;
|
|
8566
10149
|
}
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
10150
|
+
|
|
10151
|
+
// --- Cross-file moves: use moveNodeBetweenDocuments ---
|
|
10152
|
+
// Group by source file so multiple nodes from the same source are
|
|
10153
|
+
// applied sequentially against the running source content.
|
|
10154
|
+
const sourceContentMap = new Map<string, string>();
|
|
10155
|
+
for (const { draggedId, sourceFileId } of crossFileDrags) {
|
|
10156
|
+
const srcFile = files.find((f) => f.id === sourceFileId);
|
|
10157
|
+
if (!srcFile) continue;
|
|
10158
|
+
const currentSourceContent = getLayerMoveSourceContent({
|
|
10159
|
+
sourceFileId,
|
|
10160
|
+
activeFileId: activeFile?.id,
|
|
10161
|
+
activeContent,
|
|
10162
|
+
sourceFileContent: srcFile.content,
|
|
10163
|
+
sourceContentMap,
|
|
10164
|
+
});
|
|
10165
|
+
|
|
10166
|
+
// The dragged node's data-agent-native-node-id is the node id tracked
|
|
10167
|
+
// by code-layer. Look up the actual attribute value from the owner.
|
|
10168
|
+
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
10169
|
+
const nodeAttrId =
|
|
10170
|
+
draggedOwner?.node.dataAttributes["data-agent-native-node-id"] ??
|
|
10171
|
+
draggedId;
|
|
10172
|
+
const anchorAttrId =
|
|
10173
|
+
codeLayerOwnerByNodeId.get(intent.targetId)?.node.dataAttributes[
|
|
10174
|
+
"data-agent-native-node-id"
|
|
10175
|
+
] ?? intent.targetId;
|
|
10176
|
+
|
|
10177
|
+
const result = moveNodeBetweenDocuments(
|
|
10178
|
+
currentSourceContent,
|
|
10179
|
+
nextDestContent,
|
|
10180
|
+
{
|
|
10181
|
+
nodeId: nodeAttrId,
|
|
10182
|
+
anchorNodeId: anchorAttrId,
|
|
10183
|
+
placement: intent.placement,
|
|
10184
|
+
},
|
|
10185
|
+
);
|
|
10186
|
+
if (result.status !== "applied") {
|
|
10187
|
+
toast.error(
|
|
10188
|
+
codeLayerPatchMessage(
|
|
10189
|
+
result.message,
|
|
10190
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
10191
|
+
),
|
|
10192
|
+
{ duration: 4000 },
|
|
10193
|
+
);
|
|
10194
|
+
continue;
|
|
10195
|
+
}
|
|
10196
|
+
sourceContentMap.set(sourceFileId, result.sourceHtml);
|
|
10197
|
+
nextDestContent = result.destHtml;
|
|
10198
|
+
moved = true;
|
|
10199
|
+
}
|
|
10200
|
+
|
|
10201
|
+
if (!moved) return;
|
|
10202
|
+
|
|
10203
|
+
const finalDestProjection =
|
|
10204
|
+
nextDestContent !== destContent
|
|
10205
|
+
? buildCodeLayerProjection(nextDestContent)
|
|
10206
|
+
: null;
|
|
10207
|
+
const finalDestTree = finalDestProjection
|
|
10208
|
+
? buildCodeLayerTree(finalDestProjection)
|
|
10209
|
+
: [];
|
|
10210
|
+
const movedNodesAfterMove = intent.draggedIds
|
|
10211
|
+
.map((draggedId) => movedNodeSnapshots.get(draggedId))
|
|
10212
|
+
.map((node) =>
|
|
10213
|
+
node && finalDestProjection
|
|
10214
|
+
? findCodeLayerNodeInProjection(finalDestProjection, node)
|
|
10215
|
+
: null,
|
|
10216
|
+
)
|
|
10217
|
+
.filter((node): node is CodeLayerNode => Boolean(node));
|
|
10218
|
+
|
|
10219
|
+
if (movedNodesAfterMove.length > 0) {
|
|
10220
|
+
setSelectedLayerIdsState(movedNodesAfterMove.map((node) => node.id));
|
|
10221
|
+
const lastMovedNode =
|
|
10222
|
+
movedNodesAfterMove[movedNodesAfterMove.length - 1];
|
|
10223
|
+
if (lastMovedNode && targetOwner.fileId === activeFile?.id) {
|
|
10224
|
+
setSelectedElement(elementInfoFromCodeLayerNode(lastMovedNode));
|
|
10225
|
+
}
|
|
10226
|
+
const movedAncestorIds = movedNodesAfterMove.flatMap((node) =>
|
|
10227
|
+
collectCodeLayerAncestors(finalDestTree, node.id),
|
|
10228
|
+
);
|
|
10229
|
+
setExpandedLayerIds((current) => {
|
|
10230
|
+
const next = new Set(current);
|
|
10231
|
+
next.add(targetOwner.fileId);
|
|
10232
|
+
movedAncestorIds.forEach((ancestorId) => next.add(ancestorId));
|
|
10233
|
+
return next.size === current.length ? current : Array.from(next);
|
|
10234
|
+
});
|
|
10235
|
+
}
|
|
10236
|
+
|
|
10237
|
+
// Persist source files that changed.
|
|
10238
|
+
for (const [sourceFileId, newSourceContent] of sourceContentMap) {
|
|
10239
|
+
applyFileContentUpdate(sourceFileId, newSourceContent, {
|
|
10240
|
+
refreshPreview: false,
|
|
10241
|
+
});
|
|
10242
|
+
}
|
|
10243
|
+
|
|
10244
|
+
// Persist dest file (which may also be the active file).
|
|
10245
|
+
if (nextDestContent !== destContent) {
|
|
10246
|
+
applyFileContentUpdate(targetOwner.fileId, nextDestContent, {
|
|
10247
|
+
refreshPreview: false,
|
|
10248
|
+
});
|
|
10249
|
+
}
|
|
8571
10250
|
},
|
|
8572
10251
|
[
|
|
8573
10252
|
activeContent,
|
|
@@ -8616,6 +10295,21 @@ ${serializedHtml}
|
|
|
8616
10295
|
? currentLayerIds.filter((layerId) => layerId !== intent.id)
|
|
8617
10296
|
: [...currentLayerIds, intent.id];
|
|
8618
10297
|
setSelectedLayerIdsState(additiveLayerIds);
|
|
10298
|
+
if (viewModeRef.current === "overview") {
|
|
10299
|
+
const fileIds = files.map((file) => file.id);
|
|
10300
|
+
const selectedScreenIds = getOverviewScreenIdsFromLayerSelection({
|
|
10301
|
+
fileIds,
|
|
10302
|
+
layerIds: additiveLayerIds,
|
|
10303
|
+
});
|
|
10304
|
+
const toggledScreen =
|
|
10305
|
+
getOverviewScreenIdsFromLayerSelection({
|
|
10306
|
+
fileIds,
|
|
10307
|
+
layerIds: [intent.id],
|
|
10308
|
+
}).length > 0;
|
|
10309
|
+
if (toggledScreen || selectedScreenIds.length > 0) {
|
|
10310
|
+
setOverviewSelectedScreenIds(selectedScreenIds);
|
|
10311
|
+
}
|
|
10312
|
+
}
|
|
8619
10313
|
setSelectedElement(null);
|
|
8620
10314
|
focusDesignInspectorForSelection();
|
|
8621
10315
|
setActiveTool("move");
|
|
@@ -9096,6 +10790,19 @@ ${serializedHtml}
|
|
|
9096
10790
|
? t("designEditor.stopPinningComments")
|
|
9097
10791
|
: t("designEditor.pinComment")}
|
|
9098
10792
|
</DropdownMenuItem>
|
|
10793
|
+
{isSignedIn && (
|
|
10794
|
+
<>
|
|
10795
|
+
<DropdownMenuSeparator />
|
|
10796
|
+
<DropdownMenuItem
|
|
10797
|
+
onClick={() => {
|
|
10798
|
+
handleOpenMakeReal();
|
|
10799
|
+
}}
|
|
10800
|
+
>
|
|
10801
|
+
<IconRocket className="mr-2 h-4 w-4" />
|
|
10802
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
10803
|
+
</DropdownMenuItem>
|
|
10804
|
+
</>
|
|
10805
|
+
)}
|
|
9099
10806
|
</DropdownMenuContent>
|
|
9100
10807
|
</DropdownMenu>
|
|
9101
10808
|
);
|
|
@@ -9254,6 +10961,28 @@ ${serializedHtml}
|
|
|
9254
10961
|
<TooltipContent>{t("designEditor.designPreview")}</TooltipContent>
|
|
9255
10962
|
</Tooltip>
|
|
9256
10963
|
|
|
10964
|
+
{/* §6.6 — "Make this a real app" shortcut button (signed-in only).
|
|
10965
|
+
Surfaces the migration CTA without requiring the project menu. */}
|
|
10966
|
+
{isSignedIn && (
|
|
10967
|
+
<Tooltip>
|
|
10968
|
+
<TooltipTrigger asChild>
|
|
10969
|
+
<Button
|
|
10970
|
+
variant="ghost"
|
|
10971
|
+
size="icon"
|
|
10972
|
+
className="size-8 cursor-pointer rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
10973
|
+
onClick={handleOpenMakeReal}
|
|
10974
|
+
disabled={migrateMutation.isPending}
|
|
10975
|
+
aria-label={"Make this a real app" /* i18n-ignore */}
|
|
10976
|
+
>
|
|
10977
|
+
<IconRocket className="size-4" />
|
|
10978
|
+
</Button>
|
|
10979
|
+
</TooltipTrigger>
|
|
10980
|
+
<TooltipContent>
|
|
10981
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
10982
|
+
</TooltipContent>
|
|
10983
|
+
</Tooltip>
|
|
10984
|
+
)}
|
|
10985
|
+
|
|
9257
10986
|
{isSignedIn ? (
|
|
9258
10987
|
<ShareButton
|
|
9259
10988
|
resourceType="design"
|
|
@@ -9447,6 +11176,19 @@ ${serializedHtml}
|
|
|
9447
11176
|
? t("designEditor.stopPinningComments")
|
|
9448
11177
|
: t("designEditor.pinComment")}
|
|
9449
11178
|
</DropdownMenuItem>
|
|
11179
|
+
{isSignedIn && (
|
|
11180
|
+
<>
|
|
11181
|
+
<DropdownMenuSeparator />
|
|
11182
|
+
<DropdownMenuItem
|
|
11183
|
+
onClick={() => {
|
|
11184
|
+
handleOpenMakeReal();
|
|
11185
|
+
}}
|
|
11186
|
+
>
|
|
11187
|
+
<IconRocket className="mr-2 h-4 w-4" />
|
|
11188
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
11189
|
+
</DropdownMenuItem>
|
|
11190
|
+
</>
|
|
11191
|
+
)}
|
|
9450
11192
|
</DropdownMenuContent>
|
|
9451
11193
|
</DropdownMenu>
|
|
9452
11194
|
{titleEditing && canEditDesign ? (
|
|
@@ -9786,7 +11528,9 @@ ${serializedHtml}
|
|
|
9786
11528
|
canEditDesign && hasPropsClipboard && Boolean(selectedElement)
|
|
9787
11529
|
}
|
|
9788
11530
|
canCopyAsCode={Boolean(selectedElement?.selector)}
|
|
9789
|
-
|
|
11531
|
+
canGroup={canGroup}
|
|
11532
|
+
canUngroup={canUngroup}
|
|
11533
|
+
hiddenActions={["rename"]}
|
|
9790
11534
|
getCanvasPoint={getContextCanvasPoint}
|
|
9791
11535
|
onPasteHere={(details) =>
|
|
9792
11536
|
handlePasteSelection(
|
|
@@ -9818,310 +11562,396 @@ ${serializedHtml}
|
|
|
9818
11562
|
handleToggleLayerHidden(activeLayerId, !activeLayerHidden);
|
|
9819
11563
|
}
|
|
9820
11564
|
}}
|
|
11565
|
+
onGroup={canGroup ? handleGroupSelection : undefined}
|
|
11566
|
+
onUngroup={canUngroup ? handleUngroupSelection : undefined}
|
|
9821
11567
|
onCopyProps={handleCopyProps}
|
|
9822
11568
|
onPasteProps={handlePasteProps}
|
|
9823
11569
|
onCopyAsCode={handleCopySelection}
|
|
9824
11570
|
>
|
|
9825
11571
|
{activeFile ? (
|
|
9826
|
-
<div
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
11572
|
+
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
11573
|
+
{/* Banner for screens connected to a local dev server: edits
|
|
11574
|
+
route through the agent rather than being applied inline. */}
|
|
11575
|
+
{activeScreenIsLocalSource &&
|
|
11576
|
+
!localSourceBannerDismissed &&
|
|
11577
|
+
id && (
|
|
11578
|
+
<LocalSourceEditBanner
|
|
11579
|
+
designId={id}
|
|
11580
|
+
fileId={activeFile.id}
|
|
11581
|
+
routeSourceFile={activeScreenRouteSourceFile}
|
|
11582
|
+
onDismiss={() => setLocalSourceBannerDismissed(true)}
|
|
11583
|
+
/>
|
|
11584
|
+
)}
|
|
11585
|
+
<div
|
|
11586
|
+
ref={canvasContainerRef}
|
|
11587
|
+
className="relative mx-1 min-w-0 flex-1 overflow-hidden rounded-xl bg-[var(--design-editor-canvas-bg)]"
|
|
11588
|
+
onPointerMove={handleCanvasPointerMove}
|
|
11589
|
+
>
|
|
11590
|
+
{/* Transparent shield that blocks pointer events reaching the
|
|
9832
11591
|
iframe when a portaled Radix popover (e.g. color picker) is
|
|
9833
11592
|
open. The iframe has its own event context so it receives
|
|
9834
11593
|
pointer events even when visually covered by the popover. */}
|
|
9835
|
-
|
|
9836
|
-
|
|
9837
|
-
|
|
9838
|
-
|
|
9839
|
-
|
|
9840
|
-
|
|
9841
|
-
|
|
9842
|
-
|
|
9843
|
-
|
|
9844
|
-
|
|
9845
|
-
|
|
9846
|
-
|
|
9847
|
-
|
|
9848
|
-
|
|
9849
|
-
|
|
9850
|
-
|
|
9851
|
-
|
|
9852
|
-
|
|
9853
|
-
|
|
9854
|
-
|
|
9855
|
-
|
|
9856
|
-
|
|
9857
|
-
|
|
9858
|
-
|
|
9859
|
-
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
|
|
9865
|
-
|
|
9866
|
-
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
|
|
9872
|
-
|
|
9873
|
-
|
|
9874
|
-
|
|
9875
|
-
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
|
|
9879
|
-
|
|
9880
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
|
|
9893
|
-
|
|
9894
|
-
|
|
9895
|
-
|
|
9896
|
-
|
|
9897
|
-
|
|
9898
|
-
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
9907
|
-
|
|
9908
|
-
|
|
9909
|
-
|
|
9910
|
-
|
|
9911
|
-
|
|
9912
|
-
|
|
9913
|
-
)
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
9925
|
-
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
registerRuntimeBridge={screenIsActive}
|
|
9932
|
-
selectedSelector={
|
|
9933
|
-
screenIsActive ? selectedCanvasSelector : null
|
|
9934
|
-
}
|
|
9935
|
-
selectedSelectorCandidates={
|
|
9936
|
-
screenIsActive
|
|
9937
|
-
? selectedCanvasSelectorCandidates
|
|
9938
|
-
: []
|
|
9939
|
-
}
|
|
9940
|
-
hoveredSelector={
|
|
9941
|
-
hoveredElementScreenId === screen.id
|
|
9942
|
-
? hoveredCanvasSelector
|
|
9943
|
-
: null
|
|
9944
|
-
}
|
|
9945
|
-
hoveredSelectorCandidates={
|
|
9946
|
-
hoveredElementScreenId === screen.id
|
|
9947
|
-
? hoveredCanvasSelectorCandidates
|
|
9948
|
-
: []
|
|
9949
|
-
}
|
|
9950
|
-
lockedSelectors={getLayerSelectorsForFile(
|
|
9951
|
-
screen.id,
|
|
9952
|
-
lockedLayerIds,
|
|
9953
|
-
)}
|
|
9954
|
-
hiddenSelectors={getLayerSelectorsForFile(
|
|
9955
|
-
screen.id,
|
|
9956
|
-
hiddenLayerIds,
|
|
9957
|
-
)}
|
|
9958
|
-
onElementSelect={(info) =>
|
|
9959
|
-
handleScreenElementSelect(screen.id, info)
|
|
9960
|
-
}
|
|
9961
|
-
onElementHover={(info) =>
|
|
9962
|
-
handleScreenElementHover(screen.id, info)
|
|
9963
|
-
}
|
|
9964
|
-
onClearSelection={() =>
|
|
9965
|
-
handleScreenElementClear(screen.id)
|
|
11594
|
+
{inspectorPopoverOpen && (
|
|
11595
|
+
<div
|
|
11596
|
+
aria-hidden="true"
|
|
11597
|
+
style={{
|
|
11598
|
+
position: "absolute",
|
|
11599
|
+
inset: 0,
|
|
11600
|
+
zIndex: 10,
|
|
11601
|
+
pointerEvents: "auto",
|
|
11602
|
+
}}
|
|
11603
|
+
/>
|
|
11604
|
+
)}
|
|
11605
|
+
{viewMode === "overview" ? (
|
|
11606
|
+
<MultiScreenCanvas
|
|
11607
|
+
screens={overviewScreens}
|
|
11608
|
+
zoom={overviewCanvasZoom}
|
|
11609
|
+
onZoomChange={setOverviewCanvasZoom}
|
|
11610
|
+
activeId={activeFileId}
|
|
11611
|
+
selectedScreenIds={overviewSelectedScreenIds}
|
|
11612
|
+
fullViewScreenIds={fullViewScreenIds}
|
|
11613
|
+
activeScreenHasHoveredChild={
|
|
11614
|
+
Boolean(hoveredElement) &&
|
|
11615
|
+
!hoveredElementIsScreenRoot &&
|
|
11616
|
+
hoveredElementScreenId === activeFileId
|
|
11617
|
+
}
|
|
11618
|
+
hoveredChildScreenId={hoveredChildScreenId}
|
|
11619
|
+
directlyHoveredScreenId={hoveredScreenRootId}
|
|
11620
|
+
previewDeviceFrame={deviceFrame}
|
|
11621
|
+
activeTool={activeTool}
|
|
11622
|
+
onActiveToolChange={(tool) =>
|
|
11623
|
+
setActiveTool(tool === "rectangle" ? "rect" : tool)
|
|
11624
|
+
}
|
|
11625
|
+
selectAllRequest={overviewSelectAllRequest}
|
|
11626
|
+
clearSelectionRequest={overviewClearSelectionRequest}
|
|
11627
|
+
onScreenSelectionChange={
|
|
11628
|
+
handleOverviewScreenSelectionChange
|
|
11629
|
+
}
|
|
11630
|
+
geometryById={canvasFrameGeometryById}
|
|
11631
|
+
onGeometryChange={queueFrameGeometrySave}
|
|
11632
|
+
onGeometryCommit={handleGeometryCommit}
|
|
11633
|
+
onCreatePrimitive={handleCreatePrimitive}
|
|
11634
|
+
onPrimitiveCreated={handlePrimitiveCreated}
|
|
11635
|
+
onPrimitiveReparent={handleOverviewPrimitiveReparent}
|
|
11636
|
+
onCrossScreenElementDrop={handleCrossScreenElementDrop}
|
|
11637
|
+
onCreateScreenFrame={handleCreateScreenFrame}
|
|
11638
|
+
onDeleteSelection={handleDeleteOverviewSelection}
|
|
11639
|
+
onSelectionChange={setOverviewSelectedScreenIds}
|
|
11640
|
+
onPick={(id) => {
|
|
11641
|
+
pendingOverviewScreenSelectionRef.current = null;
|
|
11642
|
+
setSelectedElement(null);
|
|
11643
|
+
setHoveredElement(null);
|
|
11644
|
+
setSelectedLayerIdsState([id]);
|
|
11645
|
+
setActiveFileId(id);
|
|
11646
|
+
setActiveTool("move");
|
|
11647
|
+
setMode("edit");
|
|
11648
|
+
}}
|
|
11649
|
+
onEdit={enterSingleScreen}
|
|
11650
|
+
onDuplicate={handleDuplicateScreen}
|
|
11651
|
+
onAddBreakpoint={(screenId, widthPx) => {
|
|
11652
|
+
if (!id) return;
|
|
11653
|
+
const breakpointLabel =
|
|
11654
|
+
widthPx <= 480
|
|
11655
|
+
? "Mobile"
|
|
11656
|
+
: widthPx <= 1024
|
|
11657
|
+
? "Tablet"
|
|
11658
|
+
: "Desktop";
|
|
11659
|
+
void addBreakpointMutation.mutateAsync({
|
|
11660
|
+
designId: id,
|
|
11661
|
+
label: breakpointLabel,
|
|
11662
|
+
widthPx,
|
|
11663
|
+
});
|
|
11664
|
+
}}
|
|
11665
|
+
onActiveBreakpointChange={(_screenId, widthPx) => {
|
|
11666
|
+
setActiveBreakpointWidthState(widthPx);
|
|
11667
|
+
if (!id) return;
|
|
11668
|
+
const bpSet = (() => {
|
|
11669
|
+
try {
|
|
11670
|
+
const raw = (
|
|
11671
|
+
designDataJson as Record<string, unknown>
|
|
11672
|
+
)?.breakpointSet;
|
|
11673
|
+
if (
|
|
11674
|
+
raw &&
|
|
11675
|
+
typeof raw === "object" &&
|
|
11676
|
+
Array.isArray(
|
|
11677
|
+
(raw as Record<string, unknown>).breakpoints,
|
|
11678
|
+
)
|
|
11679
|
+
) {
|
|
11680
|
+
return raw as {
|
|
11681
|
+
breakpoints: Array<{
|
|
11682
|
+
id: string;
|
|
11683
|
+
widthPx: number;
|
|
11684
|
+
}>;
|
|
11685
|
+
};
|
|
11686
|
+
}
|
|
11687
|
+
} catch {
|
|
11688
|
+
// Ignore malformed design data; the mutation below
|
|
11689
|
+
// can still clear back to auto.
|
|
9966
11690
|
}
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
9970
|
-
|
|
11691
|
+
return null;
|
|
11692
|
+
})();
|
|
11693
|
+
const bp = bpSet?.breakpoints.find(
|
|
11694
|
+
(b) => b.widthPx === widthPx,
|
|
11695
|
+
);
|
|
11696
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
11697
|
+
designId: id,
|
|
11698
|
+
breakpointId:
|
|
11699
|
+
widthPx !== undefined && bp ? bp.id : "auto",
|
|
11700
|
+
});
|
|
11701
|
+
}}
|
|
11702
|
+
renderScreenContent={(screen, metadata, geometry) => {
|
|
11703
|
+
const screenIsActive = screen.id === activeFile?.id;
|
|
11704
|
+
const screenContent = getScreenContent(screen.id);
|
|
11705
|
+
const screenContentKey = screenIsActive
|
|
11706
|
+
? [screen.id, contentRenderRevision].join(":")
|
|
11707
|
+
: [
|
|
9971
11708
|
screen.id,
|
|
9972
|
-
|
|
9973
|
-
|
|
9974
|
-
|
|
9975
|
-
)
|
|
9976
|
-
|
|
9977
|
-
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
11709
|
+
screen.updatedAt ?? "",
|
|
11710
|
+
getContentSignature(screenContent),
|
|
11711
|
+
0,
|
|
11712
|
+
].join(":");
|
|
11713
|
+
|
|
11714
|
+
return (
|
|
11715
|
+
<DesignCanvas
|
|
11716
|
+
content={screenContent}
|
|
11717
|
+
contentKey={screenContentKey}
|
|
11718
|
+
zoom={100}
|
|
11719
|
+
deviceFrame="none"
|
|
11720
|
+
sourceType={designSourceType}
|
|
11721
|
+
fusionUrl={designFusionUrl}
|
|
11722
|
+
onComponentSourceJump={handleComponentSourceJump}
|
|
11723
|
+
embeddedFrame={{
|
|
11724
|
+
viewportWidth: Math.max(
|
|
11725
|
+
1,
|
|
11726
|
+
Math.round(geometry.width),
|
|
11727
|
+
),
|
|
11728
|
+
viewportHeight: Math.max(
|
|
11729
|
+
1,
|
|
11730
|
+
Math.round(geometry.height),
|
|
11731
|
+
),
|
|
11732
|
+
displayWidth: Math.max(
|
|
11733
|
+
1,
|
|
11734
|
+
Math.round(geometry.width),
|
|
11735
|
+
),
|
|
11736
|
+
displayHeight: Math.max(
|
|
11737
|
+
1,
|
|
11738
|
+
Math.round(geometry.height),
|
|
11739
|
+
),
|
|
11740
|
+
fluid: true,
|
|
11741
|
+
}}
|
|
11742
|
+
editorChromeScaleX={overviewCanvasZoom / 100}
|
|
11743
|
+
editorChromeScaleY={overviewCanvasZoom / 100}
|
|
11744
|
+
editMode={mode === "edit"}
|
|
11745
|
+
interactMode={false}
|
|
11746
|
+
readOnly={!canEditDesign}
|
|
11747
|
+
scaleMode={screenIsActive && activeTool === "scale"}
|
|
11748
|
+
clearSelectionRequest={
|
|
11749
|
+
overviewClearSelectionRequest
|
|
11750
|
+
}
|
|
11751
|
+
registerRuntimeBridge={screenIsActive}
|
|
11752
|
+
selectedSelector={
|
|
11753
|
+
screenIsActive ? selectedCanvasSelector : null
|
|
11754
|
+
}
|
|
11755
|
+
selectedSelectorCandidates={
|
|
11756
|
+
screenIsActive
|
|
11757
|
+
? selectedCanvasSelectorCandidates
|
|
11758
|
+
: []
|
|
11759
|
+
}
|
|
11760
|
+
hoveredSelector={
|
|
11761
|
+
hoveredElementScreenId === screen.id
|
|
11762
|
+
? hoveredCanvasSelector
|
|
11763
|
+
: null
|
|
11764
|
+
}
|
|
11765
|
+
hoveredSelectorCandidates={
|
|
11766
|
+
hoveredElementScreenId === screen.id
|
|
11767
|
+
? hoveredCanvasSelectorCandidates
|
|
11768
|
+
: []
|
|
11769
|
+
}
|
|
11770
|
+
lockedSelectors={getLayerSelectorsForFile(
|
|
9985
11771
|
screen.id,
|
|
11772
|
+
lockedLayerIds,
|
|
11773
|
+
)}
|
|
11774
|
+
hiddenSelectors={getLayerSelectorsForFile(
|
|
11775
|
+
screen.id,
|
|
11776
|
+
hiddenLayerIds,
|
|
11777
|
+
)}
|
|
11778
|
+
onElementSelect={(info) =>
|
|
11779
|
+
handleScreenElementSelect(screen.id, info)
|
|
11780
|
+
}
|
|
11781
|
+
onElementHover={(info) =>
|
|
11782
|
+
handleScreenElementHover(screen.id, info)
|
|
11783
|
+
}
|
|
11784
|
+
onClearSelection={() =>
|
|
11785
|
+
handleScreenElementClear(screen.id)
|
|
11786
|
+
}
|
|
11787
|
+
onIframeHotkey={handleIframeHotkey}
|
|
11788
|
+
onIframeContextMenu={handleIframeContextMenu}
|
|
11789
|
+
onVisualStyleChange={(selector, styles, info) =>
|
|
11790
|
+
handleScreenVisualStyleChange(
|
|
11791
|
+
screen.id,
|
|
11792
|
+
selector,
|
|
11793
|
+
styles,
|
|
11794
|
+
info,
|
|
11795
|
+
)
|
|
11796
|
+
}
|
|
11797
|
+
onVisualStructureChange={(
|
|
9986
11798
|
selector,
|
|
9987
11799
|
anchorSelector,
|
|
9988
11800
|
placement,
|
|
9989
11801
|
info,
|
|
9990
11802
|
details,
|
|
9991
|
-
)
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
11803
|
+
) =>
|
|
11804
|
+
handleScreenVisualStructureChange(
|
|
11805
|
+
screen.id,
|
|
11806
|
+
selector,
|
|
11807
|
+
anchorSelector,
|
|
11808
|
+
placement,
|
|
11809
|
+
info,
|
|
11810
|
+
details,
|
|
11811
|
+
)
|
|
11812
|
+
}
|
|
11813
|
+
onVisualDuplicateChange={(
|
|
10001
11814
|
selector,
|
|
10002
11815
|
cloneHtml,
|
|
10003
11816
|
info,
|
|
10004
11817
|
details,
|
|
10005
|
-
)
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
11818
|
+
) =>
|
|
11819
|
+
handleScreenVisualDuplicateChange(
|
|
11820
|
+
screen.id,
|
|
11821
|
+
selector,
|
|
11822
|
+
cloneHtml,
|
|
11823
|
+
info,
|
|
11824
|
+
details,
|
|
11825
|
+
)
|
|
11826
|
+
}
|
|
11827
|
+
onTextContentChange={(
|
|
10015
11828
|
selector,
|
|
10016
11829
|
value,
|
|
10017
11830
|
info,
|
|
10018
11831
|
details,
|
|
10019
|
-
)
|
|
10020
|
-
|
|
10021
|
-
|
|
10022
|
-
|
|
10023
|
-
|
|
10024
|
-
|
|
10025
|
-
|
|
10026
|
-
|
|
10027
|
-
|
|
10028
|
-
|
|
10029
|
-
|
|
10030
|
-
|
|
10031
|
-
|
|
10032
|
-
|
|
10033
|
-
|
|
10034
|
-
|
|
10035
|
-
|
|
10036
|
-
|
|
10037
|
-
|
|
10038
|
-
|
|
10039
|
-
|
|
10040
|
-
contentKey={`${activeFile.id}:${contentRenderRevision}`}
|
|
10041
|
-
zoom={zoom}
|
|
10042
|
-
onZoomChange={setZoom}
|
|
10043
|
-
deviceFrame={deviceFrame}
|
|
10044
|
-
editMode={mode === "edit"}
|
|
10045
|
-
interactMode={mode === "interact"}
|
|
10046
|
-
readOnly={!canEditDesign}
|
|
10047
|
-
scaleMode={activeTool === "scale"}
|
|
10048
|
-
clearSelectionRequest={overviewClearSelectionRequest}
|
|
10049
|
-
selectedSelector={selectedCanvasSelector}
|
|
10050
|
-
selectedSelectorCandidates={
|
|
10051
|
-
selectedCanvasSelectorCandidates
|
|
10052
|
-
}
|
|
10053
|
-
hoveredSelector={hoveredCanvasSelector}
|
|
10054
|
-
hoveredSelectorCandidates={
|
|
10055
|
-
hoveredCanvasSelectorCandidates
|
|
10056
|
-
}
|
|
10057
|
-
lockedSelectors={lockedLayerSelectors}
|
|
10058
|
-
hiddenSelectors={hiddenLayerSelectors}
|
|
10059
|
-
onElementSelect={handleElementSelect}
|
|
10060
|
-
onElementHover={handleElementHover}
|
|
10061
|
-
onClearSelection={() => {
|
|
10062
|
-
setSelectedElement(null);
|
|
10063
|
-
setHoveredElement(null);
|
|
10064
|
-
setHoveredElementScreenId(null);
|
|
10065
|
-
setSelectedLayerIdsState([]);
|
|
10066
|
-
}}
|
|
10067
|
-
onIframeHotkey={handleIframeHotkey}
|
|
10068
|
-
onIframeContextMenu={handleIframeContextMenu}
|
|
10069
|
-
onVisualStyleChange={handleVisualStyleChange}
|
|
10070
|
-
onVisualStructureChange={handleVisualStructureChange}
|
|
10071
|
-
onVisualDuplicateChange={handleVisualDuplicateChange}
|
|
10072
|
-
onTextContentChange={handleTextContentChange}
|
|
10073
|
-
onTextEditingStateChange={setTextEditingState}
|
|
10074
|
-
onElementDblClickText={handleElementDblClickText}
|
|
10075
|
-
tweakValues={cssVarValues}
|
|
10076
|
-
drawMode={drawMode}
|
|
10077
|
-
onExitDrawMode={() => {
|
|
10078
|
-
setDrawMode(false);
|
|
10079
|
-
setPinMode(false);
|
|
10080
|
-
setActiveTool("move");
|
|
10081
|
-
setMode("edit");
|
|
10082
|
-
}}
|
|
10083
|
-
pinMode={pinMode}
|
|
10084
|
-
onExitPinMode={() => {
|
|
10085
|
-
setPinMode(false);
|
|
10086
|
-
if (mode === "annotate") {
|
|
10087
|
-
setActiveTool("draw");
|
|
10088
|
-
}
|
|
10089
|
-
}}
|
|
10090
|
-
designId={id}
|
|
10091
|
-
designTitle={design?.title}
|
|
10092
|
-
commentContextId={`${id}:${activeFile.id}`}
|
|
10093
|
-
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
|
|
10094
|
-
onPrototypeNavigate={(screen) => {
|
|
10095
|
-
if (!screen) return;
|
|
10096
|
-
const norm = (s: string) =>
|
|
10097
|
-
s
|
|
10098
|
-
.replace(/^\.?\//, "")
|
|
10099
|
-
.replace(/\.html?$/i, "")
|
|
10100
|
-
.toLowerCase();
|
|
10101
|
-
const target = norm(screen);
|
|
10102
|
-
if (!target) return;
|
|
10103
|
-
// Exact (normalized) filename match only — a substring match
|
|
10104
|
-
// could send "board" to "dashboard.html".
|
|
10105
|
-
const match = files.find(
|
|
10106
|
-
(f) => norm(f.filename) === target,
|
|
11832
|
+
) =>
|
|
11833
|
+
handleScreenTextContentChange(
|
|
11834
|
+
screen.id,
|
|
11835
|
+
selector,
|
|
11836
|
+
value,
|
|
11837
|
+
info,
|
|
11838
|
+
details,
|
|
11839
|
+
)
|
|
11840
|
+
}
|
|
11841
|
+
onTextEditingStateChange={setTextEditingState}
|
|
11842
|
+
onElementDblClickText={(info) =>
|
|
11843
|
+
handleScreenElementDblClickText(screen.id, info)
|
|
11844
|
+
}
|
|
11845
|
+
tweakValues={cssVarValues}
|
|
11846
|
+
drawMode={false}
|
|
11847
|
+
pinMode={false}
|
|
11848
|
+
designId={id}
|
|
11849
|
+
designTitle={design?.title}
|
|
11850
|
+
commentContextId={`${id}:${screen.id}`}
|
|
11851
|
+
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(screen.filename)}`}
|
|
11852
|
+
/>
|
|
10107
11853
|
);
|
|
10108
|
-
if (match) {
|
|
10109
|
-
viewModeRef.current = "single";
|
|
10110
|
-
setScreenZoom(FOCUSED_SCREEN_ZOOM);
|
|
10111
|
-
setViewMode("single");
|
|
10112
|
-
setActiveFileId(match.id);
|
|
10113
|
-
}
|
|
10114
11854
|
}}
|
|
10115
11855
|
/>
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
<
|
|
10119
|
-
|
|
10120
|
-
|
|
11856
|
+
) : (
|
|
11857
|
+
<>
|
|
11858
|
+
<DesignCanvas
|
|
11859
|
+
content={activeContent}
|
|
11860
|
+
contentKey={`${activeFile.id}:${contentRenderRevision}`}
|
|
11861
|
+
zoom={zoom}
|
|
11862
|
+
onZoomChange={setZoom}
|
|
11863
|
+
deviceFrame={deviceFrame}
|
|
11864
|
+
sourceType={designSourceType}
|
|
11865
|
+
fusionUrl={designFusionUrl}
|
|
11866
|
+
previewWidthPx={activeBreakpointWidthState}
|
|
11867
|
+
onComponentSourceJump={handleComponentSourceJump}
|
|
11868
|
+
// TODO: thread motionTracks / shaderFillPreview live-preview
|
|
11869
|
+
// props once the canvas-side live preview state is plumbed
|
|
11870
|
+
// here (non-trivial new state; MotionDock already drives
|
|
11871
|
+
// motion-preview directly via canvasIframeRef for now).
|
|
11872
|
+
motionTracks={motionTracksWire}
|
|
11873
|
+
editMode={mode === "edit"}
|
|
11874
|
+
interactMode={mode === "interact"}
|
|
11875
|
+
readOnly={!canEditDesign}
|
|
11876
|
+
scaleMode={activeTool === "scale"}
|
|
11877
|
+
clearSelectionRequest={overviewClearSelectionRequest}
|
|
11878
|
+
selectedSelector={selectedCanvasSelector}
|
|
11879
|
+
selectedSelectorCandidates={
|
|
11880
|
+
selectedCanvasSelectorCandidates
|
|
11881
|
+
}
|
|
11882
|
+
hoveredSelector={hoveredCanvasSelector}
|
|
11883
|
+
hoveredSelectorCandidates={
|
|
11884
|
+
hoveredCanvasSelectorCandidates
|
|
11885
|
+
}
|
|
11886
|
+
lockedSelectors={lockedLayerSelectors}
|
|
11887
|
+
hiddenSelectors={hiddenLayerSelectors}
|
|
11888
|
+
onElementSelect={handleElementSelect}
|
|
11889
|
+
onElementHover={handleElementHover}
|
|
11890
|
+
onClearSelection={() => {
|
|
11891
|
+
setSelectedElement(null);
|
|
11892
|
+
setHoveredElement(null);
|
|
11893
|
+
setHoveredElementScreenId(null);
|
|
11894
|
+
setSelectedLayerIdsState([]);
|
|
11895
|
+
}}
|
|
11896
|
+
onIframeHotkey={handleIframeHotkey}
|
|
11897
|
+
onIframeContextMenu={handleIframeContextMenu}
|
|
11898
|
+
onVisualStyleChange={handleVisualStyleChange}
|
|
11899
|
+
onVisualStructureChange={handleVisualStructureChange}
|
|
11900
|
+
onVisualDuplicateChange={handleVisualDuplicateChange}
|
|
11901
|
+
onTextContentChange={handleTextContentChange}
|
|
11902
|
+
onTextEditingStateChange={setTextEditingState}
|
|
11903
|
+
onElementDblClickText={handleElementDblClickText}
|
|
11904
|
+
tweakValues={cssVarValues}
|
|
11905
|
+
drawMode={drawMode}
|
|
11906
|
+
onExitDrawMode={() => {
|
|
11907
|
+
setDrawMode(false);
|
|
11908
|
+
setPinMode(false);
|
|
11909
|
+
setActiveTool("move");
|
|
11910
|
+
setMode("edit");
|
|
11911
|
+
}}
|
|
11912
|
+
pinMode={pinMode}
|
|
11913
|
+
onExitPinMode={() => {
|
|
11914
|
+
setPinMode(false);
|
|
11915
|
+
if (mode === "annotate") {
|
|
11916
|
+
setActiveTool("draw");
|
|
11917
|
+
}
|
|
11918
|
+
}}
|
|
11919
|
+
designId={id}
|
|
11920
|
+
designTitle={design?.title}
|
|
11921
|
+
commentContextId={`${id}:${activeFile.id}`}
|
|
11922
|
+
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
|
|
11923
|
+
onPrototypeNavigate={(screen) => {
|
|
11924
|
+
if (!screen) return;
|
|
11925
|
+
const norm = (s: string) =>
|
|
11926
|
+
s
|
|
11927
|
+
.replace(/^\.?\//, "")
|
|
11928
|
+
.replace(/\.html?$/i, "")
|
|
11929
|
+
.toLowerCase();
|
|
11930
|
+
const target = norm(screen);
|
|
11931
|
+
if (!target) return;
|
|
11932
|
+
// Exact (normalized) filename match only — a substring match
|
|
11933
|
+
// could send "board" to "dashboard.html".
|
|
11934
|
+
const match = files.find(
|
|
11935
|
+
(f) => norm(f.filename) === target,
|
|
11936
|
+
);
|
|
11937
|
+
if (match) {
|
|
11938
|
+
viewModeRef.current = "single";
|
|
11939
|
+
setScreenZoom(FOCUSED_SCREEN_ZOOM);
|
|
11940
|
+
setViewMode("single");
|
|
11941
|
+
setActiveFileId(match.id);
|
|
11942
|
+
}
|
|
11943
|
+
}}
|
|
10121
11944
|
/>
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
|
|
11945
|
+
{/* Presence: live cursor overlay for remote participants */}
|
|
11946
|
+
{others.length > 0 && (
|
|
11947
|
+
<LiveCursorOverlay
|
|
11948
|
+
others={others}
|
|
11949
|
+
containerRef={canvasContainerRef}
|
|
11950
|
+
/>
|
|
11951
|
+
)}
|
|
11952
|
+
</>
|
|
11953
|
+
)}
|
|
11954
|
+
</div>
|
|
10125
11955
|
</div>
|
|
10126
11956
|
) : (
|
|
10127
11957
|
<div className="flex flex-1 items-center justify-center">
|
|
@@ -10140,7 +11970,7 @@ ${serializedHtml}
|
|
|
10140
11970
|
</p>
|
|
10141
11971
|
{retryablePrompt ? (
|
|
10142
11972
|
<p className="mx-auto mb-4 max-w-sm text-xs italic text-muted-foreground/70">
|
|
10143
|
-
"{retryablePrompt.prompt}"
|
|
11973
|
+
{`"${retryablePrompt.prompt}"`}
|
|
10144
11974
|
</p>
|
|
10145
11975
|
) : null}
|
|
10146
11976
|
<div className="flex items-center justify-center gap-2">
|
|
@@ -10217,8 +12047,65 @@ ${serializedHtml}
|
|
|
10217
12047
|
onRequestTweaks={handleRequestTweaks}
|
|
10218
12048
|
onStyleChange={handleStyleChange}
|
|
10219
12049
|
onStylesChange={handleStylesChange}
|
|
12050
|
+
onAutoLayoutConvert={handleAutoLayoutConvert}
|
|
10220
12051
|
onExport={handleInspectorExport}
|
|
10221
12052
|
exporting={pngExporting || svgExporting}
|
|
12053
|
+
designId={id}
|
|
12054
|
+
fileId={activeFile?.id}
|
|
12055
|
+
filename={activeFile?.filename}
|
|
12056
|
+
componentNodeId={selectedComponentNodeId}
|
|
12057
|
+
sourceCapabilities={sourceCapabilities}
|
|
12058
|
+
onCreateComponent={
|
|
12059
|
+
id && selectedElement ? handleCreateComponent : undefined
|
|
12060
|
+
}
|
|
12061
|
+
defaultComponentName={defaultComponentName}
|
|
12062
|
+
inspectCode={inspectCodeData}
|
|
12063
|
+
statesPanelProps={
|
|
12064
|
+
id
|
|
12065
|
+
? {
|
|
12066
|
+
// §6.4 / §8 — active state and breakpoint wired into
|
|
12067
|
+
// the StatesPanel so selection is agent-visible.
|
|
12068
|
+
activeStateId: selectedStateId,
|
|
12069
|
+
activeBreakpointId: statesPanelActiveBreakpointId,
|
|
12070
|
+
breakpoints: statesPanelBreakpoints,
|
|
12071
|
+
onStateSelect: (stateId) => {
|
|
12072
|
+
setSelectedStateId(stateId);
|
|
12073
|
+
},
|
|
12074
|
+
onBreakpointSelect: (breakpointId) => {
|
|
12075
|
+
// "auto" = clear the active breakpoint (overview).
|
|
12076
|
+
if (breakpointId === "auto") {
|
|
12077
|
+
setActiveBreakpointWidthState(undefined);
|
|
12078
|
+
if (id) {
|
|
12079
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
12080
|
+
designId: id,
|
|
12081
|
+
breakpointId: "auto",
|
|
12082
|
+
});
|
|
12083
|
+
}
|
|
12084
|
+
return;
|
|
12085
|
+
}
|
|
12086
|
+
const bp = statesPanelBreakpoints.find(
|
|
12087
|
+
(b) => b.id === breakpointId,
|
|
12088
|
+
);
|
|
12089
|
+
if (!bp) return;
|
|
12090
|
+
setActiveBreakpointWidthState(bp.widthPx);
|
|
12091
|
+
if (id) {
|
|
12092
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
12093
|
+
designId: id,
|
|
12094
|
+
breakpointId,
|
|
12095
|
+
});
|
|
12096
|
+
}
|
|
12097
|
+
},
|
|
12098
|
+
onAddBreakpoint: () => {
|
|
12099
|
+
// Delegate to the MultiScreenCanvas affordance by
|
|
12100
|
+
// navigating to overview where the "+" button lives.
|
|
12101
|
+
if (viewMode !== "overview") {
|
|
12102
|
+
setViewMode("overview");
|
|
12103
|
+
}
|
|
12104
|
+
},
|
|
12105
|
+
}
|
|
12106
|
+
: undefined
|
|
12107
|
+
}
|
|
12108
|
+
reviewPanelProps={reviewPanelProps}
|
|
10222
12109
|
/>
|
|
10223
12110
|
</div>
|
|
10224
12111
|
) : (
|
|
@@ -10228,6 +12115,34 @@ ${serializedHtml}
|
|
|
10228
12115
|
) : null}
|
|
10229
12116
|
</div>
|
|
10230
12117
|
|
|
12118
|
+
{/* Motion dock (§6.3) — collapsible bottom dock; visible when activeFile
|
|
12119
|
+
is open and the user has opened it. Canvas remains visible above.
|
|
12120
|
+
Preview-only scrubbing fires a motion-preview postMessage to the
|
|
12121
|
+
canvas iframe (via canvasIframeRef); "Write to CSS" fires
|
|
12122
|
+
apply-motion-edit. */}
|
|
12123
|
+
{!embedded && activeFile ? (
|
|
12124
|
+
<MotionDock
|
|
12125
|
+
tracks={motionTracks}
|
|
12126
|
+
durationMs={motionDurationMs}
|
|
12127
|
+
open={motionDockOpen}
|
|
12128
|
+
onOpenChange={setMotionDockOpen}
|
|
12129
|
+
onTracksChange={setMotionTracks}
|
|
12130
|
+
onDurationChange={setMotionDurationMs}
|
|
12131
|
+
canvasIframeRef={canvasIframeRef}
|
|
12132
|
+
selectedTarget={motionSelectedTarget}
|
|
12133
|
+
onApply={(tracks, durationMs) => {
|
|
12134
|
+
if (!id) return;
|
|
12135
|
+
applyMotionEditMutation.mutate({
|
|
12136
|
+
designId: id,
|
|
12137
|
+
fileId: activeFile.id,
|
|
12138
|
+
tracks: tracks.map(({ label: _label, ...t }) => t),
|
|
12139
|
+
durationMs,
|
|
12140
|
+
});
|
|
12141
|
+
}}
|
|
12142
|
+
applying={applyMotionEditMutation.isPending}
|
|
12143
|
+
/>
|
|
12144
|
+
) : null}
|
|
12145
|
+
|
|
10231
12146
|
<PromptPopover
|
|
10232
12147
|
open={showPrompt}
|
|
10233
12148
|
onOpenChange={handlePromptOpenChange}
|
|
@@ -10313,6 +12228,183 @@ ${serializedHtml}
|
|
|
10313
12228
|
loading={generating || pendingGenerationActive}
|
|
10314
12229
|
anchorRef={tweakPromptAnchorRef}
|
|
10315
12230
|
/>
|
|
12231
|
+
|
|
12232
|
+
{/* §6.6 — "Make this a real app" dialog.
|
|
12233
|
+
Three states:
|
|
12234
|
+
1. Idle — confirm prompt with description of what will happen.
|
|
12235
|
+
2. Migrating — spinner while the Builder cloud agent accepts the job.
|
|
12236
|
+
3. Success — branchName + url; sourceType already flipped to fusion.
|
|
12237
|
+
4. Not-configured — CTA to connect Builder.io.
|
|
12238
|
+
*/}
|
|
12239
|
+
<Dialog
|
|
12240
|
+
open={makeRealDialogOpen}
|
|
12241
|
+
onOpenChange={(open) => {
|
|
12242
|
+
if (!migrateMutation.isPending) setMakeRealDialogOpen(open);
|
|
12243
|
+
}}
|
|
12244
|
+
>
|
|
12245
|
+
<DialogContent className="sm:max-w-md">
|
|
12246
|
+
{/* Not-configured: Builder not connected or no project ID */}
|
|
12247
|
+
{migrationResult?.status === "not-configured" &&
|
|
12248
|
+
migrationResult.cta ? (
|
|
12249
|
+
<>
|
|
12250
|
+
<DialogHeader>
|
|
12251
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12252
|
+
<IconRocket className="size-5 text-muted-foreground" />
|
|
12253
|
+
{migrationResult.cta.label}
|
|
12254
|
+
</DialogTitle>
|
|
12255
|
+
<DialogDescription>
|
|
12256
|
+
{migrationResult.cta.description}
|
|
12257
|
+
</DialogDescription>
|
|
12258
|
+
</DialogHeader>
|
|
12259
|
+
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
|
12260
|
+
<Button
|
|
12261
|
+
variant="outline"
|
|
12262
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12263
|
+
className="cursor-pointer"
|
|
12264
|
+
>
|
|
12265
|
+
Cancel
|
|
12266
|
+
</Button>
|
|
12267
|
+
{migrationResult.cta.connectUrl ? (
|
|
12268
|
+
<Button asChild className="cursor-pointer">
|
|
12269
|
+
<a
|
|
12270
|
+
href={migrationResult.cta.connectUrl}
|
|
12271
|
+
target="_blank"
|
|
12272
|
+
rel="noopener noreferrer"
|
|
12273
|
+
>
|
|
12274
|
+
{migrationResult.cta.primaryAction}
|
|
12275
|
+
<IconExternalLink className="ml-1.5 size-3.5" />
|
|
12276
|
+
</a>
|
|
12277
|
+
</Button>
|
|
12278
|
+
) : null}
|
|
12279
|
+
</DialogFooter>
|
|
12280
|
+
</>
|
|
12281
|
+
) : migrationResult?.status === "processing" ? (
|
|
12282
|
+
/* Success: Builder accepted the migration job */
|
|
12283
|
+
<>
|
|
12284
|
+
<DialogHeader>
|
|
12285
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12286
|
+
<IconCircleCheck className="size-5 text-green-500" />
|
|
12287
|
+
{"Migration started" /* i18n-ignore */}
|
|
12288
|
+
</DialogTitle>
|
|
12289
|
+
<DialogDescription>
|
|
12290
|
+
{
|
|
12291
|
+
"Builder is generating a React app branch from your design. The original inline design is preserved and recoverable." /* i18n-ignore */
|
|
12292
|
+
}
|
|
12293
|
+
</DialogDescription>
|
|
12294
|
+
</DialogHeader>
|
|
12295
|
+
<div className="space-y-3 py-2">
|
|
12296
|
+
{migrationResult.branchName && (
|
|
12297
|
+
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
|
12298
|
+
<span className="text-muted-foreground">
|
|
12299
|
+
{"Branch: " /* i18n-ignore */}
|
|
12300
|
+
</span>
|
|
12301
|
+
<span className="font-mono font-medium">
|
|
12302
|
+
{migrationResult.branchName}
|
|
12303
|
+
</span>
|
|
12304
|
+
</div>
|
|
12305
|
+
)}
|
|
12306
|
+
{migrationResult.url && (
|
|
12307
|
+
<a
|
|
12308
|
+
href={migrationResult.url}
|
|
12309
|
+
target="_blank"
|
|
12310
|
+
rel="noopener noreferrer"
|
|
12311
|
+
className="flex items-center gap-1.5 text-sm text-[var(--design-editor-accent-color)] hover:underline"
|
|
12312
|
+
>
|
|
12313
|
+
{"Open in Builder" /* i18n-ignore */}
|
|
12314
|
+
<IconExternalLink className="size-3.5" />
|
|
12315
|
+
</a>
|
|
12316
|
+
)}
|
|
12317
|
+
{migrationResult.seedFileCount !== undefined && (
|
|
12318
|
+
<p className="text-xs text-muted-foreground">
|
|
12319
|
+
{
|
|
12320
|
+
`${migrationResult.seedFileCount} design file${migrationResult.seedFileCount === 1 ? "" : "s"} included in migration seed.` /* i18n-ignore */
|
|
12321
|
+
}
|
|
12322
|
+
</p>
|
|
12323
|
+
)}
|
|
12324
|
+
</div>
|
|
12325
|
+
<DialogFooter>
|
|
12326
|
+
<Button
|
|
12327
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12328
|
+
className="cursor-pointer"
|
|
12329
|
+
>
|
|
12330
|
+
{"Done" /* i18n-ignore */}
|
|
12331
|
+
</Button>
|
|
12332
|
+
</DialogFooter>
|
|
12333
|
+
</>
|
|
12334
|
+
) : (
|
|
12335
|
+
/* Idle or migrating */
|
|
12336
|
+
<>
|
|
12337
|
+
<DialogHeader>
|
|
12338
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12339
|
+
<IconRocket className="size-5" />
|
|
12340
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
12341
|
+
</DialogTitle>
|
|
12342
|
+
<DialogDescription>
|
|
12343
|
+
{
|
|
12344
|
+
"Connect Builder.io to convert this design into a React + Tailwind app with real components, props, branches, and deploys. Your current inline design is preserved as a snapshot you can restore at any time." /* i18n-ignore */
|
|
12345
|
+
}
|
|
12346
|
+
</DialogDescription>
|
|
12347
|
+
</DialogHeader>
|
|
12348
|
+
<div className="space-y-2 py-1 text-sm text-muted-foreground">
|
|
12349
|
+
<p>{"What happens:" /* i18n-ignore */}</p>
|
|
12350
|
+
<ul className="list-disc pl-4 space-y-1">
|
|
12351
|
+
<li>
|
|
12352
|
+
{
|
|
12353
|
+
"Your design HTML and tokens are sent to the Builder cloud agent" /* i18n-ignore */
|
|
12354
|
+
}
|
|
12355
|
+
</li>
|
|
12356
|
+
<li>
|
|
12357
|
+
{
|
|
12358
|
+
"A React + Tailwind branch is generated in Builder" /* i18n-ignore */
|
|
12359
|
+
}
|
|
12360
|
+
</li>
|
|
12361
|
+
<li>
|
|
12362
|
+
{
|
|
12363
|
+
"The editor switches to fusion source mode — gated panels light up" /* i18n-ignore */
|
|
12364
|
+
}
|
|
12365
|
+
</li>
|
|
12366
|
+
<li>
|
|
12367
|
+
{
|
|
12368
|
+
"The original inline design is saved as a restorable snapshot" /* i18n-ignore */
|
|
12369
|
+
}
|
|
12370
|
+
</li>
|
|
12371
|
+
</ul>
|
|
12372
|
+
<p className="pt-1 text-xs">
|
|
12373
|
+
{
|
|
12374
|
+
"Requires Builder.io to be connected with a branch project configured." /* i18n-ignore */
|
|
12375
|
+
}
|
|
12376
|
+
</p>
|
|
12377
|
+
</div>
|
|
12378
|
+
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
|
12379
|
+
<Button
|
|
12380
|
+
variant="outline"
|
|
12381
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12382
|
+
disabled={migrateMutation.isPending}
|
|
12383
|
+
className="cursor-pointer"
|
|
12384
|
+
>
|
|
12385
|
+
Cancel
|
|
12386
|
+
</Button>
|
|
12387
|
+
<Button
|
|
12388
|
+
onClick={() => void handleConfirmMakeReal()}
|
|
12389
|
+
disabled={migrateMutation.isPending}
|
|
12390
|
+
className="cursor-pointer"
|
|
12391
|
+
>
|
|
12392
|
+
{
|
|
12393
|
+
migrateMutation.isPending ? (
|
|
12394
|
+
<>
|
|
12395
|
+
<Spinner className="mr-2 size-3.5" />
|
|
12396
|
+
{"Starting migration…" /* i18n-ignore */}
|
|
12397
|
+
</>
|
|
12398
|
+
) : (
|
|
12399
|
+
"Start migration"
|
|
12400
|
+
) /* i18n-ignore */
|
|
12401
|
+
}
|
|
12402
|
+
</Button>
|
|
12403
|
+
</DialogFooter>
|
|
12404
|
+
</>
|
|
12405
|
+
)}
|
|
12406
|
+
</DialogContent>
|
|
12407
|
+
</Dialog>
|
|
10316
12408
|
</div>
|
|
10317
12409
|
);
|
|
10318
12410
|
}
|