diff --git a/scripts/i18n-raw-literal-baseline.txt b/scripts/i18n-raw-literal-baseline.txt index 7bffc299c0..3e653eece5 100644 --- a/scripts/i18n-raw-literal-baseline.txt +++ b/scripts/i18n-raw-literal-baseline.txt @@ -23,6 +23,7 @@ templates/design/app/components/design/code-workbench/workspace/types.ts|` or `l templates/design/app/components/design/edit-panel/effects-properties.tsx|, elementKey: string, nextLayers: readonly Pick templates/design/app/components/design/multi-screen/screen-content-cache.ts|, existingScreenIds: ReadonlySet templates/design/app/components/design/multi-screen/screen-content-cache.ts|, liveScreenIds: ReadonlySet +templates/design/app/pages/DesignEditor.tsx|Auto-layout children follow their container — adjust the layout instead of aligning. templates/design/app/pages/DesignEditor.tsx|Could not open component source templates/design/app/pages/DesignEditor.tsx|Migration failed templates/design/app/pages/design-editor/pending-edits.ts|, primaryInfo?: Pick diff --git a/templates/design/.generated/bridge/editor-chrome.generated.ts b/templates/design/.generated/bridge/editor-chrome.generated.ts index 5796030b6c..63ed803af4 100644 --- a/templates/design/.generated/bridge/editor-chrome.generated.ts +++ b/templates/design/.generated/bridge/editor-chrome.generated.ts @@ -23,6 +23,13 @@ export const editorChromeBridgeScript: string = `"use strict"; return false; } })(); + var selectedLayerDragPriorityEnabled = (function() { + try { + return !!__SELECTED_LAYER_DRAG_PRIORITY__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; function dndLog(phase, data) { if (!window.__DND_DEBUG) return; @@ -1596,6 +1603,7 @@ export const editorChromeBridgeScript: string = `"use strict"; clearComponentTag(); } var selectedEl = null; + var selectionChromeHidden = false; var hoveredEl = null; var highlightOverlayStyle = "default"; var activeNodeHtmlPreview = null; @@ -2773,7 +2781,11 @@ export const editorChromeBridgeScript: string = `"use strict"; hideSelectionOverlay(); } } else if (selectedEl) { - positionOverlay(selectionOverlay, selectedEl); + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else { + positionOverlay(selectionOverlay, selectedEl); + } } else { hideParentAutoLayoutOverlay(); } @@ -3338,6 +3350,28 @@ export const editorChromeBridgeScript: string = `"use strict"; window.parent.postMessage({ type: "clear-selection" }, "*"); return; } + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && selectedEl) { + var stack = collectLayerHitCandidates(e.clientX, e.clientY); + var currentIdx = stack.elements.indexOf(selectedEl); + if (currentIdx !== -1) { + var stackKeys = stack.layerCandidates.map(function(candidate) { + return candidate.key; + }); + var nextKey = nextStackCandidate( + stackKeys, + stack.layerCandidates[currentIdx].key + ); + var nextEl = nextKey === null ? null : stack.elements[stackKeys.indexOf(nextKey)]; + if (nextEl && !isLayerInteractionBlocked(nextEl)) { + selectedSpacingHovered = false; + hoveredSpacingHandleKey = ""; + selectedEl = nextEl; + positionOverlay(selectionOverlay, selectedEl); + postElementSelect(selectedEl); + return; + } + } + } selectedSpacingHovered = false; hoveredSpacingHandleKey = ""; var previousSelectedEl = selectedEl; @@ -3507,29 +3541,27 @@ export const editorChromeBridgeScript: string = `"use strict"; document.addEventListener(events.move, onMove, true); document.addEventListener(events.up, onUp, true); } - function openContextMenuAtEvent(e) { - stopNativeInteraction(e); - blurActiveTextEditor(); + function collectLayerHitCandidates(clientX, clientY) { var shieldPointerEvents = shieldOverlay.style.pointerEvents; var selectionPointerEvents = selectionOverlay.style.pointerEvents; var highlightPointerEvents = highlightOverlay.style.pointerEvents; shieldOverlay.style.pointerEvents = "none"; selectionOverlay.style.pointerEvents = "none"; highlightOverlay.style.pointerEvents = "none"; - var pointTargets = document.elementsFromPoint ? document.elementsFromPoint(e.clientX, e.clientY) : [document.elementFromPoint(e.clientX, e.clientY)]; + var pointTargets = document.elementsFromPoint ? document.elementsFromPoint(clientX, clientY) : [document.elementFromPoint(clientX, clientY)]; shieldOverlay.style.pointerEvents = shieldPointerEvents; selectionOverlay.style.pointerEvents = selectionPointerEvents; highlightOverlay.style.pointerEvents = highlightPointerEvents; - var candidateElements = []; + var elements = []; var layerCandidates = []; pointTargets.forEach(function(pointTarget) { if (!pointTarget || pointTarget.nodeType !== 1) return; if (isOverlayElement(pointTarget)) return; var candidate = selectionTargetForHit(pointTarget); - if (!candidate || isDocumentRootElement(candidate) || isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || candidateElements.indexOf(candidate) !== -1) { + if (!candidate || isDocumentRootElement(candidate) || isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || elements.indexOf(candidate) !== -1) { return; } - candidateElements.push(candidate); + elements.push(candidate); var candidateInfo = getElementInfo(candidate); var explicitLabel = candidate.getAttribute && candidate.getAttribute("data-agent-native-layer-name") || ""; var textLabel = (candidate.textContent || "").trim().replace(/\\s+/g, " "); @@ -3541,6 +3573,14 @@ export const editorChromeBridgeScript: string = `"use strict"; info: candidateInfo }); }); + return { elements, layerCandidates }; + } + function openContextMenuAtEvent(e) { + stopNativeInteraction(e); + blurActiveTextEditor(); + var collected = collectLayerHitCandidates(e.clientX, e.clientY); + var candidateElements = collected.elements; + var layerCandidates = collected.layerCandidates; var target = candidateElements[0] || null; var info = null; if (target) { @@ -6238,7 +6278,8 @@ export const editorChromeBridgeScript: string = `"use strict"; height: dragElStartHeight }, snapCandidateRects, - SNAP_THRESHOLD_PX + // Convert the screen-space base to content px (1/zoom). + SNAP_THRESHOLD_PX * chromeLineScale() ) : { dx: 0, dy: 0, guideV: null, guideH: null }; nextLeft += snapResult.dx; nextTop += snapResult.dy; @@ -6726,6 +6767,29 @@ export const editorChromeBridgeScript: string = `"use strict"; } pendingShieldDrag = null; } + function dragTargetForPointerDown(args) { + var selectedEl2 = args.selectedEl; + var hitEl = args.hitEl; + var hitRaw = args.hitRaw || hitEl; + var selectedAlive = !!args.selectedAlive; + if (selectedEl2 && selectedAlive && selectedEl2.contains && selectedEl2.contains(hitRaw)) { + return selectedEl2; + } + if (args.preferSelected && selectedEl2 && selectedAlive) { + var r = args.selectedRect; + var p = args.point; + if (r && p && r.width > 0 && r.height > 0 && p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom) { + return selectedEl2; + } + } + return hitEl; + } + function nextStackCandidate(candidateKeys, currentKey) { + if (!candidateKeys || candidateKeys.length === 0) return null; + var idx = candidateKeys.indexOf(currentKey); + if (idx === -1) return null; + return candidateKeys[(idx + 1) % candidateKeys.length]; + } function beginPotentialShieldDrag(e) { stopNativeInteraction(e); if (e.button !== 0) return; @@ -6737,7 +6801,17 @@ export const editorChromeBridgeScript: string = `"use strict"; beginMarqueeSelection(e); return; } - var dragTarget = selectedEl && document.documentElement.contains(selectedEl) && selectedEl.contains(hit) ? selectedEl : hitTarget; + var selectedAlive = !!selectedEl && document.documentElement.contains(selectedEl); + var selectedRect = selectedAlive && selectedEl.getBoundingClientRect ? selectedEl.getBoundingClientRect() : null; + var dragTarget = dragTargetForPointerDown({ + selectedEl, + selectedAlive, + selectedRect, + hitEl: hitTarget, + hitRaw: hit, + point: { x: e.clientX, y: e.clientY }, + preferSelected: selectedLayerDragPriorityEnabled + }); var clickTarget = hitTarget; if (!dragTarget || dragTarget === document.body || dragTarget === document.documentElement || isLayerInteractionBlocked(dragTarget)) { return; @@ -7876,6 +7950,17 @@ export const editorChromeBridgeScript: string = `"use strict"; ); return; } + if (e.data.type === "set-selection-chrome-hidden") { + selectionChromeHidden = !!e.data.hidden; + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else if (selectedEl) { + positionOverlay(selectionOverlay, selectedEl); + updateParentAutoLayoutOverlay(selectedEl); + refreshOverlays(); + } + return; + } if (e.data.type === "select-element") { var candidates = []; if (Array.isArray(e.data.selectorCandidates)) { diff --git a/templates/design/AGENTS.md b/templates/design/AGENTS.md index cb5210fbf2..c274788ed1 100644 --- a/templates/design/AGENTS.md +++ b/templates/design/AGENTS.md @@ -288,6 +288,32 @@ ladder. bridge URL, and snapshot/state references when moving between actions so later flow-edge derivation has stable anchors. +## Chrome placement + +Editor chrome has fixed homes; put new UI in the right one so surfaces stay +predictable and Figma-clean. + +- **Bottom toolbar**: cursor-changing creation/manipulation tools only — + move/hand/scale, frame, shapes, pen, text, comment. Never mode switches + (annotate/edit/interact) or one-off command buttons. +- **Left rail**: orientation and structure — pages, layers, assets, and the + `code` workbench. +- **Right panel**: properties, progressively disclosed (the EditPanel model). +- **Bottom status area**: warnings, sync/offline state, library updates, and the + outbox conflict count; render nothing when all-clear — the strip is + Figma-silent and usually invisible. +- **Long-tail features**: extensions in the left-rail tools/extensions surface, + not new permanent toolbar buttons. +- **No new always-visible controls for an existing command.** Do not add a + permanent button to fix discoverability; use the command menu, context menu, or + an existing surface. New permanent chrome for an existing command is a + regression. +- The mode switch (annotate/edit/interact) belongs in a compact segmented + control near where mode context already lives (top-right), not in the + cursor-tool pill. +- `Cmd+\` hides the whole chrome set (rail + inspector + toolbar); any new chrome + must participate in that hide. + ## Application State - `navigation` tells you the current view, design id, file id, and related UI diff --git a/templates/design/app/components/design/DesignCanvas.tsx b/templates/design/app/components/design/DesignCanvas.tsx index dfe3bcd1e8..1b072904c3 100644 --- a/templates/design/app/components/design/DesignCanvas.tsx +++ b/templates/design/app/components/design/DesignCanvas.tsx @@ -315,6 +315,15 @@ ${editorChromeBridgeScript} */ const LIVE_REFLOW_ENABLED = true; +/** + * Rollout gate: when on, a pointerdown inside the current selection's box keeps + * the selected element as the drag target even when an overlapping + * non-descendant sibling wins the hit test. Baked into the bridge as + * `__SELECTED_LAYER_DRAG_PRIORITY__`; flip to `false` for descendant-only + * behavior. + */ +const SELECTED_LAYER_DRAG_PRIORITY_ENABLED = true; + interface DesignCanvasProps { content: string; contentKey?: string; @@ -949,6 +958,10 @@ function buildEditorChromeBridgeScript(args: { "__LIVE_REFLOW_ENABLED__", LIVE_REFLOW_ENABLED ? "true" : "false", ) + .replace( + "__SELECTED_LAYER_DRAG_PRIORITY__", + SELECTED_LAYER_DRAG_PRIORITY_ENABLED ? "true" : "false", + ) ); } @@ -2130,6 +2143,10 @@ export function DesignCanvas({ .replace( "__LIVE_REFLOW_ENABLED__", LIVE_REFLOW_ENABLED ? "true" : "false", + ) + .replace( + "__SELECTED_LAYER_DRAG_PRIORITY__", + SELECTED_LAYER_DRAG_PRIORITY_ENABLED ? "true" : "false", ); // ALWAYS injected (like the other always-on bridges above) so // MultiScreenCanvas's cross-screen drag hit-testing 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 1ac6ac57ba..6303e6e047 100644 --- a/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +++ b/templates/design/app/components/design/bridge/editor-chrome.bridge.ts @@ -35,6 +35,7 @@ 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; +declare var __SELECTED_LAYER_DRAG_PRIORITY__: boolean; (function () { // Idempotency guard: replace-document-content / srcdoc rebuilds can end up @@ -78,6 +79,16 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; return false; } })(); + // Selected-layer drag priority: when on, a pointerdown inside the selection + // box keeps the selected element as the drag target even when an overlapping + // non-descendant sibling wins the hit test. + var selectedLayerDragPriorityEnabled = (function () { + try { + return !!__SELECTED_LAYER_DRAG_PRIORITY__; + } catch (_e) { + return false; + } + })(); var scaleToolEnabled = false; // ── Drag-and-drop debug logging ──────────────────────────────────── @@ -2248,6 +2259,9 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; } var selectedEl: Element | null = null; + // When true, selection chrome stays hidden through async reflows so a + // keyboard-nudge burst does not flicker; selection itself is unchanged. + var selectionChromeHidden = false; var hoveredEl: Element | null = null; var highlightOverlayStyle: "default" | "soft" = "default"; type NodeHtmlPreviewSession = { @@ -4080,7 +4094,11 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; hideSelectionOverlay(); } } else if (selectedEl) { - positionOverlay(selectionOverlay, selectedEl); + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else { + positionOverlay(selectionOverlay, selectedEl); + } } else { hideParentAutoLayoutOverlay(); } @@ -4895,6 +4913,34 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; (window.parent as Window).postMessage({ type: "clear-selection" }, "*"); return; } + // Cmd/Ctrl+click (no Shift) cycles to the next layer below the selection in + // the z-stack under the pointer, wrapping at the bottom. Only when the + // selection is in that stack; otherwise falls through to additive click. + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && selectedEl) { + var stack = collectLayerHitCandidates(e.clientX, e.clientY); + var currentIdx = stack.elements.indexOf(selectedEl); + if (currentIdx !== -1) { + var stackKeys = stack.layerCandidates.map(function (candidate) { + return candidate.key; + }); + var nextKey = nextStackCandidate( + stackKeys, + stack.layerCandidates[currentIdx].key, + ); + var nextEl = + nextKey === null ? null : stack.elements[stackKeys.indexOf(nextKey)]; + if (nextEl && !isLayerInteractionBlocked(nextEl)) { + selectedSpacingHovered = false; + hoveredSpacingHandleKey = ""; + selectedEl = nextEl; + positionOverlay(selectionOverlay, selectedEl); + // Plain (no-intent) select so the host replaces the selection with + // the cycled layer instead of treating Cmd/Ctrl as additive. + postElementSelect(selectedEl); + return; + } + } + } selectedSpacingHovered = false; hoveredSpacingHandleKey = ""; var previousSelectedEl = selectedEl; @@ -5103,9 +5149,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; document.addEventListener(events.up, onUp, true); } - function openContextMenuAtEvent(e) { - stopNativeInteraction(e); - blurActiveTextEditor(); + // Returns the full z-stack of selectable layers under a point (topmost + // first), each element index-aligned with a { key, label, info } descriptor. + // Overlays are briefly made pointer-transparent so elementsFromPoint sees + // through the editor chrome. + function collectLayerHitCandidates( + clientX: number, + clientY: number, + ): { + elements: Element[]; + layerCandidates: Array<{ key: string; label: string; info: unknown }>; + } { var shieldPointerEvents = shieldOverlay.style.pointerEvents; var selectionPointerEvents = selectionOverlay.style.pointerEvents; var highlightPointerEvents = highlightOverlay.style.pointerEvents; @@ -5113,13 +5167,13 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; selectionOverlay.style.pointerEvents = "none"; highlightOverlay.style.pointerEvents = "none"; var pointTargets = document.elementsFromPoint - ? document.elementsFromPoint(e.clientX, e.clientY) - : [document.elementFromPoint(e.clientX, e.clientY)]; + ? document.elementsFromPoint(clientX, clientY) + : [document.elementFromPoint(clientX, clientY)]; shieldOverlay.style.pointerEvents = shieldPointerEvents; selectionOverlay.style.pointerEvents = selectionPointerEvents; highlightOverlay.style.pointerEvents = highlightPointerEvents; - var candidateElements: Element[] = []; + var elements: Element[] = []; var layerCandidates: Array<{ key: string; label: string; @@ -5135,11 +5189,11 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; isOverlayElement(candidate) || isLayerInteractionBlocked(candidate) || isTemplateCloneElement(candidate) || - candidateElements.indexOf(candidate) !== -1 + elements.indexOf(candidate) !== -1 ) { return; } - candidateElements.push(candidate); + elements.push(candidate); var candidateInfo = getElementInfo(candidate); var explicitLabel = (candidate.getAttribute && @@ -5162,6 +5216,15 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; info: candidateInfo, }); }); + return { elements: elements, layerCandidates: layerCandidates }; + } + + function openContextMenuAtEvent(e) { + stopNativeInteraction(e); + blurActiveTextEditor(); + var collected = collectLayerHitCandidates(e.clientX, e.clientY); + var candidateElements = collected.elements; + var layerCandidates = collected.layerCandidates; var target = candidateElements[0] || null; var info = null; @@ -8227,10 +8290,9 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; // Minimal, dependency-free port of the overview canvas's edge/center snap // routine (shared/canvas-math.ts computeMoveSnap) for in-iframe element // dragging. The bridge's pointer coordinates and getBoundingClientRect() - // values are already in the same iframe-local, zoom-normalized coordinate - // space (the host CSS-scales the whole iframe, not individual elements), - // so — unlike the overview canvas, which divides a screen-px threshold by - // its own camera zoom — no extra scale correction is needed here. + // values are iframe-local content px, so SNAP_THRESHOLD_PX is a screen-space + // base converted to content px at snap time via chromeLineScale (1/zoom) to + // keep the snap tolerance constant on screen at any zoom. var SNAP_THRESHOLD_PX = 6; var SNAP_CANDIDATE_CAP = 200; @@ -9321,7 +9383,8 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; height: dragElStartHeight, }, snapCandidateRects, - SNAP_THRESHOLD_PX, + // Convert the screen-space base to content px (1/zoom). + SNAP_THRESHOLD_PX * chromeLineScale(), ) : { dx: 0, dy: 0, guideV: null, guideH: null }; nextLeft += snapResult.dx; @@ -9970,6 +10033,53 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; pendingShieldDrag = null; } + // Decides the drag target for a pointerdown. Descendant hits keep the + // selected element; with preferSelected on, a point inside the selection box + // also keeps it over an overlapping non-descendant sibling. Falls through to + // hitEl when the selection is detached or zero-area. Pure and self-contained + // so the snap test can brace-extract and evaluate it in isolation. + function dragTargetForPointerDown(args) { + var selectedEl = args.selectedEl; + var hitEl = args.hitEl; + var hitRaw = args.hitRaw || hitEl; + var selectedAlive = !!args.selectedAlive; + if ( + selectedEl && + selectedAlive && + selectedEl.contains && + selectedEl.contains(hitRaw) + ) { + return selectedEl; + } + if (args.preferSelected && selectedEl && selectedAlive) { + var r = args.selectedRect; + var p = args.point; + if ( + r && + p && + r.width > 0 && + r.height > 0 && + p.x >= r.left && + p.x <= r.right && + p.y >= r.top && + p.y <= r.bottom + ) { + return selectedEl; + } + } + return hitEl; + } + + // Given the hit-stack candidate keys (topmost first) and the current + // selection key, returns the next key below it, wrapping to the top. Returns + // null when the selection is not in the stack. Pure, for the snap test. + function nextStackCandidate(candidateKeys, currentKey) { + if (!candidateKeys || candidateKeys.length === 0) return null; + var idx = candidateKeys.indexOf(currentKey); + if (idx === -1) return null; + return candidateKeys[(idx + 1) % candidateKeys.length]; + } + function beginPotentialShieldDrag(e) { stopNativeInteraction(e); if (e.button !== 0) return; @@ -9988,12 +10098,21 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; beginMarqueeSelection(e); return; } - var dragTarget = - selectedEl && - document.documentElement.contains(selectedEl) && - selectedEl.contains(hit) - ? selectedEl - : hitTarget; + var selectedAlive = + !!selectedEl && document.documentElement.contains(selectedEl); + var selectedRect = + selectedAlive && selectedEl.getBoundingClientRect + ? selectedEl.getBoundingClientRect() + : null; + var dragTarget = dragTargetForPointerDown({ + selectedEl: selectedEl, + selectedAlive: selectedAlive, + selectedRect: selectedRect, + hitEl: hitTarget, + hitRaw: hit, + point: { x: e.clientX, y: e.clientY }, + preferSelected: selectedLayerDragPriorityEnabled, + }); var clickTarget = hitTarget; if ( !dragTarget || @@ -11599,6 +11718,17 @@ declare var __LIVE_REFLOW_ENABLED__: boolean; ); return; } + if (e.data.type === "set-selection-chrome-hidden") { + selectionChromeHidden = !!e.data.hidden; + if (selectionChromeHidden) { + hideSelectionOverlay(); + } else if (selectedEl) { + positionOverlay(selectionOverlay, selectedEl); + updateParentAutoLayoutOverlay(selectedEl); + refreshOverlays(); + } + return; + } if (e.data.type === "select-element") { var candidates: string[] = []; if (Array.isArray(e.data.selectorCandidates)) { diff --git a/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts b/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts index e26064ac70..d0ba398fff 100644 --- a/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts +++ b/templates/design/app/components/design/editor-chrome-bridge.snap.test.ts @@ -114,6 +114,204 @@ function loadSnapMath(): { const { rectBounds, computeMoveSnapOffset } = loadSnapMath(); +// Both functions read only their arguments, so a single brace-extracted +// declaration evaluates in isolation. +function loadPureBridgeFn(name: string): T { + const editorChromeBridgeScript = loadEditorChromeBridgeScript(); + const src = extractFunction(editorChromeBridgeScript, name); + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const factory = new Function(`${src}\nreturn ${name};`); + return factory() as T; +} + +interface DragTargetArgs { + selectedEl: unknown; + selectedAlive: boolean; + selectedRect: { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + } | null; + hitEl: unknown; + hitRaw?: unknown; + point: { x: number; y: number } | null; + preferSelected: boolean; +} +const dragTargetForPointerDown = loadPureBridgeFn< + (args: DragTargetArgs) => unknown +>("dragTargetForPointerDown"); +const nextStackCandidate = + loadPureBridgeFn<(keys: string[], current: string | null) => string | null>( + "nextStackCandidate", + ); + +describe("editor-chrome bridge — dragTargetForPointerDown", () => { + const selRect = { + left: 10, + top: 10, + right: 110, + bottom: 60, + width: 100, + height: 50, + }; + + it("keeps the selected element when the hit is its descendant (legacy rule, flag off)", () => { + const hitRaw = { tag: "child" }; + const selectedEl = { tag: "sel", contains: (x: unknown) => x === hitRaw }; + const hitEl = { tag: "hitTarget" }; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: null, + hitEl, + hitRaw, + point: { x: 0, y: 0 }, + preferSelected: false, + }), + ).toBe(selectedEl); + }); + + it("keeps the selected element when the point is inside its box over an overlapping sibling (flag on)", () => { + const hitRaw = { tag: "sibling-on-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(selectedEl); + }); + + it("selects the overlapping top sibling when the flag is off (legacy hit wins)", () => { + const hitRaw = { tag: "sibling-on-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: false, + }), + ).toBe(hitEl); + }); + + it("falls through to the hit when the point is outside the selected box", () => { + const hitRaw = { tag: "elsewhere" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 500, y: 500 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); + + it("falls through to the hit when the selected element is detached (selectedAlive false)", () => { + const hitRaw = { tag: "hit" }; + const selectedEl = { tag: "sel", contains: () => true }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: false, + selectedRect: selRect, + hitEl, + hitRaw, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); + + it("keeps the selected element even when the top hit is a (locked) layer — locking is the caller's concern", () => { + const lockedTop = { tag: "locked-top" }; + const selectedEl = { tag: "sel", contains: () => false }; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: selRect, + hitEl: lockedTop, + hitRaw: lockedTop, + point: { x: 50, y: 30 }, + preferSelected: true, + }), + ).toBe(selectedEl); + }); + + it("falls through when the selected box is zero-area (hidden element)", () => { + const hitRaw = { tag: "hit" }; + const selectedEl = { tag: "sel", contains: () => false }; + const hitEl = hitRaw; + expect( + dragTargetForPointerDown({ + selectedEl, + selectedAlive: true, + selectedRect: { + left: 10, + top: 10, + right: 10, + bottom: 10, + width: 0, + height: 0, + }, + hitEl, + hitRaw, + point: { x: 10, y: 10 }, + preferSelected: true, + }), + ).toBe(hitEl); + }); +}); + +describe("editor-chrome bridge — nextStackCandidate", () => { + const stack = ["a:0", "b:1", "c:2", "d:3"]; + + it("returns the next candidate below the current one", () => { + expect(nextStackCandidate(stack, "b:1")).toBe("c:2"); + }); + + it("wraps from the bottom back to the top", () => { + expect(nextStackCandidate(stack, "d:3")).toBe("a:0"); + }); + + it("moves from the top hit to the one just below it", () => { + expect(nextStackCandidate(stack, "a:0")).toBe("b:1"); + }); + + it("returns null when the current selection is not in the stack", () => { + expect(nextStackCandidate(stack, "z:9")).toBeNull(); + }); + + it("returns null for an empty stack", () => { + expect(nextStackCandidate([], "a:0")).toBeNull(); + }); + + it("wraps to itself for a single-candidate stack", () => { + expect(nextStackCandidate(["only:0"], "only:0")).toBe("only:0"); + }); +}); + function loadSelectionTargetForHit(documentRoot: { body: Element; documentElement: Element; diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index eb4f9770d2..7aec25154f 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -659,6 +659,9 @@ import { pruneGeometryHistoryEntryForDeletedFiles, remapFileDeletionHistoryEntryIds, removeRecentUndoRedoOrderKinds, + type SelectionHistoryEntry, + selectionSnapshotsEqual, + shouldRecordSelectionHistory, } from "./design-editor/history"; import { getAbsolutePositioningForNodeInHtml, @@ -806,6 +809,10 @@ import { shouldIncludeScreenRenameContentOverride, shouldUseOverviewRuntimeReplacement, } from "./design-editor/selection-state"; +import { + type AlignmentGroup, + partitionSelectionForAlignment, +} from "./design-editor/selection-topology"; import { postShaderFillPreviewClearToPreviewIframes, removeElementFromHtml, @@ -2947,6 +2954,10 @@ function DesignEditor() { const suppressContentHistoryRef = useRef(false); const geometryUndoStackRef = useRef([]); const geometryRedoStackRef = useRef([]); + // Selection-only undo. Pushed only from local selection handlers, so + // peer/agent selections never enter the stack. + const selectionOnlyUndoStackRef = useRef([]); + const selectionOnlyRedoStackRef = useRef([]); // Figma-parity undo/redo selection restore (see GeometryHistorySelection): // synchronous mirrors of the selection state, kept current every render // (like activeFileIdForUndoRef just above) so a commit/undo/redo handler @@ -2975,6 +2986,7 @@ function DesignEditor() { contentRedoSelectionStackRef.current = []; localContentRedoStackRef.current = []; geometryRedoStackRef.current = []; + selectionOnlyRedoStackRef.current = []; fileCreationRedoStackRef.current = []; fileDeletionRedoStackRef.current = []; pendingVisualStyleRedoStackRef.current = []; @@ -3008,12 +3020,38 @@ function DesignEditor() { const restoreSelectionSnapshot = useCallback( (selection: GeometryHistorySelection | undefined) => { if (!selection) return; - if (viewModeRef.current !== "overview") return; - setOverviewSelectedScreenIds(selection.overviewSelectedScreenIds); - setSelectedLayerIdsState(selection.selectedLayerIds); - if (selection.activeFileId) { + if (viewModeRef.current === "overview") { + setOverviewSelectedScreenIds(selection.overviewSelectedScreenIds); + setSelectedLayerIdsState(selection.selectedLayerIds); + if (selection.activeFileId) { + setActiveFileId(selection.activeFileId); + } + return; + } + // Single-screen restore: switch file if needed, restore layer selection, + // and re-drive the in-iframe overlay for the first layer. Resolve the + // iframe from the DOM so this stays independent of the later canvasIframeRef. + if ( + selection.activeFileId && + selection.activeFileId !== activeFileIdForUndoRef.current + ) { setActiveFileId(selection.activeFileId); } + setSelectedLayerIdsState(selection.selectedLayerIds); + const firstLayerId = selection.selectedLayerIds[0]; + if (firstLayerId && typeof document !== "undefined") { + const iframe = document.querySelector( + "iframe[data-design-preview-iframe]", + ); + iframe?.contentWindow?.postMessage( + { + type: "select-element", + nodeId: firstLayerId, + selector: `[data-agent-native-node-id="${firstLayerId.replace(/"/g, '\\"')}"]`, + }, + "*", + ); + } }, [], ); @@ -3044,6 +3082,8 @@ function DesignEditor() { Boolean(undoManager?.canUndo()) || hasLocalUndo || clipboardPasteUndoStackRef.current.length > 0 || + // Selection-only undo is available in both view modes. + selectionOnlyUndoStackRef.current.length > 0 || (canUseOverviewHistory && (contentUndoStackRef.current.length > 0 || geometryUndoStackRef.current.length > 0 || @@ -3056,6 +3096,7 @@ function DesignEditor() { Boolean(undoManager?.canRedo()) || hasLocalRedo || clipboardPasteRedoStackRef.current.length > 0 || + selectionOnlyRedoStackRef.current.length > 0 || (canUseOverviewHistory && (contentRedoStackRef.current.length > 0 || geometryRedoStackRef.current.length > 0 || @@ -3221,6 +3262,8 @@ function DesignEditor() { localContentRedoStackRef.current = []; geometryUndoStackRef.current = []; geometryRedoStackRef.current = []; + selectionOnlyUndoStackRef.current = []; + selectionOnlyRedoStackRef.current = []; fileCreationUndoStackRef.current = []; fileCreationRedoStackRef.current = []; fileDeletionUndoStackRef.current = []; @@ -7645,6 +7688,63 @@ function DesignEditor() { activeEditorDragRef.current = active; }, []); + // Recorded only from user-selection funnels; edits/paste/undo use other + // paths, so an edit's selection change isn't double-counted. The suppress + // ref blocks any in-flight scheduled record while undo/redo restores. + const suppressSelectionOnlyHistoryRef = useRef(false); + const recordSelectionOnlyHistory = useCallback( + (before: GeometryHistorySelection, after: GeometryHistorySelection) => { + if (suppressSelectionOnlyHistoryRef.current) return; + const stack = selectionOnlyUndoStackRef.current; + const lastEntry = stack.length > 0 ? stack[stack.length - 1] : null; + const now = Date.now(); + const decision = shouldRecordSelectionHistory({ + prev: before, + next: after, + lastEntry, + now, + gestureActive: activeEditorDragRef.current, + }); + if (decision === "skip") return; + if (decision === "record") { + selectionOnlyUndoStackRef.current = [ + ...stack.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + { before, after, at: now }, + ]; + clearRedoStacks(); + historyOrderRef.current = [ + ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + } else { + const top = stack[stack.length - 1]; + const merged = { before: top.before, after, at: now }; + if (selectionSnapshotsEqual(merged.before, merged.after)) { + // The burst returned to its origin — drop the now-empty entry and + // its order token so Cmd+Z doesn't stop on a no-op. + selectionOnlyUndoStackRef.current = stack.slice(0, -1); + historyOrderRef.current = removeRecentUndoRedoOrderKinds( + historyOrderRef.current, + "selection", + 1, + ); + } else { + selectionOnlyUndoStackRef.current = [...stack.slice(0, -1), merged]; + } + } + syncUndoRedoState(); + }, + [clearRedoStacks, syncUndoRedoState], + ); + const scheduleSelectionOnlyHistoryRecord = useCallback(() => { + if (suppressSelectionOnlyHistoryRef.current) return; + if (activeEditorDragRef.current) return; + const before = captureCurrentSelection(); + requestAnimationFrame(() => { + recordSelectionOnlyHistory(before, captureCurrentSelection()); + }); + }, [recordSelectionOnlyHistory]); + const cancelActiveEditorDrag = useCallback(() => { if (!activeEditorDragRef.current) return false; activeEditorDragRef.current = false; @@ -12186,6 +12286,8 @@ function DesignEditor() { breakpointWidthPx?: number; } = {}, ) => { + // Make this click's selection change undoable on its own. + scheduleSelectionOnlyHistoryRecord(); const pendingLayerId = pendingOverviewLayerSelectionRef.current; const pendingScreenId = pendingOverviewScreenSelectionRef.current; const projection = getCodeLayerProjectionForScreen(screenId); @@ -12362,6 +12464,7 @@ function DesignEditor() { getScreenContent, handleBreakpointBarSelect, id, + scheduleSelectionOnlyHistoryRecord, selectedLayerIdsState, t, ], @@ -17318,21 +17421,93 @@ function DesignEditor() { const selectedRects = selectedNodes.map(rectFromCodeLayerNode); if (selectedRects.length >= 2) { - // Multi-selection: align to the selection's own combined bbox. - const bounds = getFrameGroupBounds(selectedRects); - if (!bounds) return; - const positions = computeAlignedPositions( - selectedRects, - { - x: bounds.left, - y: bounds.top, - width: bounds.width, - height: bounds.height, - }, - edge, + // Align per hierarchy group so nesting is respected (aligning cards + // moves the cards, not their titles). Each group shares one coordinate + // space, avoiding the cross-parent left/top mismatch; results merge + // into one commit. + const groups = partitionSelectionForAlignment( + projection.nodes, + nodeIds, ); - if (positions.size === 0) return; - commitNodePositions(baseContent, positions); + const rectById = new Map( + selectedNodes.map((node) => [node.id, rectFromCodeLayerNode(node)]), + ); + const combined = new Map(); + let skippedAutoLayout = false; + for (const group of groups) { + const groupRects = group.nodeIds + .map((nodeId) => rectById.get(nodeId)) + .filter((rect): rect is AlignableRect => Boolean(rect)); + if (groupRects.length === 0) continue; + if (groupRects.length === 1) { + // A lone group member aligns within its own (direct) parent's + // content box, matching the single-selection branch below. + const node = nodesById.get(group.nodeIds[0]!); + const directParent = node?.parentId + ? nodesById.get(node.parentId) + : null; + if (!directParent) continue; + if ( + (directParent.layout.isFlexContainer || + directParent.layout.isGridContainer) && + node && + !isAbsoluteCodeLayerNode(node) + ) { + skippedAutoLayout = true; + continue; + } + const parentRect = rectFromCodeLayerNode(directParent); + const positions = computeAlignedPositions( + groupRects, + { + x: 0, + y: 0, + width: parentRect.width, + height: parentRect.height, + }, + edge, + ); + positions.forEach((position, id) => combined.set(id, position)); + continue; + } + // Multi-member group: if these are in-flow children of an auto-layout + // container, aligning would absolutize and fight the layout — honest + // no-op + toast instead of a silent absolutize. + const parentNode = group.parentId + ? nodesById.get(group.parentId) + : null; + if ( + parentNode && + (parentNode.layout.isFlexContainer || + parentNode.layout.isGridContainer) && + group.nodeIds.some((nodeId) => { + const node = nodesById.get(nodeId); + return node ? !isAbsoluteCodeLayerNode(node) : false; + }) + ) { + skippedAutoLayout = true; + continue; + } + const bounds = getFrameGroupBounds(groupRects); + if (!bounds) continue; + const positions = computeAlignedPositions( + groupRects, + { + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height, + }, + edge, + ); + positions.forEach((position, id) => combined.set(id, position)); + } + if (combined.size > 0) commitNodePositions(baseContent, combined); + if (skippedAutoLayout) { + toast.info( + "Auto-layout children follow their container — adjust the layout instead of aligning.", + ); + } return; } @@ -19408,6 +19583,60 @@ function DesignEditor() { ], ); + // Hide the in-iframe selection outline during keyboard nudges so it doesn't + // chase the element, restoring it once the burst settles. The re-armed + // settle timer is the authoritative restore (~800ms, matching the nudge + // coalesce window); an arrow keyup restores sooner when the host has focus. + const selectionChromeHiddenRef = useRef(false); + const selectionChromeSettleTimerRef = useRef(undefined); + const restoreSelectionChrome = useCallback(() => { + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + selectionChromeSettleTimerRef.current = undefined; + } + if (!selectionChromeHiddenRef.current) return; + selectionChromeHiddenRef.current = false; + canvasIframeRef.current?.contentWindow?.postMessage( + { type: "set-selection-chrome-hidden", hidden: false }, + "*", + ); + }, [canvasIframeRef]); + const hideSelectionChromeForNudge = useCallback(() => { + if (!selectionChromeHiddenRef.current) { + selectionChromeHiddenRef.current = true; + canvasIframeRef.current?.contentWindow?.postMessage( + { type: "set-selection-chrome-hidden", hidden: true }, + "*", + ); + } + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + } + selectionChromeSettleTimerRef.current = window.setTimeout(() => { + selectionChromeSettleTimerRef.current = undefined; + restoreSelectionChrome(); + }, 800); + }, [canvasIframeRef, restoreSelectionChrome]); + useEffect(() => { + const onKeyUp = (event: KeyboardEvent) => { + if ( + event.key === "ArrowUp" || + event.key === "ArrowDown" || + event.key === "ArrowLeft" || + event.key === "ArrowRight" + ) { + restoreSelectionChrome(); + } + }; + window.addEventListener("keyup", onKeyUp); + return () => { + window.removeEventListener("keyup", onKeyUp); + if (selectionChromeSettleTimerRef.current !== undefined) { + window.clearTimeout(selectionChromeSettleTimerRef.current); + } + }; + }, [restoreSelectionChrome]); + const handleNudgeSelection = useCallback( (direction: "up" | "right" | "down" | "left", largeStep: boolean) => { if (!canEditDesign) return; @@ -19460,6 +19689,7 @@ function DesignEditor() { } if (!selectedElement?.selector) return; + hideSelectionChromeForNudge(); const left = parseFloat(selectedElement.computedStyles.left || "0") || 0; const top = parseFloat(selectedElement.computedStyles.top || "0") || 0; commitVisualStyles(selectedElement.selector, { @@ -19477,6 +19707,7 @@ function DesignEditor() { canEditDesign, commitVisualStyles, handleGeometryCommit, + hideSelectionChromeForNudge, overviewScreens, overviewSelectedScreenIds, selectedElement, @@ -19946,6 +20177,33 @@ function DesignEditor() { restoreSelectionSnapshot(entry.selectionBefore); return true; }; + // Restore the entry's `before` and move it to the redo stack. The suppress + // ref stops the recorder from logging this restore as a new change. + const undoSelection = () => { + const entry = + selectionOnlyUndoStackRef.current[ + selectionOnlyUndoStackRef.current.length - 1 + ]; + if (!entry) return false; + selectionOnlyUndoStackRef.current = + selectionOnlyUndoStackRef.current.slice(0, -1); + selectionOnlyRedoStackRef.current = [ + ...selectionOnlyRedoStackRef.current.slice( + -(MAX_DESIGN_UNDO_STACK - 1), + ), + entry, + ]; + redoOrderRef.current = [ + ...redoOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + suppressSelectionOnlyHistoryRef.current = true; + restoreSelectionSnapshot(entry.before); + requestAnimationFrame(() => { + suppressSelectionOnlyHistoryRef.current = false; + }); + return true; + }; // U12: undo a screen create/duplicate by soft-deleting the file it // created (performDeleteFiles already prunes any content/geometry undo // entries for that file, mirroring U2's screen-deletion cleanup). @@ -20098,6 +20356,10 @@ function DesignEditor() { (prunedUndoHistory > prunedBefore ? false : undoGeometry()) ); } + // Fall through if the selection stack is empty so the loop can't dead-end. + if (preferred === "selection") { + return undoSelection() || undoContent() || undoGeometry(); + } return undoFileDeletion() || undoContent() || undoGeometry(); }; let didUndo = false; @@ -20108,7 +20370,9 @@ function DesignEditor() { if (didUndo || preferred === undefined) break; } } else { - didUndo = undoContent("local"); + // Single-screen mode has no chronological order list; undo local content + // first, then fall back to selection-only entries. + didUndo = undoContent("local") || undoSelection(); } if (didUndo || prunedUndoHistory > 0) { syncUndoRedoState(); @@ -20595,6 +20859,32 @@ function DesignEditor() { restoreSelectionSnapshot(entry.selectionAfter); return true; }; + // Re-apply the entry's `after` and move it back to the undo stack. + const redoSelection = () => { + const entry = + selectionOnlyRedoStackRef.current[ + selectionOnlyRedoStackRef.current.length - 1 + ]; + if (!entry) return false; + selectionOnlyRedoStackRef.current = + selectionOnlyRedoStackRef.current.slice(0, -1); + selectionOnlyUndoStackRef.current = [ + ...selectionOnlyUndoStackRef.current.slice( + -(MAX_DESIGN_UNDO_STACK - 1), + ), + entry, + ]; + historyOrderRef.current = [ + ...historyOrderRef.current.slice(-(MAX_DESIGN_UNDO_STACK - 1)), + "selection", + ]; + suppressSelectionOnlyHistoryRef.current = true; + restoreSelectionSnapshot(entry.after); + requestAnimationFrame(() => { + suppressSelectionOnlyHistoryRef.current = false; + }); + return true; + }; // U12: redo a screen create/duplicate by recreating the file with the // same filename/content/fileType and restoring its recorded geometry. // This is async (createFileMutation), unlike every other redo path here, @@ -20765,6 +21055,9 @@ function DesignEditor() { (prunedRedoHistory > prunedBefore ? false : redoGeometry()) ); } + if (preferred === "selection") { + return redoSelection() || redoContent() || redoGeometry(); + } return redoFileDeletion() || redoContent() || redoGeometry(); }; let didRedo = false; @@ -20775,7 +21068,7 @@ function DesignEditor() { if (didRedo || preferred === undefined) break; } } else { - didRedo = redoContent("local"); + didRedo = redoContent("local") || redoSelection(); } if (didRedo || prunedRedoHistory > 0) { syncUndoRedoState(); @@ -26469,6 +26762,8 @@ function DesignEditor() { selection: CanvasLayerMarqueeSelection[], intent: ElementSelectionIntent, ) => { + // Coalescing collapses the per-tick marquee reports into one undo entry. + scheduleSelectionOnlyHistoryRecord(); // PF10: MultiScreenCanvas reports the marquee hit-set on every // mousemove tick during a drag, not just on settle (see // reportLayerSelection in MultiScreenCanvas.tsx). Bail before any @@ -26575,6 +26870,7 @@ function DesignEditor() { clearPendingOverviewLayerSelectionTimer, focusDesignInspectorForSelection, getCodeLayerProjectionForScreen, + scheduleSelectionOnlyHistoryRecord, ], ); diff --git a/templates/design/app/pages/design-editor/editor-state.ts b/templates/design/app/pages/design-editor/editor-state.ts index 5e70042358..f2ca823eb3 100644 --- a/templates/design/app/pages/design-editor/editor-state.ts +++ b/templates/design/app/pages/design-editor/editor-state.ts @@ -359,7 +359,10 @@ export type UndoRedoOrderKind = | "file-content" | "geometry" | "file-created" - | "file-deleted"; + | "file-deleted" + // A selection-only change, interleaved chronologically so Cmd+Z walks + // selection changes and edits in the order they happened. + | "selection"; export function getUndoRedoPriorityOrder( preferred: UndoRedoOrderKind | undefined, diff --git a/templates/design/app/pages/design-editor/history.ts b/templates/design/app/pages/design-editor/history.ts index 7b0c9feb1c..0a2872ca94 100644 --- a/templates/design/app/pages/design-editor/history.ts +++ b/templates/design/app/pages/design-editor/history.ts @@ -18,6 +18,52 @@ export interface GeometryHistoryEntry { selectionAfter?: GeometryHistorySelection; } +/** One selection-only undo step. `at` is the epoch-ms timestamp used to + * coalesce a rapid burst of selection changes into a single undo entry. */ +export interface SelectionHistoryEntry { + before: GeometryHistorySelection; + after: GeometryHistorySelection; + at: number; +} + +/** Order-sensitive equality between two selection snapshots (arrays are in a + * stable order, so index-wise comparison is exact). */ +export function selectionSnapshotsEqual( + a: GeometryHistorySelection, + b: GeometryHistorySelection, +): boolean { + const sameIds = (x: string[], y: string[]) => + x.length === y.length && x.every((value, index) => value === y[index]); + return ( + a.activeFileId === b.activeFileId && + sameIds(a.overviewSelectedScreenIds, b.overviewSelectedScreenIds) && + sameIds(a.selectedLayerIds, b.selectedLayerIds) + ); +} + +export const SELECTION_HISTORY_COALESCE_WINDOW_MS = 800; + +export type SelectionHistoryDecision = "skip" | "record" | "coalesce"; + +/** Decides whether a selection change is skipped, coalesced into the last + * entry, or recorded as a fresh selection-only undo step. */ +export function shouldRecordSelectionHistory(input: { + prev: GeometryHistorySelection; + next: GeometryHistorySelection; + lastEntry: SelectionHistoryEntry | null; + now: number; + gestureActive: boolean; + coalesceWindowMs?: number; +}): SelectionHistoryDecision { + if (input.gestureActive) return "skip"; + if (selectionSnapshotsEqual(input.prev, input.next)) return "skip"; + const window = input.coalesceWindowMs ?? SELECTION_HISTORY_COALESCE_WINDOW_MS; + if (input.lastEntry && input.now - input.lastEntry.at <= window) { + return "coalesce"; + } + return "record"; +} + export interface FileCreationHistoryEntry { filename: string; content: string; diff --git a/templates/design/app/pages/design-editor/selection-history.test.ts b/templates/design/app/pages/design-editor/selection-history.test.ts new file mode 100644 index 0000000000..4535aa1645 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-history.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { GeometryHistorySelection } from "./history"; +import { + SELECTION_HISTORY_COALESCE_WINDOW_MS, + selectionSnapshotsEqual, + shouldRecordSelectionHistory, + type SelectionHistoryEntry, +} from "./history"; + +function sel( + overview: string[], + layers: string[], + activeFileId: string | null, +): GeometryHistorySelection { + return { + overviewSelectedScreenIds: overview, + selectedLayerIds: layers, + activeFileId, + }; +} + +const A = sel(["s1"], [], "s1"); +const B = sel(["s2"], [], "s2"); + +describe("selectionSnapshotsEqual", () => { + it("is true for structurally identical snapshots", () => { + expect( + selectionSnapshotsEqual( + sel(["s1"], ["l1"], "s1"), + sel(["s1"], ["l1"], "s1"), + ), + ).toBe(true); + }); + it("is order-sensitive on ids", () => { + expect( + selectionSnapshotsEqual( + sel(["a", "b"], [], "s1"), + sel(["b", "a"], [], "s1"), + ), + ).toBe(false); + }); + it("distinguishes activeFileId", () => { + expect(selectionSnapshotsEqual(sel([], [], "s1"), sel([], [], "s2"))).toBe( + false, + ); + }); + it("distinguishes layer ids", () => { + expect( + selectionSnapshotsEqual(sel([], ["l1"], "s1"), sel([], ["l2"], "s1")), + ).toBe(false); + }); +}); + +describe("shouldRecordSelectionHistory", () => { + it("skips while a pointer gesture is active (drag temp-selection)", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: B, + lastEntry: null, + now: 1000, + gestureActive: true, + }), + ).toBe("skip"); + }); + + it("skips a no-op selection change", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: sel(["s1"], [], "s1"), + lastEntry: null, + now: 1000, + gestureActive: false, + }), + ).toBe("skip"); + }); + + it("records a fresh entry when there is no prior entry", () => { + expect( + shouldRecordSelectionHistory({ + prev: A, + next: B, + lastEntry: null, + now: 1000, + gestureActive: false, + }), + ).toBe("record"); + }); + + it("coalesces a change within the 800ms window of the last entry", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s3"], [], "s3"), + lastEntry, + now: 1000 + SELECTION_HISTORY_COALESCE_WINDOW_MS - 1, + gestureActive: false, + }), + ).toBe("coalesce"); + }); + + it("records a fresh entry once the coalesce window has elapsed", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s3"], [], "s3"), + lastEntry, + now: 1000 + SELECTION_HISTORY_COALESCE_WINDOW_MS + 1, + gestureActive: false, + }), + ).toBe("record"); + }); + + it("still skips a no-op even inside the coalesce window", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 1000 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: sel(["s2"], [], "s2"), + lastEntry, + now: 1100, + gestureActive: false, + }), + ).toBe("skip"); + }); + + it("honors a custom coalesce window", () => { + const lastEntry: SelectionHistoryEntry = { before: A, after: B, at: 0 }; + expect( + shouldRecordSelectionHistory({ + prev: B, + next: A, + lastEntry, + now: 50, + gestureActive: false, + coalesceWindowMs: 100, + }), + ).toBe("coalesce"); + expect( + shouldRecordSelectionHistory({ + prev: B, + next: A, + lastEntry, + now: 150, + gestureActive: false, + coalesceWindowMs: 100, + }), + ).toBe("record"); + }); +}); diff --git a/templates/design/app/pages/design-editor/selection-topology.test.ts b/templates/design/app/pages/design-editor/selection-topology.test.ts new file mode 100644 index 0000000000..85246cb137 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-topology.test.ts @@ -0,0 +1,297 @@ +import type { CodeLayerNode, LayoutContext } from "@shared/code-layer"; +import { describe, expect, it } from "vitest"; + +import { + nearestMeaningfulParentId, + partitionSelectionForAlignment, +} from "./selection-topology"; + +interface NodeOpts { + parentId?: string; + children?: string[]; + isFlexContainer?: boolean; + isGridContainer?: boolean; +} + +function makeNode(id: string, opts: NodeOpts = {}): CodeLayerNode { + const layout: LayoutContext = { + parentId: opts.parentId, + siblingIndex: 0, + nthOfType: 0, + isFlexContainer: opts.isFlexContainer ?? false, + isGridContainer: opts.isGridContainer ?? false, + }; + return { + id, + tag: "div", + layerName: id, + layerNameSource: "tag", + selector: `#${id}`, + selectors: [`#${id}`], + path: id, + attributes: {}, + dataAttributes: {}, + classes: [], + textSnippet: null, + style: {}, + styleTokens: [], + parentId: opts.parentId, + children: opts.children ?? [], + layout, + capabilities: [], + confidence: 1, + source: null, + }; +} + +function mapOf(nodes: CodeLayerNode[]): Map { + return new Map(nodes.map((node) => [node.id, node])); +} + +/** + * Golden fixture: a root container holding three cards, each card holding a + * title + body. Used across the sibling / titles / mixed-ancestry cases. + */ +function nestedCardsFixture(): CodeLayerNode[] { + return [ + makeNode("root", { children: ["cardA", "cardB", "cardC"] }), + makeNode("cardA", { parentId: "root", children: ["titleA", "bodyA"] }), + makeNode("titleA", { parentId: "cardA" }), + makeNode("bodyA", { parentId: "cardA" }), + makeNode("cardB", { parentId: "root", children: ["titleB", "bodyB"] }), + makeNode("titleB", { parentId: "cardB" }), + makeNode("bodyB", { parentId: "cardB" }), + makeNode("cardC", { parentId: "root", children: ["titleC", "bodyC"] }), + makeNode("titleC", { parentId: "cardC" }), + makeNode("bodyC", { parentId: "cardC" }), + ]; +} + +describe("nearestMeaningfulParentId", () => { + it("returns null when the node has no parent", () => { + const nodes = mapOf([makeNode("root", { children: [] })]); + expect(nearestMeaningfulParentId(nodes, "root")).toBeNull(); + }); + + it("returns the raw parent when it is multi-child", () => { + const nodes = mapOf(nestedCardsFixture()); + expect(nearestMeaningfulParentId(nodes, "titleA")).toBe("cardA"); + }); + + it("keeps a flex container parent (not collapsed even with one child)", () => { + const nodes = mapOf([ + makeNode("root", { children: ["flex"] }), + makeNode("flex", { + parentId: "root", + children: ["only"], + isFlexContainer: true, + }), + makeNode("only", { parentId: "flex" }), + ]); + expect(nearestMeaningfulParentId(nodes, "only")).toBe("flex"); + }); + + it("keeps a grid container parent", () => { + const nodes = mapOf([ + makeNode("root", { children: ["grid"] }), + makeNode("grid", { + parentId: "root", + children: ["only"], + isGridContainer: true, + }), + makeNode("only", { parentId: "grid" }), + ]); + expect(nearestMeaningfulParentId(nodes, "only")).toBe("grid"); + }); + + it("collapses single-child pass-through wrappers up to the real parent", () => { + const nodes = mapOf([ + makeNode("real", { children: ["wrapper1", "wrapper2"] }), + makeNode("wrapper1", { parentId: "real", children: ["node1"] }), + makeNode("node1", { parentId: "wrapper1" }), + makeNode("wrapper2", { parentId: "real", children: ["node2"] }), + makeNode("node2", { parentId: "wrapper2" }), + ]); + expect(nearestMeaningfulParentId(nodes, "node1")).toBe("real"); + expect(nearestMeaningfulParentId(nodes, "node2")).toBe("real"); + }); + + it("collapses a chain of stacked pass-through wrappers", () => { + const nodes = mapOf([ + makeNode("real", { children: ["w1", "sibling"] }), + makeNode("sibling", { parentId: "real" }), + makeNode("w1", { parentId: "real", children: ["w2"] }), + makeNode("w2", { parentId: "w1", children: ["leaf"] }), + makeNode("leaf", { parentId: "w2" }), + ]); + expect(nearestMeaningfulParentId(nodes, "leaf")).toBe("real"); + }); + + it("returns null when pass-through wrappers climb off the top", () => { + const nodes = mapOf([ + makeNode("wrapper", { children: ["leaf"] }), + makeNode("leaf", { parentId: "wrapper" }), + ]); + expect(nearestMeaningfulParentId(nodes, "leaf")).toBeNull(); + }); + + it("returns null for an unknown node id", () => { + const nodes = mapOf(nestedCardsFixture()); + expect(nearestMeaningfulParentId(nodes, "ghost")).toBeNull(); + }); + + it("treats an unresolvable parent id as an opaque boundary", () => { + const nodes = mapOf([makeNode("child", { parentId: "detached" })]); + expect(nearestMeaningfulParentId(nodes, "child")).toBe("detached"); + }); +}); + +describe("partitionSelectionForAlignment — sibling cards", () => { + it("groups three sibling cards under their shared parent", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "cardB", + "cardC", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB", "cardC"]); + }); +}); + +describe("partitionSelectionForAlignment — titles in different cards", () => { + it("splits three titles into three single-member groups by parent", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "titleA", + "titleB", + "titleC", + ]); + expect(groups).toHaveLength(3); + expect(groups.map((g) => g.parentId)).toEqual(["cardA", "cardB", "cardC"]); + expect(groups.map((g) => g.nodeIds)).toEqual([ + ["titleA"], + ["titleB"], + ["titleC"], + ]); + }); +}); + +describe("partitionSelectionForAlignment — mixed ancestry drops descendant", () => { + it("drops a child whose ancestor is also selected", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "titleA", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA"]); + }); + + it("drops multiple deep descendants of one selected ancestor", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "titleA", + "bodyA", + "cardB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB"]); + }); +}); + +describe("partitionSelectionForAlignment — pass-through wrapper collapse", () => { + it("groups nodes under distinct wrappers by their shared real parent", () => { + const nodes = [ + makeNode("real", { children: ["wrapper1", "wrapper2"] }), + makeNode("wrapper1", { parentId: "real", children: ["node1"] }), + makeNode("node1", { parentId: "wrapper1" }), + makeNode("wrapper2", { parentId: "real", children: ["node2"] }), + makeNode("node2", { parentId: "wrapper2" }), + ]; + const groups = partitionSelectionForAlignment(nodes, ["node1", "node2"]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("real"); + expect(groups[0]!.nodeIds).toEqual(["node1", "node2"]); + }); +}); + +describe("partitionSelectionForAlignment — flex container parent", () => { + it("groups flow children by their flex parent id (not collapsed)", () => { + const nodes = [ + makeNode("root", { children: ["flex"] }), + makeNode("flex", { + parentId: "root", + children: ["fc1", "fc2"], + isFlexContainer: true, + }), + makeNode("fc1", { parentId: "flex" }), + makeNode("fc2", { parentId: "flex" }), + ]; + const groups = partitionSelectionForAlignment(nodes, ["fc1", "fc2"]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("flex"); + expect(groups[0]!.nodeIds).toEqual(["fc1", "fc2"]); + }); +}); + +describe("partitionSelectionForAlignment — top-level nodes", () => { + it("groups top-level nodes under the null parent key", () => { + const nodes = [ + makeNode("screenA", { children: [] }), + makeNode("screenB", { children: [] }), + ]; + const groups = partitionSelectionForAlignment(nodes, [ + "screenA", + "screenB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBeNull(); + expect(groups[0]!.nodeIds).toEqual(["screenA", "screenB"]); + }); +}); + +describe("partitionSelectionForAlignment — golden mixed scene", () => { + it("keeps first-appearance ordering across parents and within a group", () => { + // titleC appears first (key cardC), then two siblings that both key to root + // preserving their input order. + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "titleC", + "cardA", + "cardB", + ]); + expect(groups.map((g) => g.parentId)).toEqual(["cardC", "root"]); + expect(groups.map((g) => g.nodeIds)).toEqual([ + ["titleC"], + ["cardA", "cardB"], + ]); + }); +}); + +describe("partitionSelectionForAlignment — degenerate inputs", () => { + it("ignores unknown selected ids", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "ghost", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.parentId).toBe("root"); + expect(groups[0]!.nodeIds).toEqual(["cardA"]); + }); + + it("returns an empty array for an empty selection", () => { + expect(partitionSelectionForAlignment(nestedCardsFixture(), [])).toEqual( + [], + ); + }); + + it("de-duplicates repeated selected ids", () => { + const groups = partitionSelectionForAlignment(nestedCardsFixture(), [ + "cardA", + "cardA", + "cardB", + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.nodeIds).toEqual(["cardA", "cardB"]); + }); +}); diff --git a/templates/design/app/pages/design-editor/selection-topology.ts b/templates/design/app/pages/design-editor/selection-topology.ts new file mode 100644 index 0000000000..069ca485e2 --- /dev/null +++ b/templates/design/app/pages/design-editor/selection-topology.ts @@ -0,0 +1,76 @@ +import type { CodeLayerNode } from "@shared/code-layer"; + +export interface AlignmentGroup { + parentId: string | null; + nodeIds: string[]; +} + +/** Walk up from `nodeId` past single-child, no-layout wrapper parents and + * return the nearest meaningful parent id, or null if there is none. */ +export function nearestMeaningfulParentId( + nodesById: Map, + nodeId: string, +): string | null { + const node = nodesById.get(nodeId); + if (!node) return null; + + let currentId: string | undefined = node.parentId; + while (currentId !== undefined) { + const parent = nodesById.get(currentId); + // An unresolvable parent is an opaque boundary: treat its id as the + // nearest meaningful parent. + if (!parent) return currentId; + + const isPassThroughWrapper = + parent.children.length === 1 && + !parent.layout.isFlexContainer && + !parent.layout.isGridContainer; + if (!isPassThroughWrapper) return currentId; + + currentId = parent.parentId; + } + return null; +} + +/** Partition a multi-selection into the smallest valid alignment groups. */ +export function partitionSelectionForAlignment( + nodes: CodeLayerNode[], + selectedIds: string[], +): AlignmentGroup[] { + const nodesById = new Map( + nodes.map((node) => [node.id, node]), + ); + + const selectedExisting: string[] = []; + const selectedSet = new Set(); + for (const id of selectedIds) { + if (!nodesById.has(id) || selectedSet.has(id)) continue; + selectedSet.add(id); + selectedExisting.push(id); + } + + const hasSelectedAncestor = (id: string): boolean => { + let currentId = nodesById.get(id)?.parentId; + while (currentId !== undefined) { + if (selectedSet.has(currentId)) return true; + currentId = nodesById.get(currentId)?.parentId; + } + return false; + }; + + const groups = new Map(); + for (const id of selectedExisting) { + if (hasSelectedAncestor(id)) continue; + const key = nearestMeaningfulParentId(nodesById, id); + const members = groups.get(key); + if (members) members.push(id); + else groups.set(key, [id]); + } + + const result: AlignmentGroup[] = []; + for (const [parentId, nodeIds] of groups) { + if (nodeIds.length === 0) continue; + result.push({ parentId, nodeIds }); + } + return result; +}