@agent-native/core 0.80.7 → 0.80.8
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 +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +70 -27
- package/corpus/core/src/client/analytics.ts +3 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
- package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
- package/corpus/templates/design/AGENTS.md +4 -2
- package/corpus/templates/design/actions/edit-design.ts +6 -3
- package/corpus/templates/design/actions/generate-screens.ts +5 -3
- package/corpus/templates/design/actions/update-design.ts +47 -10
- package/corpus/templates/design/actions/update-file.ts +33 -8
- package/corpus/templates/design/actions/view-screen.ts +14 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
- package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
- package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
- package/corpus/templates/design/e2e/README.md +1 -0
- package/corpus/templates/design/e2e/global-setup.ts +13 -1
- package/corpus/templates/design/playwright.config.ts +10 -1
- package/corpus/templates/design/shared/color-utils.ts +159 -0
- package/corpus/templates/design/shared/generation-session.ts +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +54 -24
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +2 -1
- package/dist/client/analytics.js.map +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/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
|
@@ -804,6 +804,20 @@ function uniqueLayerId(prefix: string): string {
|
|
|
804
804
|
: `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
805
805
|
}
|
|
806
806
|
|
|
807
|
+
/**
|
|
808
|
+
* Re-stamp every `data-agent-native-node-id` in duplicated screen content with a
|
|
809
|
+
* fresh unique id. Without this, a duplicated screen carries the SAME node ids as
|
|
810
|
+
* its source, which collapses the cross-file layer-owner map (selecting a layer
|
|
811
|
+
* in one screen resolves to the other) and can produce a malformed aggregate
|
|
812
|
+
* projection.
|
|
813
|
+
*/
|
|
814
|
+
function reassignDuplicatedNodeIds(content: string): string {
|
|
815
|
+
return content.replace(
|
|
816
|
+
/data-agent-native-node-id="[^"]*"/g,
|
|
817
|
+
() => `data-agent-native-node-id="${uniqueLayerId("copy")}"`,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
807
821
|
function primitiveLayerName(primitive: CanvasPrimitiveInsert): string {
|
|
808
822
|
switch (primitive.kind) {
|
|
809
823
|
case "frame":
|
|
@@ -1353,12 +1367,29 @@ function codeLayerTreeToPanelNodes(
|
|
|
1353
1367
|
hiddenIds: Set<string>,
|
|
1354
1368
|
inheritedLocked = false,
|
|
1355
1369
|
inheritedHidden = false,
|
|
1370
|
+
// Ancestor-path ids guarding against a cyclic projection (e.g. duplicate or
|
|
1371
|
+
// empty node ids like "an-" that make a node its own descendant) recursing
|
|
1372
|
+
// forever and crashing the whole editor with a stack overflow.
|
|
1373
|
+
ancestors: Set<string> = new Set(),
|
|
1356
1374
|
): LayersPanelNode[] {
|
|
1357
1375
|
return nodes.map((node) => {
|
|
1358
1376
|
const selfLocked = lockedIds.has(node.id);
|
|
1359
1377
|
const selfHidden = hiddenIds.has(node.id);
|
|
1360
1378
|
const locked = inheritedLocked || selfLocked;
|
|
1361
1379
|
const hidden = inheritedHidden || selfHidden;
|
|
1380
|
+
let children: LayersPanelNode[] = [];
|
|
1381
|
+
if (!ancestors.has(node.id)) {
|
|
1382
|
+
ancestors.add(node.id);
|
|
1383
|
+
children = codeLayerTreeToPanelNodes(
|
|
1384
|
+
node.children,
|
|
1385
|
+
lockedIds,
|
|
1386
|
+
hiddenIds,
|
|
1387
|
+
locked,
|
|
1388
|
+
hidden,
|
|
1389
|
+
ancestors,
|
|
1390
|
+
);
|
|
1391
|
+
ancestors.delete(node.id);
|
|
1392
|
+
}
|
|
1362
1393
|
return {
|
|
1363
1394
|
id: node.id,
|
|
1364
1395
|
name: node.name,
|
|
@@ -1372,13 +1403,7 @@ function codeLayerTreeToPanelNodes(
|
|
|
1372
1403
|
hideable: true,
|
|
1373
1404
|
locked,
|
|
1374
1405
|
hidden,
|
|
1375
|
-
children
|
|
1376
|
-
node.children,
|
|
1377
|
-
lockedIds,
|
|
1378
|
-
hiddenIds,
|
|
1379
|
-
locked,
|
|
1380
|
-
hidden,
|
|
1381
|
-
),
|
|
1406
|
+
children,
|
|
1382
1407
|
};
|
|
1383
1408
|
});
|
|
1384
1409
|
}
|
|
@@ -1395,12 +1420,20 @@ function collectEffectiveCodeLayerState(
|
|
|
1395
1420
|
inheritedLocked: boolean,
|
|
1396
1421
|
inheritedHidden: boolean,
|
|
1397
1422
|
state: EffectiveCodeLayerState,
|
|
1423
|
+
// Ids on the current ancestor path — guards against a malformed/cyclic
|
|
1424
|
+
// projection (e.g. a node that appears as its own descendant from duplicate
|
|
1425
|
+
// node ids) recursing forever and crashing the whole editor with a stack
|
|
1426
|
+
// overflow. A true cycle is skipped; duplicate ids in disjoint subtrees are
|
|
1427
|
+
// still visited.
|
|
1428
|
+
ancestors: Set<string> = new Set(),
|
|
1398
1429
|
): EffectiveCodeLayerState {
|
|
1399
1430
|
nodes.forEach((node) => {
|
|
1431
|
+
if (ancestors.has(node.id)) return;
|
|
1400
1432
|
const locked = inheritedLocked || lockedIds.has(node.id);
|
|
1401
1433
|
const hidden = inheritedHidden || hiddenIds.has(node.id);
|
|
1402
1434
|
if (locked) state.lockedIds.add(node.id);
|
|
1403
1435
|
if (hidden) state.hiddenIds.add(node.id);
|
|
1436
|
+
ancestors.add(node.id);
|
|
1404
1437
|
collectEffectiveCodeLayerState(
|
|
1405
1438
|
node.children,
|
|
1406
1439
|
lockedIds,
|
|
@@ -1408,7 +1441,9 @@ function collectEffectiveCodeLayerState(
|
|
|
1408
1441
|
locked,
|
|
1409
1442
|
hidden,
|
|
1410
1443
|
state,
|
|
1444
|
+
ancestors,
|
|
1411
1445
|
);
|
|
1446
|
+
ancestors.delete(node.id);
|
|
1412
1447
|
});
|
|
1413
1448
|
return state;
|
|
1414
1449
|
}
|
|
@@ -2282,9 +2317,15 @@ export default function DesignEditor() {
|
|
|
2282
2317
|
const [overviewSelectAllRequest, setOverviewSelectAllRequest] = useState(0);
|
|
2283
2318
|
const [overviewClearSelectionRequest, setOverviewClearSelectionRequest] =
|
|
2284
2319
|
useState(0);
|
|
2320
|
+
const [overviewSelectedScreenIds, setOverviewSelectedScreenIds] = useState<
|
|
2321
|
+
string[]
|
|
2322
|
+
>([]);
|
|
2285
2323
|
const [hasCanvasClipboard, setHasCanvasClipboard] = useState(false);
|
|
2286
2324
|
const [hasPropsClipboard, setHasPropsClipboard] = useState(false);
|
|
2287
2325
|
const copiedLayerHtmlRef = useRef<string | null>(null);
|
|
2326
|
+
// Cascade offset for repeated keyboard pastes so successive clones don't stack
|
|
2327
|
+
// pixel-perfectly on top of each other. Reset on each fresh copy/cut.
|
|
2328
|
+
const pasteCascadeRef = useRef(0);
|
|
2288
2329
|
const copiedStylePropsRef = useRef<Record<string, string> | null>(null);
|
|
2289
2330
|
const spaceHandPreviousToolRef = useRef<DesignTool | null>(null);
|
|
2290
2331
|
const hasSelectedElement = Boolean(selectedElement);
|
|
@@ -3360,7 +3401,12 @@ export default function DesignEditor() {
|
|
|
3360
3401
|
? command.screen
|
|
3361
3402
|
: null;
|
|
3362
3403
|
const targetFile = findDesignFileByScreenTarget(files, target);
|
|
3363
|
-
|
|
3404
|
+
// A navigate command can name a screen the agent just created that the
|
|
3405
|
+
// get-design query hasn't refetched yet. Treat any unresolved named target
|
|
3406
|
+
// as not-yet-applied (return false) so the app-state key is preserved and
|
|
3407
|
+
// re-applied on the next tick once the file loads — not just when there are
|
|
3408
|
+
// zero files. Otherwise the navigate is silently consumed and dropped.
|
|
3409
|
+
if (target && !targetFile) return false;
|
|
3364
3410
|
|
|
3365
3411
|
const inspectorTab =
|
|
3366
3412
|
command.inspectorTab === "design" ||
|
|
@@ -3475,7 +3521,7 @@ export default function DesignEditor() {
|
|
|
3475
3521
|
{
|
|
3476
3522
|
designId: id,
|
|
3477
3523
|
filename,
|
|
3478
|
-
content: source.content,
|
|
3524
|
+
content: reassignDuplicatedNodeIds(source.content),
|
|
3479
3525
|
fileType: normalizedDesignFileType(source.fileType),
|
|
3480
3526
|
} as any,
|
|
3481
3527
|
{
|
|
@@ -4155,7 +4201,6 @@ export default function DesignEditor() {
|
|
|
4155
4201
|
setCollabContent(nextContent);
|
|
4156
4202
|
lastLocalContentRef.current = nextContent;
|
|
4157
4203
|
if (id) {
|
|
4158
|
-
const optimisticUpdatedAt = new Date().toISOString();
|
|
4159
4204
|
queryClient.setQueryData(
|
|
4160
4205
|
["action", "get-design", { id }],
|
|
4161
4206
|
(old: any) => {
|
|
@@ -4166,11 +4211,12 @@ export default function DesignEditor() {
|
|
|
4166
4211
|
...old,
|
|
4167
4212
|
files: old.files.map((file: DesignFile) =>
|
|
4168
4213
|
file.id === activeFile.id
|
|
4169
|
-
?
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4214
|
+
? // Update content optimistically but keep the file's prior
|
|
4215
|
+
// (server-clock) updatedAt. Seeding the reconcile watermark
|
|
4216
|
+
// from a client-clock timestamp can, under clock skew, make a
|
|
4217
|
+
// later server-authored agent edit look "older" and get
|
|
4218
|
+
// dropped by the watermark gate (agent edit silently lost).
|
|
4219
|
+
{ ...file, content: nextContent }
|
|
4174
4220
|
: file,
|
|
4175
4221
|
),
|
|
4176
4222
|
};
|
|
@@ -4433,7 +4479,7 @@ export default function DesignEditor() {
|
|
|
4433
4479
|
files: UploadedFile[],
|
|
4434
4480
|
options: PromptComposerSubmitOptions,
|
|
4435
4481
|
) => {
|
|
4436
|
-
if (!design) return;
|
|
4482
|
+
if (!design || generating || pendingGenerationActive) return;
|
|
4437
4483
|
const trimmed = prompt.trim();
|
|
4438
4484
|
if (!trimmed) return;
|
|
4439
4485
|
const fileContext = formatUploadedFileContext(files);
|
|
@@ -4499,7 +4545,13 @@ export default function DesignEditor() {
|
|
|
4499
4545
|
fileType: file.fileType,
|
|
4500
4546
|
})),
|
|
4501
4547
|
selectedScreenIds:
|
|
4502
|
-
viewMode === "overview"
|
|
4548
|
+
viewMode === "overview"
|
|
4549
|
+
? overviewSelectedScreenIds.length
|
|
4550
|
+
? overviewSelectedScreenIds
|
|
4551
|
+
: activeFile?.id
|
|
4552
|
+
? [activeFile.id]
|
|
4553
|
+
: []
|
|
4554
|
+
: [],
|
|
4503
4555
|
selectedElement,
|
|
4504
4556
|
hoveredElement,
|
|
4505
4557
|
mode,
|
|
@@ -4553,6 +4605,7 @@ export default function DesignEditor() {
|
|
|
4553
4605
|
mode,
|
|
4554
4606
|
activeTool,
|
|
4555
4607
|
activeInspectorTab,
|
|
4608
|
+
overviewSelectedScreenIds,
|
|
4556
4609
|
viewMode,
|
|
4557
4610
|
zoom,
|
|
4558
4611
|
]);
|
|
@@ -4610,7 +4663,13 @@ export default function DesignEditor() {
|
|
|
4610
4663
|
fileType: file.fileType,
|
|
4611
4664
|
})),
|
|
4612
4665
|
selectedScreenIds:
|
|
4613
|
-
viewMode === "overview"
|
|
4666
|
+
viewMode === "overview"
|
|
4667
|
+
? overviewSelectedScreenIds.length
|
|
4668
|
+
? overviewSelectedScreenIds
|
|
4669
|
+
: activeFile?.id
|
|
4670
|
+
? [activeFile.id]
|
|
4671
|
+
: []
|
|
4672
|
+
: [],
|
|
4614
4673
|
selectedElement,
|
|
4615
4674
|
mode,
|
|
4616
4675
|
activeTool,
|
|
@@ -4625,6 +4684,7 @@ export default function DesignEditor() {
|
|
|
4625
4684
|
files,
|
|
4626
4685
|
id,
|
|
4627
4686
|
mode,
|
|
4687
|
+
overviewSelectedScreenIds,
|
|
4628
4688
|
selectedElement,
|
|
4629
4689
|
tweakSelections,
|
|
4630
4690
|
viewMode,
|
|
@@ -4699,6 +4759,16 @@ export default function DesignEditor() {
|
|
|
4699
4759
|
([, value]) => value !== undefined,
|
|
4700
4760
|
);
|
|
4701
4761
|
if (entries.length === 0) return;
|
|
4762
|
+
// Base every patch off the freshest known content, not the closed-over
|
|
4763
|
+
// render value. Handlers that fire several onStyleChange calls in one
|
|
4764
|
+
// synchronous user action (e.g. fixed-size text → width+height+whiteSpace,
|
|
4765
|
+
// constraints center → both axes, linked padding → 4 sides) would
|
|
4766
|
+
// otherwise each read the same pre-render `activeContent` and clobber one
|
|
4767
|
+
// another, so only the last property survived in the saved HTML. Since we
|
|
4768
|
+
// advance lastLocalContentRef.current to resolvedNextContent below, the
|
|
4769
|
+
// next synchronous call reads the previous call's result and the patches
|
|
4770
|
+
// compose. Falls back to activeContent when the ref is unset (file switch).
|
|
4771
|
+
const baseContent = lastLocalContentRef.current ?? activeContent;
|
|
4702
4772
|
const [firstProperty, firstValue] = entries[0];
|
|
4703
4773
|
const capability =
|
|
4704
4774
|
selectedElement?.editCapabilities?.find((item) =>
|
|
@@ -4725,7 +4795,7 @@ export default function DesignEditor() {
|
|
|
4725
4795
|
: entries
|
|
4726
4796
|
.map(([property, value]) => `${property}: ${value}`)
|
|
4727
4797
|
.join("; "),
|
|
4728
|
-
previousContent:
|
|
4798
|
+
previousContent: baseContent,
|
|
4729
4799
|
capability: capability?.kind ?? "deterministic-style-edit",
|
|
4730
4800
|
confidence: capability?.confidence ?? 0.92,
|
|
4731
4801
|
status: "runtime",
|
|
@@ -4738,10 +4808,10 @@ export default function DesignEditor() {
|
|
|
4738
4808
|
});
|
|
4739
4809
|
}
|
|
4740
4810
|
|
|
4741
|
-
const nextContent = applyInlineStylesToHtml(
|
|
4811
|
+
const nextContent = applyInlineStylesToHtml(baseContent, selector, {
|
|
4742
4812
|
...Object.fromEntries(entries),
|
|
4743
4813
|
});
|
|
4744
|
-
const projection = buildCodeLayerProjection(
|
|
4814
|
+
const projection = buildCodeLayerProjection(baseContent);
|
|
4745
4815
|
const targetNode = resolveCodeLayerNodeFromBridge(
|
|
4746
4816
|
projection,
|
|
4747
4817
|
selector,
|
|
@@ -4769,7 +4839,7 @@ export default function DesignEditor() {
|
|
|
4769
4839
|
}
|
|
4770
4840
|
return { content: patch.content, failed: null };
|
|
4771
4841
|
},
|
|
4772
|
-
{ content:
|
|
4842
|
+
{ content: baseContent, failed: null },
|
|
4773
4843
|
);
|
|
4774
4844
|
const resolvedNextContent = stylePatch.failed
|
|
4775
4845
|
? nextContent
|
|
@@ -5140,6 +5210,7 @@ export default function DesignEditor() {
|
|
|
5140
5210
|
const html = getElementOuterHtml(activeContent, selectedElement.selector);
|
|
5141
5211
|
if (!html) return;
|
|
5142
5212
|
copiedLayerHtmlRef.current = html;
|
|
5213
|
+
pasteCascadeRef.current = 0;
|
|
5143
5214
|
setHasCanvasClipboard(true);
|
|
5144
5215
|
try {
|
|
5145
5216
|
await navigator.clipboard.writeText(html);
|
|
@@ -5152,32 +5223,67 @@ export default function DesignEditor() {
|
|
|
5152
5223
|
const handlePasteSelection = useCallback(
|
|
5153
5224
|
(position?: { x: number; y: number }) => {
|
|
5154
5225
|
if (!activeFile || !copiedLayerHtmlRef.current) return;
|
|
5226
|
+
// Explicit positions (e.g. "Paste here" at the cursor) are honored as-is.
|
|
5227
|
+
// Keyboard pastes cascade by 16px each so repeats don't stack exactly.
|
|
5228
|
+
const targetPosition =
|
|
5229
|
+
position ??
|
|
5230
|
+
(() => {
|
|
5231
|
+
const offset = pasteCascadeRef.current * 16;
|
|
5232
|
+
return { x: 120 + offset, y: 120 + offset };
|
|
5233
|
+
})();
|
|
5155
5234
|
const nextContent = cloneHtmlLayerAtPosition(
|
|
5156
5235
|
activeContent,
|
|
5157
5236
|
copiedLayerHtmlRef.current,
|
|
5158
|
-
|
|
5237
|
+
targetPosition,
|
|
5159
5238
|
);
|
|
5160
5239
|
if (!nextContent) return;
|
|
5240
|
+
if (!position) pasteCascadeRef.current += 1;
|
|
5161
5241
|
applyLocalContentUpdate(nextContent);
|
|
5162
5242
|
toast.success(t("designEditor.toasts.pasted"));
|
|
5163
5243
|
},
|
|
5164
5244
|
[activeContent, activeFile, applyLocalContentUpdate, t],
|
|
5165
5245
|
);
|
|
5166
5246
|
|
|
5247
|
+
const handlePasteOverSelection = useCallback(() => {
|
|
5248
|
+
if (!activeFile || !copiedLayerHtmlRef.current) return;
|
|
5249
|
+
if (selectedElement?.boundingRect) {
|
|
5250
|
+
const { x, y } = selectedElement.boundingRect;
|
|
5251
|
+
const nextContent = cloneHtmlLayerAtPosition(
|
|
5252
|
+
activeContent,
|
|
5253
|
+
copiedLayerHtmlRef.current,
|
|
5254
|
+
{ x, y },
|
|
5255
|
+
);
|
|
5256
|
+
if (!nextContent) return;
|
|
5257
|
+
applyLocalContentUpdate(nextContent);
|
|
5258
|
+
toast.success(t("designEditor.toasts.pasted"));
|
|
5259
|
+
} else {
|
|
5260
|
+
handlePasteSelection();
|
|
5261
|
+
}
|
|
5262
|
+
}, [
|
|
5263
|
+
activeContent,
|
|
5264
|
+
activeFile,
|
|
5265
|
+
applyLocalContentUpdate,
|
|
5266
|
+
handlePasteSelection,
|
|
5267
|
+
selectedElement,
|
|
5268
|
+
t,
|
|
5269
|
+
]);
|
|
5270
|
+
|
|
5167
5271
|
const handleDuplicateSelection = useCallback(() => {
|
|
5168
5272
|
if (selectedElement?.selector) {
|
|
5169
5273
|
const html = getElementOuterHtml(activeContent, selectedElement.selector);
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5274
|
+
const rect = selectedElement.boundingRect;
|
|
5275
|
+
const nextContent = html
|
|
5276
|
+
? cloneHtmlLayerAtPosition(activeContent, html, {
|
|
5277
|
+
x: rect.x + 16,
|
|
5278
|
+
y: rect.y + 16,
|
|
5279
|
+
})
|
|
5280
|
+
: null;
|
|
5281
|
+
if (nextContent) {
|
|
5282
|
+
applyLocalContentUpdate(nextContent);
|
|
5283
|
+
} else {
|
|
5284
|
+
toast.error(t("designEditor.toasts.duplicateElementFailed"));
|
|
5180
5285
|
}
|
|
5286
|
+
return;
|
|
5181
5287
|
}
|
|
5182
5288
|
if (activeFile) handleDuplicateScreen(activeFile.id);
|
|
5183
5289
|
}, [
|
|
@@ -5189,6 +5295,41 @@ export default function DesignEditor() {
|
|
|
5189
5295
|
]);
|
|
5190
5296
|
|
|
5191
5297
|
const handleDeleteSelection = useCallback(() => {
|
|
5298
|
+
// Multi-select delete: when several DOM/code layers are selected in the
|
|
5299
|
+
// panel, remove all of them — not just the single focused element. Compose
|
|
5300
|
+
// the removals against the running content (re-projecting each pass) so
|
|
5301
|
+
// nested selections resolve correctly and earlier removals aren't clobbered.
|
|
5302
|
+
const candidateIds = selectedLayerIdsState.filter(
|
|
5303
|
+
(layerId) =>
|
|
5304
|
+
layerId &&
|
|
5305
|
+
!layerId.startsWith("__") &&
|
|
5306
|
+
!files.some((file) => file.id === layerId),
|
|
5307
|
+
);
|
|
5308
|
+
if (candidateIds.length > 1) {
|
|
5309
|
+
let content = activeContent;
|
|
5310
|
+
const removedSelectors: string[] = [];
|
|
5311
|
+
for (const layerId of candidateIds) {
|
|
5312
|
+
const projection = buildCodeLayerProjection(content);
|
|
5313
|
+
const node =
|
|
5314
|
+
projection.nodes.find((candidate) => candidate.id === layerId) ??
|
|
5315
|
+
resolveCodeLayerNodeFromBridge(projection, layerId, layerId);
|
|
5316
|
+
if (!node) continue;
|
|
5317
|
+
const next = removeCodeLayerNodeFromHtml(content, node);
|
|
5318
|
+
if (!next) continue;
|
|
5319
|
+
const selector = preferredCodeLayerSelector(node);
|
|
5320
|
+
if (selector) removedSelectors.push(selector);
|
|
5321
|
+
content = next;
|
|
5322
|
+
}
|
|
5323
|
+
if (content !== activeContent) {
|
|
5324
|
+
removedSelectors.forEach((selector) => deleteRuntimeElement(selector));
|
|
5325
|
+
applyLocalContentUpdate(content, { refreshPreview: false });
|
|
5326
|
+
setSelectedElement(null);
|
|
5327
|
+
setSelectedLayerIdsState([]);
|
|
5328
|
+
return;
|
|
5329
|
+
}
|
|
5330
|
+
// Nothing resolved (stale ids) — fall through to the single path.
|
|
5331
|
+
}
|
|
5332
|
+
|
|
5192
5333
|
if (!selectedElement?.selector) return;
|
|
5193
5334
|
const projection = buildCodeLayerProjection(activeContent);
|
|
5194
5335
|
const targetNode = resolveCodeLayerNodeFromBridge(
|
|
@@ -5210,9 +5351,20 @@ export default function DesignEditor() {
|
|
|
5210
5351
|
activeContent,
|
|
5211
5352
|
applyLocalContentUpdate,
|
|
5212
5353
|
deleteRuntimeElement,
|
|
5354
|
+
files,
|
|
5213
5355
|
selectedElement,
|
|
5356
|
+
selectedLayerIdsState,
|
|
5214
5357
|
]);
|
|
5215
5358
|
|
|
5359
|
+
const handleCutSelection = useCallback(async () => {
|
|
5360
|
+
if (!selectedElement?.selector) return;
|
|
5361
|
+
// Copy first (populates the internal clipboard ref even if the async
|
|
5362
|
+
// navigator.clipboard write is blocked — handleCopySelection swallows that
|
|
5363
|
+
// error) then remove the element so a subsequent paste can re-insert it.
|
|
5364
|
+
await handleCopySelection();
|
|
5365
|
+
handleDeleteSelection();
|
|
5366
|
+
}, [handleCopySelection, handleDeleteSelection, selectedElement]);
|
|
5367
|
+
|
|
5216
5368
|
const handleDeleteOverviewSelection = useCallback(
|
|
5217
5369
|
(selectedIds: string[]) => {
|
|
5218
5370
|
if (!selectedIds.length || files.length <= 1) return false;
|
|
@@ -5752,8 +5904,9 @@ export default function DesignEditor() {
|
|
|
5752
5904
|
onCommentTool: handlePinToolToggle,
|
|
5753
5905
|
onScaleTool: handleScaleTool,
|
|
5754
5906
|
onCopy: handleCopySelection,
|
|
5907
|
+
onCut: handleCutSelection,
|
|
5755
5908
|
onPaste: () => handlePasteSelection(),
|
|
5756
|
-
onPasteOver:
|
|
5909
|
+
onPasteOver: handlePasteOverSelection,
|
|
5757
5910
|
onCopyProps: handleCopyProps,
|
|
5758
5911
|
onPasteProps: handlePasteProps,
|
|
5759
5912
|
onCopyAsCode: handleCopySelection,
|
|
@@ -6853,6 +7006,27 @@ ${serializedHtml}
|
|
|
6853
7006
|
|
|
6854
7007
|
const getContextCanvasPoint = useCallback(
|
|
6855
7008
|
({ clientX, clientY }: { clientX: number; clientY: number }) => {
|
|
7009
|
+
// In single-screen mode the iframe is inside a scale(zoom/100) wrapper
|
|
7010
|
+
// that also centers the content. Using the iframe's own
|
|
7011
|
+
// getBoundingClientRect() already incorporates centering/pan because the
|
|
7012
|
+
// rect is measured in screen space after the CSS transform. Dividing by
|
|
7013
|
+
// the zoom factor converts from post-scale screen-pixels back to the
|
|
7014
|
+
// document coordinate space written into left/top by cloneHtmlLayerAtPosition.
|
|
7015
|
+
if (viewMode === "single") {
|
|
7016
|
+
const iframe = canvasContainerRef.current?.querySelector<HTMLElement>(
|
|
7017
|
+
"[data-design-preview-iframe]",
|
|
7018
|
+
);
|
|
7019
|
+
if (iframe) {
|
|
7020
|
+
const iframeRect = iframe.getBoundingClientRect();
|
|
7021
|
+
const factor = zoom / 100;
|
|
7022
|
+
return {
|
|
7023
|
+
x: Math.max(0, (clientX - iframeRect.left) / factor),
|
|
7024
|
+
y: Math.max(0, (clientY - iframeRect.top) / factor),
|
|
7025
|
+
};
|
|
7026
|
+
}
|
|
7027
|
+
}
|
|
7028
|
+
// Overview mode: fall back to container-relative coords (overview uses its
|
|
7029
|
+
// own coordinate mapping for paste; this value is a best-effort fallback).
|
|
6856
7030
|
const rect = canvasContainerRef.current?.getBoundingClientRect();
|
|
6857
7031
|
if (!rect) return { x: 120, y: 120 };
|
|
6858
7032
|
return {
|
|
@@ -6860,7 +7034,7 @@ ${serializedHtml}
|
|
|
6860
7034
|
y: Math.max(0, clientY - rect.top),
|
|
6861
7035
|
};
|
|
6862
7036
|
},
|
|
6863
|
-
[],
|
|
7037
|
+
[zoom, viewMode],
|
|
6864
7038
|
);
|
|
6865
7039
|
|
|
6866
7040
|
const zoomLabel = `${Math.round(zoom)}%`;
|
|
@@ -7785,8 +7959,10 @@ ${serializedHtml}
|
|
|
7785
7959
|
canPasteHere={hasCanvasClipboard && Boolean(activeFile)}
|
|
7786
7960
|
canSelectAll={files.length > 0}
|
|
7787
7961
|
canZoomToFit={Boolean(activeFile)}
|
|
7788
|
-
canZoomToSelection={
|
|
7789
|
-
|
|
7962
|
+
canZoomToSelection={
|
|
7963
|
+
Boolean(selectedElement) || viewMode === "overview"
|
|
7964
|
+
}
|
|
7965
|
+
canCopy={Boolean(selectedElement?.selector)}
|
|
7790
7966
|
canPaste={hasCanvasClipboard && Boolean(activeFile)}
|
|
7791
7967
|
canPasteOver={hasCanvasClipboard && Boolean(activeFile)}
|
|
7792
7968
|
canDuplicate={Boolean(activeFile)}
|
|
@@ -7799,7 +7975,7 @@ ${serializedHtml}
|
|
|
7799
7975
|
canToggleHidden={Boolean(activeLayerId)}
|
|
7800
7976
|
canCopyProps={Boolean(selectedElement)}
|
|
7801
7977
|
canPasteProps={hasPropsClipboard && Boolean(selectedElement)}
|
|
7802
|
-
canCopyAsCode={Boolean(
|
|
7978
|
+
canCopyAsCode={Boolean(selectedElement?.selector)}
|
|
7803
7979
|
hiddenActions={["group", "ungroup", "rename"]}
|
|
7804
7980
|
getCanvasPoint={getContextCanvasPoint}
|
|
7805
7981
|
onPasteHere={(details) =>
|
|
@@ -7816,10 +7992,12 @@ ${serializedHtml}
|
|
|
7816
7992
|
setActiveTool("move");
|
|
7817
7993
|
setZoom(100);
|
|
7818
7994
|
}}
|
|
7819
|
-
onZoomToSelection={() =>
|
|
7995
|
+
onZoomToSelection={() => {
|
|
7996
|
+
if (selectedElement || viewMode === "overview") setZoom(150);
|
|
7997
|
+
}}
|
|
7820
7998
|
onCopy={handleCopySelection}
|
|
7821
7999
|
onPaste={() => handlePasteSelection()}
|
|
7822
|
-
onPasteOver={
|
|
8000
|
+
onPasteOver={handlePasteOverSelection}
|
|
7823
8001
|
onDuplicate={handleDuplicateSelection}
|
|
7824
8002
|
onDelete={handleDeleteSelection}
|
|
7825
8003
|
onBringForward={() => changeSelectedZIndex("forward")}
|
|
@@ -7864,6 +8042,7 @@ ${serializedHtml}
|
|
|
7864
8042
|
onCreatePrimitive={handleCreatePrimitive}
|
|
7865
8043
|
onCreateScreenFrame={handleCreateScreenFrame}
|
|
7866
8044
|
onDeleteSelection={handleDeleteOverviewSelection}
|
|
8045
|
+
onSelectionChange={setOverviewSelectedScreenIds}
|
|
7867
8046
|
onPick={(id) => {
|
|
7868
8047
|
setSelectedElement(null);
|
|
7869
8048
|
setSelectedLayerIdsState([id]);
|
|
@@ -8186,6 +8365,7 @@ ${serializedHtml}
|
|
|
8186
8365
|
title={t("designEditor.tweaksPromptTitle")}
|
|
8187
8366
|
placeholder={t("designEditor.tweaksPlaceholder")}
|
|
8188
8367
|
onSubmit={handleTweakPromptSubmit}
|
|
8368
|
+
loading={generating || pendingGenerationActive}
|
|
8189
8369
|
anchorRef={tweakPromptAnchorRef}
|
|
8190
8370
|
/>
|
|
8191
8371
|
</div>
|
|
@@ -10,6 +10,7 @@ resizing, moving/reparenting, the layers panel, and the iframe bridge.
|
|
|
10
10
|
pnpm e2e # headless
|
|
11
11
|
pnpm e2e:headed # watch it drive a real browser
|
|
12
12
|
pnpm e2e:ui # Playwright UI mode
|
|
13
|
+
E2E_BROWSER_CHANNEL=chrome pnpm e2e # run the suite in installed Google Chrome
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
- `playwright.config.ts` starts its own dev server on **:9333** backed by a
|
|
@@ -18,6 +18,7 @@ export const SEED_TITLE = "E2E Seed Design";
|
|
|
18
18
|
const AUTH_DIR = path.join(import.meta.dirname, ".auth");
|
|
19
19
|
const STATE_PATH = path.join(AUTH_DIR, "state.json");
|
|
20
20
|
const SEED_PATH = path.join(AUTH_DIR, "seed.json");
|
|
21
|
+
const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Fixture HTML with distinct, text-identifiable elements. Plain inline styles
|
|
@@ -40,6 +41,15 @@ export const FIXTURE_HTML = `<!doctype html>
|
|
|
40
41
|
<button style="padding:14px 28px;border-radius:10px;border:0;background:#6366f1;color:#fff;font-size:16px">Alpha Button</button>
|
|
41
42
|
<button style="padding:14px 28px;border-radius:10px;border:0;background:#22c55e;color:#06240f;font-size:16px">Beta Button</button>
|
|
42
43
|
</div>
|
|
44
|
+
<div style="padding:8px;border:1px solid #27272a;border-radius:12px">
|
|
45
|
+
<div style="padding:8px;border:1px solid #3f3f46;border-radius:10px">
|
|
46
|
+
<div style="padding:8px;border:1px solid #52525b;border-radius:8px">
|
|
47
|
+
<div style="padding:8px;border:1px solid #71717a;border-radius:6px">
|
|
48
|
+
<button style="padding:10px 18px;border-radius:8px;border:0;background:#f59e0b;color:#111827;font-size:14px">Deep Layer Button</button>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
43
53
|
<section style="margin-top:16px;padding:24px;border-radius:14px;background:#1a1d24">
|
|
44
54
|
<h2 style="font-size:24px;margin:0 0 8px">Fixture Card Title</h2>
|
|
45
55
|
<p style="margin:0;color:#a1a1aa">Card body text inside a nested container.</p>
|
|
@@ -72,7 +82,9 @@ export default async function globalSetup(config: FullConfig) {
|
|
|
72
82
|
"http://127.0.0.1:9333";
|
|
73
83
|
await mkdir(AUTH_DIR, { recursive: true });
|
|
74
84
|
|
|
75
|
-
const browser = await chromium.launch(
|
|
85
|
+
const browser = await chromium.launch(
|
|
86
|
+
BROWSER_CHANNEL ? { channel: BROWSER_CHANNEL } : {},
|
|
87
|
+
);
|
|
76
88
|
const context = await browser.newContext();
|
|
77
89
|
const page = await context.newPage();
|
|
78
90
|
|
|
@@ -15,6 +15,7 @@ import { defineConfig, devices } from "@playwright/test";
|
|
|
15
15
|
*/
|
|
16
16
|
const PORT = Number(process.env.E2E_PORT ?? 9333);
|
|
17
17
|
const BASE_URL = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${PORT}`;
|
|
18
|
+
const BROWSER_CHANNEL = process.env.E2E_BROWSER_CHANNEL;
|
|
18
19
|
|
|
19
20
|
export default defineConfig({
|
|
20
21
|
testDir: "./e2e",
|
|
@@ -33,7 +34,15 @@ export default defineConfig({
|
|
|
33
34
|
actionTimeout: 15_000,
|
|
34
35
|
navigationTimeout: 30_000,
|
|
35
36
|
},
|
|
36
|
-
projects: [
|
|
37
|
+
projects: [
|
|
38
|
+
{
|
|
39
|
+
name: BROWSER_CHANNEL ? `chromium-${BROWSER_CHANNEL}` : "chromium",
|
|
40
|
+
use: {
|
|
41
|
+
...devices["Desktop Chrome"],
|
|
42
|
+
...(BROWSER_CHANNEL ? { channel: BROWSER_CHANNEL } : {}),
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
],
|
|
37
46
|
webServer: process.env.E2E_BASE_URL
|
|
38
47
|
? undefined
|
|
39
48
|
: {
|