@agent-native/core 0.80.9 → 0.80.11
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 +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +108 -15
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/core/src/client/AssistantChat.tsx +123 -26
- package/corpus/core/src/client/sse-event-processor.ts +9 -0
- package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
- package/corpus/templates/assets/AGENTS.md +4 -0
- package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
- package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
- package/corpus/templates/assets/actions/generate-image.ts +118 -67
- package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
- package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
- package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
- package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
- package/corpus/templates/assets/app/i18n-data.ts +61 -0
- package/corpus/templates/assets/app/routes/_index.tsx +4 -0
- package/corpus/templates/assets/app/routes/library.tsx +145 -38
- package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
- package/corpus/templates/assets/server/lib/generation.ts +135 -11
- package/corpus/templates/assets/shared/api.ts +4 -0
- package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
- package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
- 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 +441 -0
- package/corpus/templates/design/actions/apply-design-state.ts +213 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +413 -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/delete-design.ts +20 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +251 -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 +277 -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 +90 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +242 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +187 -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 +436 -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 +1550 -82
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -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 +32 -0
- package/corpus/templates/design/app/i18n-data.ts +280 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
- 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-design-token-edits-now-stay-visible-in-previews-and-token-li.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/global-setup.ts +94 -2
- 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 +274 -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 +349 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
- 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/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +94 -10
- package/dist/agent/production-agent.js.map +1 -1
- 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/client/AssistantChat.d.ts +14 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +115 -22
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +10 -0
- package/dist/client/sse-event-processor.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 +8 -8
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -38,20 +38,31 @@ 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 type { A11yFinding } from "@shared/design-review";
|
|
55
|
+
import {
|
|
56
|
+
DESIGN_CAPABILITY_NAMES,
|
|
57
|
+
hasCapability,
|
|
58
|
+
} from "@shared/design-source-capabilities";
|
|
50
59
|
import { shouldUseLiveFileContent } from "@shared/html-content";
|
|
51
60
|
import {
|
|
52
61
|
resolveTweaksToCssVars,
|
|
53
62
|
type TweakSelections,
|
|
54
63
|
} from "@shared/resolve-tweaks";
|
|
64
|
+
import { utilityStem, widthToPrefix } from "@shared/responsive-classes";
|
|
65
|
+
import { normalizeDesignSourceType } from "@shared/source-mode";
|
|
55
66
|
import {
|
|
56
67
|
IconArrowLeft,
|
|
57
68
|
IconArrowUpRight,
|
|
@@ -94,6 +105,9 @@ import {
|
|
|
94
105
|
IconFileExport,
|
|
95
106
|
IconPlayerPlay,
|
|
96
107
|
IconDeviceFloppy,
|
|
108
|
+
IconRocket,
|
|
109
|
+
IconExternalLink,
|
|
110
|
+
IconCircleCheck,
|
|
97
111
|
IconTerminal2,
|
|
98
112
|
} from "@tabler/icons-react";
|
|
99
113
|
import { useQueryClient } from "@tanstack/react-query";
|
|
@@ -113,6 +127,7 @@ import { useParams, useNavigate, Link, useLocation } from "react-router";
|
|
|
113
127
|
import { toast } from "sonner";
|
|
114
128
|
import * as Y from "yjs";
|
|
115
129
|
|
|
130
|
+
import { canvasPrimitiveVisual } from "@/components/design/canvas-primitive-style";
|
|
116
131
|
import {
|
|
117
132
|
CanvasContextMenu,
|
|
118
133
|
type CanvasContextMenuHandle,
|
|
@@ -121,10 +136,15 @@ import {
|
|
|
121
136
|
DesignCanvas,
|
|
122
137
|
type IframeContextMenuPayload,
|
|
123
138
|
type IframeHotkeyPayload,
|
|
139
|
+
type MotionTrackWire,
|
|
124
140
|
} from "@/components/design/DesignCanvas";
|
|
125
141
|
import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton";
|
|
126
142
|
import type { DesignExtensionSlotContext } from "@/components/design/DesignExtensionsPanel";
|
|
127
|
-
import {
|
|
143
|
+
import {
|
|
144
|
+
EditPanel,
|
|
145
|
+
type InspectCodeData,
|
|
146
|
+
type InspectorTab,
|
|
147
|
+
} from "@/components/design/EditPanel";
|
|
128
148
|
import type { ExportSettingsValue } from "@/components/design/inspector";
|
|
129
149
|
import {
|
|
130
150
|
LayersPanel,
|
|
@@ -132,12 +152,18 @@ import {
|
|
|
132
152
|
type LayersPanelMoveIntent,
|
|
133
153
|
type LayersPanelNode,
|
|
134
154
|
} from "@/components/design/LayersPanel";
|
|
155
|
+
import { LocalSourceEditBanner } from "@/components/design/LocalSourceEditBanner";
|
|
156
|
+
import {
|
|
157
|
+
MotionDock,
|
|
158
|
+
type MotionDockTrack,
|
|
159
|
+
} from "@/components/design/MotionDock";
|
|
135
160
|
import {
|
|
136
161
|
MultiScreenCanvas,
|
|
137
162
|
OVERVIEW_FRAME_WIDTH,
|
|
138
163
|
type CanvasPrimitiveInsert,
|
|
139
164
|
} from "@/components/design/MultiScreenCanvas";
|
|
140
165
|
import { QuestionFlow } from "@/components/design/QuestionFlow";
|
|
166
|
+
import type { ReviewPanelProps } from "@/components/design/ReviewPanel";
|
|
141
167
|
import type { ElementInfo, DeviceFrameType } from "@/components/design/types";
|
|
142
168
|
import {
|
|
143
169
|
DEVICE_FRAME_VIEWPORTS,
|
|
@@ -148,6 +174,14 @@ import type { UploadedFile } from "@/components/editor/PromptDialog";
|
|
|
148
174
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
149
175
|
import { Button } from "@/components/ui/button";
|
|
150
176
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
177
|
+
import {
|
|
178
|
+
Dialog,
|
|
179
|
+
DialogContent,
|
|
180
|
+
DialogDescription,
|
|
181
|
+
DialogFooter,
|
|
182
|
+
DialogHeader,
|
|
183
|
+
DialogTitle,
|
|
184
|
+
} from "@/components/ui/dialog";
|
|
151
185
|
import {
|
|
152
186
|
DropdownMenu,
|
|
153
187
|
DropdownMenuContent,
|
|
@@ -263,6 +297,32 @@ export function getSelectedScreenIdsForEditorState(args: {
|
|
|
263
297
|
return activeFileId ? [activeFileId] : [];
|
|
264
298
|
}
|
|
265
299
|
|
|
300
|
+
function fileIdFromLayerSelectionId(
|
|
301
|
+
layerId: string,
|
|
302
|
+
fileIds: Set<string>,
|
|
303
|
+
): string | null {
|
|
304
|
+
const normalized = layerId.startsWith("code:")
|
|
305
|
+
? layerId.slice("code:".length)
|
|
306
|
+
: layerId;
|
|
307
|
+
return fileIds.has(normalized) ? normalized : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function getOverviewScreenIdsFromLayerSelection(args: {
|
|
311
|
+
fileIds: string[];
|
|
312
|
+
layerIds: string[];
|
|
313
|
+
}) {
|
|
314
|
+
const fileIds = new Set(args.fileIds);
|
|
315
|
+
const seen = new Set<string>();
|
|
316
|
+
const selectedScreenIds: string[] = [];
|
|
317
|
+
args.layerIds.forEach((layerId) => {
|
|
318
|
+
const fileId = fileIdFromLayerSelectionId(layerId, fileIds);
|
|
319
|
+
if (!fileId || seen.has(fileId)) return;
|
|
320
|
+
seen.add(fileId);
|
|
321
|
+
selectedScreenIds.push(fileId);
|
|
322
|
+
});
|
|
323
|
+
return selectedScreenIds;
|
|
324
|
+
}
|
|
325
|
+
|
|
266
326
|
export function getOverviewEnterTarget(args: {
|
|
267
327
|
activeFileId: string | null | undefined;
|
|
268
328
|
overviewSelectedScreenIds: string[];
|
|
@@ -334,6 +394,71 @@ export function getDesignEditorShareUrl(
|
|
|
334
394
|
return new URL(pathname, origin).toString();
|
|
335
395
|
}
|
|
336
396
|
|
|
397
|
+
export function getLocalhostRouteSourceFile(args: {
|
|
398
|
+
sourceFile?: string;
|
|
399
|
+
source?: string;
|
|
400
|
+
}): string | undefined {
|
|
401
|
+
if (args.sourceFile?.trim()) return args.sourceFile;
|
|
402
|
+
const raw = args.source;
|
|
403
|
+
if (!raw) return undefined;
|
|
404
|
+
try {
|
|
405
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
406
|
+
if (
|
|
407
|
+
parsed &&
|
|
408
|
+
typeof parsed === "object" &&
|
|
409
|
+
"file" in parsed &&
|
|
410
|
+
typeof (parsed as Record<string, unknown>).file === "string"
|
|
411
|
+
) {
|
|
412
|
+
return (parsed as Record<string, string>).file;
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
if (raw.length > 0) return raw;
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function getLayerMoveSourceContent(args: {
|
|
421
|
+
sourceFileId: string;
|
|
422
|
+
activeFileId?: string | null;
|
|
423
|
+
activeContent: string;
|
|
424
|
+
sourceFileContent?: string;
|
|
425
|
+
sourceContentMap: ReadonlyMap<string, string>;
|
|
426
|
+
}) {
|
|
427
|
+
return (
|
|
428
|
+
args.sourceContentMap.get(args.sourceFileId) ??
|
|
429
|
+
(args.sourceFileId === args.activeFileId
|
|
430
|
+
? args.activeContent
|
|
431
|
+
: args.sourceFileContent) ??
|
|
432
|
+
""
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function getFreshActiveFileContent(args: {
|
|
437
|
+
activeContent: string;
|
|
438
|
+
latestContent?: string | null;
|
|
439
|
+
lastLocalContent?: string | null;
|
|
440
|
+
}) {
|
|
441
|
+
return args.latestContent ?? args.lastLocalContent ?? args.activeContent;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export function getFreshScreenContent(args: {
|
|
445
|
+
screenId: string;
|
|
446
|
+
activeFileId?: string | null;
|
|
447
|
+
freshActiveContent: string;
|
|
448
|
+
fileContentById: ReadonlyMap<string, string>;
|
|
449
|
+
}) {
|
|
450
|
+
return args.screenId === args.activeFileId
|
|
451
|
+
? args.freshActiveContent
|
|
452
|
+
: (args.fileContentById.get(args.screenId) ?? "");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function getLayerMoveIterationOrder<T>(
|
|
456
|
+
orderedIds: readonly T[],
|
|
457
|
+
placement: "before" | "after" | "inside",
|
|
458
|
+
): T[] {
|
|
459
|
+
return placement === "after" ? [...orderedIds].reverse() : [...orderedIds];
|
|
460
|
+
}
|
|
461
|
+
|
|
337
462
|
function resolveZoomUpdate(update: SetStateAction<number>, current: number) {
|
|
338
463
|
return typeof update === "function" ? update(current) : update;
|
|
339
464
|
}
|
|
@@ -367,6 +492,27 @@ export function shouldEscapeToOverview(args: {
|
|
|
367
492
|
);
|
|
368
493
|
}
|
|
369
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Build the set of all node ids (both projection ids and data-agent-native-node-id
|
|
497
|
+
* attribute values) that exist in the given projection. Used by handleGroupSelection
|
|
498
|
+
* and handleUngroupSelection to filter selectedLayerIdsState to the active file's
|
|
499
|
+
* nodes before passing them to wrapNodes / unwrap, preventing cross-file stale ids
|
|
500
|
+
* from causing spurious "conflict" errors.
|
|
501
|
+
*
|
|
502
|
+
* Exported for unit testing.
|
|
503
|
+
*/
|
|
504
|
+
export function buildActiveFileNodeIdSet(
|
|
505
|
+
projection: CodeLayerProjection,
|
|
506
|
+
): Set<string> {
|
|
507
|
+
const ids = new Set<string>();
|
|
508
|
+
for (const n of projection.nodes) {
|
|
509
|
+
ids.add(n.id);
|
|
510
|
+
const attrId = n.dataAttributes["data-agent-native-node-id"];
|
|
511
|
+
if (attrId) ids.add(attrId);
|
|
512
|
+
}
|
|
513
|
+
return ids;
|
|
514
|
+
}
|
|
515
|
+
|
|
370
516
|
let html2CanvasColorContext: CanvasRenderingContext2D | null | undefined;
|
|
371
517
|
|
|
372
518
|
interface FileContentSaveRequest {
|
|
@@ -603,6 +749,12 @@ interface GeometryHistoryEntry {
|
|
|
603
749
|
after: CanvasFrameGeometryById;
|
|
604
750
|
}
|
|
605
751
|
|
|
752
|
+
interface ContentHistoryEntry {
|
|
753
|
+
fileId: string;
|
|
754
|
+
before: string;
|
|
755
|
+
after: string;
|
|
756
|
+
}
|
|
757
|
+
|
|
606
758
|
type PatchProofStatus =
|
|
607
759
|
| "runtime"
|
|
608
760
|
| "queued"
|
|
@@ -812,6 +964,113 @@ function applyInlineStylesToHtml(
|
|
|
812
964
|
}
|
|
813
965
|
}
|
|
814
966
|
|
|
967
|
+
const CSS_PROPERTY_UTILITY_STEMS: Record<string, string[]> = {
|
|
968
|
+
color: ["text-color"],
|
|
969
|
+
"background-color": ["background-color"],
|
|
970
|
+
background: ["background-color", "background-image"],
|
|
971
|
+
"font-size": ["font-size"],
|
|
972
|
+
"font-weight": ["font-weight"],
|
|
973
|
+
"font-family": ["font-family"],
|
|
974
|
+
"text-align": ["text-align"],
|
|
975
|
+
display: ["display"],
|
|
976
|
+
position: ["position"],
|
|
977
|
+
width: ["w"],
|
|
978
|
+
height: ["h"],
|
|
979
|
+
opacity: ["opacity"],
|
|
980
|
+
"border-radius": ["rounded"],
|
|
981
|
+
padding: ["p"],
|
|
982
|
+
"padding-left": ["px", "pl"],
|
|
983
|
+
"padding-right": ["px", "pr"],
|
|
984
|
+
"padding-top": ["py", "pt"],
|
|
985
|
+
"padding-bottom": ["py", "pb"],
|
|
986
|
+
margin: ["m"],
|
|
987
|
+
"margin-left": ["mx", "ml"],
|
|
988
|
+
"margin-right": ["mx", "mr"],
|
|
989
|
+
"margin-top": ["my", "mt"],
|
|
990
|
+
"margin-bottom": ["my", "mb"],
|
|
991
|
+
gap: ["gap"],
|
|
992
|
+
"column-gap": ["gap-x"],
|
|
993
|
+
"row-gap": ["gap-y"],
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
const DEFAULT_STATES_PANEL_BREAKPOINTS = [
|
|
997
|
+
{ id: "bp-mobile", label: "Mobile", widthPx: 390 },
|
|
998
|
+
{ id: "bp-tablet", label: "Tablet", widthPx: 768 },
|
|
999
|
+
{ id: "bp-desktop", label: "Desktop", widthPx: 1280 },
|
|
1000
|
+
] as const;
|
|
1001
|
+
|
|
1002
|
+
function normalizeCssPropertyName(property: string): string {
|
|
1003
|
+
return property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function looksLikeTailwindUtility(value: string): boolean {
|
|
1007
|
+
const trimmed = value.trim();
|
|
1008
|
+
if (!trimmed || /\s/.test(trimmed)) return false;
|
|
1009
|
+
if (/[;{}]/.test(trimmed) || /\/\*/.test(trimmed)) return false;
|
|
1010
|
+
if (/^(?:#|rgb\(|rgba\(|hsl\(|hsla\(|var\(|calc\()/i.test(trimmed)) {
|
|
1011
|
+
return false;
|
|
1012
|
+
}
|
|
1013
|
+
if (trimmed.includes(":")) return false;
|
|
1014
|
+
return /^[!-]?[a-z0-9][a-z0-9[\]()./%_-]*$/i.test(trimmed);
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function responsiveUtilityMatchesStyleProperty(
|
|
1018
|
+
property: string,
|
|
1019
|
+
value: string,
|
|
1020
|
+
): boolean {
|
|
1021
|
+
if (!looksLikeTailwindUtility(value)) return false;
|
|
1022
|
+
const normalizedProperty = normalizeCssPropertyName(property);
|
|
1023
|
+
const stem = utilityStem(value.trim());
|
|
1024
|
+
const allowed = CSS_PROPERTY_UTILITY_STEMS[normalizedProperty];
|
|
1025
|
+
return allowed ? allowed.includes(stem) : stem === normalizedProperty;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
interface DesignStatePreviewRow {
|
|
1029
|
+
captureData?: Record<string, unknown> | null;
|
|
1030
|
+
fixtureData?: Record<string, unknown> | null;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const STATE_PREVIEW_HTML_KEYS = [
|
|
1034
|
+
"domHtml",
|
|
1035
|
+
"domSnapshot",
|
|
1036
|
+
"documentHtml",
|
|
1037
|
+
"html",
|
|
1038
|
+
"content",
|
|
1039
|
+
"markup",
|
|
1040
|
+
] as const;
|
|
1041
|
+
|
|
1042
|
+
function looksLikePreviewHtml(value: string): boolean {
|
|
1043
|
+
return /<!doctype|<html\b|<body\b|<[a-zA-Z][\s>]/i.test(value);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function findStatePreviewHtml(value: unknown, depth = 0): string | null {
|
|
1047
|
+
if (typeof value === "string") {
|
|
1048
|
+
return looksLikePreviewHtml(value) ? value : null;
|
|
1049
|
+
}
|
|
1050
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || depth > 2)
|
|
1051
|
+
return null;
|
|
1052
|
+
const record = value as Record<string, unknown>;
|
|
1053
|
+
for (const key of STATE_PREVIEW_HTML_KEYS) {
|
|
1054
|
+
const hit = findStatePreviewHtml(record[key], depth + 1);
|
|
1055
|
+
if (hit) return hit;
|
|
1056
|
+
}
|
|
1057
|
+
for (const entry of Object.values(record)) {
|
|
1058
|
+
const hit = findStatePreviewHtml(entry, depth + 1);
|
|
1059
|
+
if (hit) return hit;
|
|
1060
|
+
}
|
|
1061
|
+
return null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function designStatePreviewHtml(
|
|
1065
|
+
row: DesignStatePreviewRow | undefined,
|
|
1066
|
+
): string | null {
|
|
1067
|
+
if (!row) return null;
|
|
1068
|
+
return (
|
|
1069
|
+
findStatePreviewHtml(row.captureData) ??
|
|
1070
|
+
findStatePreviewHtml(row.fixtureData)
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
815
1074
|
function escapeHtmlAttributeValue(value: string): string {
|
|
816
1075
|
return value
|
|
817
1076
|
.replace(/&/g, "&")
|
|
@@ -820,6 +1079,44 @@ function escapeHtmlAttributeValue(value: string): string {
|
|
|
820
1079
|
.replace(/>/g, ">");
|
|
821
1080
|
}
|
|
822
1081
|
|
|
1082
|
+
const ABS_POSITION_PROPS = [
|
|
1083
|
+
"position",
|
|
1084
|
+
"left",
|
|
1085
|
+
"top",
|
|
1086
|
+
"right",
|
|
1087
|
+
"bottom",
|
|
1088
|
+
] as const;
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Remove absolute-positioning style properties from the element identified by
|
|
1092
|
+
* `data-agent-native-node-id` so that it becomes a flow child after being
|
|
1093
|
+
* reparented into a container. Returns the updated HTML, or the original HTML
|
|
1094
|
+
* if the node cannot be found or parsing is unavailable.
|
|
1095
|
+
*
|
|
1096
|
+
* Uses DOMParser + CSSStyleDeclaration.removeProperty() rather than
|
|
1097
|
+
* applyVisualEdit({kind:"style",value:""}) because the substrate rejects
|
|
1098
|
+
* empty-string values in isSafeStyleValue, making that approach a silent no-op.
|
|
1099
|
+
*/
|
|
1100
|
+
function removeAbsolutePositioningFromNodeInHtml(
|
|
1101
|
+
content: string,
|
|
1102
|
+
nodeAttrId: string,
|
|
1103
|
+
): string {
|
|
1104
|
+
if (typeof window === "undefined") return content;
|
|
1105
|
+
try {
|
|
1106
|
+
const doc = new DOMParser().parseFromString(content, "text/html");
|
|
1107
|
+
const element = doc.querySelector(
|
|
1108
|
+
`[data-agent-native-node-id="${CSS.escape(nodeAttrId)}"]`,
|
|
1109
|
+
) as HTMLElement | null;
|
|
1110
|
+
if (!element) return content;
|
|
1111
|
+
for (const prop of ABS_POSITION_PROPS) {
|
|
1112
|
+
element.style.removeProperty(prop);
|
|
1113
|
+
}
|
|
1114
|
+
return `<!DOCTYPE html>\n${doc.documentElement.outerHTML}`;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return content;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
823
1120
|
function escapeHtmlText(value: string): string {
|
|
824
1121
|
return value
|
|
825
1122
|
.replace(/&/g, "&")
|
|
@@ -1147,12 +1444,20 @@ function appendCanvasPrimitiveToHtml(
|
|
|
1147
1444
|
element.style.transform = `rotate(${geometry.rotation}deg)`;
|
|
1148
1445
|
}
|
|
1149
1446
|
|
|
1447
|
+
// Use the shared canvas-primitive-style module so committed output is
|
|
1448
|
+
// pixel-identical to the draft preview (fixes B5 color jump, B6 ellipse
|
|
1449
|
+
// radius jump). User-supplied fill/stroke/strokeWidth override the
|
|
1450
|
+
// canonical defaults so hand-chosen colours are preserved.
|
|
1451
|
+
const canonical = canvasPrimitiveVisual(
|
|
1452
|
+
primitive.kind === "rectangle" ? "rect" : primitive.kind,
|
|
1453
|
+
);
|
|
1150
1454
|
if (primitive.kind === "frame") {
|
|
1151
|
-
element.style.background = primitive.fill ??
|
|
1152
|
-
element.style.border =
|
|
1153
|
-
primitive.stroke
|
|
1154
|
-
|
|
1155
|
-
|
|
1455
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1456
|
+
element.style.border =
|
|
1457
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1458
|
+
? `${primitive.strokeWidth ?? 1}px dashed ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1459
|
+
: canonical.border;
|
|
1460
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1156
1461
|
element.style.overflow = "hidden";
|
|
1157
1462
|
} else if (primitive.kind === "text") {
|
|
1158
1463
|
element.textContent = primitive.text ?? "Text";
|
|
@@ -1164,18 +1469,23 @@ function appendCanvasPrimitiveToHtml(
|
|
|
1164
1469
|
element.style.fontSize = "16px";
|
|
1165
1470
|
element.style.lineHeight = "1.2";
|
|
1166
1471
|
element.style.whiteSpace = "pre-wrap";
|
|
1472
|
+
element.style.border = canonical.border;
|
|
1473
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1167
1474
|
} else if (primitive.kind === "ellipse") {
|
|
1168
|
-
element.style.background = primitive.fill ??
|
|
1169
|
-
element.style.border =
|
|
1170
|
-
primitive.stroke
|
|
1171
|
-
|
|
1172
|
-
|
|
1475
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1476
|
+
element.style.border =
|
|
1477
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1478
|
+
? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1479
|
+
: canonical.border;
|
|
1480
|
+
element.style.borderRadius = canonical.borderRadius; // "50%"
|
|
1173
1481
|
} else {
|
|
1174
|
-
|
|
1175
|
-
element.style.
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1482
|
+
// rect / rectangle / frame fallthrough
|
|
1483
|
+
element.style.background = primitive.fill ?? canonical.background;
|
|
1484
|
+
element.style.border =
|
|
1485
|
+
primitive.stroke !== undefined || primitive.strokeWidth !== undefined
|
|
1486
|
+
? `${primitive.strokeWidth ?? 1}px solid ${primitive.stroke ?? canonical.border.split(" ").slice(2).join(" ")}`
|
|
1487
|
+
: canonical.border;
|
|
1488
|
+
element.style.borderRadius = canonical.borderRadius;
|
|
1179
1489
|
}
|
|
1180
1490
|
|
|
1181
1491
|
doc.body.appendChild(element);
|
|
@@ -1918,6 +2228,78 @@ function collectCodeLayerAncestors(
|
|
|
1918
2228
|
return [];
|
|
1919
2229
|
}
|
|
1920
2230
|
|
|
2231
|
+
export function sortCodeLayerIdsByTreeOrder(
|
|
2232
|
+
ids: readonly string[],
|
|
2233
|
+
tree: readonly CodeLayerTreeNode[],
|
|
2234
|
+
): string[] {
|
|
2235
|
+
const treeOrder = new Map<string, number>();
|
|
2236
|
+
let index = 0;
|
|
2237
|
+
const visit = (nodes: readonly CodeLayerTreeNode[]) => {
|
|
2238
|
+
for (const node of nodes) {
|
|
2239
|
+
treeOrder.set(node.id, index);
|
|
2240
|
+
index += 1;
|
|
2241
|
+
visit(node.children);
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
2244
|
+
visit(tree);
|
|
2245
|
+
|
|
2246
|
+
const originalOrder = new Map(
|
|
2247
|
+
ids.map((id, originalIndex) => [id, originalIndex]),
|
|
2248
|
+
);
|
|
2249
|
+
return [...ids].sort((a, b) => {
|
|
2250
|
+
const aOrder = treeOrder.get(a);
|
|
2251
|
+
const bOrder = treeOrder.get(b);
|
|
2252
|
+
if (aOrder === undefined && bOrder === undefined) {
|
|
2253
|
+
return (originalOrder.get(a) ?? 0) - (originalOrder.get(b) ?? 0);
|
|
2254
|
+
}
|
|
2255
|
+
if (aOrder === undefined) return 1;
|
|
2256
|
+
if (bOrder === undefined) return -1;
|
|
2257
|
+
return aOrder - bOrder;
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
function findCodeLayerNodeInProjection(
|
|
2262
|
+
projection: CodeLayerProjection,
|
|
2263
|
+
previousNode: CodeLayerNode,
|
|
2264
|
+
): CodeLayerNode | null {
|
|
2265
|
+
const stableSourceIds = [
|
|
2266
|
+
previousNode.dataAttributes["data-agent-native-node-id"],
|
|
2267
|
+
previousNode.dataAttributes["data-code-layer-id"],
|
|
2268
|
+
previousNode.dataAttributes["data-layer-id"],
|
|
2269
|
+
previousNode.dataAttributes["data-builder-id"],
|
|
2270
|
+
previousNode.dataAttributes["data-loc"],
|
|
2271
|
+
typeof previousNode.attributes.id === "string"
|
|
2272
|
+
? previousNode.attributes.id
|
|
2273
|
+
: undefined,
|
|
2274
|
+
].filter((id): id is string => Boolean(id));
|
|
2275
|
+
|
|
2276
|
+
for (const sourceId of stableSourceIds) {
|
|
2277
|
+
const stableMatch = projection.nodes.find(
|
|
2278
|
+
(node) =>
|
|
2279
|
+
node.dataAttributes["data-agent-native-node-id"] === sourceId ||
|
|
2280
|
+
node.dataAttributes["data-code-layer-id"] === sourceId ||
|
|
2281
|
+
node.dataAttributes["data-layer-id"] === sourceId ||
|
|
2282
|
+
node.dataAttributes["data-builder-id"] === sourceId ||
|
|
2283
|
+
node.dataAttributes["data-loc"] === sourceId ||
|
|
2284
|
+
node.attributes.id === sourceId,
|
|
2285
|
+
);
|
|
2286
|
+
if (stableMatch) return stableMatch;
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
const exactMatch = projection.nodes.find(
|
|
2290
|
+
(node) => node.id === previousNode.id,
|
|
2291
|
+
);
|
|
2292
|
+
if (exactMatch) return exactMatch;
|
|
2293
|
+
|
|
2294
|
+
const fallbackMatches = projection.nodes.filter(
|
|
2295
|
+
(node) =>
|
|
2296
|
+
node.tag === previousNode.tag &&
|
|
2297
|
+
node.layerName === previousNode.layerName &&
|
|
2298
|
+
(node.textSnippet ?? "") === (previousNode.textSnippet ?? ""),
|
|
2299
|
+
);
|
|
2300
|
+
return fallbackMatches.length === 1 ? (fallbackMatches[0] ?? null) : null;
|
|
2301
|
+
}
|
|
2302
|
+
|
|
1921
2303
|
function AgentNativeMenuMark({ className }: { className?: string }) {
|
|
1922
2304
|
return (
|
|
1923
2305
|
<svg
|
|
@@ -2599,6 +2981,11 @@ function buildAuthoritativeTweakSelections(
|
|
|
2599
2981
|
? persistedSelections[tweak.id]
|
|
2600
2982
|
: tweak.defaultValue;
|
|
2601
2983
|
}
|
|
2984
|
+
for (const [key, value] of Object.entries(persistedSelections)) {
|
|
2985
|
+
if (/^--[-_a-zA-Z0-9]+$/.test(key)) {
|
|
2986
|
+
selections[key] = value;
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2602
2989
|
return selections;
|
|
2603
2990
|
}
|
|
2604
2991
|
|
|
@@ -2815,6 +3202,9 @@ export default function DesignEditor() {
|
|
|
2815
3202
|
const [hiddenLayerIds, setHiddenLayerIds] = useState<Set<string>>(
|
|
2816
3203
|
() => new Set(),
|
|
2817
3204
|
);
|
|
3205
|
+
const layerStateOverridesRef = useRef<
|
|
3206
|
+
Map<string, { hidden?: boolean; locked?: boolean }>
|
|
3207
|
+
>(new Map());
|
|
2818
3208
|
const [overviewSelectAllRequest, setOverviewSelectAllRequest] = useState(0);
|
|
2819
3209
|
const [overviewClearSelectionRequest, setOverviewClearSelectionRequest] =
|
|
2820
3210
|
useState(0);
|
|
@@ -2828,6 +3218,34 @@ export default function DesignEditor() {
|
|
|
2828
3218
|
const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
|
|
2829
3219
|
const hasSelectedElement = Boolean(selectedElement);
|
|
2830
3220
|
|
|
3221
|
+
// ── Motion dock state (§6.3) ────────────────────────────────────────────────
|
|
3222
|
+
// The MotionDock is mounted below the canvas and shown when motionDockOpen.
|
|
3223
|
+
// Tracks and durationMs are local state; "Write to CSS" calls applyMotionEdit.
|
|
3224
|
+
const [motionDockOpen, setMotionDockOpen] = useState(false);
|
|
3225
|
+
const [motionTracks, setMotionTracks] = useState<MotionDockTrack[]>([]);
|
|
3226
|
+
const [motionDurationMs, setMotionDurationMs] = useState(1000);
|
|
3227
|
+
const [shaderFillPreview, setShaderFillPreview] = useState<{
|
|
3228
|
+
selector?: string;
|
|
3229
|
+
nodeId?: string;
|
|
3230
|
+
css: string;
|
|
3231
|
+
} | null>(null);
|
|
3232
|
+
|
|
3233
|
+
// ── Breakpoint preview state (§6.4) ─────────────────────────────────────────
|
|
3234
|
+
// Active breakpoint width for the current design (pixels). Controls which
|
|
3235
|
+
// side-by-side frame is focused. undefined = no frame selected (overview mode).
|
|
3236
|
+
const [activeBreakpointWidthState, setActiveBreakpointWidthState] = useState<
|
|
3237
|
+
number | undefined
|
|
3238
|
+
>(undefined);
|
|
3239
|
+
|
|
3240
|
+
// ── Design state selection (§6.4 / §8) ───────────────────────────────────────
|
|
3241
|
+
// null = Default (live) view; a string id = one of the design_state rows.
|
|
3242
|
+
const [selectedStateId, setSelectedStateId] = useState<string | null>(null);
|
|
3243
|
+
const [reviewFileId, setReviewFileId] = useState<string | null>(null);
|
|
3244
|
+
const [reviewFindings, setReviewFindings] = useState<A11yFinding[]>([]);
|
|
3245
|
+
const [reviewAuditLoading, setReviewAuditLoading] = useState(false);
|
|
3246
|
+
const [reviewAuditedAt, setReviewAuditedAt] = useState<string | null>(null);
|
|
3247
|
+
const [reviewAuditError, setReviewAuditError] = useState<string | null>(null);
|
|
3248
|
+
|
|
2831
3249
|
useEffect(() => {
|
|
2832
3250
|
if (!isBuilderDesignEmbed) return;
|
|
2833
3251
|
// Announce ready to Builder. The trusted origin is not yet known at this
|
|
@@ -2955,6 +3373,9 @@ export default function DesignEditor() {
|
|
|
2955
3373
|
const [canUndo, setCanUndo] = useState(false);
|
|
2956
3374
|
const [canRedo, setCanRedo] = useState(false);
|
|
2957
3375
|
const undoManagerRef = useRef<Y.UndoManager | null>(null);
|
|
3376
|
+
const contentUndoStackRef = useRef<ContentHistoryEntry[]>([]);
|
|
3377
|
+
const contentRedoStackRef = useRef<ContentHistoryEntry[]>([]);
|
|
3378
|
+
const suppressContentHistoryRef = useRef(false);
|
|
2958
3379
|
const geometryUndoStackRef = useRef<GeometryHistoryEntry[]>([]);
|
|
2959
3380
|
const geometryRedoStackRef = useRef<GeometryHistoryEntry[]>([]);
|
|
2960
3381
|
const historyOrderRef = useRef<Array<"content" | "geometry">>([]);
|
|
@@ -2963,17 +3384,30 @@ export default function DesignEditor() {
|
|
|
2963
3384
|
const undoManager = undoManagerRef.current;
|
|
2964
3385
|
setCanUndo(
|
|
2965
3386
|
Boolean(undoManager?.canUndo()) ||
|
|
3387
|
+
contentUndoStackRef.current.length > 0 ||
|
|
2966
3388
|
geometryUndoStackRef.current.length > 0,
|
|
2967
3389
|
);
|
|
2968
3390
|
setCanRedo(
|
|
2969
3391
|
Boolean(undoManager?.canRedo()) ||
|
|
3392
|
+
contentRedoStackRef.current.length > 0 ||
|
|
2970
3393
|
geometryRedoStackRef.current.length > 0,
|
|
2971
3394
|
);
|
|
2972
3395
|
}, []);
|
|
3396
|
+
const clearLocalUndoRedoStacks = useCallback(() => {
|
|
3397
|
+
contentUndoStackRef.current = [];
|
|
3398
|
+
contentRedoStackRef.current = [];
|
|
3399
|
+
geometryUndoStackRef.current = [];
|
|
3400
|
+
geometryRedoStackRef.current = [];
|
|
3401
|
+
historyOrderRef.current = [];
|
|
3402
|
+
redoOrderRef.current = [];
|
|
3403
|
+
}, []);
|
|
2973
3404
|
const persistedSelectionStateRef = useRef<string | null>(null);
|
|
2974
3405
|
const designSelectionOwnerIdRef = useRef(`${TAB_ID}:${generateTabId()}`);
|
|
2975
3406
|
const frameGeometrySaveTimerRef = useRef<number | null>(null);
|
|
2976
3407
|
const [tweakSaveActive, setTweakSaveActive] = useState(false);
|
|
3408
|
+
// Dismissible localhost-source banner (reset per session).
|
|
3409
|
+
const [localSourceBannerDismissed, setLocalSourceBannerDismissed] =
|
|
3410
|
+
useState(false);
|
|
2977
3411
|
// Shared visual-editor annotate overlays. drawMode owns the send toolbar,
|
|
2978
3412
|
// while pinMode temporarily routes canvas clicks to comment pins that queue
|
|
2979
3413
|
// into the same agent submission.
|
|
@@ -3292,6 +3726,58 @@ export default function DesignEditor() {
|
|
|
3292
3726
|
designAccessRole === "owner" || designAccessRole === "admin";
|
|
3293
3727
|
const canEditDesign = canShareDesign || designAccessRole === "editor";
|
|
3294
3728
|
const canEditDesignRef = useRef(canEditDesign);
|
|
3729
|
+
const pendingLocalFileContentsRef = useRef<
|
|
3730
|
+
Map<
|
|
3731
|
+
string,
|
|
3732
|
+
{ content: string; startedAt: number; baseUpdatedAt?: string | null }
|
|
3733
|
+
>
|
|
3734
|
+
>(new Map());
|
|
3735
|
+
const [
|
|
3736
|
+
pendingLocalFileContentsRevision,
|
|
3737
|
+
setPendingLocalFileContentsRevision,
|
|
3738
|
+
] = useState(0);
|
|
3739
|
+
|
|
3740
|
+
const markPendingLocalFileContent = useCallback(
|
|
3741
|
+
(fileId: string, content: string, baseUpdatedAt?: string | null) => {
|
|
3742
|
+
const current = pendingLocalFileContentsRef.current.get(fileId);
|
|
3743
|
+
if (current?.content === content) {
|
|
3744
|
+
if (
|
|
3745
|
+
baseUpdatedAt !== undefined &&
|
|
3746
|
+
current.baseUpdatedAt === undefined
|
|
3747
|
+
) {
|
|
3748
|
+
pendingLocalFileContentsRef.current.set(fileId, {
|
|
3749
|
+
...current,
|
|
3750
|
+
baseUpdatedAt,
|
|
3751
|
+
});
|
|
3752
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3753
|
+
}
|
|
3754
|
+
return;
|
|
3755
|
+
}
|
|
3756
|
+
pendingLocalFileContentsRef.current.set(fileId, {
|
|
3757
|
+
content,
|
|
3758
|
+
startedAt: Date.now(),
|
|
3759
|
+
baseUpdatedAt,
|
|
3760
|
+
});
|
|
3761
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3762
|
+
},
|
|
3763
|
+
[],
|
|
3764
|
+
);
|
|
3765
|
+
|
|
3766
|
+
const clearPendingLocalFileContent = useCallback(
|
|
3767
|
+
(fileId: string, expectedContent?: string) => {
|
|
3768
|
+
const current = pendingLocalFileContentsRef.current.get(fileId);
|
|
3769
|
+
if (!current) return;
|
|
3770
|
+
if (
|
|
3771
|
+
expectedContent !== undefined &&
|
|
3772
|
+
current.content !== expectedContent
|
|
3773
|
+
) {
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
pendingLocalFileContentsRef.current.delete(fileId);
|
|
3777
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
3778
|
+
},
|
|
3779
|
+
[],
|
|
3780
|
+
);
|
|
3295
3781
|
|
|
3296
3782
|
useEffect(() => {
|
|
3297
3783
|
canEditDesignRef.current = canEditDesign;
|
|
@@ -3332,6 +3818,45 @@ export default function DesignEditor() {
|
|
|
3332
3818
|
const duplicateDesignMutation = useActionMutation("duplicate-design");
|
|
3333
3819
|
const exportHtmlMutation = useActionMutation("export-html");
|
|
3334
3820
|
const exportZipMutation = useActionMutation("export-zip");
|
|
3821
|
+
const applyMotionEditMutation = useActionMutation("apply-motion-edit");
|
|
3822
|
+
// §6.4 breakpoint mutations — wired to MultiScreenCanvas + affordance
|
|
3823
|
+
const addBreakpointMutation = useActionMutation("add-breakpoint");
|
|
3824
|
+
const setActiveBreakpointMutation = useActionMutation(
|
|
3825
|
+
"set-active-breakpoint",
|
|
3826
|
+
);
|
|
3827
|
+
|
|
3828
|
+
// §6.1 — promote a selection into a reusable component instance.
|
|
3829
|
+
const createComponentMutation = useActionMutation("create-component");
|
|
3830
|
+
// §6.1 — jump to a component instance's source (selects the root + navigates).
|
|
3831
|
+
const openComponentSourceMutation = useActionMutation(
|
|
3832
|
+
"open-component-source",
|
|
3833
|
+
);
|
|
3834
|
+
|
|
3835
|
+
// §6.6 — "Make it real" migration flow (migrate-inline-design-to-app).
|
|
3836
|
+
// The mutation stays unconditional; the dialog gates on isSignedIn.
|
|
3837
|
+
const migrateMutation = useActionMutation("migrate-inline-design-to-app");
|
|
3838
|
+
|
|
3839
|
+
// Dialog open/close state for the "Make this a real app" flow.
|
|
3840
|
+
const [makeRealDialogOpen, setMakeRealDialogOpen] = useState(false);
|
|
3841
|
+
|
|
3842
|
+
// Result payload returned by migrate-inline-design-to-app on success.
|
|
3843
|
+
// `null` = not yet migrated; populated once the Builder agent accepts the job.
|
|
3844
|
+
const [migrationResult, setMigrationResult] = useState<{
|
|
3845
|
+
branchName?: string;
|
|
3846
|
+
url?: string;
|
|
3847
|
+
versionId?: string;
|
|
3848
|
+
seedFileCount?: number;
|
|
3849
|
+
status?: string;
|
|
3850
|
+
projectId?: string;
|
|
3851
|
+
cta?: {
|
|
3852
|
+
kind: string;
|
|
3853
|
+
label: string;
|
|
3854
|
+
description: string;
|
|
3855
|
+
connectUrl?: string;
|
|
3856
|
+
primaryAction: string;
|
|
3857
|
+
};
|
|
3858
|
+
} | null>(null);
|
|
3859
|
+
|
|
3335
3860
|
const [shareExportFormat, setShareExportFormat] =
|
|
3336
3861
|
useState<ShareExportFormat>("html");
|
|
3337
3862
|
const [codingHandoffResult, setCodingHandoffResult] =
|
|
@@ -3356,6 +3881,7 @@ export default function DesignEditor() {
|
|
|
3356
3881
|
const saveFileContent = useCallback(
|
|
3357
3882
|
(pending: FileContentSaveRequest) => {
|
|
3358
3883
|
if (!canEditDesignRef.current) return;
|
|
3884
|
+
markPendingLocalFileContent(pending.id, pending.content);
|
|
3359
3885
|
latestFileSaveForUnloadRef.current[pending.id] = pending;
|
|
3360
3886
|
const previous =
|
|
3361
3887
|
fileSaveChainsRef.current[pending.id] ?? Promise.resolve();
|
|
@@ -3374,6 +3900,7 @@ export default function DesignEditor() {
|
|
|
3374
3900
|
: prev,
|
|
3375
3901
|
);
|
|
3376
3902
|
} catch (error) {
|
|
3903
|
+
clearPendingLocalFileContent(pending.id, pending.content);
|
|
3377
3904
|
setPatchProof((prev) =>
|
|
3378
3905
|
prev && prev.fileId === pending.id && prev.status === "queued"
|
|
3379
3906
|
? {
|
|
@@ -3395,7 +3922,12 @@ export default function DesignEditor() {
|
|
|
3395
3922
|
}
|
|
3396
3923
|
});
|
|
3397
3924
|
},
|
|
3398
|
-
[
|
|
3925
|
+
[
|
|
3926
|
+
clearPendingLocalFileContent,
|
|
3927
|
+
markPendingLocalFileContent,
|
|
3928
|
+
t,
|
|
3929
|
+
updateFileMutation,
|
|
3930
|
+
],
|
|
3399
3931
|
);
|
|
3400
3932
|
|
|
3401
3933
|
const queueFileContentSave = useCallback(
|
|
@@ -3410,6 +3942,7 @@ export default function DesignEditor() {
|
|
|
3410
3942
|
content,
|
|
3411
3943
|
syncCollab: options.syncCollab ?? true,
|
|
3412
3944
|
};
|
|
3945
|
+
markPendingLocalFileContent(fileId, content);
|
|
3413
3946
|
latestFileSaveForUnloadRef.current[fileId] = pending;
|
|
3414
3947
|
if (options.immediate) {
|
|
3415
3948
|
const timer = fileSaveTimersRef.current[fileId];
|
|
@@ -3434,7 +3967,7 @@ export default function DesignEditor() {
|
|
|
3434
3967
|
saveFileContent(pending);
|
|
3435
3968
|
}, 400);
|
|
3436
3969
|
},
|
|
3437
|
-
[saveFileContent],
|
|
3970
|
+
[markPendingLocalFileContent, saveFileContent],
|
|
3438
3971
|
);
|
|
3439
3972
|
|
|
3440
3973
|
useEffect(() => {
|
|
@@ -3679,7 +4212,38 @@ export default function DesignEditor() {
|
|
|
3679
4212
|
updateDesignMutation,
|
|
3680
4213
|
]);
|
|
3681
4214
|
|
|
3682
|
-
const
|
|
4215
|
+
const serverFiles = design?.files ?? [];
|
|
4216
|
+
useEffect(() => {
|
|
4217
|
+
if (pendingLocalFileContentsRef.current.size === 0) return;
|
|
4218
|
+
let changed = false;
|
|
4219
|
+
for (const file of serverFiles) {
|
|
4220
|
+
const pending = pendingLocalFileContentsRef.current.get(file.id);
|
|
4221
|
+
if (pending && (file.content ?? "") === pending.content) {
|
|
4222
|
+
if (
|
|
4223
|
+
pending.baseUpdatedAt !== undefined &&
|
|
4224
|
+
file.updatedAt === pending.baseUpdatedAt
|
|
4225
|
+
) {
|
|
4226
|
+
continue;
|
|
4227
|
+
}
|
|
4228
|
+
pendingLocalFileContentsRef.current.delete(file.id);
|
|
4229
|
+
changed = true;
|
|
4230
|
+
}
|
|
4231
|
+
}
|
|
4232
|
+
if (changed) {
|
|
4233
|
+
setPendingLocalFileContentsRevision((revision) => revision + 1);
|
|
4234
|
+
}
|
|
4235
|
+
}, [serverFiles]);
|
|
4236
|
+
const pendingLocalFileContentsSnapshot = useMemo(
|
|
4237
|
+
() => new Map(pendingLocalFileContentsRef.current),
|
|
4238
|
+
[pendingLocalFileContentsRevision],
|
|
4239
|
+
);
|
|
4240
|
+
const files = useMemo(() => {
|
|
4241
|
+
if (pendingLocalFileContentsSnapshot.size === 0) return serverFiles;
|
|
4242
|
+
return serverFiles.map((file) => {
|
|
4243
|
+
const pending = pendingLocalFileContentsSnapshot.get(file.id);
|
|
4244
|
+
return pending ? { ...file, content: pending.content } : file;
|
|
4245
|
+
});
|
|
4246
|
+
}, [pendingLocalFileContentsSnapshot, serverFiles]);
|
|
3683
4247
|
const designDataJson = useMemo(
|
|
3684
4248
|
() => parseDesignDataJson(design?.data),
|
|
3685
4249
|
[design?.data],
|
|
@@ -3699,6 +4263,38 @@ export default function DesignEditor() {
|
|
|
3699
4263
|
designDataJson,
|
|
3700
4264
|
"screenMetadata",
|
|
3701
4265
|
);
|
|
4266
|
+
// §6.4 — breakpoint set stored in designs.data.breakpointSet as a
|
|
4267
|
+
// BreakpointSet { id, breakpoints: BreakpointDefinition[] }.
|
|
4268
|
+
// Each BreakpointDefinition has { id, label, widthPx, prefix }.
|
|
4269
|
+
const breakpointSet = (() => {
|
|
4270
|
+
try {
|
|
4271
|
+
const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
|
|
4272
|
+
if (
|
|
4273
|
+
raw &&
|
|
4274
|
+
typeof raw === "object" &&
|
|
4275
|
+
!Array.isArray(raw) &&
|
|
4276
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
4277
|
+
) {
|
|
4278
|
+
return raw as {
|
|
4279
|
+
id: string;
|
|
4280
|
+
breakpoints: Array<{
|
|
4281
|
+
id: string;
|
|
4282
|
+
widthPx: number;
|
|
4283
|
+
label?: string;
|
|
4284
|
+
prefix?: string;
|
|
4285
|
+
}>;
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
} catch {
|
|
4289
|
+
// ignore
|
|
4290
|
+
}
|
|
4291
|
+
return undefined;
|
|
4292
|
+
})();
|
|
4293
|
+
const bpWidths =
|
|
4294
|
+
breakpointSet && breakpointSet.breakpoints.length > 0
|
|
4295
|
+
? breakpointSet.breakpoints.map((bp) => bp.widthPx)
|
|
4296
|
+
: undefined;
|
|
4297
|
+
|
|
3702
4298
|
return files.map((file) => {
|
|
3703
4299
|
const metadata = getDesignDataRecord(metadataByFileId, file.id);
|
|
3704
4300
|
const stringValue = (key: string) =>
|
|
@@ -3716,6 +4312,7 @@ export default function DesignEditor() {
|
|
|
3716
4312
|
updatedAt: file.updatedAt,
|
|
3717
4313
|
sourceType: stringValue("sourceType"),
|
|
3718
4314
|
source: stringValue("source"),
|
|
4315
|
+
sourceFile: stringValue("sourceFile"),
|
|
3719
4316
|
lod: stringValue("lod"),
|
|
3720
4317
|
previewState: stringValue("previewState"),
|
|
3721
4318
|
status: stringValue("status"),
|
|
@@ -3724,9 +4321,19 @@ export default function DesignEditor() {
|
|
|
3724
4321
|
height: numberValue("height"),
|
|
3725
4322
|
url: stringValue("url"),
|
|
3726
4323
|
previewUrl: stringValue("previewUrl"),
|
|
4324
|
+
// Breakpoint preview widths (§6.4). When non-empty, MultiScreenCanvas
|
|
4325
|
+
// renders one iframe per width to the right of the primary frame.
|
|
4326
|
+
breakpointWidths: bpWidths,
|
|
4327
|
+
// Active breakpoint width tracked in component state; shared across all
|
|
4328
|
+
// screens (a design has one active breakpoint set at a time in v1).
|
|
4329
|
+
activeBreakpointWidth: bpWidths?.includes(
|
|
4330
|
+
activeBreakpointWidthState ?? -1,
|
|
4331
|
+
)
|
|
4332
|
+
? activeBreakpointWidthState
|
|
4333
|
+
: undefined,
|
|
3727
4334
|
};
|
|
3728
4335
|
});
|
|
3729
|
-
}, [designDataJson, files]);
|
|
4336
|
+
}, [designDataJson, files, activeBreakpointWidthState]);
|
|
3730
4337
|
const queueFrameGeometrySave = useCallback(
|
|
3731
4338
|
(geometryById: CanvasFrameGeometryById) => {
|
|
3732
4339
|
if (!id || !canEditDesignRef.current) return;
|
|
@@ -3838,6 +4445,61 @@ export default function DesignEditor() {
|
|
|
3838
4445
|
[syncUndoRedoState, writeFrameGeometrySnapshot],
|
|
3839
4446
|
);
|
|
3840
4447
|
|
|
4448
|
+
// §6.6 — "Make this a real app" handler.
|
|
4449
|
+
// Opens the dialog; actual migration fires when the user confirms.
|
|
4450
|
+
const handleOpenMakeReal = useCallback(() => {
|
|
4451
|
+
setMigrationResult(null);
|
|
4452
|
+
setMakeRealDialogOpen(true);
|
|
4453
|
+
}, []);
|
|
4454
|
+
|
|
4455
|
+
// Fires when the user clicks "Start migration" in the dialog.
|
|
4456
|
+
// Calls migrate-inline-design-to-app, then on success flips sourceType to
|
|
4457
|
+
// "fusion" in the design data blob so gated panels light up.
|
|
4458
|
+
const handleConfirmMakeReal = useCallback(async () => {
|
|
4459
|
+
if (!id) return;
|
|
4460
|
+
try {
|
|
4461
|
+
const result = await migrateMutation.mutateAsync({ designId: id } as any);
|
|
4462
|
+
const r = result as any;
|
|
4463
|
+
setMigrationResult({
|
|
4464
|
+
branchName: r?.branchName,
|
|
4465
|
+
url: r?.url,
|
|
4466
|
+
versionId: r?.versionId,
|
|
4467
|
+
seedFileCount: r?.seedFileCount,
|
|
4468
|
+
status: r?.status,
|
|
4469
|
+
projectId: r?.projectId,
|
|
4470
|
+
cta: r?.cta,
|
|
4471
|
+
});
|
|
4472
|
+
|
|
4473
|
+
// When the Builder agent accepted the job (status = "processing"),
|
|
4474
|
+
// flip the design data to sourceType "fusion" so capability-gated
|
|
4475
|
+
// panels (branches, deploy) light up on refresh.
|
|
4476
|
+
if (r?.status === "processing" && r?.url) {
|
|
4477
|
+
const nextData = {
|
|
4478
|
+
...designDataJsonRef.current,
|
|
4479
|
+
sourceType: "fusion",
|
|
4480
|
+
fusionBranchName: r.branchName,
|
|
4481
|
+
fusionUrl: r.url,
|
|
4482
|
+
fusionProjectId: r.projectId,
|
|
4483
|
+
};
|
|
4484
|
+
updateDesignMutation.mutate(
|
|
4485
|
+
{ id, data: JSON.stringify(nextData) } as any,
|
|
4486
|
+
{
|
|
4487
|
+
onSuccess: () => {
|
|
4488
|
+
queryClient.invalidateQueries({
|
|
4489
|
+
queryKey: ["action", "get-design"],
|
|
4490
|
+
});
|
|
4491
|
+
},
|
|
4492
|
+
},
|
|
4493
|
+
);
|
|
4494
|
+
} else if (r?.status === "not-configured") {
|
|
4495
|
+
// Builder not connected — leave dialog open to show the CTA.
|
|
4496
|
+
}
|
|
4497
|
+
} catch (err) {
|
|
4498
|
+
const message = err instanceof Error ? err.message : "Migration failed";
|
|
4499
|
+
toast.error(message);
|
|
4500
|
+
}
|
|
4501
|
+
}, [id, migrateMutation, updateDesignMutation, queryClient]);
|
|
4502
|
+
|
|
3841
4503
|
generationOutputReadyRef.current = files.length > 0;
|
|
3842
4504
|
|
|
3843
4505
|
useEffect(() => {
|
|
@@ -3947,11 +4609,21 @@ export default function DesignEditor() {
|
|
|
3947
4609
|
}, [files, activeFileId]);
|
|
3948
4610
|
|
|
3949
4611
|
const activeFile = files.find((f) => f.id === activeFileId) ?? files[0];
|
|
4612
|
+
useEffect(() => {
|
|
4613
|
+
if (!reviewFileId || reviewFileId === activeFile?.id) return;
|
|
4614
|
+
setReviewFileId(null);
|
|
4615
|
+
setReviewFindings([]);
|
|
4616
|
+
setReviewAuditedAt(null);
|
|
4617
|
+
setReviewAuditError(null);
|
|
4618
|
+
setReviewAuditLoading(false);
|
|
4619
|
+
}, [activeFile?.id, reviewFileId]);
|
|
4620
|
+
|
|
3950
4621
|
const initialGenerationReadOnly = shouldLockInspectorForInitialGeneration({
|
|
3951
4622
|
fileCount: files.length,
|
|
3952
4623
|
generating,
|
|
3953
4624
|
pendingGenerationActive,
|
|
3954
4625
|
});
|
|
4626
|
+
|
|
3955
4627
|
const selectedScreenIds = useMemo(
|
|
3956
4628
|
() =>
|
|
3957
4629
|
getSelectedScreenIdsForEditorState({
|
|
@@ -4340,6 +5012,7 @@ export default function DesignEditor() {
|
|
|
4340
5012
|
// The last content this client itself wrote into the Y.Doc (inline-style
|
|
4341
5013
|
// edits) — so the reconcile/observe doesn't treat our own echo as external.
|
|
4342
5014
|
const lastLocalContentRef = useRef<string | null>(null);
|
|
5015
|
+
const latestActiveContentRef = useRef<string | null>(null);
|
|
4343
5016
|
// Freshest known DB `updatedAt` for the active file, kept in a ref so the
|
|
4344
5017
|
// Yjs observe handler can advance the reconcile watermark without re-subscribing.
|
|
4345
5018
|
const documentFileUpdatedAtRef = useRef<string | null>(null);
|
|
@@ -4383,7 +5056,10 @@ export default function DesignEditor() {
|
|
|
4383
5056
|
setCollabContentFileId(null);
|
|
4384
5057
|
lastAppliedFileUpdatedAtRef.current = null;
|
|
4385
5058
|
lastLocalContentRef.current = null;
|
|
5059
|
+
latestActiveContentRef.current = null;
|
|
5060
|
+
clearLocalUndoRedoStacks();
|
|
4386
5061
|
clearStaleAgentCollabRecovery();
|
|
5062
|
+
syncUndoRedoState();
|
|
4387
5063
|
return;
|
|
4388
5064
|
}
|
|
4389
5065
|
if (activeFileId !== prevActiveFileIdRef.current) {
|
|
@@ -4392,9 +5068,18 @@ export default function DesignEditor() {
|
|
|
4392
5068
|
setCollabContentFileId(null);
|
|
4393
5069
|
lastAppliedFileUpdatedAtRef.current = null;
|
|
4394
5070
|
lastLocalContentRef.current = null;
|
|
5071
|
+
latestActiveContentRef.current = null;
|
|
5072
|
+
clearLocalUndoRedoStacks();
|
|
4395
5073
|
clearStaleAgentCollabRecovery();
|
|
5074
|
+
syncUndoRedoState();
|
|
4396
5075
|
}
|
|
4397
|
-
}, [
|
|
5076
|
+
}, [
|
|
5077
|
+
activeFileId,
|
|
5078
|
+
clearLocalUndoRedoStacks,
|
|
5079
|
+
clearStaleAgentCollabRecovery,
|
|
5080
|
+
syncUndoRedoState,
|
|
5081
|
+
viewMode,
|
|
5082
|
+
]);
|
|
4398
5083
|
|
|
4399
5084
|
useEffect(() => {
|
|
4400
5085
|
return clearStaleAgentCollabRecovery;
|
|
@@ -4406,6 +5091,20 @@ export default function DesignEditor() {
|
|
|
4406
5091
|
const fileId = activeFileId;
|
|
4407
5092
|
const ytext = ydoc.getText("content");
|
|
4408
5093
|
const text = ytext.toString();
|
|
5094
|
+
const pendingLocalContent =
|
|
5095
|
+
pendingLocalFileContentsRef.current.get(fileId)?.content;
|
|
5096
|
+
if (pendingLocalContent && text !== pendingLocalContent) {
|
|
5097
|
+
setCollabContent(pendingLocalContent);
|
|
5098
|
+
setCollabContentFileId(fileId);
|
|
5099
|
+
lastLocalContentRef.current = pendingLocalContent;
|
|
5100
|
+
latestActiveContentRef.current = pendingLocalContent;
|
|
5101
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
5102
|
+
ydoc.transact(() => {
|
|
5103
|
+
ytext.delete(0, ytext.length);
|
|
5104
|
+
ytext.insert(0, pendingLocalContent);
|
|
5105
|
+
}, TAB_ID);
|
|
5106
|
+
return;
|
|
5107
|
+
}
|
|
4409
5108
|
if (text.length > 0) {
|
|
4410
5109
|
const storedContent = activeFile?.content ?? "";
|
|
4411
5110
|
if (
|
|
@@ -4418,6 +5117,7 @@ export default function DesignEditor() {
|
|
|
4418
5117
|
setCollabContent(storedContent);
|
|
4419
5118
|
setCollabContentFileId(fileId);
|
|
4420
5119
|
lastLocalContentRef.current = storedContent;
|
|
5120
|
+
latestActiveContentRef.current = storedContent;
|
|
4421
5121
|
setContentRenderRevision((revision) => revision + 1);
|
|
4422
5122
|
ydoc.transact(() => {
|
|
4423
5123
|
ytext.delete(0, ytext.length);
|
|
@@ -4430,9 +5130,17 @@ export default function DesignEditor() {
|
|
|
4430
5130
|
// confirms or applies the current DB content.
|
|
4431
5131
|
setCollabContent(text);
|
|
4432
5132
|
setCollabContentFileId(fileId);
|
|
5133
|
+
latestActiveContentRef.current = text;
|
|
4433
5134
|
setContentRenderRevision((revision) => revision + 1);
|
|
4434
5135
|
}
|
|
4435
|
-
}, [
|
|
5136
|
+
}, [
|
|
5137
|
+
ydoc,
|
|
5138
|
+
isSynced,
|
|
5139
|
+
activeFileId,
|
|
5140
|
+
activeFile?.content,
|
|
5141
|
+
activeFile?.fileType,
|
|
5142
|
+
pendingLocalFileContentsRevision,
|
|
5143
|
+
]);
|
|
4436
5144
|
|
|
4437
5145
|
// Keep the freshest DB `updatedAt` in a ref the observe handler can read.
|
|
4438
5146
|
useEffect(() => {
|
|
@@ -4453,14 +5161,29 @@ export default function DesignEditor() {
|
|
|
4453
5161
|
const ytext = ydoc.getText("content");
|
|
4454
5162
|
const handler = (_event: unknown, transaction?: { origin?: unknown }) => {
|
|
4455
5163
|
const next = ytext.toString();
|
|
4456
|
-
setCollabContent(next);
|
|
4457
|
-
setCollabContentFileId(fileId);
|
|
4458
5164
|
// UndoManager fires with itself as the origin; treat those as local too
|
|
4459
5165
|
// so the reconcile watermark and stale-selection fix are consistent.
|
|
4460
5166
|
const isLocalEdit =
|
|
4461
5167
|
transaction?.origin === TAB_ID ||
|
|
4462
5168
|
transaction?.origin === LOCAL_EDIT_ORIGIN ||
|
|
4463
5169
|
transaction?.origin === undoManagerRef.current;
|
|
5170
|
+
const pendingLocalContent =
|
|
5171
|
+
pendingLocalFileContentsRef.current.get(fileId)?.content;
|
|
5172
|
+
if (pendingLocalContent && next !== pendingLocalContent && !isLocalEdit) {
|
|
5173
|
+
setCollabContent(pendingLocalContent);
|
|
5174
|
+
setCollabContentFileId(fileId);
|
|
5175
|
+
lastLocalContentRef.current = pendingLocalContent;
|
|
5176
|
+
latestActiveContentRef.current = pendingLocalContent;
|
|
5177
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
5178
|
+
ydoc.transact(() => {
|
|
5179
|
+
ytext.delete(0, ytext.length);
|
|
5180
|
+
ytext.insert(0, pendingLocalContent);
|
|
5181
|
+
}, TAB_ID);
|
|
5182
|
+
return;
|
|
5183
|
+
}
|
|
5184
|
+
setCollabContent(next);
|
|
5185
|
+
setCollabContentFileId(fileId);
|
|
5186
|
+
latestActiveContentRef.current = next;
|
|
4464
5187
|
if (isLocalEdit) {
|
|
4465
5188
|
lastLocalContentRef.current = next;
|
|
4466
5189
|
} else {
|
|
@@ -4565,6 +5288,7 @@ export default function DesignEditor() {
|
|
|
4565
5288
|
setCollabContent(dbContent);
|
|
4566
5289
|
setCollabContentFileId(activeFile.id);
|
|
4567
5290
|
lastLocalContentRef.current = dbContent;
|
|
5291
|
+
latestActiveContentRef.current = dbContent;
|
|
4568
5292
|
if (dbUpdatedAt) lastAppliedFileUpdatedAtRef.current = dbUpdatedAt;
|
|
4569
5293
|
setContentRenderRevision((revision) => revision + 1);
|
|
4570
5294
|
|
|
@@ -4618,6 +5342,7 @@ export default function DesignEditor() {
|
|
|
4618
5342
|
setCollabContent(expectedContent);
|
|
4619
5343
|
setCollabContentFileId(expectedFileId);
|
|
4620
5344
|
lastLocalContentRef.current = expectedContent;
|
|
5345
|
+
latestActiveContentRef.current = expectedContent;
|
|
4621
5346
|
lastAppliedFileUpdatedAtRef.current = expectedUpdatedAt;
|
|
4622
5347
|
setContentRenderRevision((revision) => revision + 1);
|
|
4623
5348
|
|
|
@@ -4643,6 +5368,7 @@ export default function DesignEditor() {
|
|
|
4643
5368
|
setCollabContent(dbContent);
|
|
4644
5369
|
setCollabContentFileId(activeFile.id);
|
|
4645
5370
|
lastLocalContentRef.current = dbContent;
|
|
5371
|
+
latestActiveContentRef.current = dbContent;
|
|
4646
5372
|
if (dbUpdatedAt) lastAppliedFileUpdatedAtRef.current = dbUpdatedAt;
|
|
4647
5373
|
setContentRenderRevision((revision) => revision + 1);
|
|
4648
5374
|
|
|
@@ -4688,6 +5414,72 @@ export default function DesignEditor() {
|
|
|
4688
5414
|
const canvasContextMenuRef = useRef<CanvasContextMenuHandle | null>(null);
|
|
4689
5415
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
|
4690
5416
|
|
|
5417
|
+
// Live handle to the active DesignCanvas preview iframe. DesignCanvas owns the
|
|
5418
|
+
// <iframe> internally (tagged data-design-preview-iframe) and does not forward
|
|
5419
|
+
// its ref, so we resolve the element lazily from the DOM at read time. The
|
|
5420
|
+
// MotionDock reads `.current` only when scrubbing, so this always returns the
|
|
5421
|
+
// currently-mounted iframe even after content swaps recreate the element.
|
|
5422
|
+
const canvasIframeRef = useMemo<React.RefObject<HTMLIFrameElement | null>>(
|
|
5423
|
+
() => ({
|
|
5424
|
+
get current() {
|
|
5425
|
+
return document.querySelector<HTMLIFrameElement>(
|
|
5426
|
+
"iframe[data-design-preview-iframe]",
|
|
5427
|
+
);
|
|
5428
|
+
},
|
|
5429
|
+
}),
|
|
5430
|
+
[],
|
|
5431
|
+
);
|
|
5432
|
+
|
|
5433
|
+
const handleRunDesignAudit = useCallback(async () => {
|
|
5434
|
+
if (!id || !activeFile?.id) return;
|
|
5435
|
+
const auditFileId = activeFile.id;
|
|
5436
|
+
setReviewFileId(auditFileId);
|
|
5437
|
+
setReviewAuditLoading(true);
|
|
5438
|
+
setReviewAuditError(null);
|
|
5439
|
+
try {
|
|
5440
|
+
const result = await callAction<{
|
|
5441
|
+
findings: A11yFinding[];
|
|
5442
|
+
auditedAt: string;
|
|
5443
|
+
}>("run-design-audit", {
|
|
5444
|
+
designId: id,
|
|
5445
|
+
fileId: auditFileId,
|
|
5446
|
+
} as any);
|
|
5447
|
+
setReviewFileId(auditFileId);
|
|
5448
|
+
setReviewFindings(Array.isArray(result.findings) ? result.findings : []);
|
|
5449
|
+
setReviewAuditedAt(result.auditedAt ?? new Date().toISOString());
|
|
5450
|
+
} catch (error) {
|
|
5451
|
+
const message =
|
|
5452
|
+
error instanceof Error
|
|
5453
|
+
? error.message
|
|
5454
|
+
: t("designEditor.toasts.auditRunFailed");
|
|
5455
|
+
setReviewAuditError(message);
|
|
5456
|
+
toast.error(message);
|
|
5457
|
+
} finally {
|
|
5458
|
+
setReviewAuditLoading(false);
|
|
5459
|
+
}
|
|
5460
|
+
}, [activeFile?.id, id, t]);
|
|
5461
|
+
|
|
5462
|
+
const handleReviewFindingClick = useCallback(
|
|
5463
|
+
(finding: A11yFinding) => {
|
|
5464
|
+
const selector =
|
|
5465
|
+
finding.selector ??
|
|
5466
|
+
(finding.nodeId
|
|
5467
|
+
? `[data-agent-native-node-id="${finding.nodeId.replace(/"/g, '\\"')}"]`
|
|
5468
|
+
: null);
|
|
5469
|
+
if (!selector) return;
|
|
5470
|
+
canvasIframeRef.current?.contentWindow?.postMessage(
|
|
5471
|
+
{
|
|
5472
|
+
type: "select-element",
|
|
5473
|
+
selector,
|
|
5474
|
+
nodeId: finding.nodeId ?? undefined,
|
|
5475
|
+
},
|
|
5476
|
+
"*",
|
|
5477
|
+
);
|
|
5478
|
+
if (finding.nodeId) setSelectedLayerIdsState([finding.nodeId]);
|
|
5479
|
+
},
|
|
5480
|
+
[canvasIframeRef],
|
|
5481
|
+
);
|
|
5482
|
+
|
|
4691
5483
|
// Broadcast pointer position (normalized to canvas container) and
|
|
4692
5484
|
// selected element selector so peers can see where the user is working.
|
|
4693
5485
|
const handleCanvasPointerMove = useCallback(
|
|
@@ -4801,26 +5593,45 @@ export default function DesignEditor() {
|
|
|
4801
5593
|
// Resolve the content to render: prefer collab content only after the
|
|
4802
5594
|
// per-file reconcile state has reset for the current active file. Otherwise a
|
|
4803
5595
|
// file switch can render one frame with the previous file's Yjs text.
|
|
5596
|
+
// Always resolve to a string — a non-string source (e.g. a collab value that
|
|
5597
|
+
// is not yet a plain string, or a not-yet-loaded file) must never reach the
|
|
5598
|
+
// many `content.trim()` / projection callers below, which would crash render.
|
|
4804
5599
|
const activeCollabFileReady =
|
|
4805
5600
|
viewMode === "single" && activeFileId === prevActiveFileIdRef.current;
|
|
4806
|
-
const
|
|
4807
|
-
|
|
5601
|
+
const pendingActiveFileContent = activeFile?.id
|
|
5602
|
+
? pendingLocalFileContentsSnapshot.get(activeFile.id)?.content
|
|
5603
|
+
: undefined;
|
|
5604
|
+
const activeContentSource =
|
|
5605
|
+
pendingActiveFileContent ??
|
|
5606
|
+
(activeCollabFileReady &&
|
|
4808
5607
|
collabContentFileId === activeFile?.id &&
|
|
4809
5608
|
collabContent !== null
|
|
4810
5609
|
? collabContent
|
|
4811
|
-
: (activeFile?.content ?? "");
|
|
5610
|
+
: (activeFile?.content ?? ""));
|
|
5611
|
+
const activeContent =
|
|
5612
|
+
typeof activeContentSource === "string" ? activeContentSource : "";
|
|
5613
|
+
useLayoutEffect(() => {
|
|
5614
|
+
latestActiveContentRef.current = activeContent;
|
|
5615
|
+
}, [activeContent]);
|
|
4812
5616
|
const fileContentById = useMemo(() => {
|
|
4813
5617
|
const map = new Map<string, string>();
|
|
4814
5618
|
for (const file of files) {
|
|
4815
|
-
map.set(file.id, file.content
|
|
5619
|
+
map.set(file.id, typeof file.content === "string" ? file.content : "");
|
|
4816
5620
|
}
|
|
4817
5621
|
return map;
|
|
4818
5622
|
}, [files]);
|
|
4819
5623
|
const getScreenContent = useCallback(
|
|
4820
5624
|
(screenId: string) =>
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
:
|
|
5625
|
+
getFreshScreenContent({
|
|
5626
|
+
screenId,
|
|
5627
|
+
activeFileId: activeFile?.id,
|
|
5628
|
+
freshActiveContent: getFreshActiveFileContent({
|
|
5629
|
+
activeContent,
|
|
5630
|
+
latestContent: latestActiveContentRef.current,
|
|
5631
|
+
lastLocalContent: lastLocalContentRef.current,
|
|
5632
|
+
}),
|
|
5633
|
+
fileContentById,
|
|
5634
|
+
}),
|
|
4824
5635
|
[activeContent, activeFile?.id, fileContentById],
|
|
4825
5636
|
);
|
|
4826
5637
|
const pageStyles = useMemo(
|
|
@@ -4846,24 +5657,227 @@ export default function DesignEditor() {
|
|
|
4846
5657
|
return selectedElement?.selector ? [selectedElement.selector] : [];
|
|
4847
5658
|
}, [selectedCodeLayerNode, selectedElement?.selector]);
|
|
4848
5659
|
const selectedCanvasSelector = selectedCanvasSelectorCandidates[0] ?? null;
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
5660
|
+
|
|
5661
|
+
const handleDesignStateSelect = useCallback(
|
|
5662
|
+
(stateId: string | null, row?: DesignStatePreviewRow) => {
|
|
5663
|
+
setSelectedStateId(stateId);
|
|
5664
|
+
const win = canvasIframeRef.current?.contentWindow;
|
|
5665
|
+
if (!win) return;
|
|
5666
|
+
|
|
5667
|
+
if (stateId === null) {
|
|
5668
|
+
win.postMessage(
|
|
5669
|
+
{
|
|
5670
|
+
type: "replace-document-content",
|
|
5671
|
+
content: activeContent,
|
|
5672
|
+
forceFullDocument: true,
|
|
5673
|
+
},
|
|
5674
|
+
"*",
|
|
5675
|
+
);
|
|
5676
|
+
return;
|
|
5677
|
+
}
|
|
5678
|
+
|
|
5679
|
+
const html = designStatePreviewHtml(row);
|
|
5680
|
+
if (!html) return;
|
|
5681
|
+
win.postMessage(
|
|
5682
|
+
{
|
|
5683
|
+
type: "replace-document-content",
|
|
5684
|
+
content: html,
|
|
5685
|
+
forceFullDocument: true,
|
|
5686
|
+
},
|
|
5687
|
+
"*",
|
|
5688
|
+
);
|
|
5689
|
+
},
|
|
5690
|
+
[activeContent, canvasIframeRef],
|
|
5691
|
+
);
|
|
5692
|
+
|
|
5693
|
+
// ── Inspector header quick actions (Create component / Inspect code) ───────
|
|
5694
|
+
// Resolve the design-level source type + capability map so the inspector can
|
|
5695
|
+
// gate the real-app affordances (jump-to-source, prop write-back).
|
|
5696
|
+
const designSourceType = useMemo(
|
|
5697
|
+
() =>
|
|
5698
|
+
normalizeDesignSourceType(designDataJson.sourceType as unknown) ??
|
|
5699
|
+
"inline",
|
|
5700
|
+
[designDataJson.sourceType],
|
|
5701
|
+
);
|
|
5702
|
+
const sourceCapabilities = useMemo(() => {
|
|
5703
|
+
const caps = resolveSourceCapabilities(designSourceType);
|
|
5704
|
+
return DESIGN_CAPABILITY_NAMES.filter((name) => hasCapability(caps, name));
|
|
5705
|
+
}, [designSourceType]);
|
|
5706
|
+
|
|
5707
|
+
// Builder-hosted preview URL for fusion-source designs, written into the
|
|
5708
|
+
// design data blob by the "Make it real" migration. Threaded into DesignCanvas
|
|
5709
|
+
// so the fusion preview renders (and so the bridge trust check can validate
|
|
5710
|
+
// the frame's origin against it).
|
|
5711
|
+
const designFusionUrl = useMemo(() => {
|
|
5712
|
+
const raw = (designDataJson as { fusionUrl?: unknown }).fusionUrl;
|
|
5713
|
+
return typeof raw === "string" && raw ? raw : undefined;
|
|
5714
|
+
}, [designDataJson]);
|
|
5715
|
+
|
|
5716
|
+
// §6.1 — open a component instance's source. open-component-source selects the
|
|
5717
|
+
// component root in the editor and emits a navigate app-state; for real-app
|
|
5718
|
+
// (localhost / fusion) sources it also resolves the external file location.
|
|
5719
|
+
const handleComponentSourceJump = useCallback(
|
|
5720
|
+
({ nodeId }: { nodeId: string; componentName: string }) => {
|
|
5721
|
+
if (!id || !nodeId) return;
|
|
5722
|
+
openComponentSourceMutation.mutate(
|
|
5723
|
+
{ designId: id, nodeId, fileId: activeFileId ?? undefined } as any,
|
|
5724
|
+
{
|
|
5725
|
+
onError: () => {
|
|
5726
|
+
toast.error(
|
|
5727
|
+
"Could not open component source" /* i18n-ignore edge-case jump failure */,
|
|
5728
|
+
);
|
|
5729
|
+
},
|
|
5730
|
+
},
|
|
5731
|
+
);
|
|
5732
|
+
},
|
|
5733
|
+
[id, activeFileId, openComponentSourceMutation],
|
|
5734
|
+
);
|
|
5735
|
+
|
|
5736
|
+
// The selected node id, when it already is a recognised component instance —
|
|
5737
|
+
// unlocks the contextual Component section at the top of the Design tab.
|
|
5738
|
+
const selectedComponentNodeId = useMemo(() => {
|
|
5739
|
+
if (!selectedCodeLayerNode) return undefined;
|
|
5740
|
+
return isComponentInstance(selectedCodeLayerNode)
|
|
5741
|
+
? bridgeSourceIdForCodeLayerNode(selectedCodeLayerNode)
|
|
5742
|
+
: undefined;
|
|
5743
|
+
}, [selectedCodeLayerNode]);
|
|
5744
|
+
|
|
5745
|
+
useEffect(() => {
|
|
5746
|
+
setShaderFillPreview(null);
|
|
5747
|
+
}, [activeFile?.id, selectedElement?.selector, selectedElement?.sourceId]);
|
|
5748
|
+
|
|
5749
|
+
// A friendly default name for the create-component dialog, derived from the
|
|
5750
|
+
// selected element's layer name / tag.
|
|
5751
|
+
const defaultComponentName = useMemo(() => {
|
|
5752
|
+
if (selectedCodeLayerNode?.layerName)
|
|
5753
|
+
return selectedCodeLayerNode.layerName;
|
|
5754
|
+
if (selectedElement?.tagName) {
|
|
5755
|
+
const tag = selectedElement.tagName;
|
|
5756
|
+
return tag.charAt(0).toUpperCase() + tag.slice(1);
|
|
5757
|
+
}
|
|
5758
|
+
return "Component";
|
|
5759
|
+
}, [selectedCodeLayerNode?.layerName, selectedElement?.tagName]);
|
|
5760
|
+
|
|
5761
|
+
// Outer HTML of the selection — backs the inline/Alpine "Inspect code" view.
|
|
5762
|
+
const selectedElementOuterHtml = useMemo(() => {
|
|
5763
|
+
if (!selectedElement?.selector) return null;
|
|
5764
|
+
return (
|
|
5765
|
+
selectedElement.htmlContent ??
|
|
5766
|
+
getElementOuterHtml(activeContent, selectedElement.selector)
|
|
5767
|
+
);
|
|
5768
|
+
}, [activeContent, selectedElement?.selector, selectedElement?.htmlContent]);
|
|
5769
|
+
|
|
5770
|
+
// §6.3 — the motion-dock target: the selected element's literal
|
|
5771
|
+
// `data-agent-native-node-id` (the value the motion compiler + preview bridge
|
|
5772
|
+
// match on, NOT the hashed projection id) plus a friendly label. Single-screen
|
|
5773
|
+
// mode auto-stamps every selectable node with this attribute (see the
|
|
5774
|
+
// ensureCodeLayerNodeIdsInHtml effect), so a selection reliably resolves to a
|
|
5775
|
+
// stable node id here. `null` when nothing animatable is selected — the dock
|
|
5776
|
+
// then disables its "Add track" affordance.
|
|
5777
|
+
const motionSelectedTarget = useMemo<{
|
|
5778
|
+
nodeId: string;
|
|
5779
|
+
label: string;
|
|
5780
|
+
} | null>(() => {
|
|
5781
|
+
if (!selectedCodeLayerNode) return null;
|
|
5782
|
+
const nodeId =
|
|
5783
|
+
selectedCodeLayerNode.dataAttributes["data-agent-native-node-id"]?.trim();
|
|
5784
|
+
if (!nodeId) return null;
|
|
5785
|
+
const label =
|
|
5786
|
+
selectedCodeLayerNode.layerName ||
|
|
5787
|
+
selectedElement?.tagName ||
|
|
5788
|
+
"Selected element";
|
|
5789
|
+
return { nodeId, label };
|
|
5790
|
+
}, [selectedCodeLayerNode, selectedElement?.tagName]);
|
|
5791
|
+
|
|
5792
|
+
// Serialisable subset of the dock's tracks for the DesignCanvas motion-preview
|
|
5793
|
+
// bridge. Strips the UI-only `label` field. Only populated while the dock is
|
|
5794
|
+
// open so a closed dock never leaves preview overrides on the canvas; an empty
|
|
5795
|
+
// array makes DesignCanvas send `motion-preview-clear`. Scrubbing previews
|
|
5796
|
+
// these tracks live in the iframe — it never writes (that is "Write to CSS").
|
|
5797
|
+
const motionTracksWire = useMemo<MotionTrackWire[]>(() => {
|
|
5798
|
+
if (!motionDockOpen || motionTracks.length === 0) return [];
|
|
5799
|
+
return motionTracks.map(({ label: _label, ...track }) => track);
|
|
5800
|
+
}, [motionDockOpen, motionTracks]);
|
|
5801
|
+
|
|
5802
|
+
const inspectCodeData = useMemo<InspectCodeData | undefined>(() => {
|
|
5803
|
+
if (!selectedElement) return undefined;
|
|
5804
|
+
// Inline/Alpine: the design HTML is the source — show the element's HTML.
|
|
5805
|
+
// Real-app source resolution (vscode:// deep link) requires the
|
|
5806
|
+
// resolveNodeToFile bridge op, which is wired through open-component-source
|
|
5807
|
+
// when an external file path is available; until that round-trip is hooked
|
|
5808
|
+
// up here the popover shows the projected HTML for all source types.
|
|
5809
|
+
return { html: selectedElementOuterHtml, sourceLocation: null };
|
|
5810
|
+
}, [selectedElement, selectedElementOuterHtml]);
|
|
5811
|
+
|
|
5812
|
+
const handleCreateComponent = useCallback(
|
|
5813
|
+
(name: string) => {
|
|
5814
|
+
if (!id || !selectedElement) return;
|
|
5815
|
+
const nodeId = selectedElementLayerId ?? undefined;
|
|
5816
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
5817
|
+
createComponentMutation.mutate(
|
|
5818
|
+
{ designId: id, nodeId, selector, name } as any,
|
|
5819
|
+
{
|
|
5820
|
+
onSuccess: () => {
|
|
5821
|
+
queryClient.invalidateQueries({
|
|
5822
|
+
queryKey: ["action", "get-design"],
|
|
5823
|
+
});
|
|
5824
|
+
toast.success(t("designEditor.toasts.componentCreated"));
|
|
5825
|
+
},
|
|
5826
|
+
onError: () => {
|
|
5827
|
+
toast.error(t("designEditor.toasts.componentCreateFailed"));
|
|
5828
|
+
},
|
|
5829
|
+
},
|
|
5830
|
+
);
|
|
5831
|
+
|
|
5832
|
+
// Follow-up: ask the Design agent to extract props and replace repeated
|
|
5833
|
+
// instances with this component. The deterministic annotate above is the
|
|
5834
|
+
// core; this is an enhancement that runs in the agent chat.
|
|
5835
|
+
sendToAgentChat({
|
|
5836
|
+
message: `Extract props for the "${name}" component and replace repeated instances on this design with it.`,
|
|
5837
|
+
context: [
|
|
5838
|
+
`Design id: "${id}".`,
|
|
5839
|
+
selectedElement.selector
|
|
5840
|
+
? `Component root selector: ${selectedElement.selector}.`
|
|
5841
|
+
: "",
|
|
5842
|
+
nodeId ? `Component root node id: ${nodeId}.` : "",
|
|
5843
|
+
`The element was just annotated with data-agent-native-component="${name}".`,
|
|
5844
|
+
"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.",
|
|
5845
|
+
]
|
|
5846
|
+
.filter(Boolean)
|
|
5847
|
+
.join("\n"),
|
|
5848
|
+
submit: true,
|
|
5849
|
+
openSidebar: true,
|
|
5850
|
+
});
|
|
5851
|
+
},
|
|
5852
|
+
[
|
|
5853
|
+
id,
|
|
5854
|
+
selectedElement,
|
|
5855
|
+
selectedElementLayerId,
|
|
5856
|
+
selectedCanvasSelector,
|
|
5857
|
+
createComponentMutation,
|
|
5858
|
+
queryClient,
|
|
5859
|
+
t,
|
|
5860
|
+
],
|
|
5861
|
+
);
|
|
5862
|
+
|
|
5863
|
+
const hoveredCodeLayerNode = useMemo(() => {
|
|
5864
|
+
if (!hoveredElement) return null;
|
|
5865
|
+
if (isScreenRootElementInfo(hoveredElement)) return null;
|
|
5866
|
+
return resolveCodeLayerNodeFromElementInfo(
|
|
5867
|
+
activeCodeLayerProjection,
|
|
5868
|
+
hoveredElement,
|
|
5869
|
+
);
|
|
5870
|
+
}, [activeCodeLayerProjection, hoveredElement]);
|
|
5871
|
+
const hoveredCanvasSelectorCandidates = useMemo(() => {
|
|
5872
|
+
if (isScreenRootElementInfo(hoveredElement)) return [];
|
|
5873
|
+
if (hoveredCodeLayerNode) {
|
|
5874
|
+
return codeLayerSelectorAliases(hoveredCodeLayerNode);
|
|
5875
|
+
}
|
|
5876
|
+
return hoveredElement?.selector ? [hoveredElement.selector] : [];
|
|
5877
|
+
}, [hoveredCodeLayerNode, hoveredElement]);
|
|
5878
|
+
const hoveredCanvasSelector = hoveredCanvasSelectorCandidates[0] ?? null;
|
|
5879
|
+
const hoveredElementIsScreenRoot = isScreenRootElementInfo(hoveredElement);
|
|
5880
|
+
const hoveredScreenRootId = hoveredElementIsScreenRoot
|
|
4867
5881
|
? hoveredElementScreenId
|
|
4868
5882
|
: null;
|
|
4869
5883
|
const hoveredChildScreenId = hoveredElementIsScreenRoot
|
|
@@ -4923,9 +5937,44 @@ export default function DesignEditor() {
|
|
|
4923
5937
|
} = {},
|
|
4924
5938
|
) => {
|
|
4925
5939
|
if (!activeFile || !canEditDesignRef.current) return;
|
|
5940
|
+
const previousContent =
|
|
5941
|
+
collabContentFileIdRef.current === activeFile.id &&
|
|
5942
|
+
typeof collabContentRef.current === "string"
|
|
5943
|
+
? collabContentRef.current
|
|
5944
|
+
: (activeFile.content ?? "");
|
|
5945
|
+
const yjsHistoryAvailable = Boolean(
|
|
5946
|
+
ydoc && isSynced && undoManagerRef.current,
|
|
5947
|
+
);
|
|
5948
|
+
if (
|
|
5949
|
+
!suppressContentHistoryRef.current &&
|
|
5950
|
+
!yjsHistoryAvailable &&
|
|
5951
|
+
previousContent !== nextContent
|
|
5952
|
+
) {
|
|
5953
|
+
contentUndoStackRef.current = [
|
|
5954
|
+
...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
5955
|
+
{
|
|
5956
|
+
fileId: activeFile.id,
|
|
5957
|
+
before: previousContent,
|
|
5958
|
+
after: nextContent,
|
|
5959
|
+
},
|
|
5960
|
+
];
|
|
5961
|
+
contentRedoStackRef.current = [];
|
|
5962
|
+
historyOrderRef.current = [
|
|
5963
|
+
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
5964
|
+
"content",
|
|
5965
|
+
];
|
|
5966
|
+
redoOrderRef.current = [];
|
|
5967
|
+
syncUndoRedoState();
|
|
5968
|
+
}
|
|
5969
|
+
markPendingLocalFileContent(
|
|
5970
|
+
activeFile.id,
|
|
5971
|
+
nextContent,
|
|
5972
|
+
activeFile.updatedAt,
|
|
5973
|
+
);
|
|
4926
5974
|
setCollabContent(nextContent);
|
|
4927
5975
|
setCollabContentFileId(activeFile.id);
|
|
4928
5976
|
lastLocalContentRef.current = nextContent;
|
|
5977
|
+
latestActiveContentRef.current = nextContent;
|
|
4929
5978
|
if (id) {
|
|
4930
5979
|
queryClient.setQueryData(
|
|
4931
5980
|
["action", "get-design", { id }],
|
|
@@ -4976,9 +6025,11 @@ export default function DesignEditor() {
|
|
|
4976
6025
|
activeFile,
|
|
4977
6026
|
id,
|
|
4978
6027
|
isSynced,
|
|
6028
|
+
markPendingLocalFileContent,
|
|
4979
6029
|
queryClient,
|
|
4980
6030
|
queueFileContentSave,
|
|
4981
6031
|
replacePreviewContent,
|
|
6032
|
+
syncUndoRedoState,
|
|
4982
6033
|
ydoc,
|
|
4983
6034
|
],
|
|
4984
6035
|
);
|
|
@@ -4994,6 +6045,8 @@ export default function DesignEditor() {
|
|
|
4994
6045
|
applyLocalContentUpdate(nextContent, options);
|
|
4995
6046
|
return;
|
|
4996
6047
|
}
|
|
6048
|
+
const previousFile = files.find((file) => file.id === fileId);
|
|
6049
|
+
markPendingLocalFileContent(fileId, nextContent, previousFile?.updatedAt);
|
|
4997
6050
|
queryClient.setQueryData(["action", "get-design", { id }], (old: any) => {
|
|
4998
6051
|
if (!old || typeof old !== "object" || !Array.isArray(old.files)) {
|
|
4999
6052
|
return old;
|
|
@@ -5011,9 +6064,79 @@ export default function DesignEditor() {
|
|
|
5011
6064
|
syncCollab: true,
|
|
5012
6065
|
});
|
|
5013
6066
|
},
|
|
5014
|
-
[
|
|
6067
|
+
[
|
|
6068
|
+
activeFile?.id,
|
|
6069
|
+
applyLocalContentUpdate,
|
|
6070
|
+
files,
|
|
6071
|
+
id,
|
|
6072
|
+
markPendingLocalFileContent,
|
|
6073
|
+
queryClient,
|
|
6074
|
+
saveFileContent,
|
|
6075
|
+
],
|
|
6076
|
+
);
|
|
6077
|
+
|
|
6078
|
+
const handleComponentPropApplied = useCallback(
|
|
6079
|
+
(fileId: string, nextContent: string) => {
|
|
6080
|
+
applyFileContentUpdate(fileId, nextContent, {
|
|
6081
|
+
refreshPreview: fileId === activeFile?.id,
|
|
6082
|
+
});
|
|
6083
|
+
},
|
|
6084
|
+
[activeFile?.id, applyFileContentUpdate],
|
|
6085
|
+
);
|
|
6086
|
+
|
|
6087
|
+
const handleReviewFixApplied = useCallback(
|
|
6088
|
+
(
|
|
6089
|
+
_finding: A11yFinding,
|
|
6090
|
+
result?: { fileId?: string; patchedContent?: string },
|
|
6091
|
+
) => {
|
|
6092
|
+
setReviewFindings((prev) =>
|
|
6093
|
+
prev.filter((finding) => finding.id !== _finding.id),
|
|
6094
|
+
);
|
|
6095
|
+
if (
|
|
6096
|
+
typeof result?.fileId === "string" &&
|
|
6097
|
+
typeof result.patchedContent === "string"
|
|
6098
|
+
) {
|
|
6099
|
+
applyFileContentUpdate(result.fileId, result.patchedContent, {
|
|
6100
|
+
refreshPreview: result.fileId === activeFile?.id,
|
|
6101
|
+
});
|
|
6102
|
+
}
|
|
6103
|
+
void handleRunDesignAudit();
|
|
6104
|
+
},
|
|
6105
|
+
[activeFile?.id, applyFileContentUpdate, handleRunDesignAudit],
|
|
5015
6106
|
);
|
|
5016
6107
|
|
|
6108
|
+
const resolvedReviewPanelProps = useMemo<
|
|
6109
|
+
Omit<ReviewPanelProps, "className"> | undefined
|
|
6110
|
+
>(() => {
|
|
6111
|
+
if (!id || !activeFile) return undefined;
|
|
6112
|
+
const reviewMatchesActiveFile = reviewFileId === activeFile.id;
|
|
6113
|
+
return {
|
|
6114
|
+
findings: reviewMatchesActiveFile ? reviewFindings : [],
|
|
6115
|
+
auditLoading: reviewMatchesActiveFile ? reviewAuditLoading : false,
|
|
6116
|
+
auditedAt: reviewMatchesActiveFile ? reviewAuditedAt : null,
|
|
6117
|
+
auditError: reviewMatchesActiveFile ? reviewAuditError : null,
|
|
6118
|
+
onRunAudit: handleRunDesignAudit,
|
|
6119
|
+
onFindingClick: handleReviewFindingClick,
|
|
6120
|
+
fixSource: {
|
|
6121
|
+
designId: id,
|
|
6122
|
+
fileId: activeFile.id,
|
|
6123
|
+
filename: activeFile.filename,
|
|
6124
|
+
},
|
|
6125
|
+
onFixApplied: handleReviewFixApplied,
|
|
6126
|
+
};
|
|
6127
|
+
}, [
|
|
6128
|
+
activeFile,
|
|
6129
|
+
handleReviewFindingClick,
|
|
6130
|
+
handleReviewFixApplied,
|
|
6131
|
+
handleRunDesignAudit,
|
|
6132
|
+
id,
|
|
6133
|
+
reviewAuditError,
|
|
6134
|
+
reviewAuditLoading,
|
|
6135
|
+
reviewAuditedAt,
|
|
6136
|
+
reviewFileId,
|
|
6137
|
+
reviewFindings,
|
|
6138
|
+
]);
|
|
6139
|
+
|
|
5017
6140
|
const handleCreatePrimitive = useCallback(
|
|
5018
6141
|
(screenId: string, primitive: CanvasPrimitiveInsert) => {
|
|
5019
6142
|
if (!canEditDesign) return false;
|
|
@@ -5085,8 +6208,12 @@ export default function DesignEditor() {
|
|
|
5085
6208
|
|
|
5086
6209
|
const handlePrimitiveCreated = useCallback(
|
|
5087
6210
|
(screenId: string, nodeId: string) => {
|
|
6211
|
+
// B2/B4 fix: stay in overview mode after drawing a primitive. The user
|
|
6212
|
+
// drew a shape on the board — they should remain on the board with the
|
|
6213
|
+
// new primitive selected, matching Figma behaviour. We activate the
|
|
6214
|
+
// target screen (so the layers panel shows its content) and select the
|
|
6215
|
+
// new node, but do NOT switch to single/full view.
|
|
5088
6216
|
pendingOverviewScreenSelectionRef.current = null;
|
|
5089
|
-
viewModeRef.current = "single";
|
|
5090
6217
|
setActiveFileId(screenId);
|
|
5091
6218
|
setSelectedElement(null);
|
|
5092
6219
|
setHoveredElement(null);
|
|
@@ -5094,7 +6221,7 @@ export default function DesignEditor() {
|
|
|
5094
6221
|
setOverviewSelectedScreenIds([]);
|
|
5095
6222
|
setActiveTool("move");
|
|
5096
6223
|
setMode("edit");
|
|
5097
|
-
setViewMode("single")
|
|
6224
|
+
// viewMode stays at "overview" — no setViewMode("single") call here.
|
|
5098
6225
|
},
|
|
5099
6226
|
[],
|
|
5100
6227
|
);
|
|
@@ -5329,6 +6456,58 @@ export default function DesignEditor() {
|
|
|
5329
6456
|
mode,
|
|
5330
6457
|
activeTool,
|
|
5331
6458
|
inspectorTab: activeInspectorTab,
|
|
6459
|
+
// §8 DesignNavigationState additions — dock + breakpoint context
|
|
6460
|
+
dock: { kind: "motion" as const, open: motionDockOpen },
|
|
6461
|
+
motion: {
|
|
6462
|
+
previewing: false,
|
|
6463
|
+
playheadMs: 0,
|
|
6464
|
+
timelineId: undefined as string | undefined,
|
|
6465
|
+
selectedTrackId: undefined as string | undefined,
|
|
6466
|
+
selectedKeyframeId: undefined as string | undefined,
|
|
6467
|
+
},
|
|
6468
|
+
// §8 breakpoint fields — "auto" = no specific breakpoint focused.
|
|
6469
|
+
breakpoint: (activeBreakpointWidthState != null
|
|
6470
|
+
? activeBreakpointWidthState < 500
|
|
6471
|
+
? "mobile"
|
|
6472
|
+
: activeBreakpointWidthState < 1024
|
|
6473
|
+
? "tablet"
|
|
6474
|
+
: "desktop"
|
|
6475
|
+
: "auto") as "auto" | "mobile" | "tablet" | "desktop",
|
|
6476
|
+
activeBreakpointId: (() => {
|
|
6477
|
+
if (activeBreakpointWidthState == null) return undefined;
|
|
6478
|
+
try {
|
|
6479
|
+
const raw = (designDataJson as Record<string, unknown>)
|
|
6480
|
+
?.breakpointSet;
|
|
6481
|
+
if (
|
|
6482
|
+
raw &&
|
|
6483
|
+
typeof raw === "object" &&
|
|
6484
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
6485
|
+
) {
|
|
6486
|
+
const bps = (
|
|
6487
|
+
raw as { breakpoints: Array<{ id: string; widthPx: number }> }
|
|
6488
|
+
).breakpoints;
|
|
6489
|
+
return bps.find((b) => b.widthPx === activeBreakpointWidthState)
|
|
6490
|
+
?.id;
|
|
6491
|
+
}
|
|
6492
|
+
} catch {
|
|
6493
|
+
// ignore
|
|
6494
|
+
}
|
|
6495
|
+
return undefined;
|
|
6496
|
+
})(),
|
|
6497
|
+
breakpointSetId: (() => {
|
|
6498
|
+
try {
|
|
6499
|
+
const raw = (designDataJson as Record<string, unknown>)
|
|
6500
|
+
?.breakpointSet;
|
|
6501
|
+
if (raw && typeof raw === "object") {
|
|
6502
|
+
return (raw as Record<string, unknown>).id as string | undefined;
|
|
6503
|
+
}
|
|
6504
|
+
} catch {
|
|
6505
|
+
// ignore
|
|
6506
|
+
}
|
|
6507
|
+
return undefined;
|
|
6508
|
+
})(),
|
|
6509
|
+
// §8 — active design state (null = Default / live view)
|
|
6510
|
+
selectedStateId,
|
|
5332
6511
|
};
|
|
5333
6512
|
(window as any).__designSelection = selection;
|
|
5334
6513
|
const persistedSelection = {
|
|
@@ -5344,6 +6523,12 @@ export default function DesignEditor() {
|
|
|
5344
6523
|
mode: selection.mode,
|
|
5345
6524
|
activeTool: selection.activeTool,
|
|
5346
6525
|
inspectorTab: selection.inspectorTab,
|
|
6526
|
+
dock: selection.dock,
|
|
6527
|
+
motion: selection.motion,
|
|
6528
|
+
breakpoint: selection.breakpoint,
|
|
6529
|
+
activeBreakpointId: selection.activeBreakpointId,
|
|
6530
|
+
breakpointSetId: selection.breakpointSetId,
|
|
6531
|
+
selectedStateId: selection.selectedStateId,
|
|
5347
6532
|
ownerId: designSelectionOwnerIdRef.current,
|
|
5348
6533
|
};
|
|
5349
6534
|
const persistedKey = JSON.stringify(persistedSelection);
|
|
@@ -5381,6 +6566,10 @@ export default function DesignEditor() {
|
|
|
5381
6566
|
overviewSelectedScreenIds,
|
|
5382
6567
|
viewMode,
|
|
5383
6568
|
zoom,
|
|
6569
|
+
motionDockOpen,
|
|
6570
|
+
activeBreakpointWidthState,
|
|
6571
|
+
designDataJson,
|
|
6572
|
+
selectedStateId,
|
|
5384
6573
|
]);
|
|
5385
6574
|
|
|
5386
6575
|
useEffect(() => {
|
|
@@ -5440,6 +6629,15 @@ export default function DesignEditor() {
|
|
|
5440
6629
|
mode,
|
|
5441
6630
|
activeTool,
|
|
5442
6631
|
tweakValues: tweakSelections,
|
|
6632
|
+
onShaderFillPreview: (_descriptor, css) => {
|
|
6633
|
+
setShaderFillPreview({
|
|
6634
|
+
selector: selectedElement?.selector ?? undefined,
|
|
6635
|
+
nodeId:
|
|
6636
|
+
selectedElement?.sourceId ?? selectedCodeLayerNode?.id ?? undefined,
|
|
6637
|
+
css,
|
|
6638
|
+
});
|
|
6639
|
+
},
|
|
6640
|
+
onShaderFillPreviewClear: () => setShaderFillPreview(null),
|
|
5443
6641
|
}),
|
|
5444
6642
|
[
|
|
5445
6643
|
activeFile?.filename,
|
|
@@ -5452,6 +6650,7 @@ export default function DesignEditor() {
|
|
|
5452
6650
|
mode,
|
|
5453
6651
|
overviewSelectedScreenIds,
|
|
5454
6652
|
selectedElement,
|
|
6653
|
+
selectedCodeLayerNode?.id,
|
|
5455
6654
|
selectedScreenIds,
|
|
5456
6655
|
tweakSelections,
|
|
5457
6656
|
viewMode,
|
|
@@ -5676,7 +6875,10 @@ export default function DesignEditor() {
|
|
|
5676
6875
|
// advance lastLocalContentRef.current to resolvedNextContent below, the
|
|
5677
6876
|
// next synchronous call reads the previous call's result and the patches
|
|
5678
6877
|
// compose. Falls back to activeContent when the ref is unset (file switch).
|
|
5679
|
-
const baseContent =
|
|
6878
|
+
const baseContent =
|
|
6879
|
+
latestActiveContentRef.current ??
|
|
6880
|
+
lastLocalContentRef.current ??
|
|
6881
|
+
activeContent;
|
|
5680
6882
|
const [firstProperty, firstValue] = entries[0];
|
|
5681
6883
|
const projection = buildCodeLayerProjection(baseContent);
|
|
5682
6884
|
const targetInfo = options.elementInfo ?? selectedElement;
|
|
@@ -5743,20 +6945,71 @@ export default function DesignEditor() {
|
|
|
5743
6945
|
});
|
|
5744
6946
|
const sendStyleChange = (window as any).__designCanvasSendStyle;
|
|
5745
6947
|
if (!options.runtimeApplied && typeof sendStyleChange === "function") {
|
|
6948
|
+
const selectorCandidates = targetNode
|
|
6949
|
+
? codeLayerSelectorAliases(targetNode)
|
|
6950
|
+
: selector
|
|
6951
|
+
? [selector]
|
|
6952
|
+
: [];
|
|
6953
|
+
const nodeId = targetNode
|
|
6954
|
+
? bridgeSourceIdForCodeLayerNode(targetNode)
|
|
6955
|
+
: targetInfo?.sourceId;
|
|
5746
6956
|
entries.forEach(([property, value]) => {
|
|
5747
|
-
sendStyleChange(selector, property, value
|
|
6957
|
+
sendStyleChange(selector, property, value, {
|
|
6958
|
+
selectorCandidates,
|
|
6959
|
+
nodeId,
|
|
6960
|
+
});
|
|
5748
6961
|
});
|
|
5749
6962
|
}
|
|
5750
6963
|
|
|
5751
6964
|
const nextContent = applyInlineStylesToHtml(baseContent, selector, {
|
|
5752
6965
|
...Object.fromEntries(entries),
|
|
5753
6966
|
});
|
|
6967
|
+
// §6.4 — Breakpoint-scoped class editing. Reuses the `projection` and
|
|
6968
|
+
// `targetNode` resolved above for the patch-proof block (same baseContent).
|
|
6969
|
+
// When an active non-base breakpoint frame is set, attempt to route class
|
|
6970
|
+
// edits through `kind: "responsive-class"` so the write targets only that
|
|
6971
|
+
// breakpoint prefix (e.g. "md:text-lg" instead of "text-lg"). This fires
|
|
6972
|
+
// when the element has a `responsive-class` EditCapability, which signals
|
|
6973
|
+
// that its values come from Tailwind class tokens and can carry a prefix.
|
|
6974
|
+
// Falls back to `kind: "style"` (inline attribute) for any entry that
|
|
6975
|
+
// fails the responsive path (e.g. raw CSS values with no Tailwind utility).
|
|
6976
|
+
const activeBreakpointPrefix =
|
|
6977
|
+
activeBreakpointWidthState != null
|
|
6978
|
+
? widthToPrefix(activeBreakpointWidthState)
|
|
6979
|
+
: null;
|
|
6980
|
+
// `responsive-class` is a code-layer EditCapability kind not yet reflected
|
|
6981
|
+
// in the ElementInfo type union (types.ts); cast to string for the check.
|
|
6982
|
+
const hasResponsiveCapability =
|
|
6983
|
+
activeBreakpointPrefix != null &&
|
|
6984
|
+
activeBreakpointPrefix !== "base" &&
|
|
6985
|
+
selectedElement?.editCapabilities?.some(
|
|
6986
|
+
(cap) => (cap.kind as string) === "responsive-class",
|
|
6987
|
+
);
|
|
5754
6988
|
const stylePatch = entries.reduce<{
|
|
5755
6989
|
content: string;
|
|
5756
6990
|
failed: string | null;
|
|
5757
6991
|
}>(
|
|
5758
6992
|
(current, [property, value]) => {
|
|
5759
6993
|
if (current.failed) return current;
|
|
6994
|
+
// Try responsive-class path first when appropriate.
|
|
6995
|
+
if (hasResponsiveCapability && activeBreakpointPrefix) {
|
|
6996
|
+
const utility = value.trim();
|
|
6997
|
+
if (responsiveUtilityMatchesStyleProperty(property, utility)) {
|
|
6998
|
+
const rcPatch = applyVisualEdit(current.content, {
|
|
6999
|
+
kind: "responsive-class",
|
|
7000
|
+
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
7001
|
+
prefix: activeBreakpointPrefix,
|
|
7002
|
+
operation: "replace",
|
|
7003
|
+
utility,
|
|
7004
|
+
stem: utilityStem(utility),
|
|
7005
|
+
});
|
|
7006
|
+
if (rcPatch.result.status === "applied") {
|
|
7007
|
+
return { content: rcPatch.content, failed: null };
|
|
7008
|
+
}
|
|
7009
|
+
}
|
|
7010
|
+
// Responsive-class path didn't apply (e.g. value is a raw CSS value,
|
|
7011
|
+
// not a Tailwind utility); fall through to the inline-style path.
|
|
7012
|
+
}
|
|
5760
7013
|
const patch = applyVisualEdit(current.content, {
|
|
5761
7014
|
kind: "style",
|
|
5762
7015
|
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
@@ -5826,6 +7079,7 @@ export default function DesignEditor() {
|
|
|
5826
7079
|
// Mark as our own write so the get-design reconcile + Yjs observe don't
|
|
5827
7080
|
// treat the echo as an external edit and fight the live value.
|
|
5828
7081
|
lastLocalContentRef.current = resolvedNextContent;
|
|
7082
|
+
latestActiveContentRef.current = resolvedNextContent;
|
|
5829
7083
|
// Write the edit into the shared Y.Doc so other open clients see it live
|
|
5830
7084
|
// through Yjs (not only via the slower update-file → applyText round-trip).
|
|
5831
7085
|
// Use LOCAL_EDIT_ORIGIN so the UndoManager captures this transaction.
|
|
@@ -5865,6 +7119,7 @@ export default function DesignEditor() {
|
|
|
5865
7119
|
[
|
|
5866
7120
|
activeContent,
|
|
5867
7121
|
activeFile,
|
|
7122
|
+
activeBreakpointWidthState,
|
|
5868
7123
|
canEditDesign,
|
|
5869
7124
|
queueFileContentSave,
|
|
5870
7125
|
selectedElement,
|
|
@@ -5884,7 +7139,10 @@ export default function DesignEditor() {
|
|
|
5884
7139
|
) {
|
|
5885
7140
|
const sendStyleChange = (window as any).__designCanvasSendStyle;
|
|
5886
7141
|
if (typeof sendStyleChange === "function") {
|
|
5887
|
-
sendStyleChange(selector, property, value
|
|
7142
|
+
sendStyleChange(selector, property, value, {
|
|
7143
|
+
selectorCandidates: selectedCanvasSelectorCandidates,
|
|
7144
|
+
nodeId: selectedElement?.sourceId,
|
|
7145
|
+
});
|
|
5888
7146
|
return;
|
|
5889
7147
|
}
|
|
5890
7148
|
}
|
|
@@ -5893,6 +7151,8 @@ export default function DesignEditor() {
|
|
|
5893
7151
|
[
|
|
5894
7152
|
commitVisualStyles,
|
|
5895
7153
|
selectedElement?.selector,
|
|
7154
|
+
selectedElement?.sourceId,
|
|
7155
|
+
selectedCanvasSelectorCandidates,
|
|
5896
7156
|
textEditingState.active,
|
|
5897
7157
|
textEditingState.hasRange,
|
|
5898
7158
|
textEditingState.selector,
|
|
@@ -5911,6 +7171,16 @@ export default function DesignEditor() {
|
|
|
5911
7171
|
[commitVisualStyles, selectedElement?.selector],
|
|
5912
7172
|
);
|
|
5913
7173
|
|
|
7174
|
+
const getFreshActiveContent = useCallback(
|
|
7175
|
+
() =>
|
|
7176
|
+
getFreshActiveFileContent({
|
|
7177
|
+
activeContent,
|
|
7178
|
+
latestContent: latestActiveContentRef.current,
|
|
7179
|
+
lastLocalContent: lastLocalContentRef.current,
|
|
7180
|
+
}),
|
|
7181
|
+
[activeContent],
|
|
7182
|
+
);
|
|
7183
|
+
|
|
5914
7184
|
const handleVisualStyleChange = useCallback(
|
|
5915
7185
|
(
|
|
5916
7186
|
selector: string,
|
|
@@ -5939,7 +7209,8 @@ export default function DesignEditor() {
|
|
|
5939
7209
|
) => {
|
|
5940
7210
|
if (!canEditDesign) return false;
|
|
5941
7211
|
if (!activeFile) return false;
|
|
5942
|
-
const
|
|
7212
|
+
const baseContent = getFreshActiveContent();
|
|
7213
|
+
const projection = buildCodeLayerProjection(baseContent);
|
|
5943
7214
|
const resolveBridgeNode = (targetSelector: string, sourceId?: string) =>
|
|
5944
7215
|
resolveCodeLayerNodeFromBridge(projection, targetSelector, sourceId);
|
|
5945
7216
|
const targetInfo = elementInfo
|
|
@@ -5956,7 +7227,7 @@ export default function DesignEditor() {
|
|
|
5956
7227
|
anchorSelector,
|
|
5957
7228
|
details?.anchorSourceId,
|
|
5958
7229
|
);
|
|
5959
|
-
const patch = applyVisualEdit(
|
|
7230
|
+
const patch = applyVisualEdit(baseContent, {
|
|
5960
7231
|
kind: "moveNode",
|
|
5961
7232
|
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
5962
7233
|
anchor: anchorNode
|
|
@@ -6004,7 +7275,13 @@ export default function DesignEditor() {
|
|
|
6004
7275
|
}
|
|
6005
7276
|
return true;
|
|
6006
7277
|
},
|
|
6007
|
-
[
|
|
7278
|
+
[
|
|
7279
|
+
activeFile,
|
|
7280
|
+
applyLocalContentUpdate,
|
|
7281
|
+
canEditDesign,
|
|
7282
|
+
getFreshActiveContent,
|
|
7283
|
+
t,
|
|
7284
|
+
],
|
|
6008
7285
|
);
|
|
6009
7286
|
|
|
6010
7287
|
const handleVisualDuplicateChange = useCallback(
|
|
@@ -6021,7 +7298,8 @@ export default function DesignEditor() {
|
|
|
6021
7298
|
) => {
|
|
6022
7299
|
if (!canEditDesign) return false;
|
|
6023
7300
|
if (!activeFile) return false;
|
|
6024
|
-
const
|
|
7301
|
+
const baseContent = getFreshActiveContent();
|
|
7302
|
+
const projection = buildCodeLayerProjection(baseContent);
|
|
6025
7303
|
const targetInfo = elementInfo
|
|
6026
7304
|
? {
|
|
6027
7305
|
...elementInfo,
|
|
@@ -6041,7 +7319,7 @@ export default function DesignEditor() {
|
|
|
6041
7319
|
details?.anchorSelector,
|
|
6042
7320
|
details?.anchorSourceId,
|
|
6043
7321
|
);
|
|
6044
|
-
const nextContent = insertClonedHtmlLayer(
|
|
7322
|
+
const nextContent = insertClonedHtmlLayer(baseContent, cloneHtml, {
|
|
6045
7323
|
targetSelectors: targetNode
|
|
6046
7324
|
? codeLayerSelectorAliases(targetNode)
|
|
6047
7325
|
: [selector],
|
|
@@ -6075,7 +7353,13 @@ export default function DesignEditor() {
|
|
|
6075
7353
|
}
|
|
6076
7354
|
return true;
|
|
6077
7355
|
},
|
|
6078
|
-
[
|
|
7356
|
+
[
|
|
7357
|
+
activeFile,
|
|
7358
|
+
applyLocalContentUpdate,
|
|
7359
|
+
canEditDesign,
|
|
7360
|
+
getFreshActiveContent,
|
|
7361
|
+
t,
|
|
7362
|
+
],
|
|
6079
7363
|
);
|
|
6080
7364
|
|
|
6081
7365
|
const handleTextContentChange = useCallback(
|
|
@@ -6087,7 +7371,8 @@ export default function DesignEditor() {
|
|
|
6087
7371
|
) => {
|
|
6088
7372
|
if (!canEditDesign) return;
|
|
6089
7373
|
if (!activeFile) return;
|
|
6090
|
-
const
|
|
7374
|
+
const baseContent = getFreshActiveContent();
|
|
7375
|
+
const projection = buildCodeLayerProjection(baseContent);
|
|
6091
7376
|
const targetInfo = elementInfo ? { ...elementInfo, selector } : null;
|
|
6092
7377
|
const targetNode = targetInfo
|
|
6093
7378
|
? resolveCodeLayerNodeFromElementInfo(projection, targetInfo)
|
|
@@ -6095,10 +7380,10 @@ export default function DesignEditor() {
|
|
|
6095
7380
|
const isEmpty = value.trim().length === 0;
|
|
6096
7381
|
const removedContent =
|
|
6097
7382
|
isEmpty && targetNode
|
|
6098
|
-
? removeCodeLayerNodeFromHtml(
|
|
7383
|
+
? removeCodeLayerNodeFromHtml(baseContent, targetNode)
|
|
6099
7384
|
: null;
|
|
6100
7385
|
const patch = !removedContent
|
|
6101
|
-
? applyVisualEdit(
|
|
7386
|
+
? applyVisualEdit(baseContent, {
|
|
6102
7387
|
kind: "textContent",
|
|
6103
7388
|
target: targetNode ? { nodeId: targetNode.id } : { selector },
|
|
6104
7389
|
value,
|
|
@@ -6108,12 +7393,7 @@ export default function DesignEditor() {
|
|
|
6108
7393
|
const nextContent =
|
|
6109
7394
|
removedContent ??
|
|
6110
7395
|
(patch?.result.status === "applied" ? patch.content : null) ??
|
|
6111
|
-
updateElementContentInHtml(
|
|
6112
|
-
activeContent,
|
|
6113
|
-
selector,
|
|
6114
|
-
value,
|
|
6115
|
-
details?.html,
|
|
6116
|
-
);
|
|
7396
|
+
updateElementContentInHtml(baseContent, selector, value, details?.html);
|
|
6117
7397
|
if (!nextContent) {
|
|
6118
7398
|
toast.error(
|
|
6119
7399
|
codeLayerPatchMessage(
|
|
@@ -6162,7 +7442,13 @@ export default function DesignEditor() {
|
|
|
6162
7442
|
: previous;
|
|
6163
7443
|
});
|
|
6164
7444
|
},
|
|
6165
|
-
[
|
|
7445
|
+
[
|
|
7446
|
+
activeFile,
|
|
7447
|
+
applyLocalContentUpdate,
|
|
7448
|
+
canEditDesign,
|
|
7449
|
+
getFreshActiveContent,
|
|
7450
|
+
t,
|
|
7451
|
+
],
|
|
6166
7452
|
);
|
|
6167
7453
|
|
|
6168
7454
|
const handleScreenVisualStyleChange = useCallback(
|
|
@@ -6483,22 +7769,71 @@ export default function DesignEditor() {
|
|
|
6483
7769
|
|
|
6484
7770
|
const handleCopySelection = useCallback(async () => {
|
|
6485
7771
|
if (!selectedElement?.selector) return;
|
|
6486
|
-
const html = getElementOuterHtml(
|
|
7772
|
+
const html = getElementOuterHtml(
|
|
7773
|
+
getFreshActiveContent(),
|
|
7774
|
+
selectedElement.selector,
|
|
7775
|
+
);
|
|
6487
7776
|
if (!html) return;
|
|
6488
7777
|
copiedLayerHtmlRef.current = html;
|
|
6489
7778
|
pasteCascadeRef.current = 0;
|
|
6490
7779
|
setHasCanvasClipboard(true);
|
|
6491
7780
|
try {
|
|
6492
7781
|
await navigator.clipboard.writeText(html);
|
|
6493
|
-
toast.success(t("designEditor.toasts.copied"));
|
|
6494
7782
|
} catch {
|
|
6495
7783
|
toast.error(t("designEditor.toasts.clipboardBlocked"));
|
|
6496
7784
|
}
|
|
6497
|
-
}, [
|
|
7785
|
+
}, [getFreshActiveContent, selectedElement, t]);
|
|
6498
7786
|
|
|
6499
7787
|
const handlePasteSelection = useCallback(
|
|
6500
7788
|
(position?: { x: number; y: number }) => {
|
|
6501
7789
|
if (!activeFile || !canEditDesign || !copiedLayerHtmlRef.current) return;
|
|
7790
|
+
const baseContent = getFreshActiveContent();
|
|
7791
|
+
|
|
7792
|
+
// B7 fix: when an element is selected and no explicit canvas position was
|
|
7793
|
+
// given, insert the clone as an in-flow sibling right AFTER the selected
|
|
7794
|
+
// element. Strip any position/left/top from the clone so it participates
|
|
7795
|
+
// in normal document flow instead of being an absolutely-positioned body
|
|
7796
|
+
// child. Fall back to the old position-based clone when nothing is
|
|
7797
|
+
// selected or a "Paste here" position is provided.
|
|
7798
|
+
if (!position && selectedElement?.selector) {
|
|
7799
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
7800
|
+
// Strip position properties from the pasted clone so it becomes an
|
|
7801
|
+
// in-flow sibling (not absolute).
|
|
7802
|
+
const strippedHtml = (() => {
|
|
7803
|
+
try {
|
|
7804
|
+
const parser = new DOMParser();
|
|
7805
|
+
const tmp = parser.parseFromString(
|
|
7806
|
+
`<template>${copiedLayerHtmlRef.current!}</template>`,
|
|
7807
|
+
"text/html",
|
|
7808
|
+
);
|
|
7809
|
+
const root =
|
|
7810
|
+
tmp.querySelector("template")?.content.firstElementChild ??
|
|
7811
|
+
tmp.body.firstElementChild;
|
|
7812
|
+
if (root && root instanceof HTMLElement) {
|
|
7813
|
+
root.style.position = "";
|
|
7814
|
+
root.style.left = "";
|
|
7815
|
+
root.style.top = "";
|
|
7816
|
+
root.style.right = "";
|
|
7817
|
+
root.style.bottom = "";
|
|
7818
|
+
}
|
|
7819
|
+
return root?.outerHTML ?? copiedLayerHtmlRef.current!;
|
|
7820
|
+
} catch {
|
|
7821
|
+
return copiedLayerHtmlRef.current!;
|
|
7822
|
+
}
|
|
7823
|
+
})();
|
|
7824
|
+
|
|
7825
|
+
const nextContent = insertClonedHtmlLayer(baseContent, strippedHtml, {
|
|
7826
|
+
targetSelectors: [selector],
|
|
7827
|
+
placement: "after",
|
|
7828
|
+
});
|
|
7829
|
+
if (nextContent) {
|
|
7830
|
+
pasteCascadeRef.current += 1;
|
|
7831
|
+
applyLocalContentUpdate(nextContent);
|
|
7832
|
+
return;
|
|
7833
|
+
}
|
|
7834
|
+
// Fall through to position-based clone if insert failed.
|
|
7835
|
+
}
|
|
7836
|
+
|
|
6502
7837
|
// Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
|
|
6503
7838
|
// Keyboard pastes land near the source layer and cascade so repeats don't
|
|
6504
7839
|
// stack exactly.
|
|
@@ -6512,53 +7847,86 @@ export default function DesignEditor() {
|
|
|
6512
7847
|
: { x: 120 + offset, y: 120 + offset };
|
|
6513
7848
|
})();
|
|
6514
7849
|
const nextContent = cloneHtmlLayerAtPosition(
|
|
6515
|
-
|
|
7850
|
+
baseContent,
|
|
6516
7851
|
copiedLayerHtmlRef.current,
|
|
6517
7852
|
targetPosition,
|
|
6518
7853
|
);
|
|
6519
7854
|
if (!nextContent) return;
|
|
6520
7855
|
if (!position) pasteCascadeRef.current += 1;
|
|
6521
7856
|
applyLocalContentUpdate(nextContent);
|
|
6522
|
-
toast.success(t("designEditor.toasts.pasted"), { duration: 3000 });
|
|
6523
7857
|
},
|
|
6524
|
-
[
|
|
7858
|
+
[
|
|
7859
|
+
activeFile,
|
|
7860
|
+
applyLocalContentUpdate,
|
|
7861
|
+
canEditDesign,
|
|
7862
|
+
getFreshActiveContent,
|
|
7863
|
+
selectedCanvasSelector,
|
|
7864
|
+
selectedElement,
|
|
7865
|
+
],
|
|
6525
7866
|
);
|
|
6526
7867
|
|
|
6527
7868
|
const handlePasteOverSelection = useCallback(() => {
|
|
6528
7869
|
if (!activeFile || !copiedLayerHtmlRef.current) return;
|
|
7870
|
+
const baseContent = getFreshActiveContent();
|
|
6529
7871
|
if (selectedElement?.boundingRect) {
|
|
6530
7872
|
const { x, y } = selectedElement.boundingRect;
|
|
6531
7873
|
const nextContent = cloneHtmlLayerAtPosition(
|
|
6532
|
-
|
|
7874
|
+
baseContent,
|
|
6533
7875
|
copiedLayerHtmlRef.current,
|
|
6534
7876
|
{ x, y },
|
|
6535
7877
|
);
|
|
6536
7878
|
if (!nextContent) return;
|
|
6537
7879
|
applyLocalContentUpdate(nextContent);
|
|
6538
|
-
toast.success(t("designEditor.toasts.pasted"));
|
|
6539
7880
|
} else {
|
|
6540
7881
|
handlePasteSelection();
|
|
6541
7882
|
}
|
|
6542
7883
|
}, [
|
|
6543
|
-
activeContent,
|
|
6544
7884
|
activeFile,
|
|
6545
7885
|
applyLocalContentUpdate,
|
|
7886
|
+
getFreshActiveContent,
|
|
6546
7887
|
handlePasteSelection,
|
|
6547
7888
|
selectedElement,
|
|
6548
|
-
t,
|
|
6549
7889
|
]);
|
|
6550
7890
|
|
|
6551
7891
|
const handleDuplicateSelection = useCallback(() => {
|
|
6552
7892
|
if (!canEditDesign) return;
|
|
6553
7893
|
if (selectedElement?.selector) {
|
|
6554
|
-
const
|
|
6555
|
-
const
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
7894
|
+
const baseContent = getFreshActiveContent();
|
|
7895
|
+
const html = getElementOuterHtml(baseContent, selectedElement.selector);
|
|
7896
|
+
if (!html) {
|
|
7897
|
+
toast.error(t("designEditor.toasts.duplicateElementFailed"));
|
|
7898
|
+
return;
|
|
7899
|
+
}
|
|
7900
|
+
// B7 fix: duplicate inserts the clone as an in-flow sibling right AFTER
|
|
7901
|
+
// the original — not as an absolutely-positioned body child. Strip
|
|
7902
|
+
// position/left/top so it joins normal document flow.
|
|
7903
|
+
const selector = selectedCanvasSelector ?? selectedElement.selector;
|
|
7904
|
+
const strippedHtml = (() => {
|
|
7905
|
+
try {
|
|
7906
|
+
const parser = new DOMParser();
|
|
7907
|
+
const tmp = parser.parseFromString(
|
|
7908
|
+
`<template>${html}</template>`,
|
|
7909
|
+
"text/html",
|
|
7910
|
+
);
|
|
7911
|
+
const root =
|
|
7912
|
+
tmp.querySelector("template")?.content.firstElementChild ??
|
|
7913
|
+
tmp.body.firstElementChild;
|
|
7914
|
+
if (root && root instanceof HTMLElement) {
|
|
7915
|
+
root.style.position = "";
|
|
7916
|
+
root.style.left = "";
|
|
7917
|
+
root.style.top = "";
|
|
7918
|
+
root.style.right = "";
|
|
7919
|
+
root.style.bottom = "";
|
|
7920
|
+
}
|
|
7921
|
+
return root?.outerHTML ?? html;
|
|
7922
|
+
} catch {
|
|
7923
|
+
return html;
|
|
7924
|
+
}
|
|
7925
|
+
})();
|
|
7926
|
+
const nextContent = insertClonedHtmlLayer(baseContent, strippedHtml, {
|
|
7927
|
+
targetSelectors: [selector],
|
|
7928
|
+
placement: "after",
|
|
7929
|
+
});
|
|
6562
7930
|
if (nextContent) {
|
|
6563
7931
|
applyLocalContentUpdate(nextContent);
|
|
6564
7932
|
} else {
|
|
@@ -6568,16 +7936,19 @@ export default function DesignEditor() {
|
|
|
6568
7936
|
}
|
|
6569
7937
|
if (activeFile) handleDuplicateScreen(activeFile.id);
|
|
6570
7938
|
}, [
|
|
6571
|
-
activeContent,
|
|
6572
7939
|
activeFile,
|
|
6573
7940
|
applyLocalContentUpdate,
|
|
6574
7941
|
canEditDesign,
|
|
7942
|
+
getFreshActiveContent,
|
|
6575
7943
|
handleDuplicateScreen,
|
|
7944
|
+
selectedCanvasSelector,
|
|
6576
7945
|
selectedElement,
|
|
7946
|
+
t,
|
|
6577
7947
|
]);
|
|
6578
7948
|
|
|
6579
7949
|
const handleDeleteSelection = useCallback(() => {
|
|
6580
7950
|
if (!canEditDesign) return;
|
|
7951
|
+
const baseContent = getFreshActiveContent();
|
|
6581
7952
|
// Multi-select delete: when several DOM/code layers are selected in the
|
|
6582
7953
|
// panel, remove all of them — not just the single focused element. Compose
|
|
6583
7954
|
// the removals against the running content (re-projecting each pass) so
|
|
@@ -6589,7 +7960,7 @@ export default function DesignEditor() {
|
|
|
6589
7960
|
!files.some((file) => file.id === layerId),
|
|
6590
7961
|
);
|
|
6591
7962
|
if (candidateIds.length > 1) {
|
|
6592
|
-
let content =
|
|
7963
|
+
let content = baseContent;
|
|
6593
7964
|
const removedSelectors: string[] = [];
|
|
6594
7965
|
for (const layerId of candidateIds) {
|
|
6595
7966
|
const projection = buildCodeLayerProjection(content);
|
|
@@ -6603,7 +7974,7 @@ export default function DesignEditor() {
|
|
|
6603
7974
|
if (selector) removedSelectors.push(selector);
|
|
6604
7975
|
content = next;
|
|
6605
7976
|
}
|
|
6606
|
-
if (content !==
|
|
7977
|
+
if (content !== baseContent) {
|
|
6607
7978
|
removedSelectors.forEach((selector) => deleteRuntimeElement(selector));
|
|
6608
7979
|
applyLocalContentUpdate(content, { refreshPreview: false });
|
|
6609
7980
|
setSelectedElement(null);
|
|
@@ -6614,48 +7985,452 @@ export default function DesignEditor() {
|
|
|
6614
7985
|
}
|
|
6615
7986
|
|
|
6616
7987
|
if (!selectedElement?.selector) return;
|
|
6617
|
-
const projection = buildCodeLayerProjection(
|
|
7988
|
+
const projection = buildCodeLayerProjection(baseContent);
|
|
6618
7989
|
const targetNode = resolveCodeLayerNodeFromElementInfo(
|
|
6619
7990
|
projection,
|
|
6620
7991
|
selectedElement,
|
|
6621
7992
|
);
|
|
6622
7993
|
const nextContent =
|
|
6623
7994
|
(targetNode
|
|
6624
|
-
? removeCodeLayerNodeFromHtml(
|
|
6625
|
-
: null) ??
|
|
6626
|
-
removeElementFromHtml(activeContent, selectedElement.selector);
|
|
7995
|
+
? removeCodeLayerNodeFromHtml(baseContent, targetNode)
|
|
7996
|
+
: null) ?? removeElementFromHtml(baseContent, selectedElement.selector);
|
|
6627
7997
|
if (!nextContent) return;
|
|
6628
7998
|
deleteRuntimeElement(selectedElement.selector);
|
|
6629
7999
|
applyLocalContentUpdate(nextContent, { refreshPreview: false });
|
|
6630
8000
|
setSelectedElement(null);
|
|
6631
8001
|
setSelectedLayerIdsState([]);
|
|
6632
8002
|
}, [
|
|
6633
|
-
activeContent,
|
|
6634
8003
|
applyLocalContentUpdate,
|
|
6635
8004
|
canEditDesign,
|
|
6636
8005
|
deleteRuntimeElement,
|
|
6637
8006
|
files,
|
|
8007
|
+
getFreshActiveContent,
|
|
6638
8008
|
selectedElement,
|
|
6639
8009
|
selectedLayerIdsState,
|
|
6640
8010
|
]);
|
|
6641
8011
|
|
|
6642
|
-
|
|
6643
|
-
|
|
6644
|
-
|
|
6645
|
-
|
|
6646
|
-
//
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
6654
|
-
|
|
8012
|
+
// Wrap the current multi-layer selection into a new group container.
|
|
8013
|
+
const handleGroupSelection = useCallback(() => {
|
|
8014
|
+
if (!canEditDesign || !activeFile) return;
|
|
8015
|
+
const baseContent = getFreshActiveContent();
|
|
8016
|
+
// Collect the DOM-node layer ids that belong to the active screen.
|
|
8017
|
+
// Build a set of ids present in the active content so stale ids from
|
|
8018
|
+
// other files (which can persist in selectedLayerIdsState after a
|
|
8019
|
+
// cross-screen layers-panel selection) are excluded before wrapNodes
|
|
8020
|
+
// runs against activeContent. Without this filter, cross-file ids
|
|
8021
|
+
// cause wrapNodes to return "conflict" even for a valid same-file
|
|
8022
|
+
// selection.
|
|
8023
|
+
const fileIds = new Set(files.map((f) => f.id));
|
|
8024
|
+
const activeNodeIdSet = buildActiveFileNodeIdSet(
|
|
8025
|
+
buildCodeLayerProjection(baseContent),
|
|
8026
|
+
);
|
|
8027
|
+
const nodeIds = selectedLayerIdsState.filter(
|
|
8028
|
+
(id) =>
|
|
8029
|
+
!id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
|
|
8030
|
+
);
|
|
8031
|
+
if (nodeIds.length < 2) return;
|
|
8032
|
+
const patch = applyVisualEdit(baseContent, {
|
|
8033
|
+
kind: "wrapNodes",
|
|
8034
|
+
targetIds: nodeIds,
|
|
8035
|
+
autoLayout: false,
|
|
8036
|
+
});
|
|
8037
|
+
if (patch.result.status !== "applied") {
|
|
8038
|
+
toast.error(
|
|
8039
|
+
codeLayerPatchMessage(
|
|
8040
|
+
patch.result.message,
|
|
8041
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8042
|
+
),
|
|
8043
|
+
{ duration: 4000 },
|
|
8044
|
+
);
|
|
8045
|
+
return;
|
|
8046
|
+
}
|
|
8047
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
8048
|
+
// Select the new wrapper node if the substrate reported its id.
|
|
8049
|
+
const wrapperId = patch.result.wrapperNodeId;
|
|
8050
|
+
if (wrapperId) {
|
|
8051
|
+
// Find the projection node whose data-agent-native-node-id matches.
|
|
8052
|
+
const wrapperNode = patch.projection.nodes.find(
|
|
8053
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === wrapperId,
|
|
8054
|
+
);
|
|
8055
|
+
if (wrapperNode) {
|
|
8056
|
+
setSelectedLayerIdsState([wrapperNode.id]);
|
|
8057
|
+
setSelectedElement(elementInfoFromCodeLayerNode(wrapperNode));
|
|
8058
|
+
}
|
|
8059
|
+
}
|
|
8060
|
+
}, [
|
|
8061
|
+
activeFile,
|
|
8062
|
+
applyLocalContentUpdate,
|
|
8063
|
+
canEditDesign,
|
|
8064
|
+
files,
|
|
8065
|
+
getFreshActiveContent,
|
|
8066
|
+
selectedLayerIdsState,
|
|
8067
|
+
t,
|
|
8068
|
+
]);
|
|
6655
8069
|
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
8070
|
+
// Unwrap the currently selected single-container layer.
|
|
8071
|
+
const handleUngroupSelection = useCallback(() => {
|
|
8072
|
+
if (!canEditDesign || !activeFile) return;
|
|
8073
|
+
const baseContent = getFreshActiveContent();
|
|
8074
|
+
// Filter to active-file nodes only (mirrors handleGroupSelection fix).
|
|
8075
|
+
// A stale id from another file must not be passed to unwrap or it will
|
|
8076
|
+
// fail with "conflict" even though the actual selection is valid.
|
|
8077
|
+
const fileIds = new Set(files.map((f) => f.id));
|
|
8078
|
+
const activeNodeIdSet = buildActiveFileNodeIdSet(
|
|
8079
|
+
buildCodeLayerProjection(baseContent),
|
|
8080
|
+
);
|
|
8081
|
+
const nodeIds = selectedLayerIdsState.filter(
|
|
8082
|
+
(id) =>
|
|
8083
|
+
!id.startsWith("__") && !fileIds.has(id) && activeNodeIdSet.has(id),
|
|
8084
|
+
);
|
|
8085
|
+
const targetId = nodeIds[0];
|
|
8086
|
+
if (!targetId) return;
|
|
8087
|
+
const patch = applyVisualEdit(baseContent, {
|
|
8088
|
+
kind: "unwrap",
|
|
8089
|
+
targetId,
|
|
8090
|
+
});
|
|
8091
|
+
if (patch.result.status !== "applied") {
|
|
8092
|
+
toast.error(
|
|
8093
|
+
codeLayerPatchMessage(
|
|
8094
|
+
patch.result.message,
|
|
8095
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8096
|
+
),
|
|
8097
|
+
{ duration: 4000 },
|
|
8098
|
+
);
|
|
8099
|
+
return;
|
|
8100
|
+
}
|
|
8101
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
8102
|
+
setSelectedElement(null);
|
|
8103
|
+
setSelectedLayerIdsState([]);
|
|
8104
|
+
}, [
|
|
8105
|
+
activeFile,
|
|
8106
|
+
applyLocalContentUpdate,
|
|
8107
|
+
canEditDesign,
|
|
8108
|
+
files,
|
|
8109
|
+
getFreshActiveContent,
|
|
8110
|
+
selectedLayerIdsState,
|
|
8111
|
+
t,
|
|
8112
|
+
]);
|
|
8113
|
+
|
|
8114
|
+
/**
|
|
8115
|
+
* Convert the selected container to full auto-layout. Applies the
|
|
8116
|
+
* { kind: "autoLayout", enabled: true } substrate intent which sets
|
|
8117
|
+
* display:flex on the target AND strips position:absolute/left/top/right/bottom
|
|
8118
|
+
* from its direct children so they become flow children.
|
|
8119
|
+
*/
|
|
8120
|
+
const handleAutoLayoutConvert = useCallback(
|
|
8121
|
+
(
|
|
8122
|
+
targetNodeId: string,
|
|
8123
|
+
opts?: { direction?: "row" | "column"; gap?: string },
|
|
8124
|
+
) => {
|
|
8125
|
+
if (!canEditDesign || !activeFile) return;
|
|
8126
|
+
const baseContent = getFreshActiveContent();
|
|
8127
|
+
const patch = applyVisualEdit(baseContent, {
|
|
8128
|
+
kind: "autoLayout",
|
|
8129
|
+
targetId: targetNodeId,
|
|
8130
|
+
enabled: true,
|
|
8131
|
+
direction: opts?.direction ?? "row",
|
|
8132
|
+
gap: opts?.gap ?? "8px",
|
|
8133
|
+
});
|
|
8134
|
+
if (patch.result.status !== "applied") {
|
|
8135
|
+
toast.error(
|
|
8136
|
+
codeLayerPatchMessage(
|
|
8137
|
+
patch.result.message,
|
|
8138
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8139
|
+
),
|
|
8140
|
+
{ duration: 4000 },
|
|
8141
|
+
);
|
|
8142
|
+
return;
|
|
8143
|
+
}
|
|
8144
|
+
applyLocalContentUpdate(patch.content, { skipPreview: true });
|
|
8145
|
+
// Re-select the container so the inspector refreshes its layout state.
|
|
8146
|
+
const containerNode = patch.projection.nodes.find(
|
|
8147
|
+
(n) =>
|
|
8148
|
+
n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
|
|
8149
|
+
n.id === targetNodeId,
|
|
8150
|
+
);
|
|
8151
|
+
if (containerNode) {
|
|
8152
|
+
setSelectedLayerIdsState([containerNode.id]);
|
|
8153
|
+
setSelectedElement(elementInfoFromCodeLayerNode(containerNode));
|
|
8154
|
+
}
|
|
8155
|
+
},
|
|
8156
|
+
[
|
|
8157
|
+
activeFile,
|
|
8158
|
+
applyLocalContentUpdate,
|
|
8159
|
+
canEditDesign,
|
|
8160
|
+
getFreshActiveContent,
|
|
8161
|
+
t,
|
|
8162
|
+
],
|
|
8163
|
+
);
|
|
8164
|
+
|
|
8165
|
+
/**
|
|
8166
|
+
* Handle a primitive being drag-dropped onto another primitive in the
|
|
8167
|
+
* MultiScreenCanvas overview (CONTRACT: onPrimitiveReparent prop).
|
|
8168
|
+
*
|
|
8169
|
+
* Same-screen: applies a moveNode intent then strips the moved node's
|
|
8170
|
+
* absolute positioning so it becomes a flow child of the container.
|
|
8171
|
+
* Cross-screen: uses moveNodeBetweenDocuments and persists both files.
|
|
8172
|
+
*/
|
|
8173
|
+
const handleOverviewPrimitiveReparent = useCallback(
|
|
8174
|
+
({
|
|
8175
|
+
sourceNodeId,
|
|
8176
|
+
sourceScreenId,
|
|
8177
|
+
targetNodeId,
|
|
8178
|
+
targetScreenId,
|
|
8179
|
+
}: {
|
|
8180
|
+
sourceNodeId: string;
|
|
8181
|
+
sourceScreenId: string;
|
|
8182
|
+
targetNodeId: string;
|
|
8183
|
+
targetScreenId: string;
|
|
8184
|
+
placement: "inside";
|
|
8185
|
+
}) => {
|
|
8186
|
+
if (!canEditDesign) return;
|
|
8187
|
+
|
|
8188
|
+
if (sourceScreenId === targetScreenId) {
|
|
8189
|
+
// --- Same-screen reparent ---
|
|
8190
|
+
const baseContent = getScreenContent(sourceScreenId);
|
|
8191
|
+
if (!baseContent) return;
|
|
8192
|
+
|
|
8193
|
+
// 1. Move the node inside the target container.
|
|
8194
|
+
const movePatch = applyVisualEdit(baseContent, {
|
|
8195
|
+
kind: "moveNode",
|
|
8196
|
+
target: { nodeId: sourceNodeId },
|
|
8197
|
+
anchor: { nodeId: targetNodeId },
|
|
8198
|
+
placement: "inside",
|
|
8199
|
+
});
|
|
8200
|
+
if (movePatch.result.status !== "applied") {
|
|
8201
|
+
toast.error(
|
|
8202
|
+
codeLayerPatchMessage(
|
|
8203
|
+
movePatch.result.message,
|
|
8204
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8205
|
+
),
|
|
8206
|
+
{ duration: 4000 },
|
|
8207
|
+
);
|
|
8208
|
+
return;
|
|
8209
|
+
}
|
|
8210
|
+
|
|
8211
|
+
// 2. Strip absolute positioning from the moved node so it flows naturally.
|
|
8212
|
+
// Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because the
|
|
8213
|
+
// applyVisualEdit substrate rejects empty-string values in isSafeStyleValue,
|
|
8214
|
+
// making applyVisualEdit({kind:"style",value:""}) a silent no-op.
|
|
8215
|
+
const movedNodeAttrId =
|
|
8216
|
+
movePatch.projection.nodes.find(
|
|
8217
|
+
(n) =>
|
|
8218
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
8219
|
+
n.id === sourceNodeId,
|
|
8220
|
+
)?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
|
|
8221
|
+
const strippedContent = removeAbsolutePositioningFromNodeInHtml(
|
|
8222
|
+
movePatch.content,
|
|
8223
|
+
movedNodeAttrId,
|
|
8224
|
+
);
|
|
8225
|
+
|
|
8226
|
+
applyFileContentUpdate(sourceScreenId, strippedContent, {
|
|
8227
|
+
skipPreview: true,
|
|
8228
|
+
});
|
|
8229
|
+
|
|
8230
|
+
// Re-select the moved node.
|
|
8231
|
+
const nextProjection = buildCodeLayerProjection(strippedContent);
|
|
8232
|
+
const movedNodeAfter = nextProjection.nodes.find(
|
|
8233
|
+
(n) =>
|
|
8234
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
8235
|
+
n.id === sourceNodeId,
|
|
8236
|
+
);
|
|
8237
|
+
if (movedNodeAfter) {
|
|
8238
|
+
setSelectedLayerIdsState([movedNodeAfter.id]);
|
|
8239
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeAfter));
|
|
8240
|
+
}
|
|
8241
|
+
return;
|
|
8242
|
+
}
|
|
8243
|
+
|
|
8244
|
+
// --- Cross-screen reparent ---
|
|
8245
|
+
const sourceContent = getScreenContent(sourceScreenId);
|
|
8246
|
+
const destContent = getScreenContent(targetScreenId);
|
|
8247
|
+
if (!sourceContent || !destContent) return;
|
|
8248
|
+
|
|
8249
|
+
// Resolve data-agent-native-node-id attributes for moveNodeBetweenDocuments.
|
|
8250
|
+
const sourceProjection = buildCodeLayerProjection(sourceContent);
|
|
8251
|
+
const destProjection = buildCodeLayerProjection(destContent);
|
|
8252
|
+
const sourceNode = sourceProjection.nodes.find(
|
|
8253
|
+
(n) =>
|
|
8254
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
8255
|
+
n.id === sourceNodeId,
|
|
8256
|
+
);
|
|
8257
|
+
const anchorNode = destProjection.nodes.find(
|
|
8258
|
+
(n) =>
|
|
8259
|
+
n.dataAttributes["data-agent-native-node-id"] === targetNodeId ||
|
|
8260
|
+
n.id === targetNodeId,
|
|
8261
|
+
);
|
|
8262
|
+
const nodeAttrId =
|
|
8263
|
+
sourceNode?.dataAttributes["data-agent-native-node-id"] ?? sourceNodeId;
|
|
8264
|
+
const anchorAttrId =
|
|
8265
|
+
anchorNode?.dataAttributes["data-agent-native-node-id"] ?? targetNodeId;
|
|
8266
|
+
|
|
8267
|
+
const result = moveNodeBetweenDocuments(sourceContent, destContent, {
|
|
8268
|
+
nodeId: nodeAttrId,
|
|
8269
|
+
anchorNodeId: anchorAttrId,
|
|
8270
|
+
placement: "inside",
|
|
8271
|
+
});
|
|
8272
|
+
if (result.status !== "applied") {
|
|
8273
|
+
toast.error(
|
|
8274
|
+
codeLayerPatchMessage(
|
|
8275
|
+
result.message,
|
|
8276
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8277
|
+
),
|
|
8278
|
+
{ duration: 4000 },
|
|
8279
|
+
);
|
|
8280
|
+
return;
|
|
8281
|
+
}
|
|
8282
|
+
|
|
8283
|
+
// Strip absolute positioning from the moved node in the destination.
|
|
8284
|
+
// Use removeAbsolutePositioningFromNodeInHtml (DOM-based) because
|
|
8285
|
+
// applyVisualEdit({kind:"style",value:""}) is a silent no-op (the
|
|
8286
|
+
// substrate rejects empty-string values in isSafeStyleValue). Use
|
|
8287
|
+
// result.movedNodeId (the final id in destHtml, which may differ from
|
|
8288
|
+
// nodeAttrId when a collision triggered an id re-stamp) so the strip and
|
|
8289
|
+
// re-selection always find the correct element.
|
|
8290
|
+
const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
|
|
8291
|
+
const strippedDest = removeAbsolutePositioningFromNodeInHtml(
|
|
8292
|
+
result.destHtml,
|
|
8293
|
+
destNodeAttrId,
|
|
8294
|
+
);
|
|
8295
|
+
|
|
8296
|
+
applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
|
|
8297
|
+
refreshPreview: true,
|
|
8298
|
+
});
|
|
8299
|
+
applyFileContentUpdate(targetScreenId, strippedDest, {
|
|
8300
|
+
refreshPreview: true,
|
|
8301
|
+
});
|
|
8302
|
+
|
|
8303
|
+
// Re-select the moved node in the destination.
|
|
8304
|
+
const finalProjection = buildCodeLayerProjection(strippedDest);
|
|
8305
|
+
const movedNodeFinal = finalProjection.nodes.find(
|
|
8306
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
|
|
8307
|
+
);
|
|
8308
|
+
if (movedNodeFinal) {
|
|
8309
|
+
setSelectedLayerIdsState([movedNodeFinal.id]);
|
|
8310
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
|
|
8311
|
+
}
|
|
8312
|
+
},
|
|
8313
|
+
[applyFileContentUpdate, canEditDesign, getScreenContent, t],
|
|
8314
|
+
);
|
|
8315
|
+
|
|
8316
|
+
/**
|
|
8317
|
+
* Cross-screen element drag-drop handler (CONTRACT: onCrossScreenElementDrop
|
|
8318
|
+
* prop on MultiScreenCanvas).
|
|
8319
|
+
*
|
|
8320
|
+
* The bridge in the source screen's iframe posts phase:"end" with the
|
|
8321
|
+
* selector / sourceNodeId of the dragged element. MultiScreenCanvas maps
|
|
8322
|
+
* the board point to a target screen and calls this handler. We resolve
|
|
8323
|
+
* both screens' content, identify the node by its data-agent-native-node-id
|
|
8324
|
+
* (falling back to a projection lookup by selector when only the selector is
|
|
8325
|
+
* available), call moveNodeBetweenDocuments to move it into the target
|
|
8326
|
+
* screen's <body>, persist both files, switch the active screen to the
|
|
8327
|
+
* target, and select the moved node — keeping viewMode "overview" throughout.
|
|
8328
|
+
*/
|
|
8329
|
+
const handleCrossScreenElementDrop = useCallback(
|
|
8330
|
+
({
|
|
8331
|
+
sourceSelector,
|
|
8332
|
+
sourceNodeId,
|
|
8333
|
+
sourceScreenId,
|
|
8334
|
+
targetScreenId,
|
|
8335
|
+
}: {
|
|
8336
|
+
sourceSelector: string;
|
|
8337
|
+
sourceNodeId?: string;
|
|
8338
|
+
sourceScreenId: string;
|
|
8339
|
+
targetScreenId: string;
|
|
8340
|
+
}) => {
|
|
8341
|
+
if (!canEditDesign) return;
|
|
8342
|
+
if (sourceScreenId === targetScreenId) return;
|
|
8343
|
+
|
|
8344
|
+
const sourceContent = getScreenContent(sourceScreenId);
|
|
8345
|
+
const destContent = getScreenContent(targetScreenId);
|
|
8346
|
+
if (!sourceContent || !destContent) return;
|
|
8347
|
+
|
|
8348
|
+
// Resolve the data-agent-native-node-id that moveNodeBetweenDocuments
|
|
8349
|
+
// uses as a stable key. Prefer the bridge-supplied sourceNodeId when it
|
|
8350
|
+
// looks like a node-attr id; otherwise look up via selector projection.
|
|
8351
|
+
const sourceProjection = buildCodeLayerProjection(sourceContent);
|
|
8352
|
+
const resolvedSourceNode = sourceNodeId
|
|
8353
|
+
? (sourceProjection.nodes.find(
|
|
8354
|
+
(n) =>
|
|
8355
|
+
n.dataAttributes["data-agent-native-node-id"] === sourceNodeId ||
|
|
8356
|
+
n.id === sourceNodeId,
|
|
8357
|
+
) ??
|
|
8358
|
+
resolveCodeLayerNodeFromBridge(
|
|
8359
|
+
sourceProjection,
|
|
8360
|
+
sourceSelector,
|
|
8361
|
+
sourceNodeId,
|
|
8362
|
+
))
|
|
8363
|
+
: resolveCodeLayerNodeFromBridge(sourceProjection, sourceSelector);
|
|
8364
|
+
const nodeAttrId =
|
|
8365
|
+
resolvedSourceNode?.dataAttributes["data-agent-native-node-id"] ??
|
|
8366
|
+
sourceNodeId ??
|
|
8367
|
+
sourceSelector;
|
|
8368
|
+
|
|
8369
|
+
const result = moveNodeBetweenDocuments(sourceContent, destContent, {
|
|
8370
|
+
nodeId: nodeAttrId,
|
|
8371
|
+
placement: "inside",
|
|
8372
|
+
});
|
|
8373
|
+
if (result.status !== "applied") {
|
|
8374
|
+
toast.error(
|
|
8375
|
+
codeLayerPatchMessage(
|
|
8376
|
+
result.message,
|
|
8377
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
8378
|
+
),
|
|
8379
|
+
{ duration: 4000 },
|
|
8380
|
+
);
|
|
8381
|
+
return;
|
|
8382
|
+
}
|
|
8383
|
+
|
|
8384
|
+
// Strip absolute positioning from the moved node in the destination so
|
|
8385
|
+
// it flows naturally as a body child (mirrors handleOverviewPrimitiveReparent).
|
|
8386
|
+
// Use result.movedNodeId (the final id in destHtml, which may differ from
|
|
8387
|
+
// nodeAttrId when a collision triggered an id re-stamp) so the strip and
|
|
8388
|
+
// re-selection always find the correct element.
|
|
8389
|
+
const destNodeAttrId = result.movedNodeId ?? nodeAttrId;
|
|
8390
|
+
const strippedDest = removeAbsolutePositioningFromNodeInHtml(
|
|
8391
|
+
result.destHtml,
|
|
8392
|
+
destNodeAttrId,
|
|
8393
|
+
);
|
|
8394
|
+
|
|
8395
|
+
applyFileContentUpdate(sourceScreenId, result.sourceHtml, {
|
|
8396
|
+
refreshPreview: true,
|
|
8397
|
+
});
|
|
8398
|
+
applyFileContentUpdate(targetScreenId, strippedDest, {
|
|
8399
|
+
refreshPreview: true,
|
|
8400
|
+
});
|
|
8401
|
+
|
|
8402
|
+
// Switch active screen to the target and select the moved node; viewMode
|
|
8403
|
+
// stays "overview" (no setViewMode call).
|
|
8404
|
+
setActiveFileId(targetScreenId);
|
|
8405
|
+
const finalProjection = buildCodeLayerProjection(strippedDest);
|
|
8406
|
+
const movedNodeFinal = finalProjection.nodes.find(
|
|
8407
|
+
(n) => n.dataAttributes["data-agent-native-node-id"] === destNodeAttrId,
|
|
8408
|
+
);
|
|
8409
|
+
if (movedNodeFinal) {
|
|
8410
|
+
setSelectedLayerIdsState([movedNodeFinal.id]);
|
|
8411
|
+
setSelectedElement(elementInfoFromCodeLayerNode(movedNodeFinal));
|
|
8412
|
+
}
|
|
8413
|
+
},
|
|
8414
|
+
[applyFileContentUpdate, canEditDesign, getScreenContent, t],
|
|
8415
|
+
);
|
|
8416
|
+
|
|
8417
|
+
const handleCutSelection = useCallback(async () => {
|
|
8418
|
+
if (!selectedElement?.selector) return;
|
|
8419
|
+
// Copy first (populates the internal clipboard ref even if the async
|
|
8420
|
+
// navigator.clipboard write is blocked — handleCopySelection swallows that
|
|
8421
|
+
// error) then remove the element so a subsequent paste can re-insert it.
|
|
8422
|
+
await handleCopySelection();
|
|
8423
|
+
handleDeleteSelection();
|
|
8424
|
+
}, [handleCopySelection, handleDeleteSelection, selectedElement]);
|
|
8425
|
+
|
|
8426
|
+
const handleDeleteOverviewSelection = useCallback(
|
|
8427
|
+
(selectedIds: string[]) => {
|
|
8428
|
+
if (!canEditDesign) return false;
|
|
8429
|
+
if (!selectedIds.length || files.length <= 1) return false;
|
|
8430
|
+
|
|
8431
|
+
const selectedIdSet = new Set(selectedIds);
|
|
8432
|
+
const selectedFiles = files.filter((file) => selectedIdSet.has(file.id));
|
|
8433
|
+
if (!selectedFiles.length) return false;
|
|
6659
8434
|
|
|
6660
8435
|
const maxDeleteCount =
|
|
6661
8436
|
selectedFiles.length >= files.length
|
|
@@ -6737,8 +8512,7 @@ export default function DesignEditor() {
|
|
|
6737
8512
|
textAlign: selectedElement.computedStyles.textAlign,
|
|
6738
8513
|
};
|
|
6739
8514
|
setHasPropsClipboard(true);
|
|
6740
|
-
|
|
6741
|
-
}, [selectedElement, t]);
|
|
8515
|
+
}, [selectedElement]);
|
|
6742
8516
|
|
|
6743
8517
|
const handlePasteProps = useCallback(() => {
|
|
6744
8518
|
if (!canEditDesign) return;
|
|
@@ -6749,8 +8523,7 @@ export default function DesignEditor() {
|
|
|
6749
8523
|
),
|
|
6750
8524
|
);
|
|
6751
8525
|
commitVisualStyles(selectedElement.selector, styles);
|
|
6752
|
-
|
|
6753
|
-
}, [canEditDesign, commitVisualStyles, selectedElement, t]);
|
|
8526
|
+
}, [canEditDesign, commitVisualStyles, selectedElement]);
|
|
6754
8527
|
|
|
6755
8528
|
const changeSelectedZIndex = useCallback(
|
|
6756
8529
|
(mode: "forward" | "front" | "backward" | "back") => {
|
|
@@ -6810,31 +8583,67 @@ export default function DesignEditor() {
|
|
|
6810
8583
|
if (!canEditDesign) return;
|
|
6811
8584
|
const um = undoManagerRef.current;
|
|
6812
8585
|
const undoContent = () => {
|
|
6813
|
-
if (
|
|
6814
|
-
|
|
6815
|
-
|
|
6816
|
-
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
6822
|
-
|
|
8586
|
+
if (um?.canUndo()) {
|
|
8587
|
+
um.undo();
|
|
8588
|
+
if (ydoc && activeFile) {
|
|
8589
|
+
const next = ydoc.getText("content").toString();
|
|
8590
|
+
markPendingLocalFileContent(
|
|
8591
|
+
activeFile.id,
|
|
8592
|
+
next,
|
|
8593
|
+
activeFile.updatedAt,
|
|
8594
|
+
);
|
|
8595
|
+
lastLocalContentRef.current = next;
|
|
8596
|
+
queueFileContentSave(activeFile.id, next, {
|
|
8597
|
+
syncCollab: !(ydoc && isSynced),
|
|
8598
|
+
});
|
|
8599
|
+
if (!replacePreviewContent(next)) {
|
|
8600
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
8601
|
+
}
|
|
8602
|
+
// Clear stale selection if the undo removed the selected element.
|
|
8603
|
+
setSelectedElement((prev) => {
|
|
8604
|
+
if (!prev) return prev;
|
|
8605
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8606
|
+
});
|
|
8607
|
+
setHoveredElement((prev) => {
|
|
8608
|
+
if (!prev) return prev;
|
|
8609
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8610
|
+
});
|
|
6823
8611
|
}
|
|
6824
|
-
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
setHoveredElement((prev) => {
|
|
6830
|
-
if (!prev) return prev;
|
|
6831
|
-
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
6832
|
-
});
|
|
8612
|
+
redoOrderRef.current = [
|
|
8613
|
+
...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8614
|
+
"content",
|
|
8615
|
+
];
|
|
8616
|
+
return true;
|
|
6833
8617
|
}
|
|
8618
|
+
|
|
8619
|
+
if (!activeFile) return false;
|
|
8620
|
+
const entry = contentUndoStackRef.current.pop();
|
|
8621
|
+
if (!entry || entry.fileId !== activeFile.id) return false;
|
|
8622
|
+
contentRedoStackRef.current = [
|
|
8623
|
+
...contentRedoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8624
|
+
entry,
|
|
8625
|
+
];
|
|
6834
8626
|
redoOrderRef.current = [
|
|
6835
8627
|
...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
6836
8628
|
"content",
|
|
6837
8629
|
];
|
|
8630
|
+
suppressContentHistoryRef.current = true;
|
|
8631
|
+
try {
|
|
8632
|
+
applyLocalContentUpdate(entry.before, {
|
|
8633
|
+
refreshPreview: false,
|
|
8634
|
+
immediateSave: true,
|
|
8635
|
+
});
|
|
8636
|
+
} finally {
|
|
8637
|
+
suppressContentHistoryRef.current = false;
|
|
8638
|
+
}
|
|
8639
|
+
setSelectedElement((prev) => {
|
|
8640
|
+
if (!prev) return prev;
|
|
8641
|
+
return elementInfoExistsInContent(entry.before, prev) ? prev : null;
|
|
8642
|
+
});
|
|
8643
|
+
setHoveredElement((prev) => {
|
|
8644
|
+
if (!prev) return prev;
|
|
8645
|
+
return elementInfoExistsInContent(entry.before, prev) ? prev : null;
|
|
8646
|
+
});
|
|
6838
8647
|
return true;
|
|
6839
8648
|
};
|
|
6840
8649
|
const undoGeometry = () => {
|
|
@@ -6870,8 +8679,10 @@ export default function DesignEditor() {
|
|
|
6870
8679
|
}, [
|
|
6871
8680
|
ydoc,
|
|
6872
8681
|
activeFile,
|
|
8682
|
+
applyLocalContentUpdate,
|
|
6873
8683
|
canEditDesign,
|
|
6874
8684
|
isSynced,
|
|
8685
|
+
markPendingLocalFileContent,
|
|
6875
8686
|
queueFileContentSave,
|
|
6876
8687
|
replacePreviewContent,
|
|
6877
8688
|
syncUndoRedoState,
|
|
@@ -6882,31 +8693,67 @@ export default function DesignEditor() {
|
|
|
6882
8693
|
if (!canEditDesign) return;
|
|
6883
8694
|
const um = undoManagerRef.current;
|
|
6884
8695
|
const redoContent = () => {
|
|
6885
|
-
if (
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
8696
|
+
if (um?.canRedo()) {
|
|
8697
|
+
um.redo();
|
|
8698
|
+
if (ydoc && activeFile) {
|
|
8699
|
+
const next = ydoc.getText("content").toString();
|
|
8700
|
+
markPendingLocalFileContent(
|
|
8701
|
+
activeFile.id,
|
|
8702
|
+
next,
|
|
8703
|
+
activeFile.updatedAt,
|
|
8704
|
+
);
|
|
8705
|
+
lastLocalContentRef.current = next;
|
|
8706
|
+
queueFileContentSave(activeFile.id, next, {
|
|
8707
|
+
syncCollab: !(ydoc && isSynced),
|
|
8708
|
+
});
|
|
8709
|
+
if (!replacePreviewContent(next)) {
|
|
8710
|
+
setContentRenderRevision((revision) => revision + 1);
|
|
8711
|
+
}
|
|
8712
|
+
// Clear stale selection if the redo removed the selected element.
|
|
8713
|
+
setSelectedElement((prev) => {
|
|
8714
|
+
if (!prev) return prev;
|
|
8715
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8716
|
+
});
|
|
8717
|
+
setHoveredElement((prev) => {
|
|
8718
|
+
if (!prev) return prev;
|
|
8719
|
+
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
8720
|
+
});
|
|
6895
8721
|
}
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
setHoveredElement((prev) => {
|
|
6902
|
-
if (!prev) return prev;
|
|
6903
|
-
return elementInfoExistsInContent(next, prev) ? prev : null;
|
|
6904
|
-
});
|
|
8722
|
+
historyOrderRef.current = [
|
|
8723
|
+
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8724
|
+
"content",
|
|
8725
|
+
];
|
|
8726
|
+
return true;
|
|
6905
8727
|
}
|
|
8728
|
+
|
|
8729
|
+
if (!activeFile) return false;
|
|
8730
|
+
const entry = contentRedoStackRef.current.pop();
|
|
8731
|
+
if (!entry || entry.fileId !== activeFile.id) return false;
|
|
8732
|
+
contentUndoStackRef.current = [
|
|
8733
|
+
...contentUndoStackRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
8734
|
+
entry,
|
|
8735
|
+
];
|
|
6906
8736
|
historyOrderRef.current = [
|
|
6907
8737
|
...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)),
|
|
6908
8738
|
"content",
|
|
6909
8739
|
];
|
|
8740
|
+
suppressContentHistoryRef.current = true;
|
|
8741
|
+
try {
|
|
8742
|
+
applyLocalContentUpdate(entry.after, {
|
|
8743
|
+
refreshPreview: false,
|
|
8744
|
+
immediateSave: true,
|
|
8745
|
+
});
|
|
8746
|
+
} finally {
|
|
8747
|
+
suppressContentHistoryRef.current = false;
|
|
8748
|
+
}
|
|
8749
|
+
setSelectedElement((prev) => {
|
|
8750
|
+
if (!prev) return prev;
|
|
8751
|
+
return elementInfoExistsInContent(entry.after, prev) ? prev : null;
|
|
8752
|
+
});
|
|
8753
|
+
setHoveredElement((prev) => {
|
|
8754
|
+
if (!prev) return prev;
|
|
8755
|
+
return elementInfoExistsInContent(entry.after, prev) ? prev : null;
|
|
8756
|
+
});
|
|
6910
8757
|
return true;
|
|
6911
8758
|
};
|
|
6912
8759
|
const redoGeometry = () => {
|
|
@@ -6942,8 +8789,10 @@ export default function DesignEditor() {
|
|
|
6942
8789
|
}, [
|
|
6943
8790
|
ydoc,
|
|
6944
8791
|
activeFile,
|
|
8792
|
+
applyLocalContentUpdate,
|
|
6945
8793
|
canEditDesign,
|
|
6946
8794
|
isSynced,
|
|
8795
|
+
markPendingLocalFileContent,
|
|
6947
8796
|
queueFileContentSave,
|
|
6948
8797
|
replacePreviewContent,
|
|
6949
8798
|
syncUndoRedoState,
|
|
@@ -7382,6 +9231,8 @@ export default function DesignEditor() {
|
|
|
7382
9231
|
setTitleDraft(design?.title ?? "");
|
|
7383
9232
|
setTitleEditing(true);
|
|
7384
9233
|
},
|
|
9234
|
+
onGroup: canEditDesign ? handleGroupSelection : undefined,
|
|
9235
|
+
onUngroup: canEditDesign ? handleUngroupSelection : undefined,
|
|
7385
9236
|
onSelectAll: handleSelectAllFrames,
|
|
7386
9237
|
onUndo: canEditDesign ? handleUndo : undefined,
|
|
7387
9238
|
onRedo: canEditDesign ? handleRedo : undefined,
|
|
@@ -8234,14 +10085,29 @@ ${serializedHtml}
|
|
|
8234
10085
|
)
|
|
8235
10086
|
.map((node) => node.id),
|
|
8236
10087
|
);
|
|
10088
|
+
const allLayerIds = new Set([
|
|
10089
|
+
...fileIds,
|
|
10090
|
+
...allCodeLayerNodes.map((node) => node.id),
|
|
10091
|
+
]);
|
|
8237
10092
|
const reconcile = (
|
|
8238
10093
|
current: Set<string>,
|
|
8239
10094
|
sourceIds: Set<string>,
|
|
10095
|
+
kind: "hidden" | "locked",
|
|
8240
10096
|
): Set<string> => {
|
|
8241
10097
|
const next = new Set(sourceIds);
|
|
8242
10098
|
current.forEach((id) => {
|
|
8243
10099
|
if (fileIds.has(id)) next.add(id);
|
|
8244
10100
|
});
|
|
10101
|
+
layerStateOverridesRef.current.forEach((override, id) => {
|
|
10102
|
+
if (!allLayerIds.has(id)) {
|
|
10103
|
+
layerStateOverridesRef.current.delete(id);
|
|
10104
|
+
return;
|
|
10105
|
+
}
|
|
10106
|
+
const value = override[kind];
|
|
10107
|
+
if (value === undefined) return;
|
|
10108
|
+
if (value) next.add(id);
|
|
10109
|
+
else next.delete(id);
|
|
10110
|
+
});
|
|
8245
10111
|
if (
|
|
8246
10112
|
next.size === current.size &&
|
|
8247
10113
|
Array.from(next).every((id) => current.has(id))
|
|
@@ -8251,8 +10117,12 @@ ${serializedHtml}
|
|
|
8251
10117
|
return next;
|
|
8252
10118
|
};
|
|
8253
10119
|
|
|
8254
|
-
setLockedLayerIds((current) =>
|
|
8255
|
-
|
|
10120
|
+
setLockedLayerIds((current) =>
|
|
10121
|
+
reconcile(current, lockedFromSource, "locked"),
|
|
10122
|
+
);
|
|
10123
|
+
setHiddenLayerIds((current) =>
|
|
10124
|
+
reconcile(current, hiddenFromSource, "hidden"),
|
|
10125
|
+
);
|
|
8256
10126
|
}, [codeLayerModelsByFile, files]);
|
|
8257
10127
|
const lockedLayerSelectors = useMemo(() => {
|
|
8258
10128
|
const selectors = Array.from(lockedLayerIds)
|
|
@@ -8458,6 +10328,59 @@ ${serializedHtml}
|
|
|
8458
10328
|
);
|
|
8459
10329
|
}, [activeContent, activeFile?.id, builderPreviewUrl, overviewScreens]);
|
|
8460
10330
|
|
|
10331
|
+
// §6.4 / §8 — Breakpoints list for the StatesPanel, derived from
|
|
10332
|
+
// designs.data.breakpointSet. Returns a stable empty array when none are set.
|
|
10333
|
+
const statesPanelBreakpoints = useMemo<
|
|
10334
|
+
Array<{ id: string; label: string; widthPx: number }>
|
|
10335
|
+
>(() => {
|
|
10336
|
+
try {
|
|
10337
|
+
const raw = (designDataJson as Record<string, unknown>)?.breakpointSet;
|
|
10338
|
+
if (
|
|
10339
|
+
raw &&
|
|
10340
|
+
typeof raw === "object" &&
|
|
10341
|
+
!Array.isArray(raw) &&
|
|
10342
|
+
Array.isArray((raw as Record<string, unknown>).breakpoints)
|
|
10343
|
+
) {
|
|
10344
|
+
const bps = (
|
|
10345
|
+
raw as {
|
|
10346
|
+
breakpoints: Array<{
|
|
10347
|
+
id: string;
|
|
10348
|
+
widthPx: number;
|
|
10349
|
+
label?: string;
|
|
10350
|
+
}>;
|
|
10351
|
+
}
|
|
10352
|
+
).breakpoints;
|
|
10353
|
+
return bps.map((bp) => ({
|
|
10354
|
+
id: bp.id,
|
|
10355
|
+
widthPx: bp.widthPx,
|
|
10356
|
+
label:
|
|
10357
|
+
bp.label ??
|
|
10358
|
+
(bp.widthPx >= 1024
|
|
10359
|
+
? "Desktop"
|
|
10360
|
+
: bp.widthPx >= 600
|
|
10361
|
+
? "Tablet"
|
|
10362
|
+
: "Mobile"),
|
|
10363
|
+
}));
|
|
10364
|
+
}
|
|
10365
|
+
} catch {
|
|
10366
|
+
// ignore
|
|
10367
|
+
}
|
|
10368
|
+
return [];
|
|
10369
|
+
}, [designDataJson]);
|
|
10370
|
+
|
|
10371
|
+
// Active breakpoint id for the StatesPanel — "auto" when no frame is focused.
|
|
10372
|
+
const statesPanelActiveBreakpointId = useMemo<string>(() => {
|
|
10373
|
+
if (activeBreakpointWidthState == null) return "auto";
|
|
10374
|
+
const match = statesPanelBreakpoints.find(
|
|
10375
|
+
(bp) => bp.widthPx === activeBreakpointWidthState,
|
|
10376
|
+
);
|
|
10377
|
+
if (match) return match.id;
|
|
10378
|
+
const defaultMatch = DEFAULT_STATES_PANEL_BREAKPOINTS.find(
|
|
10379
|
+
(bp) => bp.widthPx === activeBreakpointWidthState,
|
|
10380
|
+
);
|
|
10381
|
+
return defaultMatch?.id ?? "auto";
|
|
10382
|
+
}, [activeBreakpointWidthState, statesPanelBreakpoints]);
|
|
10383
|
+
|
|
8461
10384
|
const handleOpenDesignPreview = useCallback(() => {
|
|
8462
10385
|
if (activeScreenPreviewUrl) {
|
|
8463
10386
|
window.open(activeScreenPreviewUrl, "_blank", "noopener,noreferrer");
|
|
@@ -8479,6 +10402,16 @@ ${serializedHtml}
|
|
|
8479
10402
|
selectedElementLayerId ??
|
|
8480
10403
|
activeFile?.id ??
|
|
8481
10404
|
"";
|
|
10405
|
+
const selectedElementFullViewScreenId =
|
|
10406
|
+
viewMode === "overview" && selectedElement
|
|
10407
|
+
? selectedElementLayerId
|
|
10408
|
+
? (codeLayerOwnerByNodeId.get(selectedElementLayerId)?.fileId ??
|
|
10409
|
+
activeFileId)
|
|
10410
|
+
: activeFileId
|
|
10411
|
+
: null;
|
|
10412
|
+
const fullViewScreenIds = selectedElementFullViewScreenId
|
|
10413
|
+
? [selectedElementFullViewScreenId]
|
|
10414
|
+
: [];
|
|
8482
10415
|
const activeLayerLocked = Boolean(
|
|
8483
10416
|
activeLayerId && effectiveCodeLayerState.lockedIds.has(activeLayerId),
|
|
8484
10417
|
);
|
|
@@ -8486,6 +10419,35 @@ ${serializedHtml}
|
|
|
8486
10419
|
activeLayerId && effectiveCodeLayerState.hiddenIds.has(activeLayerId),
|
|
8487
10420
|
);
|
|
8488
10421
|
|
|
10422
|
+
// Detect if the active screen is a localhost/local source so we can show a banner.
|
|
10423
|
+
const activeScreenIsLocalSource =
|
|
10424
|
+
viewMode === "single" &&
|
|
10425
|
+
Boolean(activeFile) &&
|
|
10426
|
+
activeOverviewScreen?.sourceType === "localhost";
|
|
10427
|
+
const activeScreenRouteSourceFile = activeScreenIsLocalSource
|
|
10428
|
+
? getLocalhostRouteSourceFile({
|
|
10429
|
+
sourceFile: activeOverviewScreen?.sourceFile,
|
|
10430
|
+
source: activeOverviewScreen?.source,
|
|
10431
|
+
})
|
|
10432
|
+
: undefined;
|
|
10433
|
+
|
|
10434
|
+
// canGroup: 2+ DOM-node layers selected in the active screen (not file rows).
|
|
10435
|
+
const fileIdSet = new Set(files.map((f) => f.id));
|
|
10436
|
+
const selectedDomLayerIds = selectedLayerIds.filter(
|
|
10437
|
+
(id) => !id.startsWith("__") && !fileIdSet.has(id),
|
|
10438
|
+
);
|
|
10439
|
+
const canGroup =
|
|
10440
|
+
canEditDesign &&
|
|
10441
|
+
viewMode === "single" &&
|
|
10442
|
+
Boolean(activeFile) &&
|
|
10443
|
+
selectedDomLayerIds.length >= 2;
|
|
10444
|
+
// canUngroup: exactly one DOM-node layer selected.
|
|
10445
|
+
const canUngroup =
|
|
10446
|
+
canEditDesign &&
|
|
10447
|
+
viewMode === "single" &&
|
|
10448
|
+
Boolean(activeFile) &&
|
|
10449
|
+
selectedDomLayerIds.length === 1;
|
|
10450
|
+
|
|
8489
10451
|
const canMoveLayer = useCallback(
|
|
8490
10452
|
(intent: LayersPanelMoveIntent) => {
|
|
8491
10453
|
const targetOwner = codeLayerOwnerByNodeId.get(intent.targetId);
|
|
@@ -8498,17 +10460,24 @@ ${serializedHtml}
|
|
|
8498
10460
|
}
|
|
8499
10461
|
return intent.draggedIds.some((draggedId) => {
|
|
8500
10462
|
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
8501
|
-
|
|
8502
|
-
draggedId
|
|
8503
|
-
|
|
8504
|
-
|
|
8505
|
-
|
|
10463
|
+
if (
|
|
10464
|
+
draggedId === intent.targetId ||
|
|
10465
|
+
!draggedOwner ||
|
|
10466
|
+
effectiveCodeLayerState.lockedIds.has(draggedId) ||
|
|
10467
|
+
effectiveCodeLayerState.hiddenIds.has(draggedId)
|
|
10468
|
+
) {
|
|
10469
|
+
return false;
|
|
10470
|
+
}
|
|
10471
|
+
// Same-file move: also exclude ancestor drags (would orphan the node).
|
|
10472
|
+
if (draggedOwner.fileId === targetOwner.fileId) {
|
|
10473
|
+
return !collectCodeLayerAncestors(
|
|
8506
10474
|
targetOwner.tree,
|
|
8507
10475
|
intent.targetId,
|
|
8508
|
-
).includes(draggedId)
|
|
8509
|
-
|
|
8510
|
-
|
|
8511
|
-
)
|
|
10476
|
+
).includes(draggedId);
|
|
10477
|
+
}
|
|
10478
|
+
// Cross-file move: allowed as long as neither side is locked/hidden
|
|
10479
|
+
// (already checked above). File-row ids are excluded by the owner check.
|
|
10480
|
+
return true;
|
|
8512
10481
|
});
|
|
8513
10482
|
},
|
|
8514
10483
|
[codeLayerOwnerByNodeId, effectiveCodeLayerState],
|
|
@@ -8526,26 +10495,62 @@ ${serializedHtml}
|
|
|
8526
10495
|
) {
|
|
8527
10496
|
return;
|
|
8528
10497
|
}
|
|
8529
|
-
const
|
|
8530
|
-
const
|
|
10498
|
+
const freshActiveContent = getFreshActiveContent();
|
|
10499
|
+
const destFile = files.find((file) => file.id === targetOwner.fileId);
|
|
10500
|
+
const destContent =
|
|
8531
10501
|
targetOwner.fileId === activeFile?.id
|
|
8532
|
-
?
|
|
8533
|
-
: (
|
|
8534
|
-
if (!
|
|
8535
|
-
|
|
8536
|
-
|
|
10502
|
+
? freshActiveContent
|
|
10503
|
+
: (destFile?.content ?? "");
|
|
10504
|
+
if (!destContent) return;
|
|
10505
|
+
|
|
10506
|
+
// Group dragged ids by source file so we can handle same-file and
|
|
10507
|
+
// cross-file moves independently.
|
|
10508
|
+
const sameFileDragIds: string[] = [];
|
|
10509
|
+
const crossFileDrags: Array<{ draggedId: string; sourceFileId: string }> =
|
|
10510
|
+
[];
|
|
10511
|
+
const movedNodeSnapshots = new Map<string, CodeLayerNode>();
|
|
8537
10512
|
for (const draggedId of intent.draggedIds) {
|
|
8538
10513
|
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
8539
10514
|
if (
|
|
8540
10515
|
draggedId === intent.targetId ||
|
|
8541
10516
|
!draggedOwner ||
|
|
8542
|
-
draggedOwner.fileId !== targetOwner.fileId ||
|
|
8543
10517
|
effectiveCodeLayerState.lockedIds.has(draggedId) ||
|
|
8544
10518
|
effectiveCodeLayerState.hiddenIds.has(draggedId)
|
|
8545
10519
|
) {
|
|
8546
10520
|
continue;
|
|
8547
10521
|
}
|
|
8548
|
-
|
|
10522
|
+
movedNodeSnapshots.set(draggedId, draggedOwner.node);
|
|
10523
|
+
if (draggedOwner.fileId === targetOwner.fileId) {
|
|
10524
|
+
sameFileDragIds.push(draggedId);
|
|
10525
|
+
} else {
|
|
10526
|
+
crossFileDrags.push({
|
|
10527
|
+
draggedId,
|
|
10528
|
+
sourceFileId: draggedOwner.fileId,
|
|
10529
|
+
});
|
|
10530
|
+
}
|
|
10531
|
+
}
|
|
10532
|
+
|
|
10533
|
+
const targetTreeForMove =
|
|
10534
|
+
targetOwner.fileId === activeFile?.id
|
|
10535
|
+
? buildCodeLayerTree(buildCodeLayerProjection(freshActiveContent))
|
|
10536
|
+
: targetOwner.tree;
|
|
10537
|
+
const orderedSameFileDragIds = sortCodeLayerIdsByTreeOrder(
|
|
10538
|
+
sameFileDragIds,
|
|
10539
|
+
targetTreeForMove,
|
|
10540
|
+
);
|
|
10541
|
+
const movedIdOrder = [
|
|
10542
|
+
...orderedSameFileDragIds,
|
|
10543
|
+
...crossFileDrags.map((drag) => drag.draggedId),
|
|
10544
|
+
];
|
|
10545
|
+
|
|
10546
|
+
// --- Same-file moves (existing path) ---
|
|
10547
|
+
let nextDestContent = destContent;
|
|
10548
|
+
let moved = false;
|
|
10549
|
+
for (const draggedId of getLayerMoveIterationOrder(
|
|
10550
|
+
orderedSameFileDragIds,
|
|
10551
|
+
intent.placement,
|
|
10552
|
+
)) {
|
|
10553
|
+
const patch = applyVisualEdit(nextDestContent, {
|
|
8549
10554
|
kind: "moveNode",
|
|
8550
10555
|
target: { nodeId: draggedId },
|
|
8551
10556
|
anchor: { nodeId: intent.targetId },
|
|
@@ -8561,23 +10566,119 @@ ${serializedHtml}
|
|
|
8561
10566
|
);
|
|
8562
10567
|
continue;
|
|
8563
10568
|
}
|
|
8564
|
-
|
|
10569
|
+
nextDestContent = patch.content;
|
|
8565
10570
|
moved = true;
|
|
8566
10571
|
}
|
|
8567
|
-
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
10572
|
+
|
|
10573
|
+
// --- Cross-file moves: use moveNodeBetweenDocuments ---
|
|
10574
|
+
// Group by source file so multiple nodes from the same source are
|
|
10575
|
+
// applied sequentially against the running source content.
|
|
10576
|
+
const sourceContentMap = new Map<string, string>();
|
|
10577
|
+
for (const { draggedId, sourceFileId } of crossFileDrags) {
|
|
10578
|
+
const srcFile = files.find((f) => f.id === sourceFileId);
|
|
10579
|
+
if (!srcFile) continue;
|
|
10580
|
+
const currentSourceContent = getLayerMoveSourceContent({
|
|
10581
|
+
sourceFileId,
|
|
10582
|
+
activeFileId: activeFile?.id,
|
|
10583
|
+
activeContent: freshActiveContent,
|
|
10584
|
+
sourceFileContent: srcFile.content,
|
|
10585
|
+
sourceContentMap,
|
|
10586
|
+
});
|
|
10587
|
+
|
|
10588
|
+
// The dragged node's data-agent-native-node-id is the node id tracked
|
|
10589
|
+
// by code-layer. Look up the actual attribute value from the owner.
|
|
10590
|
+
const draggedOwner = codeLayerOwnerByNodeId.get(draggedId);
|
|
10591
|
+
const nodeAttrId =
|
|
10592
|
+
draggedOwner?.node.dataAttributes["data-agent-native-node-id"] ??
|
|
10593
|
+
draggedId;
|
|
10594
|
+
const anchorAttrId =
|
|
10595
|
+
codeLayerOwnerByNodeId.get(intent.targetId)?.node.dataAttributes[
|
|
10596
|
+
"data-agent-native-node-id"
|
|
10597
|
+
] ?? intent.targetId;
|
|
10598
|
+
|
|
10599
|
+
const result = moveNodeBetweenDocuments(
|
|
10600
|
+
currentSourceContent,
|
|
10601
|
+
nextDestContent,
|
|
10602
|
+
{
|
|
10603
|
+
nodeId: nodeAttrId,
|
|
10604
|
+
anchorNodeId: anchorAttrId,
|
|
10605
|
+
placement: intent.placement,
|
|
10606
|
+
},
|
|
10607
|
+
);
|
|
10608
|
+
if (result.status !== "applied") {
|
|
10609
|
+
toast.error(
|
|
10610
|
+
codeLayerPatchMessage(
|
|
10611
|
+
result.message,
|
|
10612
|
+
t("designEditor.toasts.layerMoveFailed"),
|
|
10613
|
+
),
|
|
10614
|
+
{ duration: 4000 },
|
|
10615
|
+
);
|
|
10616
|
+
continue;
|
|
10617
|
+
}
|
|
10618
|
+
sourceContentMap.set(sourceFileId, result.sourceHtml);
|
|
10619
|
+
nextDestContent = result.destHtml;
|
|
10620
|
+
moved = true;
|
|
10621
|
+
}
|
|
10622
|
+
|
|
10623
|
+
if (!moved) return;
|
|
10624
|
+
|
|
10625
|
+
const finalDestProjection =
|
|
10626
|
+
nextDestContent !== destContent
|
|
10627
|
+
? buildCodeLayerProjection(nextDestContent)
|
|
10628
|
+
: null;
|
|
10629
|
+
const finalDestTree = finalDestProjection
|
|
10630
|
+
? buildCodeLayerTree(finalDestProjection)
|
|
10631
|
+
: [];
|
|
10632
|
+
const movedNodesAfterMove = movedIdOrder
|
|
10633
|
+
.map((draggedId) => movedNodeSnapshots.get(draggedId))
|
|
10634
|
+
.map((node) =>
|
|
10635
|
+
node && finalDestProjection
|
|
10636
|
+
? findCodeLayerNodeInProjection(finalDestProjection, node)
|
|
10637
|
+
: null,
|
|
10638
|
+
)
|
|
10639
|
+
.filter((node): node is CodeLayerNode => Boolean(node));
|
|
10640
|
+
|
|
10641
|
+
if (movedNodesAfterMove.length > 0) {
|
|
10642
|
+
setSelectedLayerIdsState(movedNodesAfterMove.map((node) => node.id));
|
|
10643
|
+
const lastMovedNode =
|
|
10644
|
+
movedNodesAfterMove[movedNodesAfterMove.length - 1];
|
|
10645
|
+
if (lastMovedNode && targetOwner.fileId === activeFile?.id) {
|
|
10646
|
+
setSelectedElement(elementInfoFromCodeLayerNode(lastMovedNode));
|
|
10647
|
+
}
|
|
10648
|
+
const movedAncestorIds = movedNodesAfterMove.flatMap((node) =>
|
|
10649
|
+
collectCodeLayerAncestors(finalDestTree, node.id),
|
|
10650
|
+
);
|
|
10651
|
+
setExpandedLayerIds((current) => {
|
|
10652
|
+
const next = new Set(current);
|
|
10653
|
+
next.add(targetOwner.fileId);
|
|
10654
|
+
movedAncestorIds.forEach((ancestorId) => next.add(ancestorId));
|
|
10655
|
+
return next.size === current.length ? current : Array.from(next);
|
|
10656
|
+
});
|
|
10657
|
+
}
|
|
10658
|
+
|
|
10659
|
+
// Persist source files that changed.
|
|
10660
|
+
for (const [sourceFileId, newSourceContent] of sourceContentMap) {
|
|
10661
|
+
applyFileContentUpdate(sourceFileId, newSourceContent, {
|
|
10662
|
+
refreshPreview: false,
|
|
10663
|
+
});
|
|
10664
|
+
}
|
|
10665
|
+
|
|
10666
|
+
// Persist dest file (which may also be the active file).
|
|
10667
|
+
if (nextDestContent !== destContent) {
|
|
10668
|
+
applyFileContentUpdate(targetOwner.fileId, nextDestContent, {
|
|
10669
|
+
refreshPreview: false,
|
|
10670
|
+
});
|
|
10671
|
+
}
|
|
8571
10672
|
},
|
|
8572
10673
|
[
|
|
8573
|
-
activeContent,
|
|
8574
10674
|
activeFile?.id,
|
|
8575
10675
|
applyFileContentUpdate,
|
|
8576
10676
|
canEditDesign,
|
|
8577
10677
|
canMoveLayer,
|
|
8578
10678
|
codeLayerOwnerByNodeId,
|
|
8579
|
-
files,
|
|
8580
10679
|
effectiveCodeLayerState,
|
|
10680
|
+
files,
|
|
10681
|
+
getFreshActiveContent,
|
|
8581
10682
|
t,
|
|
8582
10683
|
],
|
|
8583
10684
|
);
|
|
@@ -8616,6 +10717,21 @@ ${serializedHtml}
|
|
|
8616
10717
|
? currentLayerIds.filter((layerId) => layerId !== intent.id)
|
|
8617
10718
|
: [...currentLayerIds, intent.id];
|
|
8618
10719
|
setSelectedLayerIdsState(additiveLayerIds);
|
|
10720
|
+
if (viewModeRef.current === "overview") {
|
|
10721
|
+
const fileIds = files.map((file) => file.id);
|
|
10722
|
+
const selectedScreenIds = getOverviewScreenIdsFromLayerSelection({
|
|
10723
|
+
fileIds,
|
|
10724
|
+
layerIds: additiveLayerIds,
|
|
10725
|
+
});
|
|
10726
|
+
const toggledScreen =
|
|
10727
|
+
getOverviewScreenIdsFromLayerSelection({
|
|
10728
|
+
fileIds,
|
|
10729
|
+
layerIds: [intent.id],
|
|
10730
|
+
}).length > 0;
|
|
10731
|
+
if (toggledScreen || selectedScreenIds.length > 0) {
|
|
10732
|
+
setOverviewSelectedScreenIds(selectedScreenIds);
|
|
10733
|
+
}
|
|
10734
|
+
}
|
|
8619
10735
|
setSelectedElement(null);
|
|
8620
10736
|
focusDesignInspectorForSelection();
|
|
8621
10737
|
setActiveTool("move");
|
|
@@ -8712,7 +10828,7 @@ ${serializedHtml}
|
|
|
8712
10828
|
const sourceFile = files.find((file) => file.id === owner.fileId);
|
|
8713
10829
|
const sourceContent =
|
|
8714
10830
|
owner.fileId === activeFile?.id
|
|
8715
|
-
?
|
|
10831
|
+
? getFreshActiveContent()
|
|
8716
10832
|
: (sourceFile?.content ?? "");
|
|
8717
10833
|
if (!sourceContent) return;
|
|
8718
10834
|
const nextContent = setCodeLayerAttributeInHtml(
|
|
@@ -8728,12 +10844,12 @@ ${serializedHtml}
|
|
|
8728
10844
|
setSelectedLayerIdsState([layerId]);
|
|
8729
10845
|
},
|
|
8730
10846
|
[
|
|
8731
|
-
activeContent,
|
|
8732
10847
|
activeFile?.id,
|
|
8733
10848
|
applyFileContentUpdate,
|
|
8734
10849
|
canEditDesign,
|
|
8735
10850
|
codeLayerOwnerByNodeId,
|
|
8736
10851
|
files,
|
|
10852
|
+
getFreshActiveContent,
|
|
8737
10853
|
queryClient,
|
|
8738
10854
|
t,
|
|
8739
10855
|
updateFileMutation,
|
|
@@ -8743,6 +10859,10 @@ ${serializedHtml}
|
|
|
8743
10859
|
const handleToggleLayerLocked = useCallback(
|
|
8744
10860
|
(layerId: string, locked: boolean) => {
|
|
8745
10861
|
if (!canEditDesign) return;
|
|
10862
|
+
layerStateOverridesRef.current.set(layerId, {
|
|
10863
|
+
...layerStateOverridesRef.current.get(layerId),
|
|
10864
|
+
locked,
|
|
10865
|
+
});
|
|
8746
10866
|
const applyLockedState = () => {
|
|
8747
10867
|
setLockedLayerIds((current) => {
|
|
8748
10868
|
const next = new Set(current);
|
|
@@ -8757,38 +10877,47 @@ ${serializedHtml}
|
|
|
8757
10877
|
}
|
|
8758
10878
|
const owner = codeLayerOwnerByNodeId.get(layerId);
|
|
8759
10879
|
const node = owner?.node;
|
|
8760
|
-
if (!owner || !node)
|
|
10880
|
+
if (!owner || !node) {
|
|
10881
|
+
applyLockedState();
|
|
10882
|
+
return;
|
|
10883
|
+
}
|
|
8761
10884
|
const sourceFile = files.find((file) => file.id === owner.fileId);
|
|
8762
10885
|
const sourceContent =
|
|
8763
10886
|
owner.fileId === activeFile?.id
|
|
8764
|
-
?
|
|
10887
|
+
? getFreshActiveContent()
|
|
8765
10888
|
: (sourceFile?.content ?? "");
|
|
8766
|
-
if (
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
10889
|
+
if (sourceContent) {
|
|
10890
|
+
const nextContent = setCodeLayerAttributeInHtml(
|
|
10891
|
+
sourceContent,
|
|
10892
|
+
node,
|
|
10893
|
+
"data-agent-native-locked",
|
|
10894
|
+
locked ? "true" : null,
|
|
10895
|
+
);
|
|
10896
|
+
if (nextContent && nextContent !== sourceContent) {
|
|
10897
|
+
applyFileContentUpdate(owner.fileId, nextContent, {
|
|
10898
|
+
refreshPreview: false,
|
|
10899
|
+
});
|
|
10900
|
+
}
|
|
10901
|
+
}
|
|
8777
10902
|
applyLockedState();
|
|
8778
10903
|
},
|
|
8779
10904
|
[
|
|
8780
|
-
activeContent,
|
|
8781
10905
|
activeFile?.id,
|
|
8782
10906
|
applyFileContentUpdate,
|
|
8783
10907
|
canEditDesign,
|
|
8784
10908
|
codeLayerOwnerByNodeId,
|
|
8785
10909
|
files,
|
|
10910
|
+
getFreshActiveContent,
|
|
8786
10911
|
],
|
|
8787
10912
|
);
|
|
8788
10913
|
|
|
8789
10914
|
const handleToggleLayerHidden = useCallback(
|
|
8790
10915
|
(layerId: string, hidden: boolean) => {
|
|
8791
10916
|
if (!canEditDesign) return;
|
|
10917
|
+
layerStateOverridesRef.current.set(layerId, {
|
|
10918
|
+
...layerStateOverridesRef.current.get(layerId),
|
|
10919
|
+
hidden,
|
|
10920
|
+
});
|
|
8792
10921
|
const applyHiddenState = () => {
|
|
8793
10922
|
setHiddenLayerIds((current) => {
|
|
8794
10923
|
const next = new Set(current);
|
|
@@ -8803,32 +10932,37 @@ ${serializedHtml}
|
|
|
8803
10932
|
}
|
|
8804
10933
|
const owner = codeLayerOwnerByNodeId.get(layerId);
|
|
8805
10934
|
const node = owner?.node;
|
|
8806
|
-
if (!owner || !node)
|
|
10935
|
+
if (!owner || !node) {
|
|
10936
|
+
applyHiddenState();
|
|
10937
|
+
return;
|
|
10938
|
+
}
|
|
8807
10939
|
const sourceFile = files.find((file) => file.id === owner.fileId);
|
|
8808
10940
|
const sourceContent =
|
|
8809
10941
|
owner.fileId === activeFile?.id
|
|
8810
|
-
?
|
|
10942
|
+
? getFreshActiveContent()
|
|
8811
10943
|
: (sourceFile?.content ?? "");
|
|
8812
|
-
if (
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
10944
|
+
if (sourceContent) {
|
|
10945
|
+
const nextContent = setCodeLayerAttributeInHtml(
|
|
10946
|
+
sourceContent,
|
|
10947
|
+
node,
|
|
10948
|
+
"data-agent-native-hidden",
|
|
10949
|
+
hidden ? "true" : null,
|
|
10950
|
+
);
|
|
10951
|
+
if (nextContent && nextContent !== sourceContent) {
|
|
10952
|
+
applyFileContentUpdate(owner.fileId, nextContent, {
|
|
10953
|
+
refreshPreview: false,
|
|
10954
|
+
});
|
|
10955
|
+
}
|
|
10956
|
+
}
|
|
8823
10957
|
applyHiddenState();
|
|
8824
10958
|
},
|
|
8825
10959
|
[
|
|
8826
|
-
activeContent,
|
|
8827
10960
|
activeFile?.id,
|
|
8828
10961
|
applyFileContentUpdate,
|
|
8829
10962
|
canEditDesign,
|
|
8830
10963
|
codeLayerOwnerByNodeId,
|
|
8831
10964
|
files,
|
|
10965
|
+
getFreshActiveContent,
|
|
8832
10966
|
],
|
|
8833
10967
|
);
|
|
8834
10968
|
|
|
@@ -9096,6 +11230,19 @@ ${serializedHtml}
|
|
|
9096
11230
|
? t("designEditor.stopPinningComments")
|
|
9097
11231
|
: t("designEditor.pinComment")}
|
|
9098
11232
|
</DropdownMenuItem>
|
|
11233
|
+
{isSignedIn && (
|
|
11234
|
+
<>
|
|
11235
|
+
<DropdownMenuSeparator />
|
|
11236
|
+
<DropdownMenuItem
|
|
11237
|
+
onClick={() => {
|
|
11238
|
+
handleOpenMakeReal();
|
|
11239
|
+
}}
|
|
11240
|
+
>
|
|
11241
|
+
<IconRocket className="mr-2 h-4 w-4" />
|
|
11242
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
11243
|
+
</DropdownMenuItem>
|
|
11244
|
+
</>
|
|
11245
|
+
)}
|
|
9099
11246
|
</DropdownMenuContent>
|
|
9100
11247
|
</DropdownMenu>
|
|
9101
11248
|
);
|
|
@@ -9254,6 +11401,28 @@ ${serializedHtml}
|
|
|
9254
11401
|
<TooltipContent>{t("designEditor.designPreview")}</TooltipContent>
|
|
9255
11402
|
</Tooltip>
|
|
9256
11403
|
|
|
11404
|
+
{/* §6.6 — "Make this a real app" shortcut button (signed-in only).
|
|
11405
|
+
Surfaces the migration CTA without requiring the project menu. */}
|
|
11406
|
+
{isSignedIn && (
|
|
11407
|
+
<Tooltip>
|
|
11408
|
+
<TooltipTrigger asChild>
|
|
11409
|
+
<Button
|
|
11410
|
+
variant="ghost"
|
|
11411
|
+
size="icon"
|
|
11412
|
+
className="size-8 cursor-pointer rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
11413
|
+
onClick={handleOpenMakeReal}
|
|
11414
|
+
disabled={migrateMutation.isPending}
|
|
11415
|
+
aria-label={"Make this a real app" /* i18n-ignore */}
|
|
11416
|
+
>
|
|
11417
|
+
<IconRocket className="size-4" />
|
|
11418
|
+
</Button>
|
|
11419
|
+
</TooltipTrigger>
|
|
11420
|
+
<TooltipContent>
|
|
11421
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
11422
|
+
</TooltipContent>
|
|
11423
|
+
</Tooltip>
|
|
11424
|
+
)}
|
|
11425
|
+
|
|
9257
11426
|
{isSignedIn ? (
|
|
9258
11427
|
<ShareButton
|
|
9259
11428
|
resourceType="design"
|
|
@@ -9447,6 +11616,19 @@ ${serializedHtml}
|
|
|
9447
11616
|
? t("designEditor.stopPinningComments")
|
|
9448
11617
|
: t("designEditor.pinComment")}
|
|
9449
11618
|
</DropdownMenuItem>
|
|
11619
|
+
{isSignedIn && (
|
|
11620
|
+
<>
|
|
11621
|
+
<DropdownMenuSeparator />
|
|
11622
|
+
<DropdownMenuItem
|
|
11623
|
+
onClick={() => {
|
|
11624
|
+
handleOpenMakeReal();
|
|
11625
|
+
}}
|
|
11626
|
+
>
|
|
11627
|
+
<IconRocket className="mr-2 h-4 w-4" />
|
|
11628
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
11629
|
+
</DropdownMenuItem>
|
|
11630
|
+
</>
|
|
11631
|
+
)}
|
|
9450
11632
|
</DropdownMenuContent>
|
|
9451
11633
|
</DropdownMenu>
|
|
9452
11634
|
{titleEditing && canEditDesign ? (
|
|
@@ -9786,7 +11968,9 @@ ${serializedHtml}
|
|
|
9786
11968
|
canEditDesign && hasPropsClipboard && Boolean(selectedElement)
|
|
9787
11969
|
}
|
|
9788
11970
|
canCopyAsCode={Boolean(selectedElement?.selector)}
|
|
9789
|
-
|
|
11971
|
+
canGroup={canGroup}
|
|
11972
|
+
canUngroup={canUngroup}
|
|
11973
|
+
hiddenActions={["rename"]}
|
|
9790
11974
|
getCanvasPoint={getContextCanvasPoint}
|
|
9791
11975
|
onPasteHere={(details) =>
|
|
9792
11976
|
handlePasteSelection(
|
|
@@ -9818,310 +12002,393 @@ ${serializedHtml}
|
|
|
9818
12002
|
handleToggleLayerHidden(activeLayerId, !activeLayerHidden);
|
|
9819
12003
|
}
|
|
9820
12004
|
}}
|
|
12005
|
+
onGroup={canGroup ? handleGroupSelection : undefined}
|
|
12006
|
+
onUngroup={canUngroup ? handleUngroupSelection : undefined}
|
|
9821
12007
|
onCopyProps={handleCopyProps}
|
|
9822
12008
|
onPasteProps={handlePasteProps}
|
|
9823
12009
|
onCopyAsCode={handleCopySelection}
|
|
9824
12010
|
>
|
|
9825
12011
|
{activeFile ? (
|
|
9826
|
-
<div
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
|
|
9830
|
-
|
|
9831
|
-
|
|
12012
|
+
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
12013
|
+
{/* Banner for screens connected to a local dev server: edits
|
|
12014
|
+
route through the agent rather than being applied inline. */}
|
|
12015
|
+
{activeScreenIsLocalSource &&
|
|
12016
|
+
!localSourceBannerDismissed &&
|
|
12017
|
+
id && (
|
|
12018
|
+
<LocalSourceEditBanner
|
|
12019
|
+
designId={id}
|
|
12020
|
+
fileId={activeFile.id}
|
|
12021
|
+
routeSourceFile={activeScreenRouteSourceFile}
|
|
12022
|
+
onDismiss={() => setLocalSourceBannerDismissed(true)}
|
|
12023
|
+
/>
|
|
12024
|
+
)}
|
|
12025
|
+
<div
|
|
12026
|
+
ref={canvasContainerRef}
|
|
12027
|
+
className="relative mx-1 min-w-0 flex-1 overflow-hidden rounded-xl bg-[var(--design-editor-canvas-bg)]"
|
|
12028
|
+
onPointerMove={handleCanvasPointerMove}
|
|
12029
|
+
>
|
|
12030
|
+
{/* Transparent shield that blocks pointer events reaching the
|
|
9832
12031
|
iframe when a portaled Radix popover (e.g. color picker) is
|
|
9833
12032
|
open. The iframe has its own event context so it receives
|
|
9834
12033
|
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)
|
|
12034
|
+
{inspectorPopoverOpen && (
|
|
12035
|
+
<div
|
|
12036
|
+
aria-hidden="true"
|
|
12037
|
+
style={{
|
|
12038
|
+
position: "absolute",
|
|
12039
|
+
inset: 0,
|
|
12040
|
+
zIndex: 10,
|
|
12041
|
+
pointerEvents: "auto",
|
|
12042
|
+
}}
|
|
12043
|
+
/>
|
|
12044
|
+
)}
|
|
12045
|
+
{viewMode === "overview" ? (
|
|
12046
|
+
<MultiScreenCanvas
|
|
12047
|
+
screens={overviewScreens}
|
|
12048
|
+
zoom={overviewCanvasZoom}
|
|
12049
|
+
onZoomChange={setOverviewCanvasZoom}
|
|
12050
|
+
activeId={activeFileId}
|
|
12051
|
+
selectedScreenIds={overviewSelectedScreenIds}
|
|
12052
|
+
fullViewScreenIds={fullViewScreenIds}
|
|
12053
|
+
activeScreenHasHoveredChild={
|
|
12054
|
+
Boolean(hoveredElement) &&
|
|
12055
|
+
!hoveredElementIsScreenRoot &&
|
|
12056
|
+
hoveredElementScreenId === activeFileId
|
|
12057
|
+
}
|
|
12058
|
+
hoveredChildScreenId={hoveredChildScreenId}
|
|
12059
|
+
directlyHoveredScreenId={hoveredScreenRootId}
|
|
12060
|
+
previewDeviceFrame={deviceFrame}
|
|
12061
|
+
activeTool={activeTool}
|
|
12062
|
+
onActiveToolChange={(tool) =>
|
|
12063
|
+
setActiveTool(tool === "rectangle" ? "rect" : tool)
|
|
12064
|
+
}
|
|
12065
|
+
selectAllRequest={overviewSelectAllRequest}
|
|
12066
|
+
clearSelectionRequest={overviewClearSelectionRequest}
|
|
12067
|
+
onScreenSelectionChange={
|
|
12068
|
+
handleOverviewScreenSelectionChange
|
|
12069
|
+
}
|
|
12070
|
+
geometryById={canvasFrameGeometryById}
|
|
12071
|
+
onGeometryChange={queueFrameGeometrySave}
|
|
12072
|
+
onGeometryCommit={handleGeometryCommit}
|
|
12073
|
+
onCreatePrimitive={handleCreatePrimitive}
|
|
12074
|
+
onPrimitiveCreated={handlePrimitiveCreated}
|
|
12075
|
+
onPrimitiveReparent={handleOverviewPrimitiveReparent}
|
|
12076
|
+
onCrossScreenElementDrop={handleCrossScreenElementDrop}
|
|
12077
|
+
onCreateScreenFrame={handleCreateScreenFrame}
|
|
12078
|
+
onDeleteSelection={handleDeleteOverviewSelection}
|
|
12079
|
+
onSelectionChange={setOverviewSelectedScreenIds}
|
|
12080
|
+
onPick={(id) => {
|
|
12081
|
+
pendingOverviewScreenSelectionRef.current = null;
|
|
12082
|
+
setSelectedElement(null);
|
|
12083
|
+
setHoveredElement(null);
|
|
12084
|
+
setSelectedLayerIdsState([id]);
|
|
12085
|
+
setActiveFileId(id);
|
|
12086
|
+
setActiveTool("move");
|
|
12087
|
+
setMode("edit");
|
|
12088
|
+
}}
|
|
12089
|
+
onEdit={enterSingleScreen}
|
|
12090
|
+
onDuplicate={handleDuplicateScreen}
|
|
12091
|
+
onAddBreakpoint={(screenId, widthPx) => {
|
|
12092
|
+
if (!id) return;
|
|
12093
|
+
const breakpointLabel =
|
|
12094
|
+
widthPx <= 480
|
|
12095
|
+
? "Mobile"
|
|
12096
|
+
: widthPx <= 1024
|
|
12097
|
+
? "Tablet"
|
|
12098
|
+
: "Desktop";
|
|
12099
|
+
void addBreakpointMutation.mutateAsync({
|
|
12100
|
+
designId: id,
|
|
12101
|
+
label: breakpointLabel,
|
|
12102
|
+
widthPx,
|
|
12103
|
+
});
|
|
12104
|
+
}}
|
|
12105
|
+
onActiveBreakpointChange={(_screenId, widthPx) => {
|
|
12106
|
+
setActiveBreakpointWidthState(widthPx);
|
|
12107
|
+
if (!id) return;
|
|
12108
|
+
const bpSet = (() => {
|
|
12109
|
+
try {
|
|
12110
|
+
const raw = (
|
|
12111
|
+
designDataJson as Record<string, unknown>
|
|
12112
|
+
)?.breakpointSet;
|
|
12113
|
+
if (
|
|
12114
|
+
raw &&
|
|
12115
|
+
typeof raw === "object" &&
|
|
12116
|
+
Array.isArray(
|
|
12117
|
+
(raw as Record<string, unknown>).breakpoints,
|
|
12118
|
+
)
|
|
12119
|
+
) {
|
|
12120
|
+
return raw as {
|
|
12121
|
+
breakpoints: Array<{
|
|
12122
|
+
id: string;
|
|
12123
|
+
widthPx: number;
|
|
12124
|
+
}>;
|
|
12125
|
+
};
|
|
12126
|
+
}
|
|
12127
|
+
} catch {
|
|
12128
|
+
// Ignore malformed design data; the mutation below
|
|
12129
|
+
// can still clear back to auto.
|
|
9966
12130
|
}
|
|
9967
|
-
|
|
9968
|
-
|
|
9969
|
-
|
|
9970
|
-
|
|
12131
|
+
return null;
|
|
12132
|
+
})();
|
|
12133
|
+
const bp = bpSet?.breakpoints.find(
|
|
12134
|
+
(b) => b.widthPx === widthPx,
|
|
12135
|
+
);
|
|
12136
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
12137
|
+
designId: id,
|
|
12138
|
+
breakpointId:
|
|
12139
|
+
widthPx !== undefined && bp ? bp.id : "auto",
|
|
12140
|
+
});
|
|
12141
|
+
}}
|
|
12142
|
+
renderScreenContent={(screen, metadata, geometry) => {
|
|
12143
|
+
const screenIsActive = screen.id === activeFile?.id;
|
|
12144
|
+
const screenContent = getScreenContent(screen.id);
|
|
12145
|
+
const screenContentKey = screenIsActive
|
|
12146
|
+
? [screen.id, contentRenderRevision].join(":")
|
|
12147
|
+
: [
|
|
9971
12148
|
screen.id,
|
|
9972
|
-
|
|
9973
|
-
|
|
9974
|
-
|
|
9975
|
-
)
|
|
9976
|
-
|
|
9977
|
-
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
12149
|
+
screen.updatedAt ?? "",
|
|
12150
|
+
getContentSignature(screenContent),
|
|
12151
|
+
0,
|
|
12152
|
+
].join(":");
|
|
12153
|
+
|
|
12154
|
+
return (
|
|
12155
|
+
<DesignCanvas
|
|
12156
|
+
content={screenContent}
|
|
12157
|
+
contentKey={screenContentKey}
|
|
12158
|
+
zoom={100}
|
|
12159
|
+
deviceFrame="none"
|
|
12160
|
+
sourceType={designSourceType}
|
|
12161
|
+
fusionUrl={designFusionUrl}
|
|
12162
|
+
onComponentSourceJump={handleComponentSourceJump}
|
|
12163
|
+
embeddedFrame={{
|
|
12164
|
+
viewportWidth: Math.max(
|
|
12165
|
+
1,
|
|
12166
|
+
Math.round(geometry.width),
|
|
12167
|
+
),
|
|
12168
|
+
viewportHeight: Math.max(
|
|
12169
|
+
1,
|
|
12170
|
+
Math.round(geometry.height),
|
|
12171
|
+
),
|
|
12172
|
+
displayWidth: Math.max(
|
|
12173
|
+
1,
|
|
12174
|
+
Math.round(geometry.width),
|
|
12175
|
+
),
|
|
12176
|
+
displayHeight: Math.max(
|
|
12177
|
+
1,
|
|
12178
|
+
Math.round(geometry.height),
|
|
12179
|
+
),
|
|
12180
|
+
fluid: true,
|
|
12181
|
+
}}
|
|
12182
|
+
editorChromeScaleX={overviewCanvasZoom / 100}
|
|
12183
|
+
editorChromeScaleY={overviewCanvasZoom / 100}
|
|
12184
|
+
editMode={mode === "edit"}
|
|
12185
|
+
interactMode={false}
|
|
12186
|
+
readOnly={!canEditDesign}
|
|
12187
|
+
scaleMode={screenIsActive && activeTool === "scale"}
|
|
12188
|
+
clearSelectionRequest={
|
|
12189
|
+
overviewClearSelectionRequest
|
|
12190
|
+
}
|
|
12191
|
+
registerRuntimeBridge={screenIsActive}
|
|
12192
|
+
selectedSelector={
|
|
12193
|
+
screenIsActive ? selectedCanvasSelector : null
|
|
12194
|
+
}
|
|
12195
|
+
selectedSelectorCandidates={
|
|
12196
|
+
screenIsActive
|
|
12197
|
+
? selectedCanvasSelectorCandidates
|
|
12198
|
+
: []
|
|
12199
|
+
}
|
|
12200
|
+
hoveredSelector={
|
|
12201
|
+
hoveredElementScreenId === screen.id
|
|
12202
|
+
? hoveredCanvasSelector
|
|
12203
|
+
: null
|
|
12204
|
+
}
|
|
12205
|
+
hoveredSelectorCandidates={
|
|
12206
|
+
hoveredElementScreenId === screen.id
|
|
12207
|
+
? hoveredCanvasSelectorCandidates
|
|
12208
|
+
: []
|
|
12209
|
+
}
|
|
12210
|
+
lockedSelectors={getLayerSelectorsForFile(
|
|
9985
12211
|
screen.id,
|
|
12212
|
+
lockedLayerIds,
|
|
12213
|
+
)}
|
|
12214
|
+
hiddenSelectors={getLayerSelectorsForFile(
|
|
12215
|
+
screen.id,
|
|
12216
|
+
hiddenLayerIds,
|
|
12217
|
+
)}
|
|
12218
|
+
onElementSelect={(info) =>
|
|
12219
|
+
handleScreenElementSelect(screen.id, info)
|
|
12220
|
+
}
|
|
12221
|
+
onElementHover={(info) =>
|
|
12222
|
+
handleScreenElementHover(screen.id, info)
|
|
12223
|
+
}
|
|
12224
|
+
onClearSelection={() =>
|
|
12225
|
+
handleScreenElementClear(screen.id)
|
|
12226
|
+
}
|
|
12227
|
+
onIframeHotkey={handleIframeHotkey}
|
|
12228
|
+
onIframeContextMenu={handleIframeContextMenu}
|
|
12229
|
+
onVisualStyleChange={(selector, styles, info) =>
|
|
12230
|
+
handleScreenVisualStyleChange(
|
|
12231
|
+
screen.id,
|
|
12232
|
+
selector,
|
|
12233
|
+
styles,
|
|
12234
|
+
info,
|
|
12235
|
+
)
|
|
12236
|
+
}
|
|
12237
|
+
onVisualStructureChange={(
|
|
9986
12238
|
selector,
|
|
9987
12239
|
anchorSelector,
|
|
9988
12240
|
placement,
|
|
9989
12241
|
info,
|
|
9990
12242
|
details,
|
|
9991
|
-
)
|
|
9992
|
-
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
12243
|
+
) =>
|
|
12244
|
+
handleScreenVisualStructureChange(
|
|
12245
|
+
screen.id,
|
|
12246
|
+
selector,
|
|
12247
|
+
anchorSelector,
|
|
12248
|
+
placement,
|
|
12249
|
+
info,
|
|
12250
|
+
details,
|
|
12251
|
+
)
|
|
12252
|
+
}
|
|
12253
|
+
onVisualDuplicateChange={(
|
|
10001
12254
|
selector,
|
|
10002
12255
|
cloneHtml,
|
|
10003
12256
|
info,
|
|
10004
12257
|
details,
|
|
10005
|
-
)
|
|
10006
|
-
|
|
10007
|
-
|
|
10008
|
-
|
|
10009
|
-
|
|
10010
|
-
|
|
10011
|
-
|
|
10012
|
-
|
|
10013
|
-
|
|
10014
|
-
|
|
12258
|
+
) =>
|
|
12259
|
+
handleScreenVisualDuplicateChange(
|
|
12260
|
+
screen.id,
|
|
12261
|
+
selector,
|
|
12262
|
+
cloneHtml,
|
|
12263
|
+
info,
|
|
12264
|
+
details,
|
|
12265
|
+
)
|
|
12266
|
+
}
|
|
12267
|
+
onTextContentChange={(
|
|
10015
12268
|
selector,
|
|
10016
12269
|
value,
|
|
10017
12270
|
info,
|
|
10018
12271
|
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,
|
|
12272
|
+
) =>
|
|
12273
|
+
handleScreenTextContentChange(
|
|
12274
|
+
screen.id,
|
|
12275
|
+
selector,
|
|
12276
|
+
value,
|
|
12277
|
+
info,
|
|
12278
|
+
details,
|
|
12279
|
+
)
|
|
12280
|
+
}
|
|
12281
|
+
onTextEditingStateChange={setTextEditingState}
|
|
12282
|
+
onElementDblClickText={(info) =>
|
|
12283
|
+
handleScreenElementDblClickText(screen.id, info)
|
|
12284
|
+
}
|
|
12285
|
+
tweakValues={cssVarValues}
|
|
12286
|
+
drawMode={false}
|
|
12287
|
+
pinMode={false}
|
|
12288
|
+
designId={id}
|
|
12289
|
+
designTitle={design?.title}
|
|
12290
|
+
commentContextId={`${id}:${screen.id}`}
|
|
12291
|
+
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(screen.filename)}`}
|
|
12292
|
+
/>
|
|
10107
12293
|
);
|
|
10108
|
-
if (match) {
|
|
10109
|
-
viewModeRef.current = "single";
|
|
10110
|
-
setScreenZoom(FOCUSED_SCREEN_ZOOM);
|
|
10111
|
-
setViewMode("single");
|
|
10112
|
-
setActiveFileId(match.id);
|
|
10113
|
-
}
|
|
10114
12294
|
}}
|
|
10115
12295
|
/>
|
|
10116
|
-
|
|
10117
|
-
|
|
10118
|
-
<
|
|
10119
|
-
|
|
10120
|
-
|
|
12296
|
+
) : (
|
|
12297
|
+
<>
|
|
12298
|
+
<DesignCanvas
|
|
12299
|
+
content={activeContent}
|
|
12300
|
+
contentKey={`${activeFile.id}:${contentRenderRevision}`}
|
|
12301
|
+
zoom={zoom}
|
|
12302
|
+
onZoomChange={setZoom}
|
|
12303
|
+
deviceFrame={deviceFrame}
|
|
12304
|
+
sourceType={designSourceType}
|
|
12305
|
+
fusionUrl={designFusionUrl}
|
|
12306
|
+
previewWidthPx={activeBreakpointWidthState}
|
|
12307
|
+
shaderFillPreview={shaderFillPreview}
|
|
12308
|
+
onComponentSourceJump={handleComponentSourceJump}
|
|
12309
|
+
motionTracks={motionTracksWire}
|
|
12310
|
+
editMode={mode === "edit"}
|
|
12311
|
+
interactMode={mode === "interact"}
|
|
12312
|
+
readOnly={!canEditDesign}
|
|
12313
|
+
scaleMode={activeTool === "scale"}
|
|
12314
|
+
clearSelectionRequest={overviewClearSelectionRequest}
|
|
12315
|
+
selectedSelector={selectedCanvasSelector}
|
|
12316
|
+
selectedSelectorCandidates={
|
|
12317
|
+
selectedCanvasSelectorCandidates
|
|
12318
|
+
}
|
|
12319
|
+
hoveredSelector={hoveredCanvasSelector}
|
|
12320
|
+
hoveredSelectorCandidates={
|
|
12321
|
+
hoveredCanvasSelectorCandidates
|
|
12322
|
+
}
|
|
12323
|
+
lockedSelectors={lockedLayerSelectors}
|
|
12324
|
+
hiddenSelectors={hiddenLayerSelectors}
|
|
12325
|
+
onElementSelect={handleElementSelect}
|
|
12326
|
+
onElementHover={handleElementHover}
|
|
12327
|
+
onClearSelection={() => {
|
|
12328
|
+
setSelectedElement(null);
|
|
12329
|
+
setHoveredElement(null);
|
|
12330
|
+
setHoveredElementScreenId(null);
|
|
12331
|
+
setSelectedLayerIdsState([]);
|
|
12332
|
+
}}
|
|
12333
|
+
onIframeHotkey={handleIframeHotkey}
|
|
12334
|
+
onIframeContextMenu={handleIframeContextMenu}
|
|
12335
|
+
onVisualStyleChange={handleVisualStyleChange}
|
|
12336
|
+
onVisualStructureChange={handleVisualStructureChange}
|
|
12337
|
+
onVisualDuplicateChange={handleVisualDuplicateChange}
|
|
12338
|
+
onTextContentChange={handleTextContentChange}
|
|
12339
|
+
onTextEditingStateChange={setTextEditingState}
|
|
12340
|
+
onElementDblClickText={handleElementDblClickText}
|
|
12341
|
+
tweakValues={cssVarValues}
|
|
12342
|
+
drawMode={drawMode}
|
|
12343
|
+
onExitDrawMode={() => {
|
|
12344
|
+
setDrawMode(false);
|
|
12345
|
+
setPinMode(false);
|
|
12346
|
+
setActiveTool("move");
|
|
12347
|
+
setMode("edit");
|
|
12348
|
+
}}
|
|
12349
|
+
pinMode={pinMode}
|
|
12350
|
+
onExitPinMode={() => {
|
|
12351
|
+
setPinMode(false);
|
|
12352
|
+
if (mode === "annotate") {
|
|
12353
|
+
setActiveTool("draw");
|
|
12354
|
+
}
|
|
12355
|
+
}}
|
|
12356
|
+
designId={id}
|
|
12357
|
+
designTitle={design?.title}
|
|
12358
|
+
commentContextId={`${id}:${activeFile.id}`}
|
|
12359
|
+
commentContextLabel={`${design?.title ?? t("navigation.brand")} / ${prettyScreenName(activeFile.filename)}`}
|
|
12360
|
+
onPrototypeNavigate={(screen) => {
|
|
12361
|
+
if (!screen) return;
|
|
12362
|
+
const norm = (s: string) =>
|
|
12363
|
+
s
|
|
12364
|
+
.replace(/^\.?\//, "")
|
|
12365
|
+
.replace(/\.html?$/i, "")
|
|
12366
|
+
.toLowerCase();
|
|
12367
|
+
const target = norm(screen);
|
|
12368
|
+
if (!target) return;
|
|
12369
|
+
// Exact (normalized) filename match only — a substring match
|
|
12370
|
+
// could send "board" to "dashboard.html".
|
|
12371
|
+
const match = files.find(
|
|
12372
|
+
(f) => norm(f.filename) === target,
|
|
12373
|
+
);
|
|
12374
|
+
if (match) {
|
|
12375
|
+
viewModeRef.current = "single";
|
|
12376
|
+
setScreenZoom(FOCUSED_SCREEN_ZOOM);
|
|
12377
|
+
setViewMode("single");
|
|
12378
|
+
setActiveFileId(match.id);
|
|
12379
|
+
}
|
|
12380
|
+
}}
|
|
10121
12381
|
/>
|
|
10122
|
-
|
|
10123
|
-
|
|
10124
|
-
|
|
12382
|
+
{/* Presence: live cursor overlay for remote participants */}
|
|
12383
|
+
{others.length > 0 && (
|
|
12384
|
+
<LiveCursorOverlay
|
|
12385
|
+
others={others}
|
|
12386
|
+
containerRef={canvasContainerRef}
|
|
12387
|
+
/>
|
|
12388
|
+
)}
|
|
12389
|
+
</>
|
|
12390
|
+
)}
|
|
12391
|
+
</div>
|
|
10125
12392
|
</div>
|
|
10126
12393
|
) : (
|
|
10127
12394
|
<div className="flex flex-1 items-center justify-center">
|
|
@@ -10140,7 +12407,7 @@ ${serializedHtml}
|
|
|
10140
12407
|
</p>
|
|
10141
12408
|
{retryablePrompt ? (
|
|
10142
12409
|
<p className="mx-auto mb-4 max-w-sm text-xs italic text-muted-foreground/70">
|
|
10143
|
-
"{retryablePrompt.prompt}"
|
|
12410
|
+
{`"${retryablePrompt.prompt}"`}
|
|
10144
12411
|
</p>
|
|
10145
12412
|
) : null}
|
|
10146
12413
|
<div className="flex items-center justify-center gap-2">
|
|
@@ -10206,6 +12473,51 @@ ${serializedHtml}
|
|
|
10206
12473
|
tweakValues={tweakSelections}
|
|
10207
12474
|
extensionContext={designExtensionContext}
|
|
10208
12475
|
readOnly={initialGenerationReadOnly}
|
|
12476
|
+
onComponentPropApplied={handleComponentPropApplied}
|
|
12477
|
+
onTokensApplied={(resolvedCssVars) => {
|
|
12478
|
+
if (!canEditDesign || !id) return;
|
|
12479
|
+
setTweakSelections((prev) => ({
|
|
12480
|
+
...prev,
|
|
12481
|
+
...resolvedCssVars,
|
|
12482
|
+
}));
|
|
12483
|
+
queryClient.setQueryData(
|
|
12484
|
+
["action", "get-design", { id }],
|
|
12485
|
+
(old: any) => {
|
|
12486
|
+
if (!old || typeof old !== "object") return old;
|
|
12487
|
+
let currentData: Record<string, unknown> = {};
|
|
12488
|
+
if (typeof old.data === "string" && old.data) {
|
|
12489
|
+
try {
|
|
12490
|
+
const parsed = JSON.parse(old.data);
|
|
12491
|
+
if (
|
|
12492
|
+
parsed &&
|
|
12493
|
+
typeof parsed === "object" &&
|
|
12494
|
+
!Array.isArray(parsed)
|
|
12495
|
+
) {
|
|
12496
|
+
currentData = parsed;
|
|
12497
|
+
}
|
|
12498
|
+
} catch {
|
|
12499
|
+
currentData = {};
|
|
12500
|
+
}
|
|
12501
|
+
}
|
|
12502
|
+
const currentSelections =
|
|
12503
|
+
currentData.tweakSelections &&
|
|
12504
|
+
typeof currentData.tweakSelections === "object" &&
|
|
12505
|
+
!Array.isArray(currentData.tweakSelections)
|
|
12506
|
+
? currentData.tweakSelections
|
|
12507
|
+
: {};
|
|
12508
|
+
return {
|
|
12509
|
+
...old,
|
|
12510
|
+
data: JSON.stringify({
|
|
12511
|
+
...currentData,
|
|
12512
|
+
tweakSelections: {
|
|
12513
|
+
...currentSelections,
|
|
12514
|
+
...resolvedCssVars,
|
|
12515
|
+
},
|
|
12516
|
+
}),
|
|
12517
|
+
};
|
|
12518
|
+
},
|
|
12519
|
+
);
|
|
12520
|
+
}}
|
|
10209
12521
|
onTweakChange={(tweakId, value) =>
|
|
10210
12522
|
setTweakSelections((prev) => {
|
|
10211
12523
|
if (!canEditDesign) return prev;
|
|
@@ -10217,8 +12529,67 @@ ${serializedHtml}
|
|
|
10217
12529
|
onRequestTweaks={handleRequestTweaks}
|
|
10218
12530
|
onStyleChange={handleStyleChange}
|
|
10219
12531
|
onStylesChange={handleStylesChange}
|
|
12532
|
+
onAutoLayoutConvert={handleAutoLayoutConvert}
|
|
10220
12533
|
onExport={handleInspectorExport}
|
|
10221
12534
|
exporting={pngExporting || svgExporting}
|
|
12535
|
+
designId={id}
|
|
12536
|
+
fileId={activeFile?.id}
|
|
12537
|
+
filename={activeFile?.filename}
|
|
12538
|
+
componentNodeId={selectedComponentNodeId}
|
|
12539
|
+
sourceCapabilities={sourceCapabilities}
|
|
12540
|
+
onCreateComponent={
|
|
12541
|
+
id && selectedElement ? handleCreateComponent : undefined
|
|
12542
|
+
}
|
|
12543
|
+
defaultComponentName={defaultComponentName}
|
|
12544
|
+
inspectCode={inspectCodeData}
|
|
12545
|
+
statesPanelProps={
|
|
12546
|
+
id
|
|
12547
|
+
? {
|
|
12548
|
+
// §6.4 / §8 — active state and breakpoint wired into
|
|
12549
|
+
// the StatesPanel so selection is agent-visible.
|
|
12550
|
+
activeStateId: selectedStateId,
|
|
12551
|
+
activeBreakpointId: statesPanelActiveBreakpointId,
|
|
12552
|
+
breakpoints: statesPanelBreakpoints,
|
|
12553
|
+
onStateSelect: handleDesignStateSelect,
|
|
12554
|
+
onBreakpointSelect: (breakpointId) => {
|
|
12555
|
+
// "auto" = clear the active breakpoint (overview).
|
|
12556
|
+
if (breakpointId === "auto") {
|
|
12557
|
+
setActiveBreakpointWidthState(undefined);
|
|
12558
|
+
if (id) {
|
|
12559
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
12560
|
+
designId: id,
|
|
12561
|
+
breakpointId: "auto",
|
|
12562
|
+
});
|
|
12563
|
+
}
|
|
12564
|
+
return;
|
|
12565
|
+
}
|
|
12566
|
+
const bp =
|
|
12567
|
+
statesPanelBreakpoints.find(
|
|
12568
|
+
(b) => b.id === breakpointId,
|
|
12569
|
+
) ??
|
|
12570
|
+
DEFAULT_STATES_PANEL_BREAKPOINTS.find(
|
|
12571
|
+
(b) => b.id === breakpointId,
|
|
12572
|
+
);
|
|
12573
|
+
if (!bp) return;
|
|
12574
|
+
setActiveBreakpointWidthState(bp.widthPx);
|
|
12575
|
+
if (id) {
|
|
12576
|
+
void setActiveBreakpointMutation.mutateAsync({
|
|
12577
|
+
designId: id,
|
|
12578
|
+
breakpointId,
|
|
12579
|
+
});
|
|
12580
|
+
}
|
|
12581
|
+
},
|
|
12582
|
+
onAddBreakpoint: () => {
|
|
12583
|
+
// Delegate to the MultiScreenCanvas affordance by
|
|
12584
|
+
// navigating to overview where the "+" button lives.
|
|
12585
|
+
if (viewMode !== "overview") {
|
|
12586
|
+
setViewMode("overview");
|
|
12587
|
+
}
|
|
12588
|
+
},
|
|
12589
|
+
}
|
|
12590
|
+
: undefined
|
|
12591
|
+
}
|
|
12592
|
+
reviewPanelProps={resolvedReviewPanelProps}
|
|
10222
12593
|
/>
|
|
10223
12594
|
</div>
|
|
10224
12595
|
) : (
|
|
@@ -10228,6 +12599,55 @@ ${serializedHtml}
|
|
|
10228
12599
|
) : null}
|
|
10229
12600
|
</div>
|
|
10230
12601
|
|
|
12602
|
+
{/* Motion dock (§6.3) — collapsible bottom dock; visible when activeFile
|
|
12603
|
+
is open and the user has opened it. Canvas remains visible above.
|
|
12604
|
+
Preview-only scrubbing fires a motion-preview postMessage to the
|
|
12605
|
+
canvas iframe (via canvasIframeRef); "Write to CSS" fires
|
|
12606
|
+
apply-motion-edit. */}
|
|
12607
|
+
{!embedded && activeFile ? (
|
|
12608
|
+
<MotionDock
|
|
12609
|
+
tracks={motionTracks}
|
|
12610
|
+
durationMs={motionDurationMs}
|
|
12611
|
+
open={motionDockOpen}
|
|
12612
|
+
onOpenChange={setMotionDockOpen}
|
|
12613
|
+
onTracksChange={setMotionTracks}
|
|
12614
|
+
onDurationChange={setMotionDurationMs}
|
|
12615
|
+
canvasIframeRef={canvasIframeRef}
|
|
12616
|
+
selectedTarget={motionSelectedTarget}
|
|
12617
|
+
onApply={(tracks, durationMs) => {
|
|
12618
|
+
if (!id) return;
|
|
12619
|
+
applyMotionEditMutation.mutate(
|
|
12620
|
+
{
|
|
12621
|
+
designId: id,
|
|
12622
|
+
fileId: activeFile.id,
|
|
12623
|
+
tracks: tracks.map(({ label: _label, ...t }) => t),
|
|
12624
|
+
durationMs,
|
|
12625
|
+
includeContent: true,
|
|
12626
|
+
},
|
|
12627
|
+
{
|
|
12628
|
+
onSuccess: (result) => {
|
|
12629
|
+
const response = result as {
|
|
12630
|
+
fileId?: unknown;
|
|
12631
|
+
patchedContent?: unknown;
|
|
12632
|
+
};
|
|
12633
|
+
if (
|
|
12634
|
+
typeof response.fileId === "string" &&
|
|
12635
|
+
typeof response.patchedContent === "string"
|
|
12636
|
+
) {
|
|
12637
|
+
applyFileContentUpdate(
|
|
12638
|
+
response.fileId,
|
|
12639
|
+
response.patchedContent,
|
|
12640
|
+
{ refreshPreview: response.fileId === activeFile.id },
|
|
12641
|
+
);
|
|
12642
|
+
}
|
|
12643
|
+
},
|
|
12644
|
+
},
|
|
12645
|
+
);
|
|
12646
|
+
}}
|
|
12647
|
+
applying={applyMotionEditMutation.isPending}
|
|
12648
|
+
/>
|
|
12649
|
+
) : null}
|
|
12650
|
+
|
|
10231
12651
|
<PromptPopover
|
|
10232
12652
|
open={showPrompt}
|
|
10233
12653
|
onOpenChange={handlePromptOpenChange}
|
|
@@ -10313,6 +12733,183 @@ ${serializedHtml}
|
|
|
10313
12733
|
loading={generating || pendingGenerationActive}
|
|
10314
12734
|
anchorRef={tweakPromptAnchorRef}
|
|
10315
12735
|
/>
|
|
12736
|
+
|
|
12737
|
+
{/* §6.6 — "Make this a real app" dialog.
|
|
12738
|
+
Three states:
|
|
12739
|
+
1. Idle — confirm prompt with description of what will happen.
|
|
12740
|
+
2. Migrating — spinner while the Builder cloud agent accepts the job.
|
|
12741
|
+
3. Success — branchName + url; sourceType already flipped to fusion.
|
|
12742
|
+
4. Not-configured — CTA to connect Builder.io.
|
|
12743
|
+
*/}
|
|
12744
|
+
<Dialog
|
|
12745
|
+
open={makeRealDialogOpen}
|
|
12746
|
+
onOpenChange={(open) => {
|
|
12747
|
+
if (!migrateMutation.isPending) setMakeRealDialogOpen(open);
|
|
12748
|
+
}}
|
|
12749
|
+
>
|
|
12750
|
+
<DialogContent className="sm:max-w-md">
|
|
12751
|
+
{/* Not-configured: Builder not connected or no project ID */}
|
|
12752
|
+
{migrationResult?.status === "not-configured" &&
|
|
12753
|
+
migrationResult.cta ? (
|
|
12754
|
+
<>
|
|
12755
|
+
<DialogHeader>
|
|
12756
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12757
|
+
<IconRocket className="size-5 text-muted-foreground" />
|
|
12758
|
+
{migrationResult.cta.label}
|
|
12759
|
+
</DialogTitle>
|
|
12760
|
+
<DialogDescription>
|
|
12761
|
+
{migrationResult.cta.description}
|
|
12762
|
+
</DialogDescription>
|
|
12763
|
+
</DialogHeader>
|
|
12764
|
+
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
|
12765
|
+
<Button
|
|
12766
|
+
variant="outline"
|
|
12767
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12768
|
+
className="cursor-pointer"
|
|
12769
|
+
>
|
|
12770
|
+
Cancel
|
|
12771
|
+
</Button>
|
|
12772
|
+
{migrationResult.cta.connectUrl ? (
|
|
12773
|
+
<Button asChild className="cursor-pointer">
|
|
12774
|
+
<a
|
|
12775
|
+
href={migrationResult.cta.connectUrl}
|
|
12776
|
+
target="_blank"
|
|
12777
|
+
rel="noopener noreferrer"
|
|
12778
|
+
>
|
|
12779
|
+
{migrationResult.cta.primaryAction}
|
|
12780
|
+
<IconExternalLink className="ml-1.5 size-3.5" />
|
|
12781
|
+
</a>
|
|
12782
|
+
</Button>
|
|
12783
|
+
) : null}
|
|
12784
|
+
</DialogFooter>
|
|
12785
|
+
</>
|
|
12786
|
+
) : migrationResult?.status === "processing" ? (
|
|
12787
|
+
/* Success: Builder accepted the migration job */
|
|
12788
|
+
<>
|
|
12789
|
+
<DialogHeader>
|
|
12790
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12791
|
+
<IconCircleCheck className="size-5 text-green-500" />
|
|
12792
|
+
{"Migration started" /* i18n-ignore */}
|
|
12793
|
+
</DialogTitle>
|
|
12794
|
+
<DialogDescription>
|
|
12795
|
+
{
|
|
12796
|
+
"Builder is generating a React app branch from your design. The original inline design is preserved and recoverable." /* i18n-ignore */
|
|
12797
|
+
}
|
|
12798
|
+
</DialogDescription>
|
|
12799
|
+
</DialogHeader>
|
|
12800
|
+
<div className="space-y-3 py-2">
|
|
12801
|
+
{migrationResult.branchName && (
|
|
12802
|
+
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
|
12803
|
+
<span className="text-muted-foreground">
|
|
12804
|
+
{"Branch: " /* i18n-ignore */}
|
|
12805
|
+
</span>
|
|
12806
|
+
<span className="font-mono font-medium">
|
|
12807
|
+
{migrationResult.branchName}
|
|
12808
|
+
</span>
|
|
12809
|
+
</div>
|
|
12810
|
+
)}
|
|
12811
|
+
{migrationResult.url && (
|
|
12812
|
+
<a
|
|
12813
|
+
href={migrationResult.url}
|
|
12814
|
+
target="_blank"
|
|
12815
|
+
rel="noopener noreferrer"
|
|
12816
|
+
className="flex items-center gap-1.5 text-sm text-[var(--design-editor-accent-color)] hover:underline"
|
|
12817
|
+
>
|
|
12818
|
+
{"Open in Builder" /* i18n-ignore */}
|
|
12819
|
+
<IconExternalLink className="size-3.5" />
|
|
12820
|
+
</a>
|
|
12821
|
+
)}
|
|
12822
|
+
{migrationResult.seedFileCount !== undefined && (
|
|
12823
|
+
<p className="text-xs text-muted-foreground">
|
|
12824
|
+
{
|
|
12825
|
+
`${migrationResult.seedFileCount} design file${migrationResult.seedFileCount === 1 ? "" : "s"} included in migration seed.` /* i18n-ignore */
|
|
12826
|
+
}
|
|
12827
|
+
</p>
|
|
12828
|
+
)}
|
|
12829
|
+
</div>
|
|
12830
|
+
<DialogFooter>
|
|
12831
|
+
<Button
|
|
12832
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12833
|
+
className="cursor-pointer"
|
|
12834
|
+
>
|
|
12835
|
+
{"Done" /* i18n-ignore */}
|
|
12836
|
+
</Button>
|
|
12837
|
+
</DialogFooter>
|
|
12838
|
+
</>
|
|
12839
|
+
) : (
|
|
12840
|
+
/* Idle or migrating */
|
|
12841
|
+
<>
|
|
12842
|
+
<DialogHeader>
|
|
12843
|
+
<DialogTitle className="flex items-center gap-2">
|
|
12844
|
+
<IconRocket className="size-5" />
|
|
12845
|
+
{"Make this a real app" /* i18n-ignore */}
|
|
12846
|
+
</DialogTitle>
|
|
12847
|
+
<DialogDescription>
|
|
12848
|
+
{
|
|
12849
|
+
"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 */
|
|
12850
|
+
}
|
|
12851
|
+
</DialogDescription>
|
|
12852
|
+
</DialogHeader>
|
|
12853
|
+
<div className="space-y-2 py-1 text-sm text-muted-foreground">
|
|
12854
|
+
<p>{"What happens:" /* i18n-ignore */}</p>
|
|
12855
|
+
<ul className="list-disc pl-4 space-y-1">
|
|
12856
|
+
<li>
|
|
12857
|
+
{
|
|
12858
|
+
"Your design HTML and tokens are sent to the Builder cloud agent" /* i18n-ignore */
|
|
12859
|
+
}
|
|
12860
|
+
</li>
|
|
12861
|
+
<li>
|
|
12862
|
+
{
|
|
12863
|
+
"A React + Tailwind branch is generated in Builder" /* i18n-ignore */
|
|
12864
|
+
}
|
|
12865
|
+
</li>
|
|
12866
|
+
<li>
|
|
12867
|
+
{
|
|
12868
|
+
"The editor switches to fusion source mode — gated panels light up" /* i18n-ignore */
|
|
12869
|
+
}
|
|
12870
|
+
</li>
|
|
12871
|
+
<li>
|
|
12872
|
+
{
|
|
12873
|
+
"The original inline design is saved as a restorable snapshot" /* i18n-ignore */
|
|
12874
|
+
}
|
|
12875
|
+
</li>
|
|
12876
|
+
</ul>
|
|
12877
|
+
<p className="pt-1 text-xs">
|
|
12878
|
+
{
|
|
12879
|
+
"Requires Builder.io to be connected with a branch project configured." /* i18n-ignore */
|
|
12880
|
+
}
|
|
12881
|
+
</p>
|
|
12882
|
+
</div>
|
|
12883
|
+
<DialogFooter className="flex-col gap-2 sm:flex-row">
|
|
12884
|
+
<Button
|
|
12885
|
+
variant="outline"
|
|
12886
|
+
onClick={() => setMakeRealDialogOpen(false)}
|
|
12887
|
+
disabled={migrateMutation.isPending}
|
|
12888
|
+
className="cursor-pointer"
|
|
12889
|
+
>
|
|
12890
|
+
Cancel
|
|
12891
|
+
</Button>
|
|
12892
|
+
<Button
|
|
12893
|
+
onClick={() => void handleConfirmMakeReal()}
|
|
12894
|
+
disabled={migrateMutation.isPending}
|
|
12895
|
+
className="cursor-pointer"
|
|
12896
|
+
>
|
|
12897
|
+
{
|
|
12898
|
+
migrateMutation.isPending ? (
|
|
12899
|
+
<>
|
|
12900
|
+
<Spinner className="mr-2 size-3.5" />
|
|
12901
|
+
{"Starting migration…" /* i18n-ignore */}
|
|
12902
|
+
</>
|
|
12903
|
+
) : (
|
|
12904
|
+
"Start migration"
|
|
12905
|
+
) /* i18n-ignore */
|
|
12906
|
+
}
|
|
12907
|
+
</Button>
|
|
12908
|
+
</DialogFooter>
|
|
12909
|
+
</>
|
|
12910
|
+
)}
|
|
12911
|
+
</DialogContent>
|
|
12912
|
+
</Dialog>
|
|
10316
12913
|
</div>
|
|
10317
12914
|
);
|
|
10318
12915
|
}
|