From f5ec17a7ac26035c15ea185a1783f468759a2708 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Wed, 15 Jul 2026 17:19:42 +0200 Subject: [PATCH 1/7] figma auto layout parity --- .../bridge/editor-chrome.generated.ts | 251 ++++++++- .../app/components/design/DesignCanvas.tsx | 19 +- .../design/bridge/bridge.guard.spec.ts | 357 +++++++++++++ .../design/bridge/editor-chrome.bridge.ts | 346 +++++++++++- .../app/pages/DesignEditor.selection.test.ts | 26 + templates/design/app/pages/DesignEditor.tsx | 2 +- .../design/app/pages/design-editor/history.ts | 12 +- templates/design/shared/drag-reflow.test.ts | 493 ++++++++++++++++++ templates/design/shared/drag-reflow.ts | 390 ++++++++++++++ 9 files changed, 1872 insertions(+), 24 deletions(-) create mode 100644 templates/design/shared/drag-reflow.test.ts create mode 100644 templates/design/shared/drag-reflow.ts diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 78f7f351d2..aa61e1e546 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -16,6 +16,13 @@ export const editorChromeBridgeScript: string = `"use strict"; var designCanvasContentOffsetX = Number(__DESIGN_CANVAS_CONTENT_OFFSET_X__) || 0; var designCanvasContentOffsetY = Number(__DESIGN_CANVAS_CONTENT_OFFSET_Y__) || 0; var runtimeLayerSnapshotEnabled = !!__RUNTIME_LAYER_SNAPSHOT_ENABLED__; + var liveReflowEnabled = (function() { + try { + return !!__LIVE_REFLOW_ENABLED__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; var statePreviewElement = null; var runtimeInteractionStatePreviews = []; @@ -5441,11 +5448,210 @@ export const editorChromeBridgeScript: string = `"use strict"; return; } if (isFlowReorderCandidate(gestureEl)) { - let onReorderMove2 = function(ev) { + let applyReorderLift2 = function(dx, dy) { + if (!liveReflowEnabled) return; + groupEls.forEach(function(member) { + var el = member; + var snap = reorderLiftedMembers.filter(function(s) { + return s.el === el; + })[0]; + if (!snap) { + snap = { + el, + prevTransform: el.style.transform, + prevTransition: el.style.transition, + prevZIndex: el.style.zIndex, + prevBoxShadow: el.style.boxShadow, + prevWillChange: el.style.willChange, + prevPointerEvents: el.style.pointerEvents + }; + reorderLiftedMembers.push(snap); + el.style.transition = "none"; + el.style.willChange = "transform"; + el.style.zIndex = "2147483646"; + el.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.18)"; + el.style.pointerEvents = "none"; + } + var base = snap.prevTransform ? snap.prevTransform + " " : ""; + el.style.transform = base + "translate(" + dx + "px, " + dy + "px)"; + }); + }, clearReorderLift2 = function() { + reorderLiftedMembers.forEach(function(snap) { + var el = snap.el; + el.style.transform = snap.prevTransform; + el.style.transition = snap.prevTransition; + el.style.zIndex = snap.prevZIndex; + el.style.boxShadow = snap.prevBoxShadow; + el.style.willChange = snap.prevWillChange; + el.style.pointerEvents = snap.prevPointerEvents; + }); + reorderLiftedMembers = []; + }, reorderMainAxis2 = function(target) { + return target && target.axis === "y" ? "y" : "x"; + }, reorderRealChildren2 = function(container) { + var out = []; + var kids = container.children; + for (var i = 0; i < kids.length; i += 1) { + var k = kids[i]; + if (k.nodeType === 1 && !isOverlayElement(k)) out.push(k); + } + return out; + }, reorderSlotForTarget2 = function(target, real) { + if (target.placement === "inside") { + return { slot: real.length, boundary: null }; + } + var ai = real.indexOf(target.anchor); + if (ai < 0) return null; + var rect = target.anchor.getBoundingClientRect(); + var axis = reorderMainAxis2(target); + if (target.placement === "before") { + return { slot: ai, boundary: axis === "x" ? rect.left : rect.top }; + } + return { + slot: ai + 1, + boundary: axis === "x" ? rect.right : rect.bottom + }; + }, containerIsSimplePacked2 = function(container) { + if (packedCacheContainer === container) return packedCacheResult; + packedCacheContainer = container; + packedCacheResult = false; + var cs = window.getComputedStyle(container); + if (cs.display !== "flex" && cs.display !== "inline-flex") return false; + if (cs.flexDirection !== "row" && cs.flexDirection !== "column") { + return false; + } + if (cs.flexWrap !== "nowrap") return false; + var jc = cs.justifyContent; + if (jc !== "flex-start" && jc !== "start" && jc !== "normal" && jc !== "left" && jc !== "") { + return false; + } + var kids = container.children; + for (var i = 0; i < kids.length; i += 1) { + if (kids[i].nodeType !== 1) continue; + if (parseFloat(window.getComputedStyle(kids[i]).flexGrow) > 0) { + return false; + } + } + packedCacheResult = true; + return true; + }, reorderMainGap2 = function(container, axis) { + var cs = window.getComputedStyle(container); + var raw = axis === "x" ? cs.columnGap || cs.gap : cs.rowGap || cs.gap; + var n = readPx(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + }, clearReorderReflow2 = function() { + reflowSiblings.forEach(function(s) { + s.el.style.transform = s.prevTransform; + s.el.style.transition = s.prevTransition; + }); + reflowSiblings = []; + reflowKey = null; + }, applyReorderSizeGuard2 = function(target, ev) { + if (!liveReflowEnabled || !target || target.placement !== "inside") { + return target; + } + if (ev && (ev.metaKey || ev.ctrlKey)) return target; + var container = dropContainerForTarget(target); + if (!container || container === reorderEl) return target; + var crect = container.getBoundingClientRect(); + var drect = reorderEl.getBoundingClientRect(); + if (crect.width < drect.width || crect.height < drect.height) { + return null; + } + return target; + }, stabilizeReorderTarget2 = function(rawTarget, cx, cy, now) { + if (!liveReflowEnabled || !rawTarget || rawTarget.dropMode !== "flow-insert") { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var container = dropContainerForTarget(rawTarget); + if (!container || reorderEl.parentElement !== container) { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var real = reorderRealChildren2(container); + var slotInfo = reorderSlotForTarget2(rawTarget, real); + if (!slotInfo) { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var axis = reorderMainAxis2(rawTarget); + var pointerMain = axis === "x" ? cx : cy; + if (reorderCommittedSlot === null || slotInfo.slot === reorderCommittedSlot) { + reorderCommittedSlot = slotInfo.slot; + reorderCommittedTarget = rawTarget; + if (reorderCommittedAt === 0) reorderCommittedAt = now; + return rawTarget; + } + var crossed = slotInfo.boundary !== null && Math.abs(pointerMain - slotInfo.boundary) >= 8; + var dwelled = now - reorderCommittedAt >= 60; + if (crossed || dwelled) { + reorderCommittedSlot = slotInfo.slot; + reorderCommittedTarget = rawTarget; + reorderCommittedAt = now; + return rawTarget; + } + return reorderCommittedTarget || rawTarget; + }, applyReorderReflow2 = function(target, cx, cy) { + if (!liveReflowEnabled) return; + if (!target || target.dropMode !== "flow-insert") { + clearReorderReflow2(); + return; + } + var container = dropContainerForTarget(target); + if (!container || reorderEl.parentElement !== container || !containerIsSimplePacked2(container)) { + clearReorderReflow2(); + return; + } + var real = reorderRealChildren2(container); + var originIndex = real.indexOf(reorderEl); + var slotInfo = reorderSlotForTarget2(target, real); + if (originIndex < 0 || !slotInfo) { + clearReorderReflow2(); + return; + } + var axis = reorderMainAxis2(target); + var key = axis + ":" + slotInfo.slot; + if (key === reflowKey) return; + clearReorderReflow2(); + reflowKey = key; + var drect = reorderEl.getBoundingClientRect(); + var slotMain = (axis === "x" ? drect.width : drect.height) + reorderMainGap2(container, axis); + var offsets = new Array(real.length).fill(0); + if (slotInfo.slot > originIndex + 1) { + for (var a = originIndex + 1; a <= slotInfo.slot - 1; a += 1) { + offsets[a] = -slotMain; + } + } else if (slotInfo.slot < originIndex) { + for (var b = slotInfo.slot; b <= originIndex - 1; b += 1) { + offsets[b] = slotMain; + } + } + for (var i = 0; i < real.length; i += 1) { + if (i === originIndex) continue; + var el = real[i]; + var prevTransform = el.style.transform; + reflowSiblings.push({ + el, + prevTransform, + prevTransition: el.style.transition + }); + el.style.transition = "transform 140ms cubic-bezier(0.2, 0, 0, 1)"; + var base = prevTransform ? prevTransform + " " : ""; + var tx = axis === "x" ? offsets[i] : 0; + var ty = axis === "y" ? offsets[i] : 0; + el.style.transform = base + "translate(" + tx + "px, " + ty + "px)"; + } + }, onReorderMove2 = function(ev) { var vw = window.innerWidth; var vh = window.innerHeight; var cx = ev.clientX; var cy = ev.clientY; + var dx = cx - reorderPointerStart.clientX; + var dy = cy - reorderPointerStart.clientY; var outside = cx < 0 || cy < 0 || cx > vw || cy > vh; pointerOutsideIframe = outside; if (!isGroupDrag) { @@ -5467,9 +5673,11 @@ export const editorChromeBridgeScript: string = `"use strict"; } if (outside && !isGroupDrag) { hideInsertionGuide(); + clearReorderLift2(); + clearReorderReflow2(); showTransformBadge("Move layer", cx, cy); } else { - currentTarget = flowMoveTargetForPoint( + var rawTarget = flowMoveTargetForPoint( reorderEl, cx, cy, @@ -5477,7 +5685,16 @@ export const editorChromeBridgeScript: string = `"use strict"; keepCurrentFlowParent, Boolean(ev.ctrlKey) ); + rawTarget = applyReorderSizeGuard2(rawTarget, ev); + currentTarget = stabilizeReorderTarget2( + rawTarget, + cx, + cy, + ev.timeStamp + ); showInsertionGuideFor(currentTarget); + applyReorderLift2(dx, dy); + applyReorderReflow2(currentTarget, cx, cy); showTransformBadge(currentTarget ? "Move layer" : "Move", cx, cy); } }, cleanupReorderDrag2 = function() { @@ -5486,6 +5703,8 @@ export const editorChromeBridgeScript: string = `"use strict"; document.removeEventListener("keydown", onReorderKeyDown2, true); document.removeEventListener("keyup", onReorderKeyUp2, true); clearActiveDragCancel(onReorderEscape2); + clearReorderLift2(); + clearReorderReflow2(); }, onReorderEscape2 = function() { cleanupReorderDrag2(); hideTransformBadge(); @@ -5544,14 +5763,16 @@ export const editorChromeBridgeScript: string = `"use strict"; ); } if (outsideOnDrop) return; - currentTarget = flowMoveTargetForPoint( - reorderEl, - cx, - cy, - groupOthers, - keepCurrentFlowParent, - Boolean(ev?.ctrlKey) - ); + if (!liveReflowEnabled) { + currentTarget = flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + Boolean(ev?.ctrlKey) + ); + } if (!currentTarget) { if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) { if (reorderEl.parentElement) @@ -5615,7 +5836,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }); } }; - var onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; + var applyReorderLift = applyReorderLift2, clearReorderLift = clearReorderLift2, reorderMainAxis = reorderMainAxis2, reorderRealChildren = reorderRealChildren2, reorderSlotForTarget = reorderSlotForTarget2, containerIsSimplePacked = containerIsSimplePacked2, reorderMainGap = reorderMainGap2, clearReorderReflow = clearReorderReflow2, applyReorderSizeGuard = applyReorderSizeGuard2, stabilizeReorderTarget = stabilizeReorderTarget2, applyReorderReflow = applyReorderReflow2, onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; var reorderEl = gestureEl; var reorderGroupStartRects = groupEls.map(function(member) { return member.getBoundingClientRect(); @@ -5649,6 +5870,14 @@ export const editorChromeBridgeScript: string = `"use strict"; x: reorderPointerStart.clientX - reorderRect.left, y: reorderPointerStart.clientY - reorderRect.top }; + var reorderLiftedMembers = []; + var reorderCommittedTarget = null; + var reorderCommittedSlot = null; + var reorderCommittedAt = 0; + var reflowSiblings = []; + var reflowKey = null; + var packedCacheContainer = null; + var packedCacheResult = false; document.addEventListener(events.move, onReorderMove2, true); document.addEventListener(events.up, onReorderUp2, true); document.addEventListener("keydown", onReorderKeyDown2, true); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index 4f9c70af4d..3b642fea84 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -301,6 +301,15 @@ ${editorChromeBridgeScript} `; +/** + * Master switch for the Figma-parity live-reflow drag (hysteresis-stabilized + * targeting + size guard in Phase 0; transform lift/follow, live sibling + * reflow, and exact absolute commit in Phase 1). Flip to `true` to try the new + * drag feel; flip back to `false` for the previous behavior without a revert. + * Baked into the injected bridge as `__LIVE_REFLOW_ENABLED__`. + */ +const LIVE_REFLOW_ENABLED = true; + interface DesignCanvasProps { content: string; contentKey?: string; @@ -923,6 +932,10 @@ function buildEditorChromeBridgeScript(args: { "__RUNTIME_LAYER_SNAPSHOT_ENABLED__", args.runtimeLayerSnapshotEnabled ? "true" : "false", ) + .replace( + "__LIVE_REFLOW_ENABLED__", + LIVE_REFLOW_ENABLED ? "true" : "false", + ) ); } @@ -2095,7 +2108,11 @@ export function DesignCanvas({ "__DESIGN_CANVAS_CONTENT_OFFSET_Y__", String(Math.round(embeddedFrame?.contentOffsetY ?? 0)), ) - .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", "false"); + .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", "false") + .replace( + "__LIVE_REFLOW_ENABLED__", + LIVE_REFLOW_ENABLED ? "true" : "false", + ); // ALWAYS injected (like the other always-on bridges above) so // MultiScreenCanvas's cross-screen drag hit-testing // (agent-native:hit-test / agent-native:hit-test-result) resolves an diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index cc7fd059a7..fb02352b99 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -74,6 +74,26 @@ function hydratedEditorChromeBridgeScript( ); } +// Same hydration but with the Figma-parity live-reflow drag enabled, for the +// Phase 1 lift/follow behavioral tests below. The default helper leaves +// __LIVE_REFLOW_ENABLED__ unreplaced, which the bridge's `typeof` guard reads +// as off — so every existing test runs with the feature disabled. +function hydratedEditorChromeBridgeScriptWithLiveReflow( + screenId = "bridge-guard", +): string { + return editorChromeBridgeScript + .replace("__READ_ONLY__", "false") + .replace("__TEXT_EDITING_ENABLED__", "false") + .replace("__EDITOR_CHROME_SCALE_X__", "1") + .replace("__EDITOR_CHROME_SCALE_Y__", "1") + .replace("__DESIGN_CANVAS_SCREEN_ID__", JSON.stringify(screenId)) + .replace("__DESIGN_CANVAS_BOARD_SURFACE__", "false") + .replace("__DESIGN_CANVAS_CONTENT_OFFSET_X__", "0") + .replace("__DESIGN_CANVAS_CONTENT_OFFSET_Y__", "0") + .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", "false") + .replace("__LIVE_REFLOW_ENABLED__", "true"); +} + function hydratedBoardEditorChromeBridgeScriptWithOffset( x: number, y: number, @@ -894,6 +914,343 @@ it( }, ); +it( + "editor chrome bridge (live reflow) lifts and follows a dragged flow element, then restores it on drop", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
C
+
+ +`); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScriptWithLiveReflow(), + }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + // Select child A. + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + // Install the message collector AFTER setContent (setContent replaces the + // document and would wipe a listener added earlier), then reset it so we + // only observe messages from the drag below. + await page.evaluate(() => { + (window as any).__bridgeMessages = []; + window.addEventListener("message", (event: MessageEvent) => { + (window as any).__bridgeMessages.push(event.data); + }); + }); + + // Begin the drag and move toward the end of the row. + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(330, 50, { steps: 10 }); + + // While dragging, the element must follow the cursor with a lift. + await page.waitForFunction(() => { + const a = document.querySelector("#a"); + return !!a && a.style.transform.includes("translate"); + }); + const during = await page.evaluate(() => { + const a = document.querySelector("#a")!; + return { + transform: a.style.transform, + boxShadow: a.style.boxShadow, + pointerEvents: a.style.pointerEvents, + }; + }); + + await page.mouse.up(); + await page.waitForTimeout(30); + + const after = await page.evaluate(() => { + const a = document.querySelector("#a")!; + return { + transform: a.style.transform, + boxShadow: a.style.boxShadow, + pointerEvents: a.style.pointerEvents, + order: Array.from( + document.querySelectorAll("#row > div"), + ).map((el) => el.id), + messageTypes: ((window as any).__bridgeMessages ?? []).map( + (m: { type?: string }) => m.type, + ), + }; + }); + + // During: lifted + following the cursor. + expect(during.transform).toContain("translate"); + expect(during.boxShadow).not.toBe(""); + expect(during.pointerEvents).toBe("none"); + // After drop: fully restored to the original (no residual lift). + expect(after.transform).toBe(""); + expect(after.boxShadow).toBe(""); + expect(after.pointerEvents).toBe(""); + // The reorder still commits end-to-end (A left its original slot). + expect(after.messageTypes).toContain("visual-structure-change"); + expect(after.order).not.toEqual(["a", "b", "c"]); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + +it( + "editor chrome bridge (live reflow OFF) leaves a dragged flow element untransformed", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
C
+
+ +`); + // Default hydration → __LIVE_REFLOW_ENABLED__ unreplaced → feature off. + await page.addScriptTag({ content: hydratedEditorChromeBridgeScript() }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(330, 50, { steps: 10 }); + const duringTransform = await page.evaluate( + () => document.querySelector("#a")!.style.transform, + ); + await page.mouse.up(); + await page.waitForTimeout(20); + + // With the feature off, the legacy reorder path never transforms the + // dragged element (the insertion guide is the only feedback). + expect(duringTransform).not.toContain("translate"); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + +it( + "editor chrome bridge (live reflow) Ctrl-drag free-places a flow element at the exact release point", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
+ +`); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScriptWithLiveReflow(), + }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + // Select A (top-left at 20,20; grab at its center 70,50 → grab offset 50,30). + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + + // Ctrl-drag into open space below the row and release at (400, 300). + await page.keyboard.down("Control"); + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(400, 300, { steps: 10 }); + await page.mouse.up(); + await page.keyboard.up("Control"); + await page.waitForTimeout(40); + + const result = await page.evaluate(() => { + const a = document.querySelector("#a")!; + const rect = a.getBoundingClientRect(); + return { + position: window.getComputedStyle(a).position, + rectLeft: rect.left, + rectTop: rect.top, + inlineTransform: a.style.transform, + }; + }); + + // Free-placed as absolute, landing where released minus the grab offset + // (release 400,300 − grab offset 50,30 ≈ 350,270), and the lift transform + // is fully cleared. + expect(result.position).toBe("absolute"); + expect(result.rectLeft).toBeGreaterThan(335); + expect(result.rectLeft).toBeLessThan(365); + expect(result.rectTop).toBeGreaterThan(255); + expect(result.rectTop).toBeLessThan(285); + expect(result.inlineTransform).toBe(""); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + +it( + "editor chrome bridge (live reflow) parts siblings during a packed reorder, then restores them on drop", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
C
+
D
+
+ +`); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScriptWithLiveReflow(), + }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + + // Plain drag (no modifier) of A rightward → reorder; siblings between the + // origin and the drop slot must translate aside to open the gap. + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(250, 50, { steps: 12 }); + + await page.waitForFunction(() => + ["b", "c", "d"].some((id) => + (document.getElementById(id)?.style.transform ?? "").includes( + "translate", + ), + ), + ); + const during = await page.evaluate(() => + ["b", "c", "d"].map((id) => ({ + transform: document.getElementById(id)!.style.transform, + transition: document.getElementById(id)!.style.transition, + })), + ); + + await page.mouse.up(); + await page.waitForTimeout(40); + + const after = await page.evaluate(() => ({ + transforms: ["b", "c", "d"].map( + (id) => document.getElementById(id)!.style.transform, + ), + order: Array.from( + document.querySelectorAll("#row > div"), + ).map((el) => el.id), + })); + + // During: at least one sibling parted with an animated transform. + expect(during.some((s) => s.transform.includes("translate"))).toBe(true); + expect(during.some((s) => s.transition.includes("transform"))).toBe(true); + // After drop: every reflow transform is cleared and A actually moved. + expect(after.transforms.every((t) => t === "")).toBe(true); + expect(after.order).not.toEqual(["a", "b", "c", "d"]); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + it( "editor chrome bridge keeps the previous primary outlined during shift-click multi-select", { timeout: 30_000 }, diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 8bfe6d8a5d..cd648dbc3f 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -34,6 +34,7 @@ declare var __DESIGN_CANVAS_BOARD_SURFACE__: boolean; declare var __DESIGN_CANVAS_CONTENT_OFFSET_X__: number; declare var __DESIGN_CANVAS_CONTENT_OFFSET_Y__: number; declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; +declare var __LIVE_REFLOW_ENABLED__: boolean; (function () { // Idempotency guard: replace-document-content / srcdoc rebuilds can end up @@ -62,6 +63,21 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; var designCanvasContentOffsetY = Number(__DESIGN_CANVAS_CONTENT_OFFSET_Y__) || 0; var runtimeLayerSnapshotEnabled = !!__RUNTIME_LAYER_SNAPSHOT_ENABLED__; + // Figma-parity live-reflow drag (Phase 0 + 1: hysteresis-stabilized target + // resolution, size guard, transform lift/follow, live sibling reflow, + // exact absolute commit). Gated so it can be flipped off without a revert. + // The placeholder is referenced EXACTLY ONCE so the host's single + // String.replace fully substitutes it; the try/catch keeps it crash-safe if + // an injection site forgets to replace it (an un-replaced identifier read + // throws ReferenceError, which we treat as "off") — the behavior is additive + // and rolling out incrementally. + var liveReflowEnabled = (function () { + try { + return !!__LIVE_REFLOW_ENABLED__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; // Interaction-state forced preview (phase 2 — see shared/interaction-states.ts's @@ -8251,11 +8267,293 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; x: reorderPointerStart.clientX - reorderRect.left, y: reorderPointerStart.clientY - reorderRect.top, }; + // Phase 1.1 — lift & follow (gated by liveReflowEnabled). While dragging + // a flow element, translate it (and any group members) to follow the + // cursor with a subtle lift, so it moves WITH your hand instead of + // sitting frozen under a lonely insertion line. Pure `transform` — no + // layout, no reflow — so it never disturbs the slot it will land in, is + // trivially reverted, and (being cleared before any pointer-up commit + // math) never corrupts a getBoundingClientRect read. Live sibling reflow + // (the gap opening around the cursor) is a later phase; this alone turns + // the "dead" reorder drag into a live one. + var reorderLiftedMembers: { + el: HTMLElement; + prevTransform: string; + prevTransition: string; + prevZIndex: string; + prevBoxShadow: string; + prevWillChange: string; + prevPointerEvents: string; + }[] = []; + function applyReorderLift(dx: number, dy: number): void { + if (!liveReflowEnabled) return; + groupEls.forEach(function (member) { + var el = member as HTMLElement; + var snap = reorderLiftedMembers.filter(function (s) { + return s.el === el; + })[0]; + if (!snap) { + snap = { + el: el, + prevTransform: el.style.transform, + prevTransition: el.style.transition, + prevZIndex: el.style.zIndex, + prevBoxShadow: el.style.boxShadow, + prevWillChange: el.style.willChange, + prevPointerEvents: el.style.pointerEvents, + }; + reorderLiftedMembers.push(snap); + el.style.transition = "none"; + el.style.willChange = "transform"; + el.style.zIndex = "2147483646"; + el.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.18)"; + // Hit-test through the lifted element so it never resolves as its + // own drop target while following the cursor. + el.style.pointerEvents = "none"; + } + // Compose with any pre-existing inline transform (prototype HTML is + // user content and may define its own) rather than clobbering it. + var base = snap.prevTransform ? snap.prevTransform + " " : ""; + el.style.transform = base + "translate(" + dx + "px, " + dy + "px)"; + }); + } + function clearReorderLift(): void { + reorderLiftedMembers.forEach(function (snap) { + var el = snap.el; + el.style.transform = snap.prevTransform; + el.style.transition = snap.prevTransition; + el.style.zIndex = snap.prevZIndex; + el.style.boxShadow = snap.prevBoxShadow; + el.style.willChange = snap.prevWillChange; + el.style.pointerEvents = snap.prevPointerEvents; + }); + reorderLiftedMembers = []; + } + // ── Phase 0.1 hysteresis + Phase 1.2 live sibling reflow ────────────── + // Stabilize the resolved drop target so the preview + gap don't strobe on + // a boundary, then (for a SAME-container, simple packed flex row/column + // only) translate the siblings aside so the OPEN GAP is the preview of + // exactly where the item lands. Non-packed / wrap / grid / space-between / + // flex-grow / cross-container all fall back to the indicator-only line + // (option 1: never animate a preview that wouldn't match the real drop). + // Mirrors the tested reference in shared/drag-reflow.ts + // (resolveTargetHysteresis / isSimplePackedContainer / computeReorderOffsets). + var reorderCommittedTarget: any = null; + var reorderCommittedSlot: number | null = null; + var reorderCommittedAt = 0; + var reflowSiblings: { + el: HTMLElement; + prevTransform: string; + prevTransition: string; + }[] = []; + var reflowKey: string | null = null; + var packedCacheContainer: Element | null = null; + var packedCacheResult = false; + function reorderMainAxis(target): "x" | "y" { + return target && target.axis === "y" ? "y" : "x"; + } + function reorderRealChildren(container: Element): Element[] { + var out: Element[] = []; + var kids = container.children; + for (var i = 0; i < kids.length; i += 1) { + var k = kids[i]; + if (k.nodeType === 1 && !isOverlayElement(k)) out.push(k); + } + return out; + } + function reorderSlotForTarget(target, real: Element[]) { + if (target.placement === "inside") { + return { slot: real.length, boundary: null as number | null }; + } + var ai = real.indexOf(target.anchor); + if (ai < 0) return null; + var rect = target.anchor.getBoundingClientRect(); + var axis = reorderMainAxis(target); + if (target.placement === "before") { + return { slot: ai, boundary: axis === "x" ? rect.left : rect.top }; + } + return { + slot: ai + 1, + boundary: axis === "x" ? rect.right : rect.bottom, + }; + } + function containerIsSimplePacked(container: Element): boolean { + if (packedCacheContainer === container) return packedCacheResult; + packedCacheContainer = container; + packedCacheResult = false; + var cs = window.getComputedStyle(container); + if (cs.display !== "flex" && cs.display !== "inline-flex") return false; + if (cs.flexDirection !== "row" && cs.flexDirection !== "column") { + return false; + } + if (cs.flexWrap !== "nowrap") return false; + var jc = cs.justifyContent; + if ( + jc !== "flex-start" && + jc !== "start" && + jc !== "normal" && + jc !== "left" && + jc !== "" + ) { + return false; + } + var kids = container.children; + for (var i = 0; i < kids.length; i += 1) { + if (kids[i].nodeType !== 1) continue; + if (parseFloat(window.getComputedStyle(kids[i]).flexGrow) > 0) { + return false; + } + } + packedCacheResult = true; + return true; + } + function reorderMainGap(container: Element, axis: "x" | "y"): number { + var cs = window.getComputedStyle(container); + var raw = axis === "x" ? cs.columnGap || cs.gap : cs.rowGap || cs.gap; + var n = readPx(raw); + return Number.isFinite(n) && n > 0 ? n : 0; + } + function clearReorderReflow(): void { + reflowSiblings.forEach(function (s) { + s.el.style.transform = s.prevTransform; + s.el.style.transition = s.prevTransition; + }); + reflowSiblings = []; + reflowKey = null; + } + // Figma's "don't nest into a smaller container" guard (⌘/Ctrl overrides). + function applyReorderSizeGuard(target, ev) { + if (!liveReflowEnabled || !target || target.placement !== "inside") { + return target; + } + if (ev && (ev.metaKey || ev.ctrlKey)) return target; + var container = dropContainerForTarget(target); + if (!container || container === reorderEl) return target; + var crect = container.getBoundingClientRect(); + var drect = (reorderEl as HTMLElement).getBoundingClientRect(); + if (crect.width < drect.width || crect.height < drect.height) { + return null; + } + return target; + } + // Stabilize a freshly-resolved target against the committed one. Only + // same-container flow-insert targets are damped; anything else passes + // through and resets the committed state. + function stabilizeReorderTarget(rawTarget, cx, cy, now) { + if ( + !liveReflowEnabled || + !rawTarget || + rawTarget.dropMode !== "flow-insert" + ) { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var container = dropContainerForTarget(rawTarget); + if ( + !container || + (reorderEl as HTMLElement).parentElement !== container + ) { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var real = reorderRealChildren(container); + var slotInfo = reorderSlotForTarget(rawTarget, real); + if (!slotInfo) { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + return rawTarget; + } + var axis = reorderMainAxis(rawTarget); + var pointerMain = axis === "x" ? cx : cy; + if ( + reorderCommittedSlot === null || + slotInfo.slot === reorderCommittedSlot + ) { + reorderCommittedSlot = slotInfo.slot; + reorderCommittedTarget = rawTarget; + if (reorderCommittedAt === 0) reorderCommittedAt = now; + return rawTarget; + } + var crossed = + slotInfo.boundary !== null && + Math.abs(pointerMain - slotInfo.boundary) >= 8; + var dwelled = now - reorderCommittedAt >= 60; + if (crossed || dwelled) { + reorderCommittedSlot = slotInfo.slot; + reorderCommittedTarget = rawTarget; + reorderCommittedAt = now; + return rawTarget; + } + return reorderCommittedTarget || rawTarget; + } + function applyReorderReflow(target, cx, cy): void { + if (!liveReflowEnabled) return; + if (!target || target.dropMode !== "flow-insert") { + clearReorderReflow(); + return; + } + var container = dropContainerForTarget(target); + if ( + !container || + (reorderEl as HTMLElement).parentElement !== container || + !containerIsSimplePacked(container) + ) { + clearReorderReflow(); + return; + } + var real = reorderRealChildren(container); + var originIndex = real.indexOf(reorderEl); + var slotInfo = reorderSlotForTarget(target, real); + if (originIndex < 0 || !slotInfo) { + clearReorderReflow(); + return; + } + var axis = reorderMainAxis(target); + var key = axis + ":" + slotInfo.slot; + if (key === reflowKey) return; + clearReorderReflow(); + reflowKey = key; + var drect = (reorderEl as HTMLElement).getBoundingClientRect(); + var slotMain = + (axis === "x" ? drect.width : drect.height) + + reorderMainGap(container, axis); + // Only siblings between origin and target shift, by ±slotMain — exact + // for a packed container regardless of each sibling's own size. + var offsets: number[] = new Array(real.length).fill(0); + if (slotInfo.slot > originIndex + 1) { + for (var a = originIndex + 1; a <= slotInfo.slot - 1; a += 1) { + offsets[a] = -slotMain; + } + } else if (slotInfo.slot < originIndex) { + for (var b = slotInfo.slot; b <= originIndex - 1; b += 1) { + offsets[b] = slotMain; + } + } + for (var i = 0; i < real.length; i += 1) { + if (i === originIndex) continue; + var el = real[i] as HTMLElement; + var prevTransform = el.style.transform; + reflowSiblings.push({ + el: el, + prevTransform: prevTransform, + prevTransition: el.style.transition, + }); + el.style.transition = "transform 140ms cubic-bezier(0.2, 0, 0, 1)"; + var base = prevTransform ? prevTransform + " " : ""; + var tx = axis === "x" ? offsets[i] : 0; + var ty = axis === "y" ? offsets[i] : 0; + el.style.transform = base + "translate(" + tx + "px, " + ty + "px)"; + } + } function onReorderMove(ev) { var vw = window.innerWidth; var vh = window.innerHeight; var cx = ev.clientX; var cy = ev.clientY; + var dx = cx - reorderPointerStart.clientX; + var dy = cy - reorderPointerStart.clientY; var outside = cx < 0 || cy < 0 || cx > vw || cy > vh; pointerOutsideIframe = outside; // Always notify the host frame so it can track the cursor position, @@ -8282,11 +8580,18 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; if (outside && !isGroupDrag) { // Cursor left this iframe — hide the in-iframe insertion guide so // it does not render while the host shows a cross-screen drop target. + // Drop the lift so the host-rendered ghost is the only moving visual + // (re-applied automatically if the cursor comes back inside). hideInsertionGuide(); + clearReorderLift(); + clearReorderReflow(); showTransformBadge("Move layer", cx, cy); } else { - // Cursor is inside this iframe — use existing in-iframe behavior. - currentTarget = flowMoveTargetForPoint( + // Cursor is inside this iframe — use existing in-iframe behavior, + // stabilized (hysteresis) and previewed with live sibling reflow when + // liveReflowEnabled. stabilizeReorderTarget / applyReorderReflow are + // no-ops (pass-through) when the flag is off. + var rawTarget = flowMoveTargetForPoint( reorderEl, cx, cy, @@ -8294,7 +8599,16 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; keepCurrentFlowParent, Boolean(ev.ctrlKey), ); + rawTarget = applyReorderSizeGuard(rawTarget, ev); + currentTarget = stabilizeReorderTarget( + rawTarget, + cx, + cy, + ev.timeStamp, + ); showInsertionGuideFor(currentTarget); + applyReorderLift(dx, dy); + applyReorderReflow(currentTarget, cx, cy); showTransformBadge(currentTarget ? "Move layer" : "Move", cx, cy); } } @@ -8304,6 +8618,13 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; document.removeEventListener("keydown", onReorderKeyDown, true); document.removeEventListener("keyup", onReorderKeyUp, true); clearActiveDragCancel(onReorderEscape); + // Restore the dragged element(s) AND any reflowed siblings to their + // untransformed state on every teardown path. onReorderUp calls this + // BEFORE resolving the drop target and reordering, so the commit reads + // un-transformed rects and — being one synchronous task — everything + // paints once, already in its final slot (no back-to-origin flicker). + clearReorderLift(); + clearReorderReflow(); } function onReorderEscape() { cleanupReorderDrag(); @@ -8383,14 +8704,19 @@ declare var __RUNTIME_LAYER_SNAPSHOT_ENABLED__: boolean; // already clears cross-screen state on re-entry so checking the // momentary excursion flag here would wrongly drop the element nowhere. if (outsideOnDrop) return; - currentTarget = flowMoveTargetForPoint( - reorderEl, - cx, - cy, - groupOthers, - keepCurrentFlowParent, - Boolean(ev?.ctrlKey), - ); + // With live reflow on, commit the SAME stabilized target the guide/gap + // showed during the move (so it drops exactly where it previewed); + // otherwise resolve fresh from the release point (legacy behavior). + if (!liveReflowEnabled) { + currentTarget = flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + Boolean(ev?.ctrlKey), + ); + } if (!currentTarget) { // No valid drop target — clean up the clone if one was inserted so // no ghost element is left in the DOM. diff --git a/templates/design/app/pages/DesignEditor.selection.test.ts b/templates/design/app/pages/DesignEditor.selection.test.ts index 47f1ff13cd..f6c056c14b 100644 --- a/templates/design/app/pages/DesignEditor.selection.test.ts +++ b/templates/design/app/pages/DesignEditor.selection.test.ts @@ -2546,6 +2546,32 @@ describe("U3: local content history fallback mirror", () => { }), ).toBe(stack); }); + + it("does NOT coalesce a user-edit mirror across an agent checkpoint (isCheckpoint guard)", () => { + // Repro for: AI creates design → user edits → Cmd+Z wipes all AI work. + // The agent checkpoint must remain as a distinct undo entry so the first + // Cmd+Z reverts only the user edit and a second Cmd+Z reverts the AI work. + const checkpoint = { + fileId: "a", + before: "", + after: "", + isCheckpoint: true, + }; + const stack = [checkpoint]; + const next = mergeLocalContentHistoryFallback(stack, { + fileId: "a", + before: "", + after: "", + }); + expect(next).toEqual([ + checkpoint, + { + fileId: "a", + before: "", + after: "", + }, + ]); + }); }); // L11: screen rename must preserve the file extension instead of writing the diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index 4443676a4a..25c5a41a37 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -3167,7 +3167,7 @@ function DesignEditor() { return; } undoManagerRef.current?.clear(true, false); - recordLocalContentHistoryChangeFallback(change); + recordLocalContentHistoryChangeFallback({ ...change, isCheckpoint: true }); clearRedoStacks(); syncUndoRedoState(); }, diff --git a/templates/design/app/pages/design-editor/history.ts b/templates/design/app/pages/design-editor/history.ts index 0991103c7a..be0a031c14 100644 --- a/templates/design/app/pages/design-editor/history.ts +++ b/templates/design/app/pages/design-editor/history.ts @@ -173,6 +173,11 @@ export interface ContentHistoryChange { fileId: string; before: string; after: string; + /** Agent-authored replacement checkpoint. Prevents the next user edit's + * fallback mirror from coalescing backward through this entry, which would + * collapse the agent state out of the undo stack and make a single Cmd+Z + * jump past all AI-generated content to the pre-agent baseline. */ + isCheckpoint?: boolean; } export interface ContentHistoryGroup { @@ -261,7 +266,12 @@ export function mergeLocalContentHistoryFallback( ): ContentHistoryChange[] { if (change.before === change.after) return stack; const last = stack[stack.length - 1]; - if (last && last.fileId === change.fileId && last.after === change.before) { + if ( + last && + last.fileId === change.fileId && + last.after === change.before && + !last.isCheckpoint + ) { return [...stack.slice(0, -1), { ...last, after: change.after }]; } return [...stack.slice(-(MAX_DESIGN_UNDO_STACK - 1)), change]; diff --git a/templates/design/shared/drag-reflow.test.ts b/templates/design/shared/drag-reflow.test.ts new file mode 100644 index 0000000000..6bf5eb02d2 --- /dev/null +++ b/templates/design/shared/drag-reflow.test.ts @@ -0,0 +1,493 @@ +import { describe, expect, it } from "vitest"; + +import { + computeInsertOffsets, + computeReorderOffsets, + computeVacateOffsets, + type DragTargetCandidate, + type HysteresisState, + isContainerTooSmallForDrag, + isSimplePackedContainer, + mainAxisForDirection, + type PackedContainerInfo, + resolveTargetHysteresis, +} from "./drag-reflow"; + +// --------------------------------------------------------------------------- +// Hysteresis +// --------------------------------------------------------------------------- + +function candidate( + over: Partial & Pick, +): DragTargetCandidate { + return { + pointerMain: 0, + boundaryMain: null, + containerPenetrationPx: Infinity, + isLeave: false, + ...over, + }; +} + +describe("resolveTargetHysteresis", () => { + it("clears instantly when the candidate is null", () => { + const prev: HysteresisState = { + key: { containerKey: "A", index: 0 }, + committedAt: 0, + }; + const res = resolveTargetHysteresis(prev, candidate({ key: null }), 5); + expect(res.key).toBeNull(); + expect(res.changed).toBe(true); + expect(res.state).toBeNull(); + }); + + it("reports no change when clearing from an already-empty state", () => { + const res = resolveTargetHysteresis(null, candidate({ key: null }), 5); + expect(res.key).toBeNull(); + expect(res.changed).toBe(false); + }); + + it("accepts the first target immediately (no lag before the guide appears)", () => { + const res = resolveTargetHysteresis( + null, + candidate({ key: { containerKey: "A", index: 2 } }), + 100, + ); + expect(res.key).toEqual({ containerKey: "A", index: 2 }); + expect(res.changed).toBe(true); + expect(res.state).toEqual({ + key: { containerKey: "A", index: 2 }, + committedAt: 100, + }); + }); + + it("holds an unchanged target and preserves the original commit time", () => { + const prev: HysteresisState = { + key: { containerKey: "A", index: 1 }, + committedAt: 40, + }; + const res = resolveTargetHysteresis( + prev, + candidate({ key: { containerKey: "A", index: 1 } }), + 999, + ); + expect(res.changed).toBe(false); + expect(res.state).toBe(prev); // committedAt not refreshed + }); + + describe("index change within the same container", () => { + const prev: HysteresisState = { + key: { containerKey: "A", index: 0 }, + committedAt: 0, + }; + + it("rejects while the pointer is within the boundary deadband and dwell not met", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "A", index: 1 }, + boundaryMain: 100, + pointerMain: 105, + }), + 10, // elapsed 10ms < 60ms + ); + expect(res.changed).toBe(false); + expect(res.key).toEqual({ containerKey: "A", index: 0 }); + }); + + it("accepts once the pointer crosses the boundary by >= 8px", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "A", index: 1 }, + boundaryMain: 100, + pointerMain: 92, + }), + 10, + ); + expect(res.changed).toBe(true); + expect(res.key).toEqual({ containerKey: "A", index: 1 }); + }); + + it("accepts on dwell even when still inside the deadband", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "A", index: 1 }, + boundaryMain: 100, + pointerMain: 101, + }), + 60, // elapsed 60ms >= 60ms + ); + expect(res.changed).toBe(true); + }); + + it("falls back to dwell-only when there is no boundary (empty-container inside)", () => { + const early = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "A", index: 1 }, + boundaryMain: null, + pointerMain: 5000, + }), + 30, + ); + expect(early.changed).toBe(false); + const late = resolveTargetHysteresis( + prev, + candidate({ key: { containerKey: "A", index: 1 }, boundaryMain: null }), + 60, + ); + expect(late.changed).toBe(true); + }); + }); + + describe("container change", () => { + const prev: HysteresisState = { + key: { containerKey: "A", index: 3 }, + committedAt: 0, + }; + + it("reverses instantly when leaving to an ancestor", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "PARENT", index: 1 }, + isLeave: true, + containerPenetrationPx: 0, + }), + 1, + ); + expect(res.changed).toBe(true); + expect(res.key).toEqual({ containerKey: "PARENT", index: 1 }); + }); + + it("rejects a shallow entry into a new container before penetration/dwell", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "B", index: 0 }, + containerPenetrationPx: 5, + }), + 10, + ); + expect(res.changed).toBe(false); + expect(res.key).toEqual({ containerKey: "A", index: 3 }); + }); + + it("accepts once penetration >= 10px", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "B", index: 0 }, + containerPenetrationPx: 12, + }), + 10, + ); + expect(res.changed).toBe(true); + expect(res.key).toEqual({ containerKey: "B", index: 0 }); + }); + + it("accepts a shallow entry on dwell", () => { + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "B", index: 0 }, + containerPenetrationPx: 5, + }), + 80, + ); + expect(res.changed).toBe(true); + }); + }); + + it("honors custom thresholds", () => { + const prev: HysteresisState = { + key: { containerKey: "A", index: 0 }, + committedAt: 0, + }; + const res = resolveTargetHysteresis( + prev, + candidate({ + key: { containerKey: "A", index: 1 }, + boundaryMain: 100, + pointerMain: 97, + }), + 5, + { indexBoundaryPx: 2 }, // |97-100|=3 >= 2 + ); + expect(res.changed).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Size guard +// --------------------------------------------------------------------------- + +describe("isContainerTooSmallForDrag", () => { + it("allows a container that fits the dragged element", () => { + expect( + isContainerTooSmallForDrag( + { width: 100, height: 40 }, + { width: 50, height: 20 }, + ), + ).toBe(false); + }); + + it("rejects a container narrower than the dragged element", () => { + expect( + isContainerTooSmallForDrag( + { width: 30, height: 40 }, + { width: 50, height: 20 }, + ), + ).toBe(true); + }); + + it("rejects a container shorter than the dragged element", () => { + expect( + isContainerTooSmallForDrag( + { width: 100, height: 10 }, + { width: 50, height: 20 }, + ), + ).toBe(true); + }); + + it("is bypassed by the ⌘ override", () => { + expect( + isContainerTooSmallForDrag( + { width: 5, height: 5 }, + { width: 500, height: 500 }, + { bypass: true }, + ), + ).toBe(false); + }); + + it("does not reject on an axis the container hugs (it would grow to fit)", () => { + // Container is too narrow, but it hugs its width → allowed. + expect( + isContainerTooSmallForDrag( + { width: 30, height: 40 }, + { width: 50, height: 20 }, + { hugAxis: "width" }, + ), + ).toBe(false); + // …still rejected if it is also too short on the non-hug axis. + expect( + isContainerTooSmallForDrag( + { width: 30, height: 10 }, + { width: 50, height: 20 }, + { hugAxis: "width" }, + ), + ).toBe(true); + }); + + it("respects tolerance slack", () => { + expect( + isContainerTooSmallForDrag( + { width: 48, height: 40 }, + { width: 50, height: 20 }, + { tolerancePx: 4 }, + ), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Packed-container detection +// --------------------------------------------------------------------------- + +function packed(over: Partial = {}): PackedContainerInfo { + return { + display: "flex", + flexDirection: "row", + flexWrap: "nowrap", + justifyContent: "flex-start", + gap: 8, + hasFlexGrowChild: false, + ...over, + }; +} + +describe("isSimplePackedContainer", () => { + it("accepts a start-aligned, nowrap, fixed-gap flex row/column", () => { + expect(isSimplePackedContainer(packed())).toBe(true); + expect(isSimplePackedContainer(packed({ flexDirection: "column" }))).toBe( + true, + ); + expect(isSimplePackedContainer(packed({ display: "inline-flex" }))).toBe( + true, + ); + expect(isSimplePackedContainer(packed({ justifyContent: "start" }))).toBe( + true, + ); + expect(isSimplePackedContainer(packed({ justifyContent: "normal" }))).toBe( + true, + ); + expect(isSimplePackedContainer(packed({ justifyContent: "" }))).toBe(true); + expect(isSimplePackedContainer(packed({ gap: 0 }))).toBe(true); + }); + + it("rejects non-flex containers", () => { + expect(isSimplePackedContainer(packed({ display: "block" }))).toBe(false); + expect(isSimplePackedContainer(packed({ display: "grid" }))).toBe(false); + }); + + it("rejects distributed justification (the constant-shift model would lie)", () => { + for (const jc of [ + "space-between", + "space-around", + "space-evenly", + "center", + "flex-end", + "end", + ]) { + expect(isSimplePackedContainer(packed({ justifyContent: jc }))).toBe( + false, + ); + } + }); + + it("rejects wrap", () => { + expect(isSimplePackedContainer(packed({ flexWrap: "wrap" }))).toBe(false); + expect(isSimplePackedContainer(packed({ flexWrap: "wrap-reverse" }))).toBe( + false, + ); + }); + + it("rejects reverse directions (offset signs would invert)", () => { + expect( + isSimplePackedContainer(packed({ flexDirection: "row-reverse" })), + ).toBe(false); + expect( + isSimplePackedContainer(packed({ flexDirection: "column-reverse" })), + ).toBe(false); + }); + + it("rejects a negative or non-finite gap", () => { + expect(isSimplePackedContainer(packed({ gap: -4 }))).toBe(false); + expect(isSimplePackedContainer(packed({ gap: NaN }))).toBe(false); + }); + + it("rejects a container with a flex-grow child (it resizes, not translates)", () => { + expect(isSimplePackedContainer(packed({ hasFlexGrowChild: true }))).toBe( + false, + ); + }); +}); + +describe("mainAxisForDirection", () => { + it("maps row → x and column → y", () => { + expect(mainAxisForDirection("row")).toBe("x"); + expect(mainAxisForDirection("row-reverse")).toBe("x"); + expect(mainAxisForDirection("column")).toBe("y"); + expect(mainAxisForDirection("column-reverse")).toBe("y"); + }); +}); + +// --------------------------------------------------------------------------- +// Reflow offsets +// --------------------------------------------------------------------------- + +describe("computeReorderOffsets", () => { + const slotMain = 60; + + it("shifts intermediate siblings toward the start when moving later", () => { + // 5 items, drag item 1 to before item 4. + const offsets = computeReorderOffsets({ + count: 5, + originIndex: 1, + targetSlot: 4, + slotMain, + }); + expect(offsets).toEqual([0, 0, -60, -60, 0]); + }); + + it("shifts intermediate siblings toward the end when moving earlier", () => { + // 5 items, drag item 3 to before item 1. + const offsets = computeReorderOffsets({ + count: 5, + originIndex: 3, + targetSlot: 1, + slotMain, + }); + expect(offsets).toEqual([0, 60, 60, 0, 0]); + }); + + it("moves nothing when dropped back into the same slot", () => { + expect( + computeReorderOffsets({ + count: 5, + originIndex: 2, + targetSlot: 2, + slotMain, + }), + ).toEqual([0, 0, 0, 0, 0]); + // targetSlot === originIndex + 1 is also a no-op (before the very next sibling). + expect( + computeReorderOffsets({ + count: 5, + originIndex: 2, + targetSlot: 3, + slotMain, + }), + ).toEqual([0, 0, 0, 0, 0]); + }); + + it("moves a single sibling when swapping adjacent neighbors", () => { + // drag item 0 to before item 2 → only item 1 shifts start-ward. + expect( + computeReorderOffsets({ + count: 3, + originIndex: 0, + targetSlot: 2, + slotMain, + }), + ).toEqual([0, -60, 0]); + }); + + it("moves to the very end", () => { + // drag item 0 to end (before slot count) → items 1 and 2 shift start-ward. + expect( + computeReorderOffsets({ + count: 3, + originIndex: 0, + targetSlot: 3, + slotMain, + }), + ).toEqual([0, -60, -60]); + }); +}); + +describe("computeVacateOffsets", () => { + it("closes the gap by shifting following siblings toward the start", () => { + expect( + computeVacateOffsets({ count: 5, originIndex: 1, slotMain: 60 }), + ).toEqual([0, 0, -60, -60, -60]); + }); + + it("moves nothing when the dragged item is last", () => { + expect( + computeVacateOffsets({ count: 3, originIndex: 2, slotMain: 60 }), + ).toEqual([0, 0, 0]); + }); +}); + +describe("computeInsertOffsets", () => { + it("opens a slot by shifting the insertion point and everything after it end-ward", () => { + expect( + computeInsertOffsets({ count: 4, targetSlot: 2, slotMain: 60 }), + ).toEqual([0, 0, 60, 60]); + }); + + it("opens a leading slot (insert at front) by shifting all children", () => { + expect( + computeInsertOffsets({ count: 3, targetSlot: 0, slotMain: 60 }), + ).toEqual([60, 60, 60]); + }); + + it("opens a trailing slot (append) with no sibling movement", () => { + expect( + computeInsertOffsets({ count: 3, targetSlot: 3, slotMain: 60 }), + ).toEqual([0, 0, 0]); + }); +}); diff --git a/templates/design/shared/drag-reflow.ts b/templates/design/shared/drag-reflow.ts new file mode 100644 index 0000000000..109e5340be --- /dev/null +++ b/templates/design/shared/drag-reflow.ts @@ -0,0 +1,390 @@ +/** + * Pure decision logic for Figma-parity in-screen drag & drop. + * + * This module is the typed, unit-tested source of truth for the drag + * behaviors added in the "live reflow" work (Phase 0 + Phase 1 of the + * auto-layout DnD plan). It is deliberately free of DOM access so it can be + * exercised directly by vitest. + * + * The editor bridge (`app/components/design/bridge/editor-chrome.bridge.ts`) + * cannot `import` anything — it is compiled to a self-contained IIFE by + * `bridge/codegen.ts`. So, exactly like `shared/canvas-math.ts` → + * `computeMoveSnapOffset`, the bridge carries a hand-ported copy of these + * functions. Keep the two in sync; this file is the reference implementation + * and the place where behavior is proven. + * + * Four independent concerns live here: + * 1. Hysteresis — stabilize the resolved drop target so the insertion + * preview never strobes on a boundary (Phase 0.1). + * 2. Size guard — refuse to auto-nest a large element into a smaller + * container, Figma-style (Phase 0.2). + * 3. Packed-container detection — decide whether a flex container is simple + * enough that live sibling reflow can be modeled by a constant slot + * shift; everything else falls back to an indicator only (Phase 1). + * 4. Reflow offsets — the per-sibling translate deltas that visually open / + * close a slot while dragging, for the packed case (Phase 1). + */ + +// --------------------------------------------------------------------------- +// 1. Hysteresis +// --------------------------------------------------------------------------- + +/** + * Identity of a resolved drop target. `containerKey` is any stable string id + * for the target container (the bridge uses a per-drag element→key map); + * `index` is the insertion index within that container. + */ +export interface DragTargetKey { + containerKey: string; + index: number; +} + +/** + * A freshly resolved (raw) drop-target candidate for the current pointer + * position, plus the geometry the hysteresis gate needs to decide whether to + * accept it. All coordinates are in one consistent space (the bridge uses + * iframe-local client coordinates — see canvas-math note about the host + * scaling the whole iframe uniformly). + */ +export interface DragTargetCandidate { + /** The resolved target, or null when the pointer is over no valid target. */ + key: DragTargetKey | null; + /** + * The pointer coordinate along the container's flow (main) axis. Used to + * measure how far past an insertion boundary the pointer has travelled. + */ + pointerMain: number; + /** + * The insertion boundary coordinate along the flow axis (e.g. the shared + * edge between the two siblings the item would drop between). `null` when + * the candidate has no meaningful boundary (an empty-container "inside" + * drop), in which case index changes fall back to the dwell timer only. + */ + boundaryMain: number | null; + /** + * How far the pointer has penetrated the candidate container from its + * nearest entering edge, in px (>= 0 inside). Only consulted when switching + * to a *different* container. Pass `Infinity` for a same-container change. + */ + containerPenetrationPx: number; + /** + * True when the candidate container is an ancestor of the currently + * committed container — i.e. the pointer is *leaving* toward a parent. + * Leaving reverses instantly (Figma behavior), bypassing dwell/penetration. + */ + isLeave: boolean; +} + +export interface HysteresisState { + key: DragTargetKey; + /** Timestamp (ms) at which this target was committed. */ + committedAt: number; +} + +export interface HysteresisOptions { + /** + * Pointer must cross the insertion boundary by at least this many px before + * an index change within the same container is accepted. Default 8. + */ + indexBoundaryPx?: number; + /** …or the candidate index must be stable for this long (ms). Default 60. */ + indexDwellMs?: number; + /** + * Pointer must penetrate a *different* container by at least this many px + * before the container change is accepted. Default 10. + */ + containerPenetrationPx?: number; + /** …or the new container must be hovered for this long (ms). Default 80. */ + containerDwellMs?: number; +} + +export interface HysteresisResult { + /** The stabilized target to actually use this tick (may equal previous). */ + key: DragTargetKey | null; + /** True when the stabilized target changed vs the previous committed one. */ + changed: boolean; + /** The state to carry into the next tick. */ + state: HysteresisState | null; +} + +const DEFAULT_HYSTERESIS: Required = { + indexBoundaryPx: 8, + indexDwellMs: 60, + containerPenetrationPx: 10, + containerDwellMs: 80, +}; + +function keysEqual(a: DragTargetKey | null, b: DragTargetKey | null): boolean { + if (a === null || b === null) return a === b; + return a.containerKey === b.containerKey && a.index === b.index; +} + +/** + * Stabilize a raw drop-target candidate against the previously committed + * target so the insertion preview only transitions when the pointer clearly + * moves to a new slot/container. + * + * Rules (mirrors the plan's Phase 0.1): + * - No previous target → accept immediately (the guide should appear at once). + * - Candidate is null → clear immediately (leaving all targets is instant). + * - Same container + same index → hold. + * - Same container, different index → switch only once the pointer is + * `indexBoundaryPx` past the boundary, OR the index has been stable for + * `indexDwellMs`. + * - Different container → if it is a *leave* (ancestor), switch instantly; + * otherwise switch only once penetration ≥ `containerPenetrationPx`, OR the + * container has been hovered for `containerDwellMs`. + * + * Pure: pass `now` (ms) explicitly so it is deterministic in tests. + */ +export function resolveTargetHysteresis( + prev: HysteresisState | null, + candidate: DragTargetCandidate, + now: number, + options: HysteresisOptions = {}, +): HysteresisResult { + const opts = { ...DEFAULT_HYSTERESIS, ...options }; + + // Leaving every target — instant. + if (candidate.key === null) { + return { + key: null, + changed: prev !== null, + state: null, + }; + } + + // First acquisition — instant, so the preview shows up without lag. + if (prev === null) { + return { + key: candidate.key, + changed: true, + state: { key: candidate.key, committedAt: now }, + }; + } + + // Unchanged target — hold, preserving the original commit time so dwell is + // measured from when we first committed, not refreshed every tick. + if (keysEqual(candidate.key, prev.key)) { + return { key: prev.key, changed: false, state: prev }; + } + + const sameContainer = candidate.key.containerKey === prev.key.containerKey; + const elapsed = now - prev.committedAt; + + let accept: boolean; + if (sameContainer) { + const crossedBoundary = + candidate.boundaryMain !== null && + Math.abs(candidate.pointerMain - candidate.boundaryMain) >= + opts.indexBoundaryPx; + accept = crossedBoundary || elapsed >= opts.indexDwellMs; + } else if (candidate.isLeave) { + // Exiting to an ancestor reverses instantly. + accept = true; + } else { + const penetrated = + candidate.containerPenetrationPx >= opts.containerPenetrationPx; + accept = penetrated || elapsed >= opts.containerDwellMs; + } + + if (accept) { + return { + key: candidate.key, + changed: true, + state: { key: candidate.key, committedAt: now }, + }; + } + // Reject the change this tick — keep showing the committed target. + return { key: prev.key, changed: false, state: prev }; +} + +// --------------------------------------------------------------------------- +// 2. Size guard +// --------------------------------------------------------------------------- + +export interface SizeGuardBox { + width: number; + height: number; +} + +export interface SizeGuardOptions { + /** Cmd/⌘ held — user override, always allow. Default false. */ + bypass?: boolean; + /** Slack in px before a container counts as "too small". Default 0. */ + tolerancePx?: number; + /** + * Axis on which the container hugs its content (and would therefore grow to + * fit a larger child, so it should NOT be rejected on that axis). Matches + * Figma "Hug contents". Default "none". + */ + hugAxis?: "none" | "main" | "cross" | "both" | "width" | "height"; +} + +/** + * Figma's "don't drop a large image into a button" guard: reject a container + * whose content box is smaller than the dragged element on an axis it cannot + * grow on. Bypassed by ⌘ (the same modifier that disables snapping). + */ +export function isContainerTooSmallForDrag( + containerContentBox: SizeGuardBox, + draggedRect: SizeGuardBox, + options: SizeGuardOptions = {}, +): boolean { + if (options.bypass) return false; + const tol = options.tolerancePx ?? 0; + const hug = options.hugAxis ?? "none"; + const hugsWidth = hug === "both" || hug === "width" || hug === "main"; + const hugsHeight = hug === "both" || hug === "height" || hug === "cross"; + + const tooNarrow = + !hugsWidth && containerContentBox.width + tol < draggedRect.width; + const tooShort = + !hugsHeight && containerContentBox.height + tol < draggedRect.height; + return tooNarrow || tooShort; +} + +// --------------------------------------------------------------------------- +// 3. Packed-container detection (Phase 1, option-1 restriction) +// --------------------------------------------------------------------------- + +export interface PackedContainerInfo { + /** Computed `display`. */ + display: string; + /** Computed `flex-direction`. */ + flexDirection: string; + /** Computed `flex-wrap`. */ + flexWrap: string; + /** Computed `justify-content`. */ + justifyContent: string; + /** Resolved main-axis gap in px (row-gap or column-gap as appropriate). */ + gap: number; + /** True if any direct child has `flex-grow` > 0 (it absorbs space instead + * of translating, so a constant slot shift would misrepresent the drop). */ + hasFlexGrowChild: boolean; +} + +const START_JUSTIFY = new Set(["flex-start", "start", "normal", "left", ""]); + +/** + * Whether a container is simple enough that a same-magnitude per-sibling + * translate exactly reproduces the post-drop layout (see + * `computeReorderOffsets`). Only START-aligned, non-wrapping, fixed-gap, + * non-reverse flex rows/columns with no growing child qualify. + * + * Everything else (space-between/around/evenly, center/end, wrap, grid, + * reverse, or a flex-grow child) must fall back to an indicator-only preview, + * because the real reflow is NOT a uniform shift and animating a constant + * translate would show a preview that does not match where the item lands. + */ +export function isSimplePackedContainer(info: PackedContainerInfo): boolean { + const isFlex = info.display === "flex" || info.display === "inline-flex"; + if (!isFlex) return false; + if (info.flexDirection !== "row" && info.flexDirection !== "column") { + return false; + } + if (info.flexWrap !== "nowrap") return false; + if (!START_JUSTIFY.has(info.justifyContent)) return false; + if (!Number.isFinite(info.gap) || info.gap < 0) return false; + if (info.hasFlexGrowChild) return false; + return true; +} + +/** The main flow axis (x/y) for a packed container's flex-direction. */ +export function mainAxisForDirection(flexDirection: string): "x" | "y" { + return flexDirection === "column" || flexDirection === "column-reverse" + ? "y" + : "x"; +} + +// --------------------------------------------------------------------------- +// 4. Reflow offsets (packed case) +// --------------------------------------------------------------------------- + +export interface ReorderOffsetsInput { + /** Number of direct children currently in the container (incl. dragged). */ + count: number; + /** The dragged element's current index. */ + originIndex: number; + /** + * Insertion slot in current indexing: the item lands *before* the child at + * `targetSlot` (0..count, where `count` means "at the end"). + */ + targetSlot: number; + /** Dragged element's main-axis size + the container gap, in px. */ + slotMain: number; +} + +/** + * Per-sibling translate offsets (px, signed along the main axis) that preview + * a same-container reorder by opening the destination slot and closing the + * origin slot. Negative = toward the container start (up/left); positive = + * toward the end (down/right). The dragged element's own index is always 0 + * here (it follows the cursor separately). + * + * For a packed container, moving the dragged item (size s, plus one gap g) + * from `originIndex` to `targetSlot` shifts exactly the siblings between the + * two positions by ±(s + g) — the shift magnitude is independent of each + * sibling's own size, which is why the constant model is exact for this case. + */ +export function computeReorderOffsets(input: ReorderOffsetsInput): number[] { + const { count, originIndex, targetSlot, slotMain } = input; + const offsets = new Array(count).fill(0); + if (targetSlot > originIndex + 1) { + // Moving later: siblings between move toward the start to fill the origin. + for (let i = originIndex + 1; i <= targetSlot - 1; i += 1) { + offsets[i] = -slotMain; + } + } else if (targetSlot < originIndex) { + // Moving earlier: siblings from targetSlot..originIndex-1 move toward end. + for (let i = targetSlot; i <= originIndex - 1; i += 1) { + offsets[i] = slotMain; + } + } + // targetSlot === originIndex or originIndex + 1 → no net movement. + return offsets; +} + +export interface VacateOffsetsInput { + count: number; + originIndex: number; + slotMain: number; +} + +/** + * Offsets for the ORIGIN container when the dragged item leaves it entirely + * (drag-out or cross-container move): every following sibling closes the gap + * by shifting toward the start. This is what erases the "hole" the old + * relative-promotion approach left behind. + */ +export function computeVacateOffsets(input: VacateOffsetsInput): number[] { + const { count, originIndex, slotMain } = input; + const offsets = new Array(count).fill(0); + for (let i = originIndex + 1; i <= count - 1; i += 1) { + offsets[i] = -slotMain; + } + return offsets; +} + +export interface InsertOffsetsInput { + /** Number of children currently in the TARGET container (excl. incoming). */ + count: number; + /** Insertion slot: incoming item lands before child at `targetSlot`. */ + targetSlot: number; + /** Incoming element's main-axis size + the target container's gap, in px. */ + slotMain: number; +} + +/** + * Offsets for a TARGET container the dragged item is entering: children at and + * after the insertion slot shift toward the end to open a slot sized to the + * incoming element. + */ +export function computeInsertOffsets(input: InsertOffsetsInput): number[] { + const { count, targetSlot, slotMain } = input; + const offsets = new Array(count).fill(0); + for (let i = targetSlot; i <= count - 1; i += 1) { + offsets[i] = slotMain; + } + return offsets; +} From b0a60d5c006d3a868470092e37ceba903340150f Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Thu, 16 Jul 2026 13:32:42 +0200 Subject: [PATCH 2/7] change --- templates/design/app/pages/DesignEditor.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index 5b2ede5687..dcd776f4cf 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -3169,7 +3169,10 @@ function DesignEditor() { return; } undoManagerRef.current?.clear(true, false); - recordLocalContentHistoryChangeFallback({ ...change, isCheckpoint: true }); + recordLocalContentHistoryChangeFallback({ + ...change, + isCheckpoint: true, + }); clearRedoStacks(); syncUndoRedoState(); }, From 1193998f5a85622532778eb6885fe4210df53638 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Thu, 16 Jul 2026 16:33:26 +0200 Subject: [PATCH 3/7] more fixes --- .../bridge/editor-chrome.generated.ts | 119 ++++++----- .../design/bridge/bridge.guard.spec.ts | 163 ++++++++++++++- .../design/bridge/editor-chrome.bridge.ts | 190 ++++++++++-------- .../app/pages/DesignEditor.selection.test.ts | 22 ++ .../design/app/pages/design-editor/history.ts | 5 +- templates/design/e2e/canvas-tools.spec.ts | 46 +++++ templates/design/shared/drag-reflow.test.ts | 155 ++++++++------ templates/design/shared/drag-reflow.ts | 165 +++++++-------- 8 files changed, 573 insertions(+), 292 deletions(-) diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index aa61e1e546..3c19ca86a4 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -5497,20 +5497,10 @@ export const editorChromeBridgeScript: string = `"use strict"; } return out; }, reorderSlotForTarget2 = function(target, real) { - if (target.placement === "inside") { - return { slot: real.length, boundary: null }; - } + if (target.placement === "inside") return { slot: real.length }; var ai = real.indexOf(target.anchor); if (ai < 0) return null; - var rect = target.anchor.getBoundingClientRect(); - var axis = reorderMainAxis2(target); - if (target.placement === "before") { - return { slot: ai, boundary: axis === "x" ? rect.left : rect.top }; - } - return { - slot: ai + 1, - boundary: axis === "x" ? rect.right : rect.bottom - }; + return { slot: target.placement === "before" ? ai : ai + 1 }; }, containerIsSimplePacked2 = function(container) { if (packedCacheContainer === container) return packedCacheResult; packedCacheContainer = container; @@ -5546,6 +5536,15 @@ export const editorChromeBridgeScript: string = `"use strict"; }); reflowSiblings = []; reflowKey = null; + }, resolveReorderOrFreeTarget2 = function(cx, cy, ctrlKey) { + return flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + ctrlKey + ); }, applyReorderSizeGuard2 = function(target, ev) { if (!liveReflowEnabled || !target || target.placement !== "inside") { return target; @@ -5555,49 +5554,72 @@ export const editorChromeBridgeScript: string = `"use strict"; if (!container || container === reorderEl) return target; var crect = container.getBoundingClientRect(); var drect = reorderEl.getBoundingClientRect(); - if (crect.width < drect.width || crect.height < drect.height) { - return null; + if (crect.width >= drect.width && crect.height >= drect.height) { + return target; } - return target; + var parent = container.parentElement; + if (!parent) return null; + var pcs = window.getComputedStyle(parent); + var pAxis = pcs.flexDirection === "column" || pcs.flexDirection === "column-reverse" ? "y" : "x"; + var center = pAxis === "x" ? crect.left + crect.width / 2 : crect.top + crect.height / 2; + var ptr = pAxis === "x" ? ev ? ev.clientX : center : ev ? ev.clientY : center; + return { + anchor: container, + placement: ptr < center ? "before" : "after", + axis: pAxis, + dropMode: "flow-insert" + }; }, stabilizeReorderTarget2 = function(rawTarget, cx, cy, now) { - if (!liveReflowEnabled || !rawTarget || rawTarget.dropMode !== "flow-insert") { + var reset = function() { reorderCommittedTarget = null; reorderCommittedSlot = null; + reorderPendingSlot = null; + }; + if (!liveReflowEnabled || !rawTarget || rawTarget.dropMode !== "flow-insert") { + reset(); return rawTarget; } var container = dropContainerForTarget(rawTarget); if (!container || reorderEl.parentElement !== container) { - reorderCommittedTarget = null; - reorderCommittedSlot = null; + reset(); return rawTarget; } - var real = reorderRealChildren2(container); - var slotInfo = reorderSlotForTarget2(rawTarget, real); + var slotInfo = reorderSlotForTarget2( + rawTarget, + reorderRealChildren2(container) + ); if (!slotInfo) { - reorderCommittedTarget = null; - reorderCommittedSlot = null; + reset(); return rawTarget; } - var axis = reorderMainAxis2(rawTarget); - var pointerMain = axis === "x" ? cx : cy; - if (reorderCommittedSlot === null || slotInfo.slot === reorderCommittedSlot) { - reorderCommittedSlot = slotInfo.slot; + var slot = slotInfo.slot; + var commit = function() { + reorderCommittedSlot = slot; reorderCommittedTarget = rawTarget; - if (reorderCommittedAt === 0) reorderCommittedAt = now; + reorderCommittedPointer = { x: cx, y: cy }; + reorderCommittedAt = now; + reorderPendingSlot = null; return rawTarget; - } - var crossed = slotInfo.boundary !== null && Math.abs(pointerMain - slotInfo.boundary) >= 8; - var dwelled = now - reorderCommittedAt >= 60; - if (crossed || dwelled) { - reorderCommittedSlot = slotInfo.slot; + }; + if (reorderCommittedSlot === null) return commit(); + if (slot === reorderCommittedSlot) { reorderCommittedTarget = rawTarget; - reorderCommittedAt = now; + reorderPendingSlot = null; return rawTarget; } + if (reorderPendingSlot !== slot) { + reorderPendingSlot = slot; + reorderPendingAt = now; + } + var movedPx = Math.hypot( + cx - reorderCommittedPointer.x, + cy - reorderCommittedPointer.y + ); + if (movedPx >= 8 || now - reorderPendingAt >= 60) return commit(); return reorderCommittedTarget || rawTarget; }, applyReorderReflow2 = function(target, cx, cy) { if (!liveReflowEnabled) return; - if (!target || target.dropMode !== "flow-insert") { + if (isGroupDrag || !target || target.dropMode !== "flow-insert") { clearReorderReflow2(); return; } @@ -5677,12 +5699,9 @@ export const editorChromeBridgeScript: string = `"use strict"; clearReorderReflow2(); showTransformBadge("Move layer", cx, cy); } else { - var rawTarget = flowMoveTargetForPoint( - reorderEl, + var rawTarget = resolveReorderOrFreeTarget2( cx, cy, - groupOthers, - keepCurrentFlowParent, Boolean(ev.ctrlKey) ); rawTarget = applyReorderSizeGuard2(rawTarget, ev); @@ -5700,6 +5719,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }, cleanupReorderDrag2 = function() { document.removeEventListener(events.move, onReorderMove2, true); document.removeEventListener(events.up, onReorderUp2, true); + document.removeEventListener("pointercancel", onReorderEscape2, true); document.removeEventListener("keydown", onReorderKeyDown2, true); document.removeEventListener("keyup", onReorderKeyUp2, true); clearActiveDragCancel(onReorderEscape2); @@ -5763,16 +5783,13 @@ export const editorChromeBridgeScript: string = `"use strict"; ); } if (outsideOnDrop) return; - if (!liveReflowEnabled) { - currentTarget = flowMoveTargetForPoint( - reorderEl, - cx, - cy, - groupOthers, - keepCurrentFlowParent, - Boolean(ev?.ctrlKey) - ); - } + var finalRaw = resolveReorderOrFreeTarget2(cx, cy, Boolean(ev?.ctrlKey)); + currentTarget = liveReflowEnabled ? stabilizeReorderTarget2( + applyReorderSizeGuard2(finalRaw, ev), + cx, + cy, + ev && typeof ev.timeStamp === "number" ? ev.timeStamp : reorderCommittedAt + ) : finalRaw; if (!currentTarget) { if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) { if (reorderEl.parentElement) @@ -5836,7 +5853,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }); } }; - var applyReorderLift = applyReorderLift2, clearReorderLift = clearReorderLift2, reorderMainAxis = reorderMainAxis2, reorderRealChildren = reorderRealChildren2, reorderSlotForTarget = reorderSlotForTarget2, containerIsSimplePacked = containerIsSimplePacked2, reorderMainGap = reorderMainGap2, clearReorderReflow = clearReorderReflow2, applyReorderSizeGuard = applyReorderSizeGuard2, stabilizeReorderTarget = stabilizeReorderTarget2, applyReorderReflow = applyReorderReflow2, onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; + var applyReorderLift = applyReorderLift2, clearReorderLift = clearReorderLift2, reorderMainAxis = reorderMainAxis2, reorderRealChildren = reorderRealChildren2, reorderSlotForTarget = reorderSlotForTarget2, containerIsSimplePacked = containerIsSimplePacked2, reorderMainGap = reorderMainGap2, clearReorderReflow = clearReorderReflow2, resolveReorderOrFreeTarget = resolveReorderOrFreeTarget2, applyReorderSizeGuard = applyReorderSizeGuard2, stabilizeReorderTarget = stabilizeReorderTarget2, applyReorderReflow = applyReorderReflow2, onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; var reorderEl = gestureEl; var reorderGroupStartRects = groupEls.map(function(member) { return member.getBoundingClientRect(); @@ -5874,12 +5891,16 @@ export const editorChromeBridgeScript: string = `"use strict"; var reorderCommittedTarget = null; var reorderCommittedSlot = null; var reorderCommittedAt = 0; + var reorderCommittedPointer = { x: 0, y: 0 }; + var reorderPendingSlot = null; + var reorderPendingAt = 0; var reflowSiblings = []; var reflowKey = null; var packedCacheContainer = null; var packedCacheResult = false; document.addEventListener(events.move, onReorderMove2, true); document.addEventListener(events.up, onReorderUp2, true); + document.addEventListener("pointercancel", onReorderEscape2, true); document.addEventListener("keydown", onReorderKeyDown2, true); document.addEventListener("keyup", onReorderKeyUp2, true); setActiveDragCancel(onReorderEscape2); diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index fb02352b99..7ab3e4008d 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -81,17 +81,10 @@ function hydratedEditorChromeBridgeScript( function hydratedEditorChromeBridgeScriptWithLiveReflow( screenId = "bridge-guard", ): string { - return editorChromeBridgeScript - .replace("__READ_ONLY__", "false") - .replace("__TEXT_EDITING_ENABLED__", "false") - .replace("__EDITOR_CHROME_SCALE_X__", "1") - .replace("__EDITOR_CHROME_SCALE_Y__", "1") - .replace("__DESIGN_CANVAS_SCREEN_ID__", JSON.stringify(screenId)) - .replace("__DESIGN_CANVAS_BOARD_SURFACE__", "false") - .replace("__DESIGN_CANVAS_CONTENT_OFFSET_X__", "0") - .replace("__DESIGN_CANVAS_CONTENT_OFFSET_Y__", "0") - .replace("__RUNTIME_LAYER_SNAPSHOT_ENABLED__", "false") - .replace("__LIVE_REFLOW_ENABLED__", "true"); + return hydratedEditorChromeBridgeScript(false, screenId).replace( + "__LIVE_REFLOW_ENABLED__", + "true", + ); } function hydratedBoardEditorChromeBridgeScriptWithOffset( @@ -1162,6 +1155,154 @@ it( }, ); +it( + "editor chrome bridge (live reflow) honors Ctrl pressed only at release (free-place)", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
+ +`); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScriptWithLiveReflow(), + }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + + // Drag WITHOUT a modifier, then press Ctrl only just before releasing + // (no pointer move after) — the drop must still free-place as absolute. + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(400, 300, { steps: 10 }); + await page.keyboard.down("Control"); + await page.mouse.up(); + await page.keyboard.up("Control"); + await page.waitForTimeout(40); + + const position = await page.evaluate( + () => window.getComputedStyle(document.querySelector("#a")!).position, + ); + expect(position).toBe("absolute"); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + +it( + "editor chrome bridge (live reflow) clears drag transforms on pointercancel without committing", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
A
+
B
+
C
+
+ +`); + await page.addScriptTag({ + content: hydratedEditorChromeBridgeScriptWithLiveReflow(), + }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + await page.mouse.click(70, 50); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + await page.evaluate(() => { + (window as any).__bridgeMessages = []; + window.addEventListener("message", (event: MessageEvent) => { + (window as any).__bridgeMessages.push(event.data); + }); + }); + + await page.mouse.move(70, 50); + await page.mouse.down(); + await page.mouse.move(250, 50, { steps: 10 }); + await page.waitForFunction(() => + (document.getElementById("a")?.style.transform ?? "").includes( + "translate", + ), + ); + // A cancelled gesture must restore everything and commit nothing. + await page.evaluate(() => + document.dispatchEvent(new PointerEvent("pointercancel")), + ); + await page.waitForTimeout(30); + + const after = await page.evaluate(() => ({ + transforms: ["a", "b", "c"].map( + (id) => document.getElementById(id)!.style.transform, + ), + order: Array.from( + document.querySelectorAll("#row > div"), + ).map((el) => el.id), + messageTypes: ((window as any).__bridgeMessages ?? []).map( + (m: { type?: string }) => m.type, + ), + })); + + expect(after.transforms.every((t) => t === "")).toBe(true); + expect(after.order).toEqual(["a", "b", "c"]); + expect(after.messageTypes).not.toContain("visual-structure-change"); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + it( "editor chrome bridge (live reflow) parts siblings during a packed reorder, then restores them on drop", { timeout: 30_000 }, diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index cd648dbc3f..21be444f01 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -8267,15 +8267,8 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; x: reorderPointerStart.clientX - reorderRect.left, y: reorderPointerStart.clientY - reorderRect.top, }; - // Phase 1.1 — lift & follow (gated by liveReflowEnabled). While dragging - // a flow element, translate it (and any group members) to follow the - // cursor with a subtle lift, so it moves WITH your hand instead of - // sitting frozen under a lonely insertion line. Pure `transform` — no - // layout, no reflow — so it never disturbs the slot it will land in, is - // trivially reverted, and (being cleared before any pointer-up commit - // math) never corrupts a getBoundingClientRect read. Live sibling reflow - // (the gap opening around the cursor) is a later phase; this alone turns - // the "dead" reorder drag into a live one. + // Transform-only follow: must be cleared before any pointer-up commit + // reads getBoundingClientRect, or the drag delta corrupts the result. var reorderLiftedMembers: { el: HTMLElement; prevTransform: string; @@ -8329,18 +8322,15 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; }); reorderLiftedMembers = []; } - // ── Phase 0.1 hysteresis + Phase 1.2 live sibling reflow ────────────── - // Stabilize the resolved drop target so the preview + gap don't strobe on - // a boundary, then (for a SAME-container, simple packed flex row/column - // only) translate the siblings aside so the OPEN GAP is the preview of - // exactly where the item lands. Non-packed / wrap / grid / space-between / - // flex-grow / cross-container all fall back to the indicator-only line - // (option 1: never animate a preview that wouldn't match the real drop). - // Mirrors the tested reference in shared/drag-reflow.ts - // (resolveTargetHysteresis / isSimplePackedContainer / computeReorderOffsets). + // Live sibling reflow, restricted to same-container simple-packed flex so + // a constant per-sibling shift always matches the real drop; ported from + // shared/drag-reflow.ts. var reorderCommittedTarget: any = null; var reorderCommittedSlot: number | null = null; var reorderCommittedAt = 0; + var reorderCommittedPointer = { x: 0, y: 0 }; + var reorderPendingSlot: number | null = null; + var reorderPendingAt = 0; var reflowSiblings: { el: HTMLElement; prevTransform: string; @@ -8362,20 +8352,10 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; return out; } function reorderSlotForTarget(target, real: Element[]) { - if (target.placement === "inside") { - return { slot: real.length, boundary: null as number | null }; - } + if (target.placement === "inside") return { slot: real.length }; var ai = real.indexOf(target.anchor); if (ai < 0) return null; - var rect = target.anchor.getBoundingClientRect(); - var axis = reorderMainAxis(target); - if (target.placement === "before") { - return { slot: ai, boundary: axis === "x" ? rect.left : rect.top }; - } - return { - slot: ai + 1, - boundary: axis === "x" ? rect.right : rect.bottom, - }; + return { slot: target.placement === "before" ? ai : ai + 1 }; } function containerIsSimplePacked(container: Element): boolean { if (packedCacheContainer === container) return packedCacheResult; @@ -8421,7 +8401,22 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; reflowSiblings = []; reflowKey = null; } + // Auto-layout children reorder into a slot on plain drag — they have no + // free x/y without leaving the layout, which would collapse it. Ctrl + // "ignore auto layout" is the explicit free-place escape. + function resolveReorderOrFreeTarget(cx, cy, ctrlKey) { + return flowMoveTargetForPoint( + reorderEl, + cx, + cy, + groupOthers, + keepCurrentFlowParent, + ctrlKey, + ); + } // Figma's "don't nest into a smaller container" guard (⌘/Ctrl overrides). + // Instead of nesting into a too-small container, fall back to placing + // beside it in its parent so the drop is never silently discarded. function applyReorderSizeGuard(target, ev) { if (!liveReflowEnabled || !target || target.placement !== "inside") { return target; @@ -8431,22 +8426,46 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; if (!container || container === reorderEl) return target; var crect = container.getBoundingClientRect(); var drect = (reorderEl as HTMLElement).getBoundingClientRect(); - if (crect.width < drect.width || crect.height < drect.height) { - return null; + if (crect.width >= drect.width && crect.height >= drect.height) { + return target; } - return target; + var parent = container.parentElement; + if (!parent) return null; + var pcs = window.getComputedStyle(parent); + var pAxis = + pcs.flexDirection === "column" || + pcs.flexDirection === "column-reverse" + ? "y" + : "x"; + var center = + pAxis === "x" + ? crect.left + crect.width / 2 + : crect.top + crect.height / 2; + var ptr = + pAxis === "x" ? (ev ? ev.clientX : center) : ev ? ev.clientY : center; + return { + anchor: container, + placement: ptr < center ? "before" : "after", + axis: pAxis, + dropMode: "flow-insert", + }; } - // Stabilize a freshly-resolved target against the committed one. Only - // same-container flow-insert targets are damped; anything else passes - // through and resets the committed state. + // Stabilize a freshly-resolved target against the committed one so the + // preview only moves on a deliberate ≥8px pointer move from the last + // commit or after the NEW candidate persists ≥60ms. Mirrors + // shared/drag-reflow.ts resolveTargetHysteresis; same-container only. function stabilizeReorderTarget(rawTarget, cx, cy, now) { + var reset = function () { + reorderCommittedTarget = null; + reorderCommittedSlot = null; + reorderPendingSlot = null; + }; if ( !liveReflowEnabled || !rawTarget || rawTarget.dropMode !== "flow-insert" ) { - reorderCommittedTarget = null; - reorderCommittedSlot = null; + reset(); return rawTarget; } var container = dropContainerForTarget(rawTarget); @@ -8454,43 +8473,49 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; !container || (reorderEl as HTMLElement).parentElement !== container ) { - reorderCommittedTarget = null; - reorderCommittedSlot = null; + reset(); return rawTarget; } - var real = reorderRealChildren(container); - var slotInfo = reorderSlotForTarget(rawTarget, real); + var slotInfo = reorderSlotForTarget( + rawTarget, + reorderRealChildren(container), + ); if (!slotInfo) { - reorderCommittedTarget = null; - reorderCommittedSlot = null; + reset(); return rawTarget; } - var axis = reorderMainAxis(rawTarget); - var pointerMain = axis === "x" ? cx : cy; - if ( - reorderCommittedSlot === null || - slotInfo.slot === reorderCommittedSlot - ) { - reorderCommittedSlot = slotInfo.slot; + var slot = slotInfo.slot; + var commit = function () { + reorderCommittedSlot = slot; reorderCommittedTarget = rawTarget; - if (reorderCommittedAt === 0) reorderCommittedAt = now; + reorderCommittedPointer = { x: cx, y: cy }; + reorderCommittedAt = now; + reorderPendingSlot = null; return rawTarget; - } - var crossed = - slotInfo.boundary !== null && - Math.abs(pointerMain - slotInfo.boundary) >= 8; - var dwelled = now - reorderCommittedAt >= 60; - if (crossed || dwelled) { - reorderCommittedSlot = slotInfo.slot; + }; + if (reorderCommittedSlot === null) return commit(); + if (slot === reorderCommittedSlot) { reorderCommittedTarget = rawTarget; - reorderCommittedAt = now; + reorderPendingSlot = null; return rawTarget; } + if (reorderPendingSlot !== slot) { + reorderPendingSlot = slot; + reorderPendingAt = now; + } + var movedPx = Math.hypot( + cx - reorderCommittedPointer.x, + cy - reorderCommittedPointer.y, + ); + if (movedPx >= 8 || now - reorderPendingAt >= 60) return commit(); return reorderCommittedTarget || rawTarget; } function applyReorderReflow(target, cx, cy): void { if (!liveReflowEnabled) return; - if (!target || target.dropMode !== "flow-insert") { + // Group members are each lifted and follow the cursor; also reflowing + // them would double-transform and strand a residual on teardown, so + // group drags stay indicator-only. + if (isGroupDrag || !target || target.dropMode !== "flow-insert") { clearReorderReflow(); return; } @@ -8591,12 +8616,9 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // stabilized (hysteresis) and previewed with live sibling reflow when // liveReflowEnabled. stabilizeReorderTarget / applyReorderReflow are // no-ops (pass-through) when the flag is off. - var rawTarget = flowMoveTargetForPoint( - reorderEl, + var rawTarget = resolveReorderOrFreeTarget( cx, cy, - groupOthers, - keepCurrentFlowParent, Boolean(ev.ctrlKey), ); rawTarget = applyReorderSizeGuard(rawTarget, ev); @@ -8615,14 +8637,13 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; function cleanupReorderDrag() { document.removeEventListener(events.move, onReorderMove, true); document.removeEventListener(events.up, onReorderUp, true); + document.removeEventListener("pointercancel", onReorderEscape, true); document.removeEventListener("keydown", onReorderKeyDown, true); document.removeEventListener("keyup", onReorderKeyUp, true); clearActiveDragCancel(onReorderEscape); - // Restore the dragged element(s) AND any reflowed siblings to their - // untransformed state on every teardown path. onReorderUp calls this - // BEFORE resolving the drop target and reordering, so the commit reads - // un-transformed rects and — being one synchronous task — everything - // paints once, already in its final slot (no back-to-origin flicker). + // onReorderUp calls this before resolving/reordering, so the commit + // reads un-transformed rects and — one synchronous task — paints once + // in the final slot with no back-to-origin flicker. clearReorderLift(); clearReorderReflow(); } @@ -8704,19 +8725,21 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // already clears cross-screen state on re-entry so checking the // momentary excursion flag here would wrongly drop the element nowhere. if (outsideOnDrop) return; - // With live reflow on, commit the SAME stabilized target the guide/gap - // showed during the move (so it drops exactly where it previewed); - // otherwise resolve fresh from the release point (legacy behavior). - if (!liveReflowEnabled) { - currentTarget = flowMoveTargetForPoint( - reorderEl, - cx, - cy, - groupOthers, - keepCurrentFlowParent, - Boolean(ev?.ctrlKey), - ); - } + // Resolve from the RELEASE point + release-time modifiers so a Ctrl or + // Space held only at release still takes effect; live reflow then runs + // one final stabilize tick so the drop still lands on the previewed + // slot rather than jumping. + var finalRaw = resolveReorderOrFreeTarget(cx, cy, Boolean(ev?.ctrlKey)); + currentTarget = liveReflowEnabled + ? stabilizeReorderTarget( + applyReorderSizeGuard(finalRaw, ev), + cx, + cy, + ev && typeof ev.timeStamp === "number" + ? ev.timeStamp + : reorderCommittedAt, + ) + : finalRaw; if (!currentTarget) { // No valid drop target — clean up the clone if one was inserted so // no ghost element is left in the DOM. @@ -8809,6 +8832,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } document.addEventListener(events.move, onReorderMove, true); document.addEventListener(events.up, onReorderUp, true); + document.addEventListener("pointercancel", onReorderEscape, true); document.addEventListener("keydown", onReorderKeyDown, true); document.addEventListener("keyup", onReorderKeyUp, true); setActiveDragCancel(onReorderEscape); diff --git a/templates/design/app/pages/DesignEditor.selection.test.ts b/templates/design/app/pages/DesignEditor.selection.test.ts index f6c056c14b..7363ab08e5 100644 --- a/templates/design/app/pages/DesignEditor.selection.test.ts +++ b/templates/design/app/pages/DesignEditor.selection.test.ts @@ -2572,6 +2572,28 @@ describe("U3: local content history fallback mirror", () => { }, ]); }); + + it("does NOT coalesce an incoming agent checkpoint into a preceding user edit (reverse ordering)", () => { + // Mirror of the case above: a user edit is already on the stack when the + // agent edits the same file. The incoming checkpoint is contiguous + // (agent.before === user.after) so the one-sided guard used to merge it + // backward and drop isCheckpoint — one Cmd+Z then wiped both. + const userEdit = { + fileId: "a", + before: "", + after: "", + }; + const stack = [userEdit]; + const checkpoint = { + fileId: "a", + before: "", + after: "", + isCheckpoint: true, + }; + const next = mergeLocalContentHistoryFallback(stack, checkpoint); + expect(next).toEqual([userEdit, checkpoint]); + expect(next[next.length - 1]?.isCheckpoint).toBe(true); + }); }); // L11: screen rename must preserve the file extension instead of writing the diff --git a/templates/design/app/pages/design-editor/history.ts b/templates/design/app/pages/design-editor/history.ts index be0a031c14..7b0c9feb1c 100644 --- a/templates/design/app/pages/design-editor/history.ts +++ b/templates/design/app/pages/design-editor/history.ts @@ -270,7 +270,10 @@ export function mergeLocalContentHistoryFallback( last && last.fileId === change.fileId && last.after === change.before && - !last.isCheckpoint + // An incoming checkpoint must stay its own undo boundary, or one Cmd+Z + // reverts an agent edit together with the preceding user edit. + !last.isCheckpoint && + !change.isCheckpoint ) { return [...stack.slice(0, -1), { ...last, after: change.after }]; } diff --git a/templates/design/e2e/canvas-tools.spec.ts b/templates/design/e2e/canvas-tools.spec.ts index d265797dc5..65efdcf645 100644 --- a/templates/design/e2e/canvas-tools.spec.ts +++ b/templates/design/e2e/canvas-tools.spec.ts @@ -1890,6 +1890,52 @@ test("rectangle insertion keeps the new primitive selected", async ({ await restoreHome(page); }); +test("creating a layer does not restore a layer deleted immediately before it", async ({ + page, +}) => { + const alphaLayer = page + .getByRole("tree", { name: "Layers" }) + .getByRole("treeitem") + .filter({ hasText: "Alpha Button" }) + .first(); + await expect(alphaLayer).toBeVisible(); + await alphaLayer.click(); + await page.keyboard.press("Delete"); + await expect + .poll( + async () => + (await fileContent(page, "index.html")).includes( + 'data-agent-native-node-id="e2e-alpha-button"', + ), + { timeout: 20_000 }, + ) + .toBe(false); + + const card = await homeScreenCard(page); + const cardBox = await card.boundingBox(); + if (!cardBox) throw new Error("no home screen card box"); + await createDraftPrimitive(page, "Rectangle", "Rectangle", { + start: { + x: cardBox.x + cardBox.width * 0.58, + y: cardBox.y + cardBox.height * 0.56, + }, + end: { + x: cardBox.x + cardBox.width * 0.8, + y: cardBox.y + cardBox.height * 0.78, + }, + }); + + await expect + .poll( + async () => + (await fileContent(page, "index.html")).includes( + 'data-agent-native-node-id="e2e-alpha-button"', + ), + { timeout: 20_000 }, + ) + .toBe(false); +}); + test("dragging a rectangle between screens moves it across files", async ({ page, }) => { diff --git a/templates/design/shared/drag-reflow.test.ts b/templates/design/shared/drag-reflow.test.ts index 6bf5eb02d2..fddc44569e 100644 --- a/templates/design/shared/drag-reflow.test.ts +++ b/templates/design/shared/drag-reflow.test.ts @@ -21,20 +21,30 @@ function candidate( over: Partial & Pick, ): DragTargetCandidate { return { - pointerMain: 0, - boundaryMain: null, + pointer: { x: 0, y: 0 }, containerPenetrationPx: Infinity, isLeave: false, ...over, }; } +function committed( + key: { containerKey: string; index: number }, + at: number, + pointer = { x: 0, y: 0 }, +): HysteresisState { + return { + key, + committedAt: at, + committedPointer: pointer, + pendingKey: null, + pendingAt: 0, + }; +} + describe("resolveTargetHysteresis", () => { it("clears instantly when the candidate is null", () => { - const prev: HysteresisState = { - key: { containerKey: "A", index: 0 }, - committedAt: 0, - }; + const prev = committed({ containerKey: "A", index: 0 }, 0); const res = resolveTargetHysteresis(prev, candidate({ key: null }), 5); expect(res.key).toBeNull(); expect(res.changed).toBe(true); @@ -50,58 +60,65 @@ describe("resolveTargetHysteresis", () => { it("accepts the first target immediately (no lag before the guide appears)", () => { const res = resolveTargetHysteresis( null, - candidate({ key: { containerKey: "A", index: 2 } }), + candidate({ + key: { containerKey: "A", index: 2 }, + pointer: { x: 10, y: 5 }, + }), 100, ); expect(res.key).toEqual({ containerKey: "A", index: 2 }); expect(res.changed).toBe(true); - expect(res.state).toEqual({ - key: { containerKey: "A", index: 2 }, - committedAt: 100, - }); + expect(res.state?.committedPointer).toEqual({ x: 10, y: 5 }); }); - it("holds an unchanged target and preserves the original commit time", () => { + it("holds an unchanged target and clears any pending candidate", () => { const prev: HysteresisState = { - key: { containerKey: "A", index: 1 }, - committedAt: 40, + ...committed({ containerKey: "A", index: 1 }, 40, { x: 50, y: 0 }), + pendingKey: { containerKey: "A", index: 2 }, + pendingAt: 30, }; const res = resolveTargetHysteresis( prev, - candidate({ key: { containerKey: "A", index: 1 } }), + candidate({ + key: { containerKey: "A", index: 1 }, + pointer: { x: 52, y: 0 }, + }), 999, ); expect(res.changed).toBe(false); - expect(res.state).toBe(prev); // committedAt not refreshed + expect(res.state?.pendingKey).toBeNull(); + expect(res.state?.committedPointer).toEqual({ x: 50, y: 0 }); }); describe("index change within the same container", () => { - const prev: HysteresisState = { - key: { containerKey: "A", index: 0 }, - committedAt: 0, - }; - - it("rejects while the pointer is within the boundary deadband and dwell not met", () => { + it("rejects a small jitter that has not moved far or dwelled", () => { + const prev = committed({ containerKey: "A", index: 0 }, 0, { + x: 100, + y: 0, + }); const res = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "A", index: 1 }, - boundaryMain: 100, - pointerMain: 105, + pointer: { x: 103, y: 0 }, }), - 10, // elapsed 10ms < 60ms + 10, ); expect(res.changed).toBe(false); expect(res.key).toEqual({ containerKey: "A", index: 0 }); + expect(res.state?.pendingKey).toEqual({ containerKey: "A", index: 1 }); }); - it("accepts once the pointer crosses the boundary by >= 8px", () => { + it("accepts once the pointer moves >= 8px from the last commit", () => { + const prev = committed({ containerKey: "A", index: 0 }, 0, { + x: 100, + y: 0, + }); const res = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "A", index: 1 }, - boundaryMain: 100, - pointerMain: 92, + pointer: { x: 112, y: 0 }, }), 10, ); @@ -109,44 +126,59 @@ describe("resolveTargetHysteresis", () => { expect(res.key).toEqual({ containerKey: "A", index: 1 }); }); - it("accepts on dwell even when still inside the deadband", () => { - const res = resolveTargetHysteresis( + it("dwell times the NEW candidate, not the committed slot's age", () => { + const prev = committed({ containerKey: "A", index: 0 }, 0, { + x: 100, + y: 0, + }); + const first = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "A", index: 1 }, - boundaryMain: 100, - pointerMain: 101, + pointer: { x: 101, y: 0 }, }), - 60, // elapsed 60ms >= 60ms + 1000, ); - expect(res.changed).toBe(true); + expect(first.changed).toBe(false); + const second = resolveTargetHysteresis( + first.state, + candidate({ + key: { containerKey: "A", index: 1 }, + pointer: { x: 101, y: 0 }, + }), + 1060, + ); + expect(second.changed).toBe(true); }); - it("falls back to dwell-only when there is no boundary (empty-container inside)", () => { - const early = resolveTargetHysteresis( + it("resets the dwell timer when the candidate slot changes", () => { + const prev = committed({ containerKey: "A", index: 0 }, 0, { + x: 100, + y: 0, + }); + const t1 = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "A", index: 1 }, - boundaryMain: null, - pointerMain: 5000, + pointer: { x: 101, y: 0 }, }), - 30, + 1000, ); - expect(early.changed).toBe(false); - const late = resolveTargetHysteresis( - prev, - candidate({ key: { containerKey: "A", index: 1 }, boundaryMain: null }), - 60, + const t2 = resolveTargetHysteresis( + t1.state, + candidate({ + key: { containerKey: "A", index: 2 }, + pointer: { x: 102, y: 0 }, + }), + 1050, ); - expect(late.changed).toBe(true); + expect(t2.changed).toBe(false); + expect(t2.state?.pendingAt).toBe(1050); }); }); describe("container change", () => { - const prev: HysteresisState = { - key: { containerKey: "A", index: 3 }, - committedAt: 0, - }; + const prev = committed({ containerKey: "A", index: 3 }, 0); it("reverses instantly when leaving to an ancestor", () => { const res = resolveTargetHysteresis( @@ -189,32 +221,39 @@ describe("resolveTargetHysteresis", () => { }); it("accepts a shallow entry on dwell", () => { - const res = resolveTargetHysteresis( + const t1 = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "B", index: 0 }, containerPenetrationPx: 5, }), + 0, + ); + const t2 = resolveTargetHysteresis( + t1.state, + candidate({ + key: { containerKey: "B", index: 0 }, + containerPenetrationPx: 5, + }), 80, ); - expect(res.changed).toBe(true); + expect(t2.changed).toBe(true); }); }); it("honors custom thresholds", () => { - const prev: HysteresisState = { - key: { containerKey: "A", index: 0 }, - committedAt: 0, - }; + const prev = committed({ containerKey: "A", index: 0 }, 0, { + x: 100, + y: 0, + }); const res = resolveTargetHysteresis( prev, candidate({ key: { containerKey: "A", index: 1 }, - boundaryMain: 100, - pointerMain: 97, + pointer: { x: 103, y: 0 }, }), 5, - { indexBoundaryPx: 2 }, // |97-100|=3 >= 2 + { movePx: 2 }, ); expect(res.changed).toBe(true); }); diff --git a/templates/design/shared/drag-reflow.ts b/templates/design/shared/drag-reflow.ts index 109e5340be..1c604fffac 100644 --- a/templates/design/shared/drag-reflow.ts +++ b/templates/design/shared/drag-reflow.ts @@ -46,70 +46,55 @@ export interface DragTargetKey { * iframe-local client coordinates — see canvas-math note about the host * scaling the whole iframe uniformly). */ +export interface CandidatePoint { + x: number; + y: number; +} + export interface DragTargetCandidate { /** The resolved target, or null when the pointer is over no valid target. */ key: DragTargetKey | null; - /** - * The pointer coordinate along the container's flow (main) axis. Used to - * measure how far past an insertion boundary the pointer has travelled. - */ - pointerMain: number; - /** - * The insertion boundary coordinate along the flow axis (e.g. the shared - * edge between the two siblings the item would drop between). `null` when - * the candidate has no meaningful boundary (an empty-container "inside" - * drop), in which case index changes fall back to the dwell timer only. - */ - boundaryMain: number | null; - /** - * How far the pointer has penetrated the candidate container from its - * nearest entering edge, in px (>= 0 inside). Only consulted when switching - * to a *different* container. Pass `Infinity` for a same-container change. - */ + /** Pointer position this tick, in a consistent space, for the movement + * deadband (distance from where the current target was committed). */ + pointer: CandidatePoint; + /** How far the pointer has penetrated a *different* container from its + * entering edge, px. Pass Infinity for a same-container change. */ containerPenetrationPx: number; - /** - * True when the candidate container is an ancestor of the currently - * committed container — i.e. the pointer is *leaving* toward a parent. - * Leaving reverses instantly (Figma behavior), bypassing dwell/penetration. - */ + /** True when the candidate container is an ancestor of the committed one + * (leaving toward a parent) — reverses instantly. */ isLeave: boolean; } export interface HysteresisState { key: DragTargetKey; - /** Timestamp (ms) at which this target was committed. */ committedAt: number; + committedPointer: CandidatePoint; + /** The differing candidate currently being timed out, and since when. */ + pendingKey: DragTargetKey | null; + pendingAt: number; } export interface HysteresisOptions { - /** - * Pointer must cross the insertion boundary by at least this many px before - * an index change within the same container is accepted. Default 8. - */ - indexBoundaryPx?: number; - /** …or the candidate index must be stable for this long (ms). Default 60. */ - indexDwellMs?: number; - /** - * Pointer must penetrate a *different* container by at least this many px - * before the container change is accepted. Default 10. - */ + /** Pointer must move at least this far from the last commit before a + * same-container slot change is accepted. Default 8. */ + movePx?: number; + /** …or the new candidate must persist this long (ms). Default 60. */ + dwellMs?: number; + /** Pointer must penetrate a different container this far, px. Default 10. */ containerPenetrationPx?: number; - /** …or the new container must be hovered for this long (ms). Default 80. */ + /** …or the new container must persist this long (ms). Default 80. */ containerDwellMs?: number; } export interface HysteresisResult { - /** The stabilized target to actually use this tick (may equal previous). */ key: DragTargetKey | null; - /** True when the stabilized target changed vs the previous committed one. */ changed: boolean; - /** The state to carry into the next tick. */ state: HysteresisState | null; } const DEFAULT_HYSTERESIS: Required = { - indexBoundaryPx: 8, - indexDwellMs: 60, + movePx: 8, + dwellMs: 60, containerPenetrationPx: 10, containerDwellMs: 80, }; @@ -119,23 +104,32 @@ function keysEqual(a: DragTargetKey | null, b: DragTargetKey | null): boolean { return a.containerKey === b.containerKey && a.index === b.index; } +function commitTarget( + key: DragTargetKey, + pointer: CandidatePoint, + now: number, +): HysteresisResult { + return { + key, + changed: true, + state: { + key, + committedAt: now, + committedPointer: pointer, + pendingKey: null, + pendingAt: 0, + }, + }; +} + /** - * Stabilize a raw drop-target candidate against the previously committed - * target so the insertion preview only transitions when the pointer clearly - * moves to a new slot/container. - * - * Rules (mirrors the plan's Phase 0.1): - * - No previous target → accept immediately (the guide should appear at once). - * - Candidate is null → clear immediately (leaving all targets is instant). - * - Same container + same index → hold. - * - Same container, different index → switch only once the pointer is - * `indexBoundaryPx` past the boundary, OR the index has been stable for - * `indexDwellMs`. - * - Different container → if it is a *leave* (ancestor), switch instantly; - * otherwise switch only once penetration ≥ `containerPenetrationPx`, OR the - * container has been hovered for `containerDwellMs`. - * - * Pure: pass `now` (ms) explicitly so it is deterministic in tests. + * Stabilize a raw drop-target candidate against the committed one so the + * preview only transitions when the pointer deliberately moves to a new + * slot/container. A change is accepted when the pointer has moved `movePx` + * from the last commit (deliberate move) OR the *new* candidate has persisted + * `dwellMs` — dwell is timed from when the candidate first appeared, not from + * the committed target's age. Cross-container leaves reverse instantly; other + * container entries need penetration or dwell. Pure: `now` is passed in. */ export function resolveTargetHysteresis( prev: HysteresisState | null, @@ -145,58 +139,49 @@ export function resolveTargetHysteresis( ): HysteresisResult { const opts = { ...DEFAULT_HYSTERESIS, ...options }; - // Leaving every target — instant. if (candidate.key === null) { - return { - key: null, - changed: prev !== null, - state: null, - }; + return { key: null, changed: prev !== null, state: null }; } - - // First acquisition — instant, so the preview shows up without lag. if (prev === null) { - return { - key: candidate.key, - changed: true, - state: { key: candidate.key, committedAt: now }, - }; + return commitTarget(candidate.key, candidate.pointer, now); } - - // Unchanged target — hold, preserving the original commit time so dwell is - // measured from when we first committed, not refreshed every tick. if (keysEqual(candidate.key, prev.key)) { - return { key: prev.key, changed: false, state: prev }; + return { + key: prev.key, + changed: false, + state: { ...prev, pendingKey: null, pendingAt: 0 }, + }; } + const pendingContinues = keysEqual(candidate.key, prev.pendingKey); + const pendingKey = candidate.key; + const pendingAt = pendingContinues ? prev.pendingAt : now; + const dwelledFor = now - pendingAt; const sameContainer = candidate.key.containerKey === prev.key.containerKey; - const elapsed = now - prev.committedAt; let accept: boolean; if (sameContainer) { - const crossedBoundary = - candidate.boundaryMain !== null && - Math.abs(candidate.pointerMain - candidate.boundaryMain) >= - opts.indexBoundaryPx; - accept = crossedBoundary || elapsed >= opts.indexDwellMs; + const movedPx = Math.hypot( + candidate.pointer.x - prev.committedPointer.x, + candidate.pointer.y - prev.committedPointer.y, + ); + accept = movedPx >= opts.movePx || dwelledFor >= opts.dwellMs; } else if (candidate.isLeave) { - // Exiting to an ancestor reverses instantly. accept = true; } else { - const penetrated = - candidate.containerPenetrationPx >= opts.containerPenetrationPx; - accept = penetrated || elapsed >= opts.containerDwellMs; + accept = + candidate.containerPenetrationPx >= opts.containerPenetrationPx || + dwelledFor >= opts.containerDwellMs; } if (accept) { - return { - key: candidate.key, - changed: true, - state: { key: candidate.key, committedAt: now }, - }; + return commitTarget(candidate.key, candidate.pointer, now); } - // Reject the change this tick — keep showing the committed target. - return { key: prev.key, changed: false, state: prev }; + return { + key: prev.key, + changed: false, + state: { ...prev, pendingKey, pendingAt }, + }; } // --------------------------------------------------------------------------- From 69d60893aa05ba161fd1e3eca1e431fecdc68b9f Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Fri, 17 Jul 2026 14:07:39 +0200 Subject: [PATCH 4/7] changes --- .../bridge/editor-chrome.generated.ts | 84 ++++++++++++- templates/design/actions/update-file.ts | 11 +- .../app/components/design/DesignCanvas.tsx | 12 ++ .../components/design/MultiScreenCanvas.tsx | 9 ++ .../design/bridge/bridge.guard.spec.ts | 90 ++++++++++++++ .../design/bridge/editor-chrome.bridge.ts | 117 +++++++++++++++++- .../design/app/components/design/dnd-debug.ts | 28 +++++ .../design/app/lib/design-save-outbox.test.ts | 23 +++- .../design/app/lib/design-save-outbox.ts | 45 ++++++- templates/design/app/pages/DesignEditor.tsx | 22 ++++ 10 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 templates/design/app/components/design/dnd-debug.ts diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 3c19ca86a4..24d7cd531c 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -24,6 +24,35 @@ export const editorChromeBridgeScript: string = `"use strict"; } })(); var scaleToolEnabled = false; + if (window.__DND_DEBUG === void 0) { + window.__DND_DEBUG = true; + } + function dndLog(phase, data) { + if (!window.__DND_DEBUG) return; + try { + var tag = "%c[dnd:" + phase + "]"; + var style = "color:#8b5cf6;font-weight:bold"; + if (data === void 0) console.log(tag, style); + else console.log(tag, style, data); + } catch (_e) { + } + } + function dndTarget(t) { + if (!t) return null; + try { + var container = dropContainerForTarget(t); + return { + anchor: t.anchor ? getSelector(t.anchor) : null, + placement: t.placement, + dropMode: t.dropMode, + axis: t.axis, + container: container ? getSelector(container) : null, + needsConversion: !!t.needsAutoLayoutConversion + }; + } catch (_e) { + return { placement: t.placement, dropMode: t.dropMode }; + } + } var statePreviewElement = null; var runtimeInteractionStatePreviews = []; var runtimeInteractionStatePreviewSequence = 0; @@ -1401,14 +1430,23 @@ export const editorChromeBridgeScript: string = `"use strict"; document.body.appendChild(gradientOverlay); var transformBadge = document.createElement("div"); transformBadge.setAttribute("data-agent-native-transform-badge", ""); + transformBadge.setAttribute( + "data-agent-native-edit-overlay", + "transform-badge" + ); transformBadge.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;border:1px solid rgba(255,255,255,0.16);border-radius:4px;background:rgba(24,24,27,0.96);color:rgba(255,255,255,0.96);font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;padding:3px 5px;box-shadow:0 8px 20px rgba(0,0,0,0.28);"; document.body.appendChild(transformBadge); var spacingBadge = document.createElement("div"); spacingBadge.setAttribute("data-agent-native-spacing-badge", ""); + spacingBadge.setAttribute("data-agent-native-edit-overlay", "spacing-badge"); spacingBadge.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;border-radius:3px;color:white;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;padding:2px 4px;box-shadow:0 4px 14px rgba(0,0,0,0.18);"; document.body.appendChild(spacingBadge); var insertionGuide = document.createElement("div"); insertionGuide.setAttribute("data-agent-native-insertion-guide", ""); + insertionGuide.setAttribute( + "data-agent-native-edit-overlay", + "insertion-guide" + ); insertionGuide.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;background:var(--design-editor-accent-color);border-radius:999px;box-shadow:0 0 0 1px var(--design-editor-accent-color);"; document.body.appendChild(insertionGuide); var snapGuideV = document.createElement("div"); @@ -4500,6 +4538,7 @@ export const editorChromeBridgeScript: string = `"use strict"; return clientX < 0 || clientY < 0 || clientX > window.innerWidth || clientY > window.innerHeight; } function postCrossScreenDrag(phase, el, ev) { + dndLog("post:cross-screen", { phase, el: getSelector(el ?? null) }); if (phase === "cancel") { activeCrossScreenStyleSnapshot = void 0; window.parent.postMessage( @@ -4609,6 +4648,9 @@ export const editorChromeBridgeScript: string = `"use strict"; } return true; } + function isTextBearingLeaf(el) { + return hasOnlyLeafContent(el) && (el.textContent || "").trim().length > 0; + } function isContainerDropTarget(el) { if (!el || el === document.documentElement) return false; if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false; @@ -4705,7 +4747,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } var hit = elementFromEditorPoint(clientX, clientY); if (hit && hit !== document.documentElement && !isDraggedOrInsideDragged(hit) && !isOverlayElement(hit) && !isTemplateCloneElement(hit)) { - if (isContainerDropTarget(hit)) { + if (isContainerDropTarget(hit) && !isTextBearingLeaf(hit)) { var containerRect = hit.getBoundingClientRect(); var edgeAxis = hit.parentElement ? parentFlowAxis(hit.parentElement) : parentFlowAxis(hit); var edgePlacement = edgePlacementForRect( @@ -5184,6 +5226,12 @@ export const editorChromeBridgeScript: string = `"use strict"; } function postVisualStructureChange(el, target, origin) { if (!el || !target || !target.anchor) return; + dndLog("post:structure-change", { + el: getSelector(el), + anchor: getSelector(target.anchor), + placement: target.placement, + dropMode: target.dropMode || "flow-insert" + }); var requestId = "move-" + Date.now() + "-" + Math.random().toString(16).slice(2); pendingStructureMoves[requestId] = { requestId, @@ -5712,6 +5760,11 @@ export const editorChromeBridgeScript: string = `"use strict"; ev.timeStamp ); showInsertionGuideFor(currentTarget); + var _dndKey = currentTarget ? getSelector(currentTarget.anchor) + "|" + currentTarget.placement + "|" + currentTarget.dropMode : "none"; + if (_dndKey !== reorderLastTargetKey) { + reorderLastTargetKey = _dndKey; + dndLog("target", dndTarget(currentTarget)); + } applyReorderLift2(dx, dy); applyReorderReflow2(currentTarget, cx, cy); showTransformBadge(currentTarget ? "Move layer" : "Move", cx, cy); @@ -5790,6 +5843,11 @@ export const editorChromeBridgeScript: string = `"use strict"; cy, ev && typeof ev.timeStamp === "number" ? ev.timeStamp : reorderCommittedAt ) : finalRaw; + dndLog("commit:resolve", { + raw: dndTarget(finalRaw), + final: dndTarget(currentTarget), + ctrl: Boolean(ev && ev.ctrlKey) + }); if (!currentTarget) { if (duplicatedForDrag && reorderEl && reorderEl !== originalSelectedEl) { if (reorderEl.parentElement) @@ -5846,6 +5904,10 @@ export const editorChromeBridgeScript: string = `"use strict"; dropContainerForTarget(currentTarget) ); applyRuntimeReorder(reorderEl, currentTarget); + dndLog("commit:done", { + el: getSelector(reorderEl), + parent: reorderEl.parentElement ? getSelector(reorderEl.parentElement) : null + }); postVisualStructureChange(reorderEl, currentTarget, { prevParent, prevNextSibling, @@ -5867,6 +5929,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }; }); var reorderGestureStartRect = reorderEl.getBoundingClientRect(); + var reorderLastTargetKey = null; var keepCurrentFlowParent = bridgeSpaceKeyPressed; var currentTarget = flowMoveTargetForPoint( reorderEl, @@ -5877,6 +5940,12 @@ export const editorChromeBridgeScript: string = `"use strict"; Boolean(e.ctrlKey) ); showInsertionGuideFor(currentTarget); + dndLog("start:reorder", { + el: getSelector(reorderEl), + isGroup: isGroupDrag, + ctrl: Boolean(e.ctrlKey), + target: dndTarget(currentTarget) + }); var pointerOutsideIframe = false; var reorderSelector = getSelector(reorderEl); var reorderSourceId = getSourceId(reorderEl); @@ -5937,6 +6006,7 @@ export const editorChromeBridgeScript: string = `"use strict"; var dragEl = gestureEl; var moved = false; var DRAG_THRESHOLD = 3; + dndLog("start:free", { el: getSelector(gestureEl), isGroup: isGroupDrag }); var currentAutoLayoutTarget = null; var snapCandidateRects = collectSnapCandidateRects(dragEl, groupOthers); var dragElStartRect = dragEl.getBoundingClientRect(); @@ -6149,6 +6219,10 @@ export const editorChromeBridgeScript: string = `"use strict"; dropContainerForTarget(currentAutoLayoutTarget) ); applyRuntimeReorder(dragEl, currentAutoLayoutTarget); + dndLog("commit:free-nest", { + el: getSelector(dragEl), + target: dndTarget(currentAutoLayoutTarget) + }); postVisualStructureChange(dragEl, currentAutoLayoutTarget, { prevParent, prevNextSibling, @@ -6157,6 +6231,7 @@ export const editorChromeBridgeScript: string = `"use strict"; } } else { setMembersOpacity(null); + dndLog("commit:free-absolute", { count: memberStates.length }); memberStates.forEach(function(state) { var styles = { position: state.el.style.position, @@ -6505,6 +6580,9 @@ export const editorChromeBridgeScript: string = `"use strict"; var previousSelectedEl = selectedEl; selectedEl = target; positionOverlay(selectionOverlay, selectedEl); + if (!ev?.shiftKey && passiveSelectionEls.length) { + setPassiveSelectionElements([]); + } preservePreviousSelectedElementForShiftClick( previousSelectedEl, selectedEl, @@ -7701,6 +7779,10 @@ export const editorChromeBridgeScript: string = `"use strict"; return; } if (e.data.type === "visual-structure-ack") { + dndLog("ack", { + requestId: e.data.requestId, + applied: Boolean(e.data.applied) + }); var move = pendingStructureMoves[e.data.requestId]; if (!move) return; delete pendingStructureMoves[e.data.requestId]; diff --git a/templates/design/actions/update-file.ts b/templates/design/actions/update-file.ts index 286e2b6de5..e1068e8b59 100644 --- a/templates/design/actions/update-file.ts +++ b/templates/design/actions/update-file.ts @@ -169,7 +169,16 @@ export default defineAction({ .limit(1); if (!file) { - throw new Error(`File not found: ${id}`); + // Terminal (not transient): the row is gone or out of access scope, so a + // retry can never succeed. Mark it 404 so the client save-outbox drops the + // entry instead of retrying forever (which turns one orphaned/deleted + // screen into an update-file 500 storm — see design-save-outbox + // isTerminalSaveError). + const notFound = new Error(`File not found: ${id}`) as Error & { + status?: number; + }; + notFound.status = 404; + throw notFound; } await assertAccess("design", file.designId, "editor"); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index 3b642fea84..d74a2bacd7 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -61,6 +61,7 @@ import { shaderRuntimeBridgeScript } from "../../../.generated/bridge/shader-run import { tweakBridgeScript } from "../../../.generated/bridge/tweak.generated"; import { zoomBridgeScript } from "../../../.generated/bridge/zoom.generated"; import { isTrustedCanvasBridgeMessage } from "./bridge-security"; +import { dndHostLog } from "./dnd-debug"; import { captureAnnotatedScreenshot } from "./design-canvas/annotation-snapshot"; import { submitDesignAnnotations } from "./design-canvas/annotation-submit"; import { @@ -2485,6 +2486,12 @@ export function DesignCanvas({ const selector = String(e.data.selector || ""); const anchorSelector = String(e.data.anchorSelector || ""); const placement = String(e.data.placement || "after"); + dndHostLog("recv:structure-change", { + selector, + anchorSelector, + placement, + dropMode: e.data.dropMode, + }); const requestId = typeof e.data.requestId === "string" ? e.data.requestId : undefined; const sourceId = @@ -2552,6 +2559,11 @@ export function DesignCanvas({ : undefined, }, ); + dndHostLog("persist:result", { + applied, + requestId, + willAck: Boolean(requestId) && applied !== "pending", + }); if (requestId && applied !== "pending") { iframeRef.current?.contentWindow?.postMessage( { diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index 47321a603c..83379491ee 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -84,6 +84,7 @@ import { } from "./canvas-primitive-style"; import { appendHitTestResponder } from "./design-canvas/hit-test"; import { DesignCanvas } from "./DesignCanvas"; +import { dndHostLog } from "./dnd-debug"; import { gradientToCss, parseGradientCss, @@ -2263,6 +2264,13 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ return; } + if (msg.phase !== "move") { + dndHostLog("overview:cross-screen", { + phase: msg.phase, + source: sourceScreenId, + selector: msg.selector, + }); + } if (msg.phase === "start") { setCrossScreenSourceIsBoard(sourceScreenId === boardFileId); crossScreenDragMsgRef.current = { @@ -5161,6 +5169,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ frameGeometryWithOverrides(after, state.originFrames), after, ); + dndHostLog("overview:frame-commit", { ids: state.targetIds }); suppressNextPick.current = true; } finishDrag(); diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index 7ab3e4008d..ed547a19a5 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -9571,3 +9571,93 @@ it( } }, ); + +it( + "flow-reorder of a card past a sibling text card reorders in the column, never nesting into it or converting it to flex (Phase 0.3)", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + await page.setContent(` + + + + + +
+
Card A — first card
+
Card B — second card
+
Card C — third card
+
+ +`); + await page.addScriptTag({ content: hydratedEditorChromeBridgeScript() }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + // Select #card-b (center ~170,126), then drag it straight down past + // #card-c's midline (~202) — the clip's "move Card B below Card C" gesture. + await page.mouse.click(170, 126); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + await page.mouse.move(170, 126); + await page.mouse.down(); + await page.mouse.move(170, 135, { steps: 4 }); // cross the 3px threshold + await page.mouse.move(170, 210, { steps: 10 }); // past card-c midline + + // Phase 0.1: the insertion guide must be a live, visible DOM node while + // dragging (previously stripped by the content-stamp pass → invisible). + const guideMidDrag = await page.evaluate(() => { + const guide = document.querySelector( + "[data-agent-native-insertion-guide]", + ); + return { + connected: !!guide?.isConnected, + display: guide ? window.getComputedStyle(guide).display : "missing", + }; + }); + + await page.mouse.up(); + await page.waitForTimeout(40); + + const result = await page.evaluate(() => { + const b = document.querySelector("#card-b")!; + const c = document.querySelector("#card-c")!; + return { + cardBParentId: b.parentElement?.id, + cardCInlineDisplay: c.style.display, + order: Array.from(document.querySelectorAll("#col > div")).map( + (el) => el.id, + ), + }; + }); + + // 0.3: reordered within the column, NOT nested into card-c, and card-c was + // NOT silently rewritten to display:flex. + expect(result.cardBParentId).toBe("col"); + expect(result.cardCInlineDisplay).not.toBe("flex"); + expect(result.order).toEqual(["card-a", "card-c", "card-b"]); + // 0.1: guide alive + shown during the drag. + expect(guideMidDrag.connected).toBe(true); + expect(guideMidDrag.display).not.toBe("none"); + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 21be444f01..c262e3aa71 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -80,6 +80,44 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; })(); var scaleToolEnabled = false; + // ── Drag-and-drop debug logging ──────────────────────────────────── + // Every DnD seam logs under the "[dnd]" prefix with a phase tag, so the + // console reads as a narrated timeline of ONE gesture: + // start:* → target → lift/reflow → commit:* → post:* → ack:* + // The iframe posts these to its own console; the host mirrors its side + // under the same prefix. Toggle at runtime from the iframe console with + // `window.__DND_DEBUG = false` — no rebuild needed. + if ((window as any).__DND_DEBUG === undefined) { + (window as any).__DND_DEBUG = true; + } + function dndLog(phase: string, data?: unknown): void { + if (!(window as any).__DND_DEBUG) return; + try { + var tag = "%c[dnd:" + phase + "]"; + var style = "color:#8b5cf6;font-weight:bold"; + if (data === undefined) console.log(tag, style); + else console.log(tag, style, data); + } catch (_e) {} + } + // Describe a resolved drop target compactly for the log timeline. Reads + // getSelector/dropContainerForTarget (declared later, hoisted) at call time. + function dndTarget(t): unknown { + if (!t) return null; + try { + var container = dropContainerForTarget(t); + return { + anchor: t.anchor ? getSelector(t.anchor) : null, + placement: t.placement, + dropMode: t.dropMode, + axis: t.axis, + container: container ? getSelector(container) : null, + needsConversion: !!t.needsAutoLayoutConversion, + }; + } catch (_e) { + return { placement: t.placement, dropMode: t.dropMode }; + } + } + // Interaction-state forced preview (phase 2 — see shared/interaction-states.ts's // "Forced-preview mechanism" doc comment). Keep the actual element rather // than only its node id: localhost React layers can resolve through runtime @@ -1977,18 +2015,30 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; var transformBadge = document.createElement("div"); transformBadge.setAttribute("data-agent-native-transform-badge", ""); + // Classify as edit-overlay so the content-stamp pass (isEditorChrome) does + // not strip it and replaceRuntimeDocument preserves it — otherwise the badge + // is detached from the DOM and its styling targets a dead node (invisible). + transformBadge.setAttribute( + "data-agent-native-edit-overlay", + "transform-badge", + ); transformBadge.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;border:1px solid rgba(255,255,255,0.16);border-radius:4px;background:rgba(24,24,27,0.96);color:rgba(255,255,255,0.96);font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;padding:3px 5px;box-shadow:0 8px 20px rgba(0,0,0,0.28);"; document.body.appendChild(transformBadge); var spacingBadge = document.createElement("div"); spacingBadge.setAttribute("data-agent-native-spacing-badge", ""); + spacingBadge.setAttribute("data-agent-native-edit-overlay", "spacing-badge"); spacingBadge.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;border-radius:3px;color:white;font:10px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;padding:2px 4px;box-shadow:0 4px 14px rgba(0,0,0,0.18);"; document.body.appendChild(spacingBadge); var insertionGuide = document.createElement("div"); insertionGuide.setAttribute("data-agent-native-insertion-guide", ""); + insertionGuide.setAttribute( + "data-agent-native-edit-overlay", + "insertion-guide", + ); insertionGuide.style.cssText = "position:fixed;z-index:100000;display:none;pointer-events:none;background:var(--design-editor-accent-color);border-radius:999px;box-shadow:0 0 0 1px var(--design-editor-accent-color);"; document.body.appendChild(insertionGuide); @@ -6619,6 +6669,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; el?: Element | null, ev?: { clientX?: number; clientY?: number } | null, ): void { + dndLog("post:cross-screen", { phase: phase, el: getSelector(el ?? null) }); if (phase === "cancel") { activeCrossScreenStyleSnapshot = undefined; (window.parent as Window).postMessage( @@ -6754,6 +6805,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; return true; } + // Figma R11: an element whose entire content is text / inline leaves is a + // text node, not a frame — it must never swallow a flow-reordered sibling as + // a child. Used to keep the flow-reorder resolver on before/after for text + // cards (§1.1/§1.2 nest-into-text + silent flex-conversion bug). A truly + // empty element (no text, no children) is NOT a text leaf — it stays a valid + // empty frame you can drop into, and the absolute-drag nest path is + // unaffected (it targets frames, not reordered flow siblings). + function isTextBearingLeaf(el: Element): boolean { + return hasOnlyLeafContent(el) && (el.textContent || "").trim().length > 0; + } + function isContainerDropTarget(el: Element | null): boolean { if (!el || el === document.documentElement) return false; if (isOverlayElement(el) || isLayerInteractionBlocked(el)) return false; @@ -6946,7 +7008,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; !isOverlayElement(hit) && !isTemplateCloneElement(hit) ) { - if (isContainerDropTarget(hit)) { + if (isContainerDropTarget(hit) && !isTextBearingLeaf(hit)) { var containerRect = hit.getBoundingClientRect(); var edgeAxis = hit.parentElement ? parentFlowAxis(hit.parentElement) @@ -7819,6 +7881,12 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; function postVisualStructureChange(el, target, origin) { if (!el || !target || !target.anchor) return; + dndLog("post:structure-change", { + el: getSelector(el), + anchor: getSelector(target.anchor), + placement: target.placement, + dropMode: target.dropMode || "flow-insert", + }); var requestId = "move-" + Date.now() + "-" + Math.random().toString(16).slice(2); pendingStructureMoves[requestId] = { @@ -8244,6 +8312,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; }; }); var reorderGestureStartRect = reorderEl.getBoundingClientRect(); + var reorderLastTargetKey = null; var keepCurrentFlowParent = bridgeSpaceKeyPressed; var currentTarget = flowMoveTargetForPoint( reorderEl, @@ -8254,6 +8323,12 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; Boolean(e.ctrlKey), ); showInsertionGuideFor(currentTarget); + dndLog("start:reorder", { + el: getSelector(reorderEl), + isGroup: isGroupDrag, + ctrl: Boolean(e.ctrlKey), + target: dndTarget(currentTarget), + }); // Cross-screen drag state: true when the pointer is outside this iframe's // viewport bounds. The host frame renders the ghost + highlight and owns // the drop when this is true; the bridge suppresses its in-iframe reorder. @@ -8629,6 +8704,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; ev.timeStamp, ); showInsertionGuideFor(currentTarget); + var _dndKey = currentTarget + ? getSelector(currentTarget.anchor) + + "|" + + currentTarget.placement + + "|" + + currentTarget.dropMode + : "none"; + if (_dndKey !== reorderLastTargetKey) { + reorderLastTargetKey = _dndKey; + dndLog("target", dndTarget(currentTarget)); + } applyReorderLift(dx, dy); applyReorderReflow(currentTarget, cx, cy); showTransformBadge(currentTarget ? "Move layer" : "Move", cx, cy); @@ -8740,6 +8826,11 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; : reorderCommittedAt, ) : finalRaw; + dndLog("commit:resolve", { + raw: dndTarget(finalRaw), + final: dndTarget(currentTarget), + ctrl: Boolean(ev && ev.ctrlKey), + }); if (!currentTarget) { // No valid drop target — clean up the clone if one was inserted so // no ghost element is left in the DOM. @@ -8823,6 +8914,12 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // visual feedback; the visual-structure-ack handler will confirm // or revert once the parent processes the change. applyRuntimeReorder(reorderEl, currentTarget); + dndLog("commit:done", { + el: getSelector(reorderEl), + parent: reorderEl.parentElement + ? getSelector(reorderEl.parentElement) + : null, + }); postVisualStructureChange(reorderEl, currentTarget, { prevParent: prevParent, prevNextSibling: prevNextSibling, @@ -8878,6 +8975,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; var dragEl = gestureEl; var moved = false; var DRAG_THRESHOLD = 3; + dndLog("start:free", { el: getSelector(gestureEl), isGroup: isGroupDrag }); var currentAutoLayoutTarget: { anchor: Element; placement: string; @@ -9190,6 +9288,10 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; dropContainerForTarget(currentAutoLayoutTarget), ); applyRuntimeReorder(dragEl, currentAutoLayoutTarget); + dndLog("commit:free-nest", { + el: getSelector(dragEl), + target: dndTarget(currentAutoLayoutTarget), + }); postVisualStructureChange(dragEl, currentAutoLayoutTarget, { prevParent: prevParent, prevNextSibling: prevNextSibling, @@ -9198,6 +9300,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } } else { setMembersOpacity(null); + dndLog("commit:free-absolute", { count: memberStates.length }); // Free absolute placement: one style-change message per member, in // order — the host composes them against its synchronous same-tick // content refs exactly like multi-property style commits. @@ -9672,6 +9775,14 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; var previousSelectedEl = selectedEl; selectedEl = target; positionOverlay(selectionOverlay, selectedEl); + // A plain (non-shift) select on a fresh target collapses any prior + // multi-selection, so a following drag can never inherit stale passive + // members from an earlier gesture (phantom-passenger fix, §3.5). Shift + // keeps + extends the set via the helper below. Only reached for + // single-element drags — an intentional group drag returns earlier. + if (!ev?.shiftKey && passiveSelectionEls.length) { + setPassiveSelectionElements([]); + } preservePreviousSelectedElementForShiftClick( previousSelectedEl, selectedEl, @@ -11377,6 +11488,10 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; return; } if (e.data.type === "visual-structure-ack") { + dndLog("ack", { + requestId: e.data.requestId, + applied: Boolean(e.data.applied), + }); var move = pendingStructureMoves[e.data.requestId]; if (!move) return; delete pendingStructureMoves[e.data.requestId]; diff --git a/templates/design/app/components/design/dnd-debug.ts b/templates/design/app/components/design/dnd-debug.ts new file mode 100644 index 0000000000..a688cc30c8 --- /dev/null +++ b/templates/design/app/components/design/dnd-debug.ts @@ -0,0 +1,28 @@ +// Drag-and-drop debug logging (host side). Mirrors the bridge's own [dnd:*] +// console timeline (editor-chrome.bridge.ts) so a single gesture reads +// end-to-end across the prototype iframe and the parent React app. +// +// The iframe and the host are separate windows, so each has its OWN toggle: +// • bridge (in the iframe console): window.__DND_DEBUG = false +// • host (in the top-frame console): window.__DND_DEBUG = false +// Both default ON. Host lines are cyan and prefixed [dnd:host:*]; bridge +// lines are purple and prefixed [dnd:*]. +declare global { + interface Window { + __DND_DEBUG?: boolean; + } +} + +export function dndHostLog(phase: string, data?: unknown): void { + if (typeof window === "undefined") return; + if (window.__DND_DEBUG === undefined) window.__DND_DEBUG = true; + if (!window.__DND_DEBUG) return; + try { + const tag = `%c[dnd:host:${phase}]`; + const style = "color:#0ea5e9;font-weight:bold"; + if (data === undefined) console.log(tag, style); + else console.log(tag, style, data); + } catch { + /* logging must never break a drag */ + } +} diff --git a/templates/design/app/lib/design-save-outbox.test.ts b/templates/design/app/lib/design-save-outbox.test.ts index ca42a39925..4fd4b74491 100644 --- a/templates/design/app/lib/design-save-outbox.test.ts +++ b/templates/design/app/lib/design-save-outbox.test.ts @@ -289,8 +289,29 @@ describe("design save outbox", () => { storage, }); - expect(result).toEqual({ saved: [], failed: [] }); + expect(result).toEqual({ saved: [], failed: [], dropped: [] }); expect(invokeAction).not.toHaveBeenCalled(); expect(await storage.list("design-1", "user-1")).toHaveLength(1); }); + + it("drops an unrecoverable save when the target file no longer exists", async () => { + const storage = new MemoryOutboxStorage(); + await journalDesignSaveOutboxEntry(fileEntry(1), storage); + const notFound = Object.assign(new Error("File not found: file-1"), { + status: 404, + }); + + const result = await drainDesignSaveOutbox({ + designId: "design-1", + actorScope: "user-1", + invokeAction: vi.fn().mockRejectedValue(notFound), + storage, + }); + + // A permanent failure is dropped (never retried), unlike a 409 conflict + // which stays queued — otherwise an orphaned screen loops 500s forever. + expect(result.dropped).toHaveLength(1); + expect(result.failed).toEqual([]); + expect(await storage.list("design-1", "user-1")).toEqual([]); + }); }); diff --git a/templates/design/app/lib/design-save-outbox.ts b/templates/design/app/lib/design-save-outbox.ts index c70b4c73ee..13b27cbf1b 100644 --- a/templates/design/app/lib/design-save-outbox.ts +++ b/templates/design/app/lib/design-save-outbox.ts @@ -29,6 +29,30 @@ export interface DesignSaveOutboxStorage { export interface DrainDesignSaveOutboxResult { saved: DesignSaveOutboxEntry[]; failed: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>; + /** + * Entries dropped because retrying can never succeed — the target file no + * longer exists (deleted or never created). Retained-and-retried forever they + * turn one orphaned screen into a 500 storm that jams every save; dropping + * them self-heals stale outbox residue. Separate from `failed`, which is for + * transient/conflict errors that SHOULD be retried. + */ + dropped: Array<{ entry: DesignSaveOutboxEntry; error: unknown }>; +} + +/** + * A save failure that can never succeed on retry: the server reports the target + * file is gone (HTTP 404, or a "File not found" message from update-file's + * missing-row guard). Distinct from 409 conflicts and network errors, which are + * transient and must stay queued. + */ +export function isTerminalSaveError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { status?: unknown; message?: unknown }; + if (candidate.status === 404) return true; + return ( + typeof candidate.message === "string" && + /file not found/i.test(candidate.message) + ); } const DATABASE_NAME = "agent-native-design-save-outbox"; @@ -293,7 +317,11 @@ async function drainEntries( ) => Promise, storage: DesignSaveOutboxStorage, ): Promise { - const result: DrainDesignSaveOutboxResult = { saved: [], failed: [] }; + const result: DrainDesignSaveOutboxResult = { + saved: [], + failed: [], + dropped: [], + }; for (const entry of await storage.list(designId, actorScope)) { try { if ( @@ -332,7 +360,20 @@ async function drainEntries( await storage.deleteIfRevision(entry); result.saved.push(entry); } catch (error) { - result.failed.push({ entry, error }); + if (isTerminalSaveError(error)) { + // The target file is gone (deleted/never created) — retrying can never + // succeed. Drop the entry so one orphaned screen can't loop update-file + // 500s forever and jam every save. Logged, never silently swallowed. + await storage.deleteIfRevision(entry); + result.dropped.push({ entry, error }); + if (typeof console !== "undefined") { + console.warn( + `[design-save-outbox] dropped unrecoverable save for ${entry.actionName} ${entry.resourceId} (file no longer exists)`, + ); + } + } else { + result.failed.push({ entry, error }); + } } } return result; diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index dcd776f4cf..f53111568a 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -215,6 +215,7 @@ import type { } from "@/components/design/design-canvas/iframe-events"; import type { MotionTrackWire } from "@/components/design/design-canvas/motion-types"; import { DesignCanvas } from "@/components/design/DesignCanvas"; +import { dndHostLog } from "@/components/design/dnd-debug"; import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton"; import { AssetLibraryPanel, @@ -13950,6 +13951,13 @@ function DesignEditor() { anchorRect?: { x: number; y: number; width: number; height: number }; }, ) => { + dndHostLog("persist:begin", { + selector, + anchorSelector, + placement, + dropMode: details?.dropMode, + source: activeCanvasSourceType, + }); if (!canEditDesign) return false; if (!activeFile) return false; if (activeCanvasSourceType === "localhost") { @@ -13989,6 +13997,10 @@ function DesignEditor() { : { selector: anchorSelector }, placement, }); + dndHostLog("persist:rewrite", { + status: patch.result.status, + message: patch.result.message, + }); if (patch.result.status !== "applied") { toast.error( codeLayerPatchMessage( @@ -14494,6 +14506,10 @@ function DesignEditor() { : { selector: anchorSelector }, placement, }); + dndHostLog("persist:rewrite", { + status: patch.result.status, + message: patch.result.message, + }); if (patch.result.status !== "applied") { toast.error( codeLayerPatchMessage( @@ -18203,6 +18219,12 @@ function DesignEditor() { sourcePointerOffset?: { x: number; y: number }; styleSnapshot?: PortableStyleSnapshot; }) => { + dndHostLog("persist:cross-screen", { + sourceScreenId, + targetScreenId, + targetAnchorPlacement, + targetDropMode, + }); if (!canEditDesign) return; if (sourceScreenId === targetScreenId) return; From 5ee7ef095c7bec2330450af24a0fd647cb79a047 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Fri, 17 Jul 2026 22:57:51 +0200 Subject: [PATCH 5/7] fixes --- .../bridge/editor-chrome.generated.ts | 44 +++--- .../components/design/MultiScreenCanvas.tsx | 6 +- .../design/bridge/bridge.guard.spec.ts | 147 +++++++++++++----- .../design/bridge/editor-chrome.bridge.ts | 70 ++++----- .../multi-screen/draft-primitives.test.ts | 74 +++++++++ .../design/multi-screen/draft-primitives.ts | 15 +- templates/design/app/pages/DesignEditor.tsx | 7 +- .../canvas-primitive-insert.test.ts | 24 +++ .../design-editor/canvas-primitive-insert.ts | 13 +- ...hapes-stay-free-positioned-when-dragged.md | 6 + ...apes-show-a-live-outline-while-you-draw.md | 6 + 11 files changed, 298 insertions(+), 114 deletions(-) create mode 100644 templates/design/app/components/design/multi-screen/draft-primitives.test.ts create mode 100644 templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts create mode 100644 templates/design/changelog/2026-07-17-drawn-shapes-stay-free-positioned-when-dragged.md create mode 100644 templates/design/changelog/2026-07-17-shapes-show-a-live-outline-while-you-draw.md diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 24d7cd531c..5f52e7e0d9 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -4929,6 +4929,15 @@ export const editorChromeBridgeScript: string = `"use strict"; }; } if (cursor !== document.body && isContainerDropTarget(cursor) && !(parent && parent !== document.body && isAutoLayoutElement(parent))) { + if (!isAutoLayoutElement(cursor)) { + if (cursor === el.parentElement) return null; + return { + anchor: cursor, + placement: "inside", + axis: "y", + dropMode: "absolute-container" + }; + } var betweenContainerChildren = nearestChildInsertionTarget( cursor, clientX, @@ -4940,21 +4949,26 @@ export const editorChromeBridgeScript: string = `"use strict"; anchor: betweenContainerChildren.anchor, placement: betweenContainerChildren.placement, axis: betweenContainerChildren.axis, - dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(cursor), - conversionTarget: cursor + dropMode: "flow-insert" }; } return { anchor: cursor, placement: "inside", axis: parentFlowAxis(cursor), - dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(cursor), - conversionTarget: cursor + dropMode: "flow-insert" }; } if (parent && parent !== document.body && isContainerDropTarget(parent)) { + if (!isAutoLayoutElement(parent)) { + if (parent === el.parentElement) return null; + return { + anchor: parent, + placement: "inside", + axis: "y", + dropMode: "absolute-container" + }; + } if (isTemplateCloneElement(cursor)) { var cloneFallback = nearestChildInsertionTarget( parent, @@ -4967,18 +4981,14 @@ export const editorChromeBridgeScript: string = `"use strict"; anchor: cloneFallback.anchor, placement: cloneFallback.placement, axis: cloneFallback.axis, - dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent + dropMode: "flow-insert" }; } return { anchor: parent, placement: "inside", axis: parentFlowAxis(parent), - dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent + dropMode: "flow-insert" }; } var parentAxis = parentFlowAxis(parent); @@ -4989,9 +4999,7 @@ export const editorChromeBridgeScript: string = `"use strict"; anchor: cursor, placement: childPointer < childCenter ? "before" : "after", axis: parentAxis, - dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent + dropMode: "flow-insert" }; } cursor = parent; @@ -6192,12 +6200,6 @@ export const editorChromeBridgeScript: string = `"use strict"; postVisualDuplicateChange(originalSelectedEl, dragEl); } else if (currentAutoLayoutTarget) { setMembersOpacity(null); - if (currentAutoLayoutTarget.needsAutoLayoutConversion && currentAutoLayoutTarget.conversionTarget) { - applyAutoLayoutConversionForDrop( - currentAutoLayoutTarget.conversionTarget, - groupEls - ); - } if (isGroupDrag) { applyGroupStructureDrop( groupEls, diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index 83379491ee..2a63050762 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -155,6 +155,10 @@ const TRANSFORM_BADGE_MAX_WIDTH = 180; // (frame z-order is a small per-design integer) while staying well under the // reserved resize-handle stacking range (999_999+). const TOP_SCREEN_Z_BOOST = 100_000; +// A live draw preview must paint above every screen — including the +// TOP_SCREEN_Z_BOOSTed active one — or its outline hides behind the opaque +// screen iframe until commit. Stays below the resize-handle range (999_999+). +const DRAFT_PREVIEW_Z = TOP_SCREEN_Z_BOOST + 50_000; const EMPTY_SELECTED_LAYER_SELECTOR_GROUPS_BY_SCREEN: Record< string, string[][] @@ -8157,7 +8161,7 @@ function DraftPrimitiveLayer({ ...frameStyleLeftTop(geometry), width: geometry.width, height: geometry.height, - zIndex: geometry.z ?? 40, + zIndex: preview ? DRAFT_PREVIEW_Z : (geometry.z ?? 40), transform: geometry.rotation ? `rotate(${geometry.rotation}deg)` : undefined, diff --git a/templates/design/app/components/design/bridge/bridge.guard.spec.ts b/templates/design/app/components/design/bridge/bridge.guard.spec.ts index ed547a19a5..a071133e02 100644 --- a/templates/design/app/components/design/bridge/bridge.guard.spec.ts +++ b/templates/design/app/components/design/bridge/bridge.guard.spec.ts @@ -907,6 +907,75 @@ it( }, ); +it( + "editor chrome bridge keeps a free element free when dragged into a plain container (no strip, no auto-layout conversion)", + { timeout: 30_000 }, + async () => { + const browser = await chromium.launch({ headless: true }); + const pageErrors: string[] = []; + + try { + const page = await browser.newPage({ + viewport: { width: 900, height: 700 }, + }); + page.on("pageerror", (err) => pageErrors.push(err.message)); + + // A drawn rectangle (absolute, body child) + a plain
frame. + // Dragging the rect into
must keep it absolute and NOT convert + //
to auto layout — the "trapped shape inside
" regression. + await page.setContent(` + + + + + +
+
+ +`); + await page.addScriptTag({ content: hydratedEditorChromeBridgeScript() }); + await page.waitForSelector('[data-agent-native-edit-overlay="shield"]'); + + // Select the rect (center at 40+60, 40+40 = 100, 80). + await page.mouse.click(100, 80); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '[data-agent-native-edit-overlay="selection"]', + ); + return overlay && window.getComputedStyle(overlay).display === "block"; + }); + + // Drag it into the middle of
. + await page.mouse.move(100, 80); + await page.mouse.down(); + await page.mouse.move(490, 350, { steps: 12 }); + await page.mouse.up(); + await page.waitForTimeout(60); + + const result = await page.evaluate(() => { + const rect = document.querySelector("#rect"); + const frame = document.querySelector("#frame"); + return { + rectPosition: rect ? window.getComputedStyle(rect).position : null, + frameDisplay: frame ? window.getComputedStyle(frame).display : null, + rectMoved: rect?.style.left !== "40px" || rect?.style.top !== "40px", + }; + }); + + expect(result.rectPosition).toBe("absolute"); // not stripped to static + expect(result.frameDisplay).toBe("block"); //
NOT converted to flex + expect(result.rectMoved).toBe(true); // the drag actually committed + expect(pageErrors).toEqual([]); + } finally { + await browser.close(); + } + }, +); + it( "editor chrome bridge (live reflow) lifts and follows a dragged flow element, then restores it on drop", { timeout: 30_000 }, @@ -4576,7 +4645,7 @@ async function readBridgeMessages(page: import("@playwright/test").Page) { } it( - "editor chrome bridge nests a dragged rectangle into a plain rectangle target and converts it to auto-layout", + "editor chrome bridge nests a dragged rectangle into a plain rectangle target as a free child (no auto-layout conversion)", { timeout: 30_000 }, async () => { const browser = await chromium.launch({ headless: true }); @@ -4676,37 +4745,32 @@ it( return { draggedParentId: dragged.parentElement?.id, frameDisplay: frameStyle.display, - frameFlexDirection: frameStyle.flexDirection, + draggedPosition: window.getComputedStyle(dragged).position, }; }); + // A free (absolute) element dropped into a plain rectangle nests as a + // FREE child: it keeps position:absolute and the target is NOT converted + // to auto layout. Implicit conversion would rewrite the user's source + // (auto layout is an explicit, previewed action) and trap the shape. expect(result.draggedParentId).toBe("frame"); - expect(result.frameDisplay).toBe("flex"); - expect(result.frameFlexDirection).toBe("column"); - // Note: clearing the moved child's absolute position/left/top is a - // host-side effect (DesignEditor.tsx's handleVisualStructureChange - // calls removeAbsolutePositioningFromNodeInHtml whenever dropMode is - // "flow-insert", which this drop already asserts below) driven by the - // structure-change message the bridge posts — there is no host - // attached in this bridge-only Playwright page to round-trip that - // patched HTML back into the iframe, so the optimistic DOM here still - // shows the pre-strip inline left/top. The message-level assertions - // below are what prove the bridge asked for the strip. + expect(result.frameDisplay).toBe("block"); + expect(result.draggedPosition).toBe("absolute"); const messages = await readBridgeMessages(page); - const styleMessage = messages.find( + const frameFlexMessage = messages.find( (m) => m.type === "visual-style-change" && - (m as any).selector?.includes("frame"), - ) as any; + (m as any).selector?.includes("frame") && + (m as any).styles?.display === "flex", + ); const structureMessage = messages.find( (m) => m.type === "visual-structure-change", ) as any; - expect(styleMessage).toBeTruthy(); - expect(styleMessage.styles.display).toBe("flex"); - expect(styleMessage.styles["flex-direction"]).toBe("column"); + // No implicit auto-layout conversion is posted for the target. + expect(frameFlexMessage).toBeFalsy(); expect(structureMessage).toBeTruthy(); - expect(structureMessage.dropMode).toBe("flow-insert"); + expect(structureMessage.dropMode).toBe("absolute-container"); expect(structureMessage.placement).toBe("inside"); expect(structureMessage.anchorPayload).toMatchObject({ tagName: "div", @@ -4721,12 +4785,7 @@ it( }, }); expect(structureMessage.anchorPayload.computedStyles.display).toBe( - "flex", - ); - // The style conversion must be posted before the structural move so the - // host applies them in order against its synchronous content refs. - expect(messages.indexOf(styleMessage)).toBeLessThan( - messages.indexOf(structureMessage), + "block", ); expect(pageErrors).toEqual([]); } finally { @@ -4795,22 +4854,22 @@ it( return { labelParentId: label.parentElement?.id, frameDisplay: window.getComputedStyle(frame).display, + labelPosition: window.getComputedStyle(label).position, }; }); + // Text nests into a plain rectangle as a FREE child, same as a shape: + // it keeps position:absolute and the target is not converted to flex. expect(result.labelParentId).toBe("frame"); - expect(result.frameDisplay).toBe("flex"); + expect(result.frameDisplay).toBe("block"); + expect(result.labelPosition).toBe("absolute"); - // Clearing the moved node's absolute position is a host-side effect of - // the "flow-insert" dropMode (see the rectangle-onto-rectangle test's - // comment above) — assert the message that drives it instead of the - // unattached bridge-only page's optimistic DOM state. const messages = await readBridgeMessages(page); const structureMessage = messages.find( (m) => m.type === "visual-structure-change", ) as any; expect(structureMessage).toBeTruthy(); - expect(structureMessage.dropMode).toBe("flow-insert"); + expect(structureMessage.dropMode).toBe("absolute-container"); expect(pageErrors).toEqual([]); } finally { await browser.close(); @@ -5476,7 +5535,7 @@ it( ); it( - "editor chrome bridge drops a multi-selected group consecutively into a container with one auto-layout conversion", + "editor chrome bridge drops a multi-selected group consecutively into a container as free children (no auto-layout conversion)", { timeout: 30_000 }, async () => { const browser = await chromium.launch({ headless: true }); @@ -5542,15 +5601,18 @@ it( return { childIds: Array.from(target.children).map((c) => c.id), targetDisplay: window.getComputedStyle(target).display, - targetFlexDirection: window.getComputedStyle(target).flexDirection, + childPositions: Array.from(target.children).map( + (c) => window.getComputedStyle(c).position, + ), }; }); // Both members nested CONSECUTIVELY, in document order (A before B even - // though B was the dragged member). + // though B was the dragged member) — as FREE children: each keeps + // position:absolute and the container is NOT converted to auto layout. expect(result.childIds).toEqual(["boxA", "boxB"]); - expect(result.targetDisplay).toBe("flex"); - expect(result.targetFlexDirection).toBe("column"); + expect(result.targetDisplay).toBe("block"); + expect(result.childPositions).toEqual(["absolute", "absolute"]); const messages = await readBridgeMessages(page); const conversionMessages = messages.filter( @@ -5564,8 +5626,8 @@ it( const marqueeMessages = messages.filter( (m) => m.type === "agent-native:layer-marquee-selection", ) as any[]; - // Auto-layout conversion fires exactly ONCE for the container. - expect(conversionMessages.length).toBe(1); + // No implicit auto-layout conversion of the container. + expect(conversionMessages.length).toBe(0); // One structure change per member. expect(structureMessages.length).toBe(2); // Selection restored (final marquee-selection message carries both). @@ -6054,7 +6116,10 @@ it( await page.mouse.up(); await page.waitForTimeout(30); - // Drag the user-red text into the light frame too. + // Drag the user-red text into the light frame too — to a DIFFERENT spot + // than the auto-white text. Free-placed shapes stay where dropped, so a + // second drop onto the first text's landing point would nest into that + // text instead of the frame; separate the two drop points. await page.mouse.click(110, 432); await page.waitForFunction(() => { const overlay = document.querySelector( @@ -6065,7 +6130,7 @@ it( await page.mouse.move(110, 432); await page.mouse.down(); await page.mouse.move(120, 442, { steps: 2 }); - await page.mouse.move(560, 240, { steps: 8 }); + await page.mouse.move(480, 330, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(30); diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index c262e3aa71..81d35803d4 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -7189,8 +7189,9 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; }; } - // Flow child dropped into a plain container: match the absolute-drag path - // by converting that container to auto layout before the structural move. + // Flow child (a real flow-reorder gesture) dropped into a plain container: + // convert it to auto layout before the structural move. This is the flow + // path only; the absolute/free drag keeps shapes free (no conversion). if ( target && target.dropMode === "flow-insert" && @@ -7366,11 +7367,21 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; isContainerDropTarget(cursor) && !(parent && parent !== document.body && isAutoLayoutElement(parent)) ) { - // B5-4 (absolute path): pointer over the container's own - // background — its padding or a gap between children. Prefer the - // nearest child slot (insertion line at the pointer index) over - // plain "inside" (which appends after the last child); fall back - // to "inside" only for containers with no visible children. + // Free (absolute) element into a non-auto-layout container stays free: + // nest as an absolute child at the drop point, never convert to flex. + // Same-parent drop is a pure reposition (null → onUp writes left/top). + if (!isAutoLayoutElement(cursor)) { + if (cursor === el.parentElement) return null; + return { + anchor: cursor, + placement: "inside", + axis: "y", + dropMode: "absolute-container", + }; + } + // B5-4: pointer over an auto-layout container's background (padding or a + // gap). Prefer the nearest child slot over plain "inside" (append last); + // fall back to "inside" for an empty container. var betweenContainerChildren = nearestChildInsertionTarget( cursor, clientX, @@ -7383,8 +7394,6 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; placement: betweenContainerChildren.placement, axis: betweenContainerChildren.axis, dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(cursor), - conversionTarget: cursor, }; } return { @@ -7392,11 +7401,21 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; placement: "inside", axis: parentFlowAxis(cursor), dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(cursor), - conversionTarget: cursor, }; } if (parent && parent !== document.body && isContainerDropTarget(parent)) { + // Free element over a sibling in a non-auto-layout parent stays free: + // absolute child at the drop point (same-parent → null reposition). + if (!isAutoLayoutElement(parent)) { + if (parent === el.parentElement) return null; + return { + anchor: parent, + placement: "inside", + axis: "y", + dropMode: "absolute-container", + }; + } + // parent is an established auto-layout list: reorder within its flow. // Anchor-candidate gate: cursor is a plain sibling under `parent` // being used as a before/after anchor — but if it's a template // clone (no counterpart in source HTML), fall back to the nearest @@ -7416,8 +7435,6 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; placement: cloneFallback.placement, axis: cloneFallback.axis, dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent, }; } return { @@ -7425,8 +7442,6 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; placement: "inside", axis: parentFlowAxis(parent), dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent, }; } var parentAxis = parentFlowAxis(parent); @@ -7441,8 +7456,6 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; placement: childPointer < childCenter ? "before" : "after", axis: parentAxis, dropMode: "flow-insert", - needsAutoLayoutConversion: !isAutoLayoutElement(parent), - conversionTarget: parent, }; } cursor = parent; @@ -9238,25 +9251,10 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; postVisualDuplicateChange(originalSelectedEl, dragEl); } else if (currentAutoLayoutTarget) { setMembersOpacity(null); - // Figma-parity nest-on-drop: the target container may be a plain - // rect/div that isn't auto-layout yet (see - // autoLayoutInsertionTargetForPoint's needsAutoLayoutConversion). - // Convert it to flex BEFORE reparenting/posting the move so the - // host applies the two edits in the right order against its - // synchronous same-tick content refs (container becomes flex, then - // the child moves into it and loses absolute positioning via the - // existing "flow-insert" dropMode handling). For group drags the - // conversion fires ONCE for the container; every member then nests - // consecutively via applyGroupStructureDrop. - if ( - currentAutoLayoutTarget.needsAutoLayoutConversion && - currentAutoLayoutTarget.conversionTarget - ) { - applyAutoLayoutConversionForDrop( - currentAutoLayoutTarget.conversionTarget, - groupEls, - ); - } + // Nest-on-drop: a free element nests as an absolute child of a plain + // container ("absolute-container", keeps left/top) or flow-inserts into + // an existing auto-layout frame. The resolver never requests an implicit + // flex conversion, so there is none to apply here. if (isGroupDrag) { applyGroupStructureDrop( groupEls, diff --git a/templates/design/app/components/design/multi-screen/draft-primitives.test.ts b/templates/design/app/components/design/multi-screen/draft-primitives.test.ts new file mode 100644 index 0000000000..ecbd299ab0 --- /dev/null +++ b/templates/design/app/components/design/multi-screen/draft-primitives.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { draftPrimitiveToInsert } from "./draft-primitives"; +import type { + DraftPrimitive, + FrameGeometry, + ResolvedScreenMetadata, +} from "./types"; + +function frame( + x: number, + y: number, + width: number, + height: number, +): FrameGeometry { + return { x, y, width, height }; +} + +function rectDraft(geometry: FrameGeometry): DraftPrimitive { + return { id: "draft-rect", kind: "rectangle", geometry } as DraftPrimitive; +} + +describe("draftPrimitiveToInsert draw scaling", () => { + it("keeps a drawn rectangle the exact size it was dragged on a landscape inline frame", () => { + // Regression: a blank inline screen carries the 1280×2560 metadata default; + // on a landscape frame that stretched a dragged box into a tall ribbon. + // Inline reflows to the frame, so the draw must land 1:1. + const landscapeFrame = frame(-528, 179, 578, 398); + const draft = rectDraft({ x: -400, y: 250, width: 200, height: 150 }); + const inlineDefault = { + source: "inline", + previewState: "preview", + width: 1280, + height: 2560, + } as ResolvedScreenMetadata; + + const result = draftPrimitiveToInsert(draft, landscapeFrame, inlineDefault); + + expect(result.geometry.width).toBe(200); + expect(result.geometry.height).toBe(150); + // Positioned relative to the frame origin (no per-axis stretch). + expect(result.geometry.x).toBe(128); + expect(result.geometry.y).toBe(71); + }); + + it("falls back to 1:1 when an inline screen has no metadata at all", () => { + const landscapeFrame = frame(0, 0, 800, 500); + const draft = rectDraft({ x: 100, y: 80, width: 260, height: 180 }); + + const result = draftPrimitiveToInsert(draft, landscapeFrame, undefined); + + expect(result.geometry.width).toBe(260); + expect(result.geometry.height).toBe(180); + }); + + it("still scales a fixed-viewport (localhost) screen by its metadata", () => { + // localhost/fusion screens render at their own viewport and are scaled to + // fit the frame, so their draw scale must remain metadata-driven. + const displayFrame = frame(0, 0, 640, 400); + const draft = rectDraft({ x: 100, y: 50, width: 100, height: 50 }); + const localhost = { + source: "localhost", + previewState: "live", + width: 1280, + height: 800, + } as ResolvedScreenMetadata; + + const result = draftPrimitiveToInsert(draft, displayFrame, localhost); + + // scaleX = 1280/640 = 2, scaleY = 800/400 = 2 (uniform) → 100×50 → 200×100. + expect(result.geometry.width).toBe(200); + expect(result.geometry.height).toBe(100); + }); +}); diff --git a/templates/design/app/components/design/multi-screen/draft-primitives.ts b/templates/design/app/components/design/multi-screen/draft-primitives.ts index 76b179598f..1e5f4e9978 100644 --- a/templates/design/app/components/design/multi-screen/draft-primitives.ts +++ b/templates/design/app/components/design/multi-screen/draft-primitives.ts @@ -229,10 +229,17 @@ export function draftPrimitiveToInsert( frameGeometry: FrameGeometry, metadata?: ResolvedScreenMetadata, ): CanvasPrimitiveInsert { - const viewport = { - width: metadata?.width ?? frameGeometry.width, - height: metadata?.height ?? frameGeometry.height, - }; + // Inline screens reflow to the frame, so the frame IS the content space and a + // draw must land 1:1 (its dragged size). The metadata 1280×2560 fallback for + // blank screens would otherwise stretch shapes on a non-portrait frame; only + // fixed-viewport sources (localhost/fusion) render scaled-to-fit. + const reflowsToFrame = !metadata || metadata.source === "inline"; + const viewport = reflowsToFrame + ? { width: frameGeometry.width, height: frameGeometry.height } + : { + width: metadata.width ?? frameGeometry.width, + height: metadata.height ?? frameGeometry.height, + }; const scaleX = viewport.width / Math.max(1, frameGeometry.width); const scaleY = viewport.height / Math.max(1, frameGeometry.height); const roundLocal = (value: number) => Math.round(value) || 0; diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index f53111568a..c1c294c887 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -215,7 +215,6 @@ import type { } from "@/components/design/design-canvas/iframe-events"; import type { MotionTrackWire } from "@/components/design/design-canvas/motion-types"; import { DesignCanvas } from "@/components/design/DesignCanvas"; -import { dndHostLog } from "@/components/design/dnd-debug"; import { DesignEditorSkeleton } from "@/components/design/DesignEditorSkeleton"; import { AssetLibraryPanel, @@ -223,6 +222,7 @@ import { type DesignExtensionSlotContext, } from "@/components/design/DesignExtensionsPanel"; import { DesignImportPanel } from "@/components/design/DesignImportPanel"; +import { dndHostLog } from "@/components/design/dnd-debug"; import { nextTextDecorationLineValue } from "@/components/design/edit-panel/typography-helpers"; import { EditPanel, @@ -27057,7 +27057,10 @@ function DesignEditor() { details, ) => { activateResponsiveScope(); - handleScreenVisualStructureChange( + // Return the result so the bridge gets a real applied/false/pending + // ack; without it a rejected drop could never roll back (undefined + // !== false read as applied). + return handleScreenVisualStructureChange( screen.id, selector, anchorSelector, diff --git a/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts b/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts new file mode 100644 index 0000000000..73963ec301 --- /dev/null +++ b/templates/design/app/pages/design-editor/canvas-primitive-insert.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { blankScreenHtml } from "./canvas-primitive-insert"; + +describe("blankScreenHtml", () => { + const html = blankScreenHtml("Screen 1"); + + it("is a free canvas: no centering grid and no
wrapper", () => { + // The centering grid +
wrapper trapped drawn shapes at center and, + // once dragged, flow-inserted them (converting the wrapper to auto layout + // and stripping their absolute position). A blank screen must be a plain + // free canvas so absolute children keep their x,y. + expect(html).not.toMatch(/display:\s*grid/); + expect(html).not.toMatch(/place-items:\s*center/); + expect(html).not.toContain(" { + expect(blankScreenHtml("A & B")).toContain( + 'data-agent-native-layer-name="A & B"', + ); + expect(blankScreenHtml("A & B")).toContain("A & B"); + }); +}); diff --git a/templates/design/app/pages/design-editor/canvas-primitive-insert.ts b/templates/design/app/pages/design-editor/canvas-primitive-insert.ts index c07d40c514..fec699c807 100644 --- a/templates/design/app/pages/design-editor/canvas-primitive-insert.ts +++ b/templates/design/app/pages/design-editor/canvas-primitive-insert.ts @@ -62,6 +62,9 @@ export function nextBlankScreenFilename(files: DesignFile[]): string { export function blankScreenHtml(title: string): string { const safeTitle = escapeHtmlText(title); const safeTitleAttribute = escapeHtmlAttributeValue(title); + // Blank screen = free canvas: is the positioned root and drawn shapes + // are absolute children (x,y in the HTML). A centering grid /
wrapper + // trapped shapes at center and got auto-layout-converted on drop. return ` @@ -77,17 +80,9 @@ export function blankScreenHtml(title: string): string { color: var(--color-text, #111827); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - main { - min-height: 100vh; - display: grid; - place-items: center; - padding: 48px; - } - -
-
+ `; } diff --git a/templates/design/changelog/2026-07-17-drawn-shapes-stay-free-positioned-when-dragged.md b/templates/design/changelog/2026-07-17-drawn-shapes-stay-free-positioned-when-dragged.md new file mode 100644 index 0000000000..61911a2cc5 --- /dev/null +++ b/templates/design/changelog/2026-07-17-drawn-shapes-stay-free-positioned-when-dragged.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-17 +--- + +Drawn shapes now land exactly where and how big you dragged them — they no longer snap to center, get trapped inside a container, or stretch into a tall ribbon. diff --git a/templates/design/changelog/2026-07-17-shapes-show-a-live-outline-while-you-draw.md b/templates/design/changelog/2026-07-17-shapes-show-a-live-outline-while-you-draw.md new file mode 100644 index 0000000000..d1f3c955c3 --- /dev/null +++ b/templates/design/changelog/2026-07-17-shapes-show-a-live-outline-while-you-draw.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-17 +--- + +Shapes now show a live outline while you drag to draw them, even when drawing over an existing screen. From d0381496a41b257be3d24e5f3b8736b3a096af69 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Sat, 18 Jul 2026 20:25:03 +0200 Subject: [PATCH 6/7] fixes --- .../bridge/editor-chrome.generated.ts | 24 ++++---- .../app/components/design/DesignCanvas.tsx | 2 +- .../design/bridge/editor-chrome.bridge.ts | 58 +++++++++++++++---- .../design/app/components/design/dnd-debug.ts | 9 ++- .../multi-screen/draft-primitives.test.ts | 36 ++++++++++++ .../design/multi-screen/draft-primitives.ts | 34 +++++++---- templates/design/app/i18n-data.ts | 2 + .../design/app/lib/design-save-outbox.test.ts | 19 ++++++ .../design/app/lib/design-save-outbox.ts | 5 +- templates/design/app/pages/DesignEditor.tsx | 17 +++++- 10 files changed, 164 insertions(+), 42 deletions(-) diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 5f52e7e0d9..718686171e 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -24,9 +24,6 @@ export const editorChromeBridgeScript: string = `"use strict"; } })(); var scaleToolEnabled = false; - if (window.__DND_DEBUG === void 0) { - window.__DND_DEBUG = true; - } function dndLog(phase, data) { if (!window.__DND_DEBUG) return; try { @@ -5504,7 +5501,11 @@ export const editorChromeBridgeScript: string = `"use strict"; return; } if (isFlowReorderCandidate(gestureEl)) { - let applyReorderLift2 = function(dx, dy) { + let authoredTransformOf2 = function(el) { + if (el.style.transform) return el.style.transform; + var computed = window.getComputedStyle(el).transform; + return computed && computed !== "none" ? computed : ""; + }, applyReorderLift2 = function(dx, dy) { if (!liveReflowEnabled) return; groupEls.forEach(function(member) { var el = member; @@ -5515,6 +5516,7 @@ export const editorChromeBridgeScript: string = `"use strict"; snap = { el, prevTransform: el.style.transform, + authoredTransform: authoredTransformOf2(el), prevTransition: el.style.transition, prevZIndex: el.style.zIndex, prevBoxShadow: el.style.boxShadow, @@ -5528,8 +5530,7 @@ export const editorChromeBridgeScript: string = `"use strict"; el.style.boxShadow = "0 8px 24px rgba(0, 0, 0, 0.18)"; el.style.pointerEvents = "none"; } - var base = snap.prevTransform ? snap.prevTransform + " " : ""; - el.style.transform = base + "translate(" + dx + "px, " + dy + "px)"; + el.style.transform = "translate(" + dx + "px, " + dy + "px)" + (snap.authoredTransform ? " " + snap.authoredTransform : ""); }); }, clearReorderLift2 = function() { reorderLiftedMembers.forEach(function(snap) { @@ -5607,7 +5608,9 @@ export const editorChromeBridgeScript: string = `"use strict"; } if (ev && (ev.metaKey || ev.ctrlKey)) return target; var container = dropContainerForTarget(target); - if (!container || container === reorderEl) return target; + if (!container || container === reorderEl || container === document.body || container === document.documentElement) { + return target; + } var crect = container.getBoundingClientRect(); var drect = reorderEl.getBoundingClientRect(); if (crect.width >= drect.width && crect.height >= drect.height) { @@ -5712,16 +5715,17 @@ export const editorChromeBridgeScript: string = `"use strict"; if (i === originIndex) continue; var el = real[i]; var prevTransform = el.style.transform; + var authoredTransform = authoredTransformOf2(el); reflowSiblings.push({ el, prevTransform, + authoredTransform, prevTransition: el.style.transition }); el.style.transition = "transform 140ms cubic-bezier(0.2, 0, 0, 1)"; - var base = prevTransform ? prevTransform + " " : ""; var tx = axis === "x" ? offsets[i] : 0; var ty = axis === "y" ? offsets[i] : 0; - el.style.transform = base + "translate(" + tx + "px, " + ty + "px)"; + el.style.transform = "translate(" + tx + "px, " + ty + "px)" + (authoredTransform ? " " + authoredTransform : ""); } }, onReorderMove2 = function(ev) { var vw = window.innerWidth; @@ -5923,7 +5927,7 @@ export const editorChromeBridgeScript: string = `"use strict"; }); } }; - var applyReorderLift = applyReorderLift2, clearReorderLift = clearReorderLift2, reorderMainAxis = reorderMainAxis2, reorderRealChildren = reorderRealChildren2, reorderSlotForTarget = reorderSlotForTarget2, containerIsSimplePacked = containerIsSimplePacked2, reorderMainGap = reorderMainGap2, clearReorderReflow = clearReorderReflow2, resolveReorderOrFreeTarget = resolveReorderOrFreeTarget2, applyReorderSizeGuard = applyReorderSizeGuard2, stabilizeReorderTarget = stabilizeReorderTarget2, applyReorderReflow = applyReorderReflow2, onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; + var authoredTransformOf = authoredTransformOf2, applyReorderLift = applyReorderLift2, clearReorderLift = clearReorderLift2, reorderMainAxis = reorderMainAxis2, reorderRealChildren = reorderRealChildren2, reorderSlotForTarget = reorderSlotForTarget2, containerIsSimplePacked = containerIsSimplePacked2, reorderMainGap = reorderMainGap2, clearReorderReflow = clearReorderReflow2, resolveReorderOrFreeTarget = resolveReorderOrFreeTarget2, applyReorderSizeGuard = applyReorderSizeGuard2, stabilizeReorderTarget = stabilizeReorderTarget2, applyReorderReflow = applyReorderReflow2, onReorderMove = onReorderMove2, cleanupReorderDrag = cleanupReorderDrag2, onReorderEscape = onReorderEscape2, onReorderKeyDown = onReorderKeyDown2, onReorderKeyUp = onReorderKeyUp2, onReorderUp = onReorderUp2; var reorderEl = gestureEl; var reorderGroupStartRects = groupEls.map(function(member) { return member.getBoundingClientRect(); diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index d74a2bacd7..85b4d05f63 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -61,7 +61,6 @@ import { shaderRuntimeBridgeScript } from "../../../.generated/bridge/shader-run import { tweakBridgeScript } from "../../../.generated/bridge/tweak.generated"; import { zoomBridgeScript } from "../../../.generated/bridge/zoom.generated"; import { isTrustedCanvasBridgeMessage } from "./bridge-security"; -import { dndHostLog } from "./dnd-debug"; import { captureAnnotatedScreenshot } from "./design-canvas/annotation-snapshot"; import { submitDesignAnnotations } from "./design-canvas/annotation-submit"; import { @@ -107,6 +106,7 @@ import { schedulePendingTextEditActivation, } from "./design-canvas/pending-text-edit"; import { DeviceFrame } from "./DeviceFrame"; +import { dndHostLog } from "./dnd-debug"; import { shapeClosingHandles } from "./multi-screen/draft-primitives"; import type { ElementInfo, diff --git a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts index 81d35803d4..6325aa7427 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -85,11 +85,8 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // console reads as a narrated timeline of ONE gesture: // start:* → target → lift/reflow → commit:* → post:* → ack:* // The iframe posts these to its own console; the host mirrors its side - // under the same prefix. Toggle at runtime from the iframe console with - // `window.__DND_DEBUG = false` — no rebuild needed. - if ((window as any).__DND_DEBUG === undefined) { - (window as any).__DND_DEBUG = true; - } + // under the same prefix. Defaults OFF; opt in at runtime from the iframe + // console with `window.__DND_DEBUG = true` — no rebuild needed. function dndLog(phase: string, data?: unknown): void { if (!(window as any).__DND_DEBUG) return; try { @@ -8357,9 +8354,18 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; }; // Transform-only follow: must be cleared before any pointer-up commit // reads getBoundingClientRect, or the drag delta corrupts the result. + function authoredTransformOf(el: HTMLElement): string { + // The element's own transform to compose the drag translate WITH: inline + // if set, else the class/stylesheet value so a drag never wipes an + // authored rotate/scale. "none" → "" so we don't emit an identity. + if (el.style.transform) return el.style.transform; + var computed = window.getComputedStyle(el).transform; + return computed && computed !== "none" ? computed : ""; + } var reorderLiftedMembers: { el: HTMLElement; prevTransform: string; + authoredTransform: string; prevTransition: string; prevZIndex: string; prevBoxShadow: string; @@ -8377,6 +8383,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; snap = { el: el, prevTransform: el.style.transform, + authoredTransform: authoredTransformOf(el), prevTransition: el.style.transition, prevZIndex: el.style.zIndex, prevBoxShadow: el.style.boxShadow, @@ -8392,10 +8399,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // own drop target while following the cursor. el.style.pointerEvents = "none"; } - // Compose with any pre-existing inline transform (prototype HTML is - // user content and may define its own) rather than clobbering it. - var base = snap.prevTransform ? snap.prevTransform + " " : ""; - el.style.transform = base + "translate(" + dx + "px, " + dy + "px)"; + // Translate FIRST so movement is in screen space (an authored rotate + // would otherwise send the drag off-axis), composed with the element's + // own transform (inline OR class/stylesheet) so the drag never wipes + // it. + el.style.transform = + "translate(" + + dx + + "px, " + + dy + + "px)" + + (snap.authoredTransform ? " " + snap.authoredTransform : ""); }); } function clearReorderLift(): void { @@ -8422,6 +8436,7 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; var reflowSiblings: { el: HTMLElement; prevTransform: string; + authoredTransform: string; prevTransition: string; }[] = []; var reflowKey: string | null = null; @@ -8511,7 +8526,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } if (ev && (ev.metaKey || ev.ctrlKey)) return target; var container = dropContainerForTarget(target); - if (!container || container === reorderEl) return target; + // Never treat the screen root (body/html) as a too-small container: the + // before/after fallback would reparent to documentElement and insert a + // full-size layer as a sibling of . Guard nested frames only. + if ( + !container || + container === reorderEl || + container === document.body || + container === document.documentElement + ) { + return target; + } var crect = container.getBoundingClientRect(); var drect = (reorderEl as HTMLElement).getBoundingClientRect(); if (crect.width >= drect.width && crect.height >= drect.height) { @@ -8648,16 +8673,25 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; if (i === originIndex) continue; var el = real[i] as HTMLElement; var prevTransform = el.style.transform; + var authoredTransform = authoredTransformOf(el); reflowSiblings.push({ el: el, prevTransform: prevTransform, + authoredTransform: authoredTransform, prevTransition: el.style.transition, }); el.style.transition = "transform 140ms cubic-bezier(0.2, 0, 0, 1)"; - var base = prevTransform ? prevTransform + " " : ""; var tx = axis === "x" ? offsets[i] : 0; var ty = axis === "y" ? offsets[i] : 0; - el.style.transform = base + "translate(" + tx + "px, " + ty + "px)"; + // Translate FIRST (screen space) composed with the sibling's own + // transform so an authored rotate/scale survives the reflow shift. + el.style.transform = + "translate(" + + tx + + "px, " + + ty + + "px)" + + (authoredTransform ? " " + authoredTransform : ""); } } function onReorderMove(ev) { diff --git a/templates/design/app/components/design/dnd-debug.ts b/templates/design/app/components/design/dnd-debug.ts index a688cc30c8..491d9233e9 100644 --- a/templates/design/app/components/design/dnd-debug.ts +++ b/templates/design/app/components/design/dnd-debug.ts @@ -3,10 +3,10 @@ // end-to-end across the prototype iframe and the parent React app. // // The iframe and the host are separate windows, so each has its OWN toggle: -// • bridge (in the iframe console): window.__DND_DEBUG = false -// • host (in the top-frame console): window.__DND_DEBUG = false -// Both default ON. Host lines are cyan and prefixed [dnd:host:*]; bridge -// lines are purple and prefixed [dnd:*]. +// • bridge (in the iframe console): window.__DND_DEBUG = true +// • host (in the top-frame console): window.__DND_DEBUG = true +// Both default OFF (opt in at runtime). Host lines are cyan and prefixed +// [dnd:host:*]; bridge lines are purple and prefixed [dnd:*]. declare global { interface Window { __DND_DEBUG?: boolean; @@ -15,7 +15,6 @@ declare global { export function dndHostLog(phase: string, data?: unknown): void { if (typeof window === "undefined") return; - if (window.__DND_DEBUG === undefined) window.__DND_DEBUG = true; if (!window.__DND_DEBUG) return; try { const tag = `%c[dnd:host:${phase}]`; diff --git a/templates/design/app/components/design/multi-screen/draft-primitives.test.ts b/templates/design/app/components/design/multi-screen/draft-primitives.test.ts index ecbd299ab0..ecd25f1d95 100644 --- a/templates/design/app/components/design/multi-screen/draft-primitives.test.ts +++ b/templates/design/app/components/design/multi-screen/draft-primitives.test.ts @@ -71,4 +71,40 @@ describe("draftPrimitiveToInsert draw scaling", () => { expect(result.geometry.width).toBe(200); expect(result.geometry.height).toBe(100); }); + + it("scales the draw for a K-scaled inline screen (frame matches the metadata aspect but is larger)", () => { + // A resized-with-aspect inline screen scales its content instead of + // reflowing, so the draw maps through the metadata scale, not 1:1. + const resizedFrame = frame(0, 0, 892, 748); // 2× natural, same aspect + const draft = rectDraft({ x: 0, y: 0, width: 446, height: 374 }); + const natural = { + source: "inline", + previewState: "preview", + width: 446, + height: 374, + } as ResolvedScreenMetadata; + + const result = draftPrimitiveToInsert(draft, resizedFrame, natural); + + // scaleX = 446/892 = 0.5, scaleY = 374/748 = 0.5. + expect(result.geometry.width).toBe(223); + expect(result.geometry.height).toBe(187); + }); + + it("scales the draw for a '+'-added inline screen carrying the 1280×2560 default at a matching aspect", () => { + const portraitFrame = frame(0, 0, 640, 1280); // aspect 0.5 matches the default + const draft = rectDraft({ x: 0, y: 0, width: 320, height: 640 }); + const defaultMeta = { + source: "inline", + previewState: "preview", + width: 1280, + height: 2560, + } as ResolvedScreenMetadata; + + const result = draftPrimitiveToInsert(draft, portraitFrame, defaultMeta); + + // scaleX = 1280/640 = 2, scaleY = 2560/1280 = 2. + expect(result.geometry.width).toBe(640); + expect(result.geometry.height).toBe(1280); + }); }); diff --git a/templates/design/app/components/design/multi-screen/draft-primitives.ts b/templates/design/app/components/design/multi-screen/draft-primitives.ts index 1e5f4e9978..153e883793 100644 --- a/templates/design/app/components/design/multi-screen/draft-primitives.ts +++ b/templates/design/app/components/design/multi-screen/draft-primitives.ts @@ -12,7 +12,7 @@ import { import { DEFAULT_LINE_STROKE_WIDTH_PX } from "../canvas-primitive-style"; import { boardPointToScreenLocalPoint } from "./coordinate-transforms"; -import { getFrameCenter } from "./frame-geometry"; +import { getFrameCenter, getScreenPreviewViewport } from "./frame-geometry"; import type { CanvasPrimitiveInsert, DraftCreationPreview, @@ -229,17 +229,27 @@ export function draftPrimitiveToInsert( frameGeometry: FrameGeometry, metadata?: ResolvedScreenMetadata, ): CanvasPrimitiveInsert { - // Inline screens reflow to the frame, so the frame IS the content space and a - // draw must land 1:1 (its dragged size). The metadata 1280×2560 fallback for - // blank screens would otherwise stretch shapes on a non-portrait frame; only - // fixed-viewport sources (localhost/fusion) render scaled-to-fit. - const reflowsToFrame = !metadata || metadata.source === "inline"; - const viewport = reflowsToFrame - ? { width: frameGeometry.width, height: frameGeometry.height } - : { - width: metadata.width ?? frameGeometry.width, - height: metadata.height ?? frameGeometry.height, - }; + const metadataViewport = { + width: metadata?.width ?? frameGeometry.width, + height: metadata?.height ?? frameGeometry.height, + }; + // A draw must serialize in the same viewport the content is laid out in. + // getScreenPreviewViewport is the source of truth: an inline screen renders at + // metadata dims and CSS-scales when the frame's aspect matches, but reflows to + // the frame when it differs (so the metadata 1280×2560 default no longer + // stretches shapes on a non-portrait frame). Fixed-viewport sources + // (localhost/fusion) always use their own metadata viewport. + const isInline = !metadata || metadata.source === "inline"; + const previewViewport = getScreenPreviewViewport(metadataViewport, { + width: frameGeometry.width, + height: frameGeometry.height, + }); + const viewport = isInline + ? { + width: previewViewport.viewportWidth, + height: previewViewport.viewportHeight, + } + : metadataViewport; const scaleX = viewport.width / Math.max(1, frameGeometry.width); const scaleY = viewport.height / Math.max(1, frameGeometry.height); const roundLocal = (value: number) => Math.round(value) || 0; diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index f816a7491a..150bf04d02 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -1145,6 +1145,8 @@ const enUS = { escToExit: "Esc to exit", tellAgentWhatToChange: "Tell the agent what to change…", changesSaveWhenReconnected: "Changes will save when reconnected", + changesDiscarded: + "Some changes couldn't be saved because the file no longer exists and were discarded.", offline: "Offline", saving: "Saving...", clearedAllAnnotations: "Cleared all annotations", diff --git a/templates/design/app/lib/design-save-outbox.test.ts b/templates/design/app/lib/design-save-outbox.test.ts index 4fd4b74491..a1c8ad281a 100644 --- a/templates/design/app/lib/design-save-outbox.test.ts +++ b/templates/design/app/lib/design-save-outbox.test.ts @@ -314,4 +314,23 @@ describe("design save outbox", () => { expect(result.failed).toEqual([]); expect(await storage.list("design-1", "user-1")).toEqual([]); }); + + it("retries (never drops) a bare 404 that does not name a missing file", async () => { + const storage = new MemoryOutboxStorage(); + await journalDesignSaveOutboxEntry(fileEntry(1), storage); + // A cold-start action route can 404 transiently; the message does not name + // a missing file, so the edit must stay queued for retry, never discarded. + const routeMiss = Object.assign(new Error("Not Found"), { status: 404 }); + + const result = await drainDesignSaveOutbox({ + designId: "design-1", + actorScope: "user-1", + invokeAction: vi.fn().mockRejectedValue(routeMiss), + storage, + }); + + expect(result.dropped).toEqual([]); + expect(result.failed).toHaveLength(1); + expect(await storage.list("design-1", "user-1")).toHaveLength(1); + }); }); diff --git a/templates/design/app/lib/design-save-outbox.ts b/templates/design/app/lib/design-save-outbox.ts index 13b27cbf1b..080d339de5 100644 --- a/templates/design/app/lib/design-save-outbox.ts +++ b/templates/design/app/lib/design-save-outbox.ts @@ -48,8 +48,11 @@ export interface DrainDesignSaveOutboxResult { export function isTerminalSaveError(error: unknown): boolean { if (!error || typeof error !== "object") return false; const candidate = error as { status?: unknown; message?: unknown }; - if (candidate.status === 404) return true; + // Terminal only when an explicit 404 ALSO names a missing file. A bare 404 can + // be a transient route-not-found (e.g. a cold-start action route), so both + // signals are required — dropping an edit is unrecoverable, a retry is not. return ( + candidate.status === 404 && typeof candidate.message === "string" && /file not found/i.test(candidate.message) ); diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index c1c294c887..fcd84e8c84 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -3966,6 +3966,12 @@ function DesignEditor() { }); }, [t]); + const warnChangesDiscarded = useCallback(() => { + toast.error(t("visualEditor.changesDiscarded"), { + id: "design-save-outbox-discarded", + }); + }, [t]); + const journalOutboxEntry = useCallback( async (entry: DesignSaveOutboxEntry) => { try { @@ -4009,12 +4015,21 @@ function DesignEditor() { if (result.failed.length > 0 && navigator.onLine === false) { warnChangesWillRetry(); } + if (result.dropped.length > 0) { + warnChangesDiscarded(); + } } catch (error) { if (classifyDesignSaveFailure(error, navigator.onLine) === "offline") { warnChangesWillRetry(); } } - }, [designSaveActorScope, id, queryClient, warnChangesWillRetry]); + }, [ + designSaveActorScope, + id, + queryClient, + warnChangesWillRetry, + warnChangesDiscarded, + ]); useEffect(() => { const handleRetryOpportunity = () => void retryDesignSaveOutbox(); From 49bb8e6228441b2d68ac36e76d2acf02c4529de9 Mon Sep 17 00:00:00 2001 From: sidmohanty11 Date: Sat, 18 Jul 2026 20:46:04 +0200 Subject: [PATCH 7/7] fix --- templates/design/app/i18n-data.ts | 17 +++++++++++++++++ templates/design/app/i18n/zh-TW.ts | 1 + 2 files changed, 18 insertions(+) diff --git a/templates/design/app/i18n-data.ts b/templates/design/app/i18n-data.ts index 150bf04d02..7eba0241c9 100644 --- a/templates/design/app/i18n-data.ts +++ b/templates/design/app/i18n-data.ts @@ -5403,6 +5403,7 @@ const designRawLiteralOverrides = { escToExit: "Esc 退出", tellAgentWhatToChange: "告诉代理要更改什么...", changesSaveWhenReconnected: "重新连接时将保存更改", + changesDiscarded: "部分更改因文件不存在而无法保存,已被丢弃。", offline: "离线", saving: "正在保存...", clearedAllAnnotations: "已清除所有批注", @@ -5563,6 +5564,8 @@ const designRawLiteralOverrides = { tellAgentWhatToChange: "Dígale al agente qué cambiar...", changesSaveWhenReconnected: "Los cambios se guardarán cuando se vuelva a conectar", + changesDiscarded: + "Algunos cambios no se pudieron guardar porque el archivo ya no existe y se descartaron.", offline: "Sin conexión", saving: "Guardando...", clearedAllAnnotations: "Se borraron todas las anotaciones", @@ -5724,6 +5727,8 @@ const designRawLiteralOverrides = { tellAgentWhatToChange: "Dites à l’agent ce qu’il faut changer…", changesSaveWhenReconnected: "Les modifications seront enregistrées une fois reconnecté", + changesDiscarded: + "Certaines modifications n’ont pas pu être enregistrées car le fichier n’existe plus et ont été ignorées.", offline: "Hors ligne", saving: "Enregistrement...", clearedAllAnnotations: "Toutes les annotations ont été effacées", @@ -5886,6 +5891,8 @@ const designRawLiteralOverrides = { tellAgentWhatToChange: "Sagen Sie dem Agenten, was er ändern soll ...", changesSaveWhenReconnected: "Änderungen werden gespeichert, wenn die Verbindung wiederhergestellt wird", + changesDiscarded: + "Einige Änderungen konnten nicht gespeichert werden, da die Datei nicht mehr existiert, und wurden verworfen.", offline: "Offline", saving: "Speichern...", clearedAllAnnotations: "Alle Anmerkungen gelöscht", @@ -6044,6 +6051,8 @@ const designRawLiteralOverrides = { escToExit: "Esc 終了", tellAgentWhatToChange: "何を変更するかをエージェントに伝えてください...", changesSaveWhenReconnected: "変更は再接続時に保存されます", + changesDiscarded: + "ファイルが存在しないため、一部の変更を保存できず破棄しました。", offline: "オフライン", saving: "保存中...", clearedAllAnnotations: "すべての注釈を消去しました", @@ -6200,6 +6209,8 @@ const designRawLiteralOverrides = { escToExit: "Esc 종료", tellAgentWhatToChange: "상담사에게 무엇을 변경해야 할지 알려주세요...", changesSaveWhenReconnected: "다시 연결되면 변경사항이 저장됩니다.", + changesDiscarded: + "파일이 더 이상 존재하지 않아 일부 변경사항을 저장하지 못하고 삭제했습니다.", offline: "오프라인", saving: "저장 중...", clearedAllAnnotations: "모든 주석이 지워졌습니다", @@ -6360,6 +6371,8 @@ const designRawLiteralOverrides = { tellAgentWhatToChange: "Diga ao agente o que mudar…", changesSaveWhenReconnected: "As alterações serão salvas quando reconectadas", + changesDiscarded: + "Algumas alterações não puderam ser salvas porque o arquivo não existe mais e foram descartadas.", offline: "Off-line", saving: "Salvando...", clearedAllAnnotations: "Todas as anotações foram limpas", @@ -6517,6 +6530,8 @@ const designRawLiteralOverrides = { escToExit: "बाहर निकलने के लिए Esc", tellAgentWhatToChange: "एजेंट को बताएं कि क्या बदलना है...", changesSaveWhenReconnected: "पुन: कनेक्ट होने पर परिवर्तन सहेजे जाएंगे", + changesDiscarded: + "कुछ परिवर्तन सहेजे नहीं जा सके क्योंकि फ़ाइल अब मौजूद नहीं है, और उन्हें छोड़ दिया गया।", offline: "ऑफ़लाइन", saving: "सहेजा जा रहा है...", clearedAllAnnotations: "सभी एनोटेशन साफ़ किए गए", @@ -6673,6 +6688,8 @@ const designRawLiteralOverrides = { escToExit: "Esc للخروج", tellAgentWhatToChange: "أخبر الوكيل بما يجب تغييره...", changesSaveWhenReconnected: "سيتم حفظ التغييرات عند إعادة الاتصال", + changesDiscarded: + "تعذّر حفظ بعض التغييرات لأن الملف لم يعد موجودًا، وتم تجاهلها.", offline: "غير متصل", saving: "جارٍ الحفظ...", clearedAllAnnotations: "تم مسح جميع التعليقات التوضيحية", diff --git a/templates/design/app/i18n/zh-TW.ts b/templates/design/app/i18n/zh-TW.ts index cde99a3111..ad38708939 100644 --- a/templates/design/app/i18n/zh-TW.ts +++ b/templates/design/app/i18n/zh-TW.ts @@ -1015,6 +1015,7 @@ const messages = { escToExit: "Esc 退出", tellAgentWhatToChange: "告訴代理要更改什麼...", changesSaveWhenReconnected: "重新連線時將儲存更改", + changesDiscarded: "部分變更因檔案已不存在而無法儲存,已被捨棄。", offline: "離線", saving: "儲存...", clearedAllAnnotations: "已清除所有批注",