diff --git a/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx b/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx index f4069d760a..68e0207599 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.gestures.test.tsx @@ -385,6 +385,167 @@ describe("MultiScreenCanvas gesture cancellation and drag thresholds", () => { }); }); + it("leaves Cmd+D for the layer hotkey when no frame is selected", async () => { + const onDuplicate = vi.fn(); + const rendered = (selectedScreenIds: string[]) => ( + ", + }, + ]} + zoom={100} + activeTool="move" + selectedScreenIds={selectedScreenIds} + geometryById={{ "screen-a": { x: 0, y: 0, width: 320, height: 640 } }} + onDuplicate={onDuplicate} + onPick={() => {}} + /> + ); + const pressDuplicate = () => { + const event = new KeyboardEvent("keydown", { + key: "d", + metaKey: true, + bubbles: true, + cancelable: true, + }); + window.dispatchEvent(event); + return event; + }; + + await act(async () => { + root.render(rendered([])); + }); + let event = await act(async () => pressDuplicate()); + // The shared hotkey hook drops anything already defaultPrevented, so + // claiming the event here would silently swallow layer duplication. + expect(event.defaultPrevented).toBe(false); + expect(onDuplicate).not.toHaveBeenCalled(); + + await act(async () => { + root.render(rendered(["screen-a"])); + }); + event = await act(async () => pressDuplicate()); + expect(event.defaultPrevented).toBe(true); + expect(onDuplicate).toHaveBeenCalledTimes(1); + expect(onDuplicate.mock.calls[0]![0]).toBe("screen-a"); + }); + + it("copies a selected frame on alt-drag from its selection outline instead of moving it", async () => { + const onDuplicate = vi.fn(); + await act(async () => { + root.render( + ", + }, + ]} + zoom={100} + activeTool="move" + activeId="screen-a" + selectedScreenIds={["screen-a"]} + geometryById={{ + "screen-a": { x: 0, y: 0, width: 320, height: 640 }, + }} + onDuplicate={onDuplicate} + onPick={() => {}} + />, + ); + }); + const frame = container.querySelector( + '[data-frame-id="screen-a"]', + ); + const dragSurface = container.querySelector( + "[data-frame-drag-surface]", + ); + expect(frame).not.toBeNull(); + expect(dragSurface).not.toBeNull(); + const before = { left: frame!.style.left, top: frame!.style.top }; + + await act(async () => { + dispatchMouseAlt(dragSurface!, "mousedown", 320, 400); + dispatchMouseAlt(window, "mousemove", 400, 460); + await nextAnimationFrame(); + dispatchMouseAlt(window, "mouseup", 400, 460); + }); + + expect(frame!.style.left).toBe(before.left); + expect(frame!.style.top).toBe(before.top); + expect(onDuplicate).toHaveBeenCalledTimes(1); + const [duplicatedId, request] = onDuplicate.mock.calls[0]!; + expect(duplicatedId).toBe("screen-a"); + expect(request.mode).toBe("alt-drag"); + expect(request.canvasPosition.x).toBeGreaterThan(0); + expect(request.canvasPosition.y).toBeGreaterThan(0); + }); + + it("copies every frame in a multi-selection on alt-drag, keeping their relative layout", async () => { + const onDuplicate = vi.fn(); + await act(async () => { + root.render( + ", + }, + { + id: "screen-b", + filename: "screen-b.html", + content: "", + }, + ]} + zoom={100} + activeTool="move" + selectedScreenIds={["screen-a", "screen-b"]} + geometryById={{ + "screen-a": { x: 0, y: 0, width: 320, height: 640 }, + "screen-b": { x: 420, y: 100, width: 320, height: 640 }, + }} + onDuplicate={onDuplicate} + onPick={() => {}} + />, + ); + }); + const frameA = container.querySelector( + '[data-frame-id="screen-a"]', + ); + const dragSurface = container.querySelector( + "[data-frame-drag-surface]", + ); + expect(frameA).not.toBeNull(); + expect(dragSurface).not.toBeNull(); + const beforeA = { left: frameA!.style.left, top: frameA!.style.top }; + + await act(async () => { + dispatchMouseAlt(dragSurface!, "mousedown", 450, 450); + dispatchMouseAlt(window, "mousemove", 490, 480); + await nextAnimationFrame(); + dispatchMouseAlt(window, "mouseup", 490, 480); + }); + + expect(frameA!.style.left).toBe(beforeA.left); + expect(frameA!.style.top).toBe(beforeA.top); + expect(onDuplicate).toHaveBeenCalledTimes(2); + const placed = new Map( + onDuplicate.mock.calls.map(([duplicatedId, request]) => [ + duplicatedId, + request.canvasPosition, + ]), + ); + const placedA = placed.get("screen-a")!; + const placedB = placed.get("screen-b")!; + expect(placedA.x).toBeGreaterThan(0); + expect(placedB.x - placedA.x).toBeCloseTo(420); + expect(placedB.y - placedA.y).toBeCloseTo(100); + }); + it("keeps pending review discoverable in constant-size frame chrome", async () => { const onReviewPendingScreen = vi.fn(); await act(async () => { diff --git a/templates/design/app/components/design/MultiScreenCanvas.tsx b/templates/design/app/components/design/MultiScreenCanvas.tsx index 36d471b522..35d9138e92 100644 --- a/templates/design/app/components/design/MultiScreenCanvas.tsx +++ b/templates/design/app/components/design/MultiScreenCanvas.tsx @@ -5074,10 +5074,210 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ [updateSelectedDraftIds, updateSelectedIds], ); + const beginDuplicateGesture = useCallback( + (id: string, e: React.MouseEvent) => { + const entries = getCurrentFrameEntries(); + const selection = selectedIdsRef.current; + const targets = (selection.includes(id) ? selection : [id]) + .filter((targetId) => !lockedScreenIdSet.has(targetId)) + .flatMap((targetId) => { + const target = screensRef.current.find((s) => s.id === targetId); + const geometry = entries.find( + (entry) => entry.id === targetId, + )?.geometry; + return target && geometry ? [{ screen: target, geometry }] : []; + }); + const source = targets.find((target) => target.screen.id === id); + if (!source) return; + e.preventDefault(); + e.stopPropagation(); + duplicateCleanup.current?.(); + + const display = screenDisplayName( + source.screen, + getResolvedMetadata(source.screen), + ); + const surfaceRect = surfaceRef.current?.getBoundingClientRect(); + const origin = { x: e.clientX, y: e.clientY }; + const originCanvas = canvasPointFromClient(e.clientX, e.clientY); + const previewPoint = { + x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX, + y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY, + }; + + const previewWidth = source.geometry.width; + const previewHeight = source.geometry.height; + + setDuplicatePreview({ + display, + count: targets.length, + x: previewPoint.x, + y: previewPoint.y, + width: previewWidth, + height: previewHeight, + canDuplicate: !!onDuplicate, + moved: false, + }); + // Mount the interaction shield and mute preview-iframe pointer events for + // the duration of the gesture, same as every other drag — otherwise the + // pointer freezes crossing a live embedded iframe and a release over a + // screen never reaches handleMouseUp. + setIsDragging(true); + // Figma parity: show a copy-affordance cursor while the alt-drag + // duplicate gesture is armed. Tracked live in handleMouseMove below + // (same live e.altKey tracking that already drives canDuplicate), so + // releasing alt mid-drag falls back to the default arrow instead of + // staying on the copy cursor for a gesture that will no longer + // duplicate on mouseup. + setDragCursor(e.altKey ? "copy" : null); + + // PERF: track the last committed canDuplicate/moved/cursor values in + // plain closure locals (not state) so the mousemove tick below can tell + // whether anything conditional actually changed without reading back + // through React state (which the imperative writes below intentionally + // stop keeping fresh every tick — see duplicatePreviewElRef). + let lastCanDuplicate = !!onDuplicate; + let lastMoved = false; + let lastCursor: string | null = e.altKey ? "copy" : null; + + const handleMouseMove = (ev: MouseEvent) => { + const dx = ev.clientX - origin.x; + const dy = ev.clientY - origin.y; + const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD; + const rect = surfaceRef.current?.getBoundingClientRect(); + const x = rect ? ev.clientX - rect.left + 16 : ev.clientX; + const y = rect ? ev.clientY - rect.top + 16 : ev.clientY; + // Live alt state, not just capability: if the user releases alt + // mid-drag the preview should visibly fall back to its "not armed" + // dashed/preview styling, matching that mouseup will then cancel + // the duplicate instead of creating one (see handleMouseUp below). + const canDuplicate = !!onDuplicate && ev.altKey; + + // PERF9: the ghost's position is a direct DOM write every tick + // instead of a setDuplicatePreview (full re-render) — same + // "imperative now, commit state only when something conditional + // changes" discipline as the frame/draft drag paths above. Falls + // back to setDuplicatePreview itself if the node hasn't mounted yet + // (e.g. the very first tick right after mousedown, before React has + // committed the initial preview). + const el = duplicatePreviewElRef.current; + if (el) { + el.style.left = `${x}px`; + el.style.top = `${y}px`; + } + + if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) { + lastCanDuplicate = canDuplicate; + lastMoved = moved; + setDuplicatePreview({ + display, + count: targets.length, + x, + y, + width: previewWidth, + height: previewHeight, + canDuplicate, + moved, + }); + } + + const cursor = ev.altKey ? "copy" : null; + if (cursor !== lastCursor) { + lastCursor = cursor; + setDragCursor(cursor); + } + }; + + const cleanupDuplicateGesture = () => { + setDuplicatePreview(null); + duplicateCleanup.current = null; + // finishDrag clears isDragging, unmounts the shield, and — critically — + // runs dragCleanup.current() to detach the window listeners installed + // by installDragListeners and restore preview-iframe pointer events. + // dragState.current was never set for this gesture, so finishDrag's + // other resets (marquee/creation-preview/etc.) are no-ops here. + finishDrag(); + }; + + const handleMouseUp = (ev: MouseEvent) => { + const moved = + Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >= + DUPLICATE_DRAG_THRESHOLD; + // Alt is read live, not from mousedown: the gesture only ever showed a + // ghost, so releasing alt must cancel outright, never fall back to a + // move the original frame never started. + const shouldDuplicate = moved && ev.altKey; + + if (onDuplicate && shouldDuplicate) { + const dropCanvasPosition = canvasPointFromClient( + ev.clientX, + ev.clientY, + ); + const delta = { + x: dropCanvasPosition.x - originCanvas.x, + y: dropCanvasPosition.y - originCanvas.y, + }; + // The user placed these clones deliberately — suppress the + // lineup-recenter camera move when the created screens land in + // `screens` (Figma keeps the camera still on alt-drag duplicate). + lineupRecenterSuppressRef.current = { + atMs: Date.now(), + fromCount: screensRef.current.length, + addedCount: targets.length, + }; + targets.forEach((target) => { + const canvasPosition = { + x: target.geometry.x + delta.x, + y: target.geometry.y + delta.y, + }; + onDuplicate(target.screen.id, { + mode: "alt-drag", + screen: target.screen, + canvasPosition, + canvasOffset: { + x: dropCanvasPosition.x - canvasPosition.x, + y: dropCanvasPosition.y - canvasPosition.y, + }, + dropCanvasPosition, + }); + }); + } else if (!moved) { + onPick(id); + } + + cleanupDuplicateGesture(); + }; + + duplicateCleanup.current = cleanupDuplicateGesture; + installDragListeners( + handleMouseMove, + handleMouseUp, + cleanupDuplicateGesture, + ); + }, + [ + canvasPointFromClient, + finishDrag, + getCurrentFrameEntries, + getResolvedMetadata, + installDragListeners, + lockedScreenIdSet, + onDuplicate, + onPick, + ], + ); + const beginFrameDrag = useCallback( (id: string, e: React.MouseEvent) => { if (readOnly) return; - if (e.button !== 0 || e.shiftKey || lockedScreenIdSet.has(id)) return; + if (e.button !== 0 || lockedScreenIdSet.has(id)) return; + // Frame body, label row, and the selection box over a selected frame all + // start drags here, so alt's move-vs-copy meaning is decided exactly once. + if (e.altKey) { + beginDuplicateGesture(id, e); + return; + } + if (e.shiftKey) return; e.preventDefault(); e.stopPropagation(); // Frame mousedowns stop propagation, so they never reach handleMouseDown. @@ -5429,6 +5629,7 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ }, [ activeId, + beginDuplicateGesture, findPrimitiveDropTarget, finishDrag, getCanvasPoint, @@ -6284,191 +6485,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ], ); - const beginDuplicateGesture = useCallback( - (screen: ScreenFile, display: string, e: React.MouseEvent) => { - if (readOnly) return; - if (e.button !== 0 || !e.altKey || lockedScreenIdSet.has(screen.id)) { - return; - } - e.preventDefault(); - e.stopPropagation(); - duplicateCleanup.current?.(); - - const surfaceRect = surfaceRef.current?.getBoundingClientRect(); - const origin = { x: e.clientX, y: e.clientY }; - const originCanvas = canvasPointFromClient(e.clientX, e.clientY); - const sourceFrame = getCurrentFrameEntries().find( - (entry) => entry.id === screen.id, - ); - const pointerOffset = sourceFrame - ? { - x: originCanvas.x - sourceFrame.geometry.x, - y: originCanvas.y - sourceFrame.geometry.y, - } - : { x: 0, y: 0 }; - const previewPoint = { - x: surfaceRect ? e.clientX - surfaceRect.left + 16 : e.clientX, - y: surfaceRect ? e.clientY - surfaceRect.top + 16 : e.clientY, - }; - - const previewWidth = sourceFrame?.geometry.width ?? SCREEN_WIDTH; - const previewHeight = sourceFrame?.geometry.height ?? SCREEN_HEIGHT; - - setDuplicatePreview({ - display, - x: previewPoint.x, - y: previewPoint.y, - width: previewWidth, - height: previewHeight, - canDuplicate: !!onDuplicate, - moved: false, - }); - // Mount the interaction shield and mute preview-iframe pointer events for - // the duration of the gesture, same as every other drag — otherwise the - // pointer freezes crossing a live embedded iframe and a release over a - // screen never reaches handleMouseUp. - setIsDragging(true); - // Figma parity: show a copy-affordance cursor while the alt-drag - // duplicate gesture is armed. Tracked live in handleMouseMove below - // (same live e.altKey tracking that already drives canDuplicate), so - // releasing alt mid-drag falls back to the default arrow instead of - // staying on the copy cursor for a gesture that will no longer - // duplicate on mouseup. - setDragCursor(e.altKey ? "copy" : null); - - // PERF: track the last committed canDuplicate/moved/cursor values in - // plain closure locals (not state) so the mousemove tick below can tell - // whether anything conditional actually changed without reading back - // through React state (which the imperative writes below intentionally - // stop keeping fresh every tick — see duplicatePreviewElRef). - let lastCanDuplicate = !!onDuplicate; - let lastMoved = false; - let lastCursor: string | null = e.altKey ? "copy" : null; - - const handleMouseMove = (ev: MouseEvent) => { - const dx = ev.clientX - origin.x; - const dy = ev.clientY - origin.y; - const moved = Math.hypot(dx, dy) >= DUPLICATE_DRAG_THRESHOLD; - const rect = surfaceRef.current?.getBoundingClientRect(); - const x = rect ? ev.clientX - rect.left + 16 : ev.clientX; - const y = rect ? ev.clientY - rect.top + 16 : ev.clientY; - // Live alt state, not just capability: if the user releases alt - // mid-drag the preview should visibly fall back to its "not armed" - // dashed/preview styling, matching that mouseup will then cancel - // the duplicate instead of creating one (see handleMouseUp below). - const canDuplicate = !!onDuplicate && ev.altKey; - - // PERF9: the ghost's position is a direct DOM write every tick - // instead of a setDuplicatePreview (full re-render) — same - // "imperative now, commit state only when something conditional - // changes" discipline as the frame/draft drag paths above. Falls - // back to setDuplicatePreview itself if the node hasn't mounted yet - // (e.g. the very first tick right after mousedown, before React has - // committed the initial preview). - const el = duplicatePreviewElRef.current; - if (el) { - el.style.left = `${x}px`; - el.style.top = `${y}px`; - } - - if (!el || canDuplicate !== lastCanDuplicate || moved !== lastMoved) { - lastCanDuplicate = canDuplicate; - lastMoved = moved; - setDuplicatePreview({ - display, - x, - y, - width: previewWidth, - height: previewHeight, - canDuplicate, - moved, - }); - } - - const cursor = ev.altKey ? "copy" : null; - if (cursor !== lastCursor) { - lastCursor = cursor; - setDragCursor(cursor); - } - }; - - const cleanupDuplicateGesture = () => { - setDuplicatePreview(null); - duplicateCleanup.current = null; - // finishDrag clears isDragging, unmounts the shield, and — critically — - // runs dragCleanup.current() to detach the window listeners installed - // by installDragListeners and restore preview-iframe pointer events. - // dragState.current was never set for this gesture, so finishDrag's - // other resets (marquee/creation-preview/etc.) are no-ops here. - finishDrag(); - }; - - const handleMouseUp = (ev: MouseEvent) => { - const moved = - Math.hypot(ev.clientX - origin.x, ev.clientY - origin.y) >= - DUPLICATE_DRAG_THRESHOLD; - const mode = moved ? "alt-drag" : "alt-click"; - // Figma semantics: a plain alt-click (no drag) never duplicates — - // only an actual alt-drag does. And alt is evaluated live: releasing - // it before mouseup cancels the pending duplicate rather than - // creating one anyway (this gesture never moves the original frame - // during the drag — it only shows a floating ghost preview — so - // "cancel" here means no-op, not handing off to a live move). - const shouldDuplicate = moved && ev.altKey; - - if (onDuplicate && shouldDuplicate) { - const dropCanvasPosition = canvasPointFromClient( - ev.clientX, - ev.clientY, - ); - // shouldDuplicate implies moved, so the drop position is always - // relative to the pointer's offset into the source frame (the - // "snap next to source" placement only applied to the old - // zero-move alt-click case, which no longer duplicates at all). - const canvasPosition = { - x: dropCanvasPosition.x - pointerOffset.x, - y: dropCanvasPosition.y - pointerOffset.y, - }; - // The user placed this clone deliberately — suppress the - // lineup-recenter camera move when the created screen lands in - // `screens` (Figma keeps the camera still on alt-drag duplicate). - lineupRecenterSuppressRef.current = { - atMs: Date.now(), - fromCount: screensRef.current.length, - addedCount: 1, - }; - onDuplicate(screen.id, { - mode, - screen, - canvasPosition, - canvasOffset: pointerOffset, - dropCanvasPosition, - }); - } else if (!moved) { - onPick(screen.id); - } - - cleanupDuplicateGesture(); - }; - - duplicateCleanup.current = cleanupDuplicateGesture; - installDragListeners( - handleMouseMove, - handleMouseUp, - cleanupDuplicateGesture, - ); - }, - [ - canvasPointFromClient, - finishDrag, - getCurrentFrameEntries, - installDragListeners, - lockedScreenIdSet, - onDuplicate, - onPick, - ], - ); - const handleMouseDown = useCallback( (e: React.MouseEvent) => { claimKeyboardFocus(); @@ -7386,15 +7402,15 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ ) { return; } - // Always suppress the browser default (bookmark dialog) — but leave - // propagation intact until we know a frame is duplicable here, so the - // global hotkey hook can still duplicate non-frame layer selections. - event.preventDefault(); // Only act on frame IDs — filter out canvas primitives (sub-elements). const frameIds = selectedIdsRef.current.filter( (id) => frameGeometryRef.current[id], ); + // Claim the event only once a frame will really be duplicated — the + // global hotkey hook skips anything already defaultPrevented, and it + // suppresses the bookmark dialog itself when it takes over. if (frameIds.length === 0) return; + event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); // Duplicate every selected frame, not just the first — each duplicate @@ -8152,7 +8168,6 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ onStartFrameDrag={beginFrameDrag} onStartResize={beginResize} onStartRotate={beginRotate} - onStartDuplicateGesture={beginDuplicateGesture} // Pass the id-first callbacks straight through (PF18): Screen // itself binds screen.id when it calls these, so every screen // instance gets the exact same stable function reference here @@ -8574,7 +8589,9 @@ export const MultiScreenCanvas = memo(function MultiScreenCanvas({ >
- {duplicatePreview.display} + {duplicatePreview.count > 1 + ? `${duplicatePreview.display} +${duplicatePreview.count - 1}` + : duplicatePreview.display} @@ -9411,6 +9428,14 @@ function GradientEditOverlay({ ); } +/** The name shown in a frame's label row and in the alt-drag copy ghost. */ +function screenDisplayName( + screen: ScreenFile, + metadata: ResolvedScreenMetadata, +): string { + return metadata.title ?? prettyScreenName(screen.filename); +} + /** Standard Tailwind breakpoint widths, mobile-first (base / md: / lg: / xl:). */ const STANDARD_BREAKPOINT_WIDTHS = [390, 768, 1280] as const; @@ -9483,11 +9508,6 @@ interface ScreenProps { e: React.MouseEvent, ) => void; onStartRotate: (id: string, e: React.MouseEvent) => void; - onStartDuplicateGesture: ( - screen: ScreenFile, - display: string, - e: React.MouseEvent, - ) => void; // Id-first (screenId, widthPx) shape, same as MultiScreenCanvas's own // onActiveBreakpointChange prop (PF18): Screen binds screen.id itself when // calling these, so the parent can pass the same stable function reference @@ -9541,7 +9561,6 @@ const Screen = memo(function Screen({ onStartFrameDrag, onStartResize, onStartRotate, - onStartDuplicateGesture, screenContent, renderBreakpointContent, cullTier, @@ -9552,7 +9571,7 @@ const Screen = memo(function Screen({ onEditBreakpoint, }: ScreenProps) { const t = useT(); - const display = metadata.title ?? prettyScreenName(screen.filename); + const display = screenDisplayName(screen, metadata); const previewUrl = metadata.previewUrl ?? getPreviewUrl(screen.content); const previewViewport = getScreenPreviewViewport(metadata, geometry); const suppressNextClick = useRef(false); @@ -9684,15 +9703,11 @@ const Screen = memo(function Screen({ return; } if (e.altKey) { - // Matches the data-screen-card mousedown handler below: without - // this, a trailing click after the alt-drag/duplicate gesture - // ends falls through to this row's onClick and steals selection - // away from the newly created duplicate. + // Without this the trailing click after an alt-drag copy falls + // through to this row's onClick and steals selection back from the + // new duplicate. suppressNextClick.current = true; - onStartDuplicateGesture(screen, display, e); - return; - } - if (e.shiftKey) { + } else if (e.shiftKey) { e.stopPropagation(); return; } @@ -9838,13 +9853,10 @@ const Screen = memo(function Screen({ e.stopPropagation(); return; } - if (e.altKey && e.button === 0) { - suppressNextClick.current = true; - onStartDuplicateGesture(screen, display, e); - return; - } if (e.button === 0) { - if (e.shiftKey) { + if (e.altKey) { + suppressNextClick.current = true; + } else if (e.shiftKey) { e.stopPropagation(); return; } @@ -10105,7 +10117,6 @@ function areScreenPropsEqual(prev: ScreenProps, next: ScreenProps) { prev.onStartFrameDrag === next.onStartFrameDrag && prev.onStartResize === next.onStartResize && prev.onStartRotate === next.onStartRotate && - prev.onStartDuplicateGesture === next.onStartDuplicateGesture && // Now id-first (screenId, widthPx) callbacks passed straight through // from MultiScreenCanvas's own props (PF18) instead of a fresh // per-screen arrow allocated in the render loop, so these are expected diff --git a/templates/design/app/components/design/multi-screen/types.ts b/templates/design/app/components/design/multi-screen/types.ts index abb7b1169a..3bfd83b458 100644 --- a/templates/design/app/components/design/multi-screen/types.ts +++ b/templates/design/app/components/design/multi-screen/types.ts @@ -976,6 +976,9 @@ export interface ResolvedScreenMetadata { export interface DuplicatePreview { display: string; + /** How many frames the drop will copy — the whole frame selection when the + * alt-drag started on a selected frame, otherwise just the pressed one. */ + count: number; x: number; y: number; width: number; diff --git a/templates/design/app/pages/DesignEditor.tsx b/templates/design/app/pages/DesignEditor.tsx index 274ae5740a..7bd258f72e 100644 --- a/templates/design/app/pages/DesignEditor.tsx +++ b/templates/design/app/pages/DesignEditor.tsx @@ -4009,6 +4009,7 @@ function DesignEditor() { const updateFileMutation = useActionMutation("update-file"); const renameScreenMutation = useActionMutation("rename-screen"); const createFileMutation = useActionMutation("create-file"); + const createFileAsync = createFileMutation.mutateAsync; const deleteFileMutation = useActionMutation("delete-file"); const updateDesignMutation = useActionMutation("update-design"); const updateDesignAsync = updateDesignMutation.mutateAsync; @@ -6883,127 +6884,121 @@ function DesignEditor() { const createdGeometry: FrameGeometry = request?.canvasPosition ? { ...fallbackGeometry, - ...canvasFrameGeometryById[screenId], + ...liveFrameGeometryRef.current[screenId], x: request.canvasPosition.x, y: request.canvasPosition.y, } : fallbackGeometry; - createFileMutation.mutate( - { - designId: id, - filename, - content, - fileType, - } as any, - { - onSuccess: (result: any) => { - const nextId = typeof result?.id === "string" ? result.id : null; - // Refetch only when there is no created id to insert optimistically: - // a whole-design refetch re-downloads every screen's HTML, which is - // what made adding a frame feel slow. - if (!nextId) { - queryClient.invalidateQueries({ - queryKey: ["action", "get-design"], - }); - } else { - optimisticallyInsertCreatedFile({ - fileId: nextId, - filename, - fileType, - content, - result, - }); - // Use the same immediate optimistic geometry path for every - // duplicate entry point. This makes the frame, selection, and - // camera agree before the authoritative refetch completes. - writeFrameGeometrySnapshot({ - ...canvasFrameGeometryById, - [nextId]: createdGeometry, - }); - focusCreatedScreen(nextId, createdGeometry); - recordFileCreationHistoryEntry({ - filename, - content, - fileType, - geometry: createdGeometry, - }); - // Duplicating a localhost/fusion screen must keep it a live, - // editable URL-backed screen — not just a copied HTML snapshot. - // Carry the source screen's screenMetadata entry (sourceType, - // connectionId, url, path, bridgeUrl, etc.) and its - // localhostScreens entry (if present) over to the new file id, - // using path-addressed operations so a peer's metadata for any - // other screen cannot be replaced by this duplicate's snapshot. - const sourceMetadataById = getDesignDataRecord( + // Per-call mutate callbacks, not a promise, would silently strand every + // duplicate but the last: a second mutate() detaches the observer from + // the first mutation, so only the newest call's onSuccess ever runs. + void createFileAsync({ + designId: id, + filename, + content, + fileType, + } as any) + .then((result: any) => { + const nextId = typeof result?.id === "string" ? result.id : null; + // Refetch only when there is no created id to insert optimistically: + // a whole-design refetch re-downloads every screen's HTML, which is + // what made adding a frame feel slow. + if (!nextId) { + queryClient.invalidateQueries({ + queryKey: ["action", "get-design"], + }); + } else { + optimisticallyInsertCreatedFile({ + fileId: nextId, + filename, + fileType, + content, + result, + }); + // Optimistic geometry keeps frame, selection, and camera agreeing + // before the refetch. Base it on the map writeFrameGeometrySnapshot + // diffs against, or a sibling duplicate's placement is deleted. + writeFrameGeometrySnapshot({ + ...getCanvasFrameGeometry(designDataJsonRef.current), + [nextId]: createdGeometry, + }); + focusCreatedScreen(nextId, createdGeometry); + recordFileCreationHistoryEntry({ + filename, + content, + fileType, + geometry: createdGeometry, + }); + // A duplicated localhost/fusion screen stays URL-backed only if its + // metadata comes along, and the carry must be path-addressed or it + // replaces a peer's metadata for every other screen. + const sourceMetadataById = getDesignDataRecord( + designDataJsonRef.current, + "screenMetadata", + ); + const sourceMetadata = getDesignDataRecord( + sourceMetadataById, + screenId, + ); + const sourceType = sourceMetadata.sourceType; + if (sourceType === "localhost" || sourceType === "fusion") { + const dataOperations: DesignDataOperation[] = [ + { + op: "set", + path: ["screenMetadata", nextId], + value: { ...sourceMetadata }, + }, + ]; + const sourceLocalhostScreensById = getDesignDataRecord( designDataJsonRef.current, - "screenMetadata", + "localhostScreens", ); - const sourceMetadata = getDesignDataRecord( - sourceMetadataById, + const sourceLocalhostScreen = getDesignDataRecord( + sourceLocalhostScreensById, screenId, ); - const sourceType = sourceMetadata.sourceType; - if (sourceType === "localhost" || sourceType === "fusion") { - const dataOperations: DesignDataOperation[] = [ - { - op: "set", - path: ["screenMetadata", nextId], - value: { ...sourceMetadata }, - }, - ]; - const sourceLocalhostScreensById = getDesignDataRecord( - designDataJsonRef.current, - "localhostScreens", - ); - const sourceLocalhostScreen = getDesignDataRecord( - sourceLocalhostScreensById, - screenId, - ); - if (Object.keys(sourceLocalhostScreen).length > 0) { - dataOperations.push({ - op: "set", - path: ["localhostScreens", nextId], - value: { ...sourceLocalhostScreen }, - }); - } - const nextData = applyDesignDataOperations( - designDataJsonRef.current, - dataOperations, - ); - designDataJsonRef.current = nextData; - queryClient.setQueryData( - ["action", "get-design", { id }], - (old: any) => { - if (!old || typeof old !== "object") return old; - return { ...old, data: JSON.stringify(nextData) }; - }, - ); - updateDesignMutation.mutate({ id, dataOperations } as any, { - onError: () => { - queryClient.invalidateQueries({ - queryKey: ["action", "get-design"], - }); - }, + if (Object.keys(sourceLocalhostScreen).length > 0) { + dataOperations.push({ + op: "set", + path: ["localhostScreens", nextId], + value: { ...sourceLocalhostScreen }, }); } + const nextData = applyDesignDataOperations( + designDataJsonRef.current, + dataOperations, + ); + designDataJsonRef.current = nextData; + queryClient.setQueryData( + ["action", "get-design", { id }], + (old: any) => { + if (!old || typeof old !== "object") return old; + return { ...old, data: JSON.stringify(nextData) }; + }, + ); + void updateDesignAsync({ id, dataOperations } as any).catch( + () => { + queryClient.invalidateQueries({ + queryKey: ["action", "get-design"], + }); + }, + ); } - toast.success(t("designEditor.toasts.screenDuplicated")); - }, - onError: (error) => { - toast.error( - error instanceof Error - ? error.message - : t("designEditor.toasts.screenDuplicateError"), - ); - }, - }, - ); + } + toast.success(t("designEditor.toasts.screenDuplicated")); + }) + .catch((error: unknown) => { + toast.error( + error instanceof Error + ? error.message + : t("designEditor.toasts.screenDuplicateError"), + ); + }); }, [ canEditDesign, - canvasFrameGeometryById, - createFileMutation, + createFileAsync, files, focusCreatedScreen, recordFileCreationHistoryEntry, @@ -7012,7 +7007,7 @@ function DesignEditor() { overviewScreens, queryClient, t, - updateDesignMutation, + updateDesignAsync, writeFrameGeometrySnapshot, ], ); @@ -11487,7 +11482,6 @@ function DesignEditor() { screenId: string, nodeId: string, options?: { - selectFrame?: boolean; nextTool?: "move" | "pen"; preserveActiveTool?: boolean; }, @@ -11509,8 +11503,7 @@ function DesignEditor() { // for the board), but keep the previous active FILE and never put the // board id into the overview screen-frame selection. const isBoardTarget = Boolean(boardFileId && screenId === boardFileId); - pendingOverviewScreenSelectionRef.current = - isBoardTarget || options?.selectFrame === false ? null : screenId; + pendingOverviewScreenSelectionRef.current = null; pendingOverviewLayerSelectionRef.current = nodeId; clearPendingOverviewLayerSelectionTimer(); flushSync(() => { @@ -11521,9 +11514,9 @@ function DesignEditor() { setSelectedElement(null); setHoveredElement(null); setSelectedLayerIdsState([nodeId]); - setOverviewSelectedScreenIds( - isBoardTarget || options?.selectFrame === false ? [] : [screenId], - ); + // The new node is the selection; leaving its parent frame selected too + // hands Cmd+D and alt-drag to the screen-level duplicate instead. + setOverviewSelectedScreenIds([]); if (!options?.preserveActiveTool) { setActiveTool(options?.nextTool ?? "move"); } @@ -11631,7 +11624,7 @@ function DesignEditor() { if (!result) return false; const nodeId = typeof result === "string" ? result : primitive.nodeId; if (nodeId) { - handlePrimitiveCreated(boardFileId, nodeId, { selectFrame: false }); + handlePrimitiveCreated(boardFileId, nodeId); } return result; @@ -11659,7 +11652,6 @@ function DesignEditor() { if (!result) return; const resultNodeId = typeof result === "string" ? result : nodeId; handlePrimitiveCreated(activeFile.id, resultNodeId, { - selectFrame: false, nextTool: spec.tool === "pen" ? "pen" : undefined, preserveActiveTool: spec.preserveActiveTool, }); @@ -15070,7 +15062,13 @@ function DesignEditor() { }); return false; } - applyLocalContentUpdate(nextContent, { refreshPreview: false }); + // Structural insert: a selector-scoped push matches the live clone but + // not the re-keyed copy in the new source, and the bridge deletes what + // it cannot match. + applyLocalContentUpdate(nextContent, { + refreshPreview: false, + forcePreviewFullDocument: true, + }); const nextProjection = buildCodeLayerProjection(nextContent); const nextNode = elementInfo ? resolveCodeLayerNodeFromElementInfo(nextProjection, elementInfo) diff --git a/templates/design/changelog/2026-08-07-alt-dragging-an-element-to-copy-it-now-keeps-the-copy-visibl.md b/templates/design/changelog/2026-08-07-alt-dragging-an-element-to-copy-it-now-keeps-the-copy-visibl.md new file mode 100644 index 0000000000..a08159e2b8 --- /dev/null +++ b/templates/design/changelog/2026-08-07-alt-dragging-an-element-to-copy-it-now-keeps-the-copy-visibl.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-07 +--- + +Alt-dragging an element to copy it now keeps the copy visible on the canvas instead of only adding it to the layer list. diff --git a/templates/design/changelog/2026-08-07-drawing-a-shape-on-the-overview-canvas-now-keeps-the-shape-s.md b/templates/design/changelog/2026-08-07-drawing-a-shape-on-the-overview-canvas-now-keeps-the-shape-s.md new file mode 100644 index 0000000000..eb5500dd32 --- /dev/null +++ b/templates/design/changelog/2026-08-07-drawing-a-shape-on-the-overview-canvas-now-keeps-the-shape-s.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-07 +--- + +Drawing a shape on the overview canvas now keeps the shape selected instead of its screen, so duplicating copies the shape rather than the whole screen. diff --git a/templates/design/changelog/2026-08-07-hold-option-alt-while-dragging-a-frame-on-the-overview-canva.md b/templates/design/changelog/2026-08-07-hold-option-alt-while-dragging-a-frame-on-the-overview-canva.md new file mode 100644 index 0000000000..8b5c04ba0e --- /dev/null +++ b/templates/design/changelog/2026-08-07-hold-option-alt-while-dragging-a-frame-on-the-overview-canva.md @@ -0,0 +1,6 @@ +--- +type: improved +date: 2026-08-07 +--- + +Hold Option/Alt while dragging a frame on the overview canvas to drop a copy of it, including a whole multi-frame selection. diff --git a/templates/design/e2e/overview-alt-drag-copy.spec.ts b/templates/design/e2e/overview-alt-drag-copy.spec.ts new file mode 100644 index 0000000000..9bb459bc57 --- /dev/null +++ b/templates/design/e2e/overview-alt-drag-copy.spec.ts @@ -0,0 +1,184 @@ +import { + expect, + test, + type APIRequestContext, + type Locator, + type Page, +} from "@playwright/test"; + +import { appPath } from "./helpers"; + +const BASE_URL = + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? "9333"}`; +const SCREEN_HTML = ` + +
+

Hero

+
`; + +async function action( + request: APIRequestContext, + name: string, + input: Record, +) { + const response = await request.post( + `${BASE_URL}/_agent-native/actions/${name}`, + { data: input }, + ); + if (!response.ok()) { + throw new Error(`${name}: ${response.status()} ${await response.text()}`); + } + return response.json(); +} + +async function createDesign(request: APIRequestContext, fileCount: number) { + const created = await action(request, "create-design", { + title: `Alt-drag copy QA ${Date.now()}`, + projectType: "prototype", + }); + const designId = created.id ?? created.data?.id ?? created.design?.id; + if (!designId) throw new Error("create-design returned no id"); + const fileIds: string[] = []; + for (let index = 0; index < fileCount; index += 1) { + const file = await action(request, "create-file", { + designId, + filename: index === 0 ? "index.html" : `screen-${index + 1}.html`, + content: SCREEN_HTML, + fileType: "html", + }); + const fileId = file.id ?? file.data?.id; + if (!fileId) throw new Error("create-file returned no id"); + fileIds.push(fileId); + } + await action(request, "update-design", { + id: designId, + dataOperations: fileIds.flatMap((fileId, index) => [ + { + op: "set", + path: ["screenMetadata", fileId], + value: { sourceType: "inline", width: 1280, height: 900 }, + }, + { + op: "set", + path: ["canvasFrames", fileId], + value: { x: index * 1600, y: 0, width: 1280, height: 900, z: index }, + }, + ]), + }); + return { designId, fileIds }; +} + +async function openOverview(page: Page, designId: string, screens: number) { + await page.goto(appPath(`/design/${designId}?view=overview`), { + waitUntil: "domcontentloaded", + }); + await expect(page.locator("[data-screen-shell]")).toHaveCount(screens, { + timeout: 30_000, + }); + await expect(page.locator("[data-screen-card]").first()).toBeVisible(); + await page.waitForTimeout(1500); +} + +/** Frames are taller than the window, so the drag surface's own centre is + * routinely off-screen and a mouse gesture there lands on . */ +async function visibleCentre(page: Page, locator: Locator) { + const box = (await locator.boundingBox())!; + const view = page.viewportSize()!; + return { + x: (Math.max(box.x, 0) + Math.min(box.x + box.width, view.width)) / 2, + y: (Math.max(box.y, 0) + Math.min(box.y + box.height, view.height)) / 2, + }; +} + +async function altDrag(page: Page, from: Locator, dx: number, dy: number) { + const start = await visibleCentre(page, from); + await page.mouse.move(start.x, start.y); + await page.keyboard.down("Alt"); + await page.mouse.down(); + await page.mouse.move(start.x + dx, start.y + dy, { steps: 12 }); + await expect(page.locator("[data-duplicate-preview-ghost]")).toBeVisible(); + await page.mouse.up(); + await page.keyboard.up("Alt"); +} + +async function frameOffsets(page: Page) { + return page.evaluate(() => + Object.fromEntries( + Array.from(document.querySelectorAll("[data-frame-id]")).map( + (node) => [ + node.getAttribute("data-frame-id")!, + { + left: Number.parseFloat(node.style.left), + top: Number.parseFloat(node.style.top), + }, + ], + ), + ), + ); +} + +test("alt-dragging a selected frame drops a copy and leaves the original in place", async ({ + page, + request, +}) => { + const { designId, fileIds } = await createDesign(request, 1); + try { + await openOverview(page, designId, 1); + // Only the label row selects the frame itself; the card body drills in. + await page.locator("[data-frame-label]").first().click(); + const dragSurface = page.locator("[data-frame-drag-surface]"); + await expect(dragSurface).toBeVisible(); + + const before = await frameOffsets(page); + await altDrag(page, dragSurface, 220, 140); + + await expect(page.locator("[data-screen-shell]")).toHaveCount(2); + const after = await frameOffsets(page); + expect(after[fileIds[0]!]).toEqual(before[fileIds[0]!]); + const copyId = Object.keys(after).find((id) => id !== fileIds[0])!; + expect(after[copyId]!.left).toBeGreaterThan(before[fileIds[0]!]!.left); + expect(after[copyId]!.top).toBeGreaterThan(before[fileIds[0]!]!.top); + } finally { + await action(request, "delete-design", { id: designId }).catch(() => {}); + } +}); + +test("alt-dragging a multi-frame selection copies every frame and keeps their spacing", async ({ + page, + request, +}) => { + const { designId, fileIds } = await createDesign(request, 2); + try { + await openOverview(page, designId, 2); + await page.locator("[data-frame-label]").first().click(); + await page.keyboard.press("ControlOrMeta+a"); + const dragSurface = page.locator("[data-frame-drag-surface]"); + await expect(dragSurface).toBeVisible(); + + const before = await frameOffsets(page); + await altDrag(page, dragSurface, 0, 200); + + await expect(page.locator("[data-screen-shell]")).toHaveCount(4, { + timeout: 30_000, + }); + const after = await frameOffsets(page); + for (const sourceId of fileIds) { + expect(after[sourceId]).toEqual(before[sourceId]); + } + const copyLefts = Object.keys(after) + .filter((id) => !fileIds.includes(id)) + .map((id) => after[id]!.left) + .sort((a, b) => a - b); + const sourceLefts = fileIds + .map((id) => before[id]!.left) + .sort((a, b) => a - b); + expect(copyLefts).toHaveLength(2); + expect(copyLefts[1]! - copyLefts[0]!).toBeCloseTo( + sourceLefts[1]! - sourceLefts[0]!, + 0, + ); + } finally { + await action(request, "delete-design", { id: designId }).catch(() => {}); + } +}); diff --git a/templates/design/e2e/overview-alt-drag-element-copy.spec.ts b/templates/design/e2e/overview-alt-drag-element-copy.spec.ts new file mode 100644 index 0000000000..81cb6c7d7f --- /dev/null +++ b/templates/design/e2e/overview-alt-drag-element-copy.spec.ts @@ -0,0 +1,130 @@ +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +import { appPath } from "./helpers"; + +const BASE_URL = + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? "9333"}`; +const SCREEN_HTML = ` +Screen + +
+`; + +async function action( + request: APIRequestContext, + name: string, + input: Record, +) { + const response = await request.post( + `${BASE_URL}/_agent-native/actions/${name}`, + { data: input }, + ); + if (!response.ok()) { + throw new Error(`${name}: ${response.status()} ${await response.text()}`); + } + return response.json(); +} + +async function createDesign(request: APIRequestContext) { + const created = await action(request, "create-design", { + title: `Alt-drag element QA ${Date.now()}`, + projectType: "prototype", + }); + const designId = created.id ?? created.data?.id ?? created.design?.id; + if (!designId) throw new Error("create-design returned no id"); + const file = await action(request, "create-file", { + designId, + filename: "index.html", + content: SCREEN_HTML, + fileType: "html", + }); + const fileId = file.id ?? file.data?.id; + if (!fileId) throw new Error("create-file returned no id"); + await action(request, "update-design", { + id: designId, + dataOperations: [ + { + op: "set", + path: ["screenMetadata", fileId], + value: { sourceType: "inline", width: 1280, height: 1400 }, + }, + { + op: "set", + path: ["canvasFrames", fileId], + value: { x: 0, y: 0, width: 1280, height: 1400, z: 0 }, + }, + ], + }); + return { designId }; +} + +/** Shapes actually painted in the screen's live preview document. */ +async function paintedShapes(page: Page) { + return page.evaluate(() => { + const frame = document.querySelector( + "iframe[data-screen-iframe-id]", + ); + const doc = frame?.contentDocument; + if (!doc) return -1; + return doc.querySelectorAll("body > div[data-agent-native-node-id]").length; + }); +} + +test("alt-dragging an element keeps every copy on the canvas, not just in state", async ({ + page, + request, +}) => { + const { designId } = await createDesign(request); + try { + await page.goto(appPath(`/design/${designId}?view=overview&zoom=30`), { + waitUntil: "domcontentloaded", + }); + await expect + .poll(async () => page.locator("[data-screen-shell]").count(), { + timeout: 40_000, + }) + .toBeGreaterThan(0); + await page.waitForTimeout(3500); + + const card = (await page + .locator("[data-screen-card]") + .first() + .boundingBox())!; + const scale = card.width / 1280; + const at = (x: number, y: number) => ({ + x: card.x + x * scale, + y: card.y + y * scale, + }); + + // Drill into the frame so the rectangle itself is the drag target. + const source = at(270, 240); + await page.mouse.dblclick(source.x, source.y); + await page.waitForTimeout(1500); + expect(await paintedShapes(page)).toBe(1); + + for (let copy = 0; copy < 2; copy += 1) { + await page.mouse.click(source.x, source.y); + await page.waitForTimeout(700); + const drop = at(400 + copy * 330, 500 + copy * 260); + await page.mouse.move(source.x, source.y); + await page.keyboard.down("Alt"); + await page.mouse.down(); + await page.mouse.move(drop.x, drop.y, { steps: 14 }); + await page.mouse.up(); + await page.keyboard.up("Alt"); + + // Settle past the host's follow-up source push, which is what can + // delete a clone it fails to match by selector. + await page.waitForTimeout(3500); + expect(await paintedShapes(page)).toBe(copy + 2); + } + } finally { + await action(request, "delete-design", { id: designId }).catch(() => {}); + } +}); diff --git a/templates/design/e2e/overview-create-primitive.spec.ts b/templates/design/e2e/overview-create-primitive.spec.ts new file mode 100644 index 0000000000..06e961a3a4 --- /dev/null +++ b/templates/design/e2e/overview-create-primitive.spec.ts @@ -0,0 +1,152 @@ +import { + expect, + test, + type APIRequestContext, + type Page, +} from "@playwright/test"; + +import { appPath } from "./helpers"; + +const BASE_URL = + process.env.E2E_BASE_URL ?? + `http://127.0.0.1:${process.env.E2E_PORT ?? "9333"}`; +const SCREEN_HTML = ` +Screen + +
+

Hero

+
`; + +async function action( + request: APIRequestContext, + name: string, + input: Record, +) { + const response = await request.post( + `${BASE_URL}/_agent-native/actions/${name}`, + { data: input }, + ); + if (!response.ok()) { + throw new Error(`${name}: ${response.status()} ${await response.text()}`); + } + return response.json(); +} + +async function createDesign(request: APIRequestContext) { + const created = await action(request, "create-design", { + title: `Create primitive QA ${Date.now()}`, + projectType: "prototype", + }); + const designId = created.id ?? created.data?.id ?? created.design?.id; + if (!designId) throw new Error("create-design returned no id"); + const file = await action(request, "create-file", { + designId, + filename: "index.html", + content: SCREEN_HTML, + fileType: "html", + }); + const fileId = file.id ?? file.data?.id; + if (!fileId) throw new Error("create-file returned no id"); + await action(request, "update-design", { + id: designId, + dataOperations: [ + { + op: "set", + path: ["screenMetadata", fileId], + value: { sourceType: "inline", width: 1280, height: 900 }, + }, + { + op: "set", + path: ["canvasFrames", fileId], + value: { x: 0, y: 0, width: 1280, height: 900, z: 0 }, + }, + ], + }); + return { designId, fileId }; +} + +/** Counts source-backed nodes inside the screen's live preview iframe. */ +async function previewNodeCount(page: Page) { + return page.evaluate(() => { + const frame = document.querySelector( + "iframe[data-screen-iframe-id]", + ); + const doc = frame?.contentDocument; + if (!doc) return -1; + return doc.querySelectorAll("[data-agent-native-node-id]").length; + }); +} + +async function drawRectangle(page: Page) { + await page.getByRole("button", { name: "Rectangle", exact: true }).click(); + const box = (await page.locator("[data-screen-card]").first().boundingBox())!; + const x = Math.max(box.x, 0) + 60; + const y = Math.max(box.y, 0) + 60; + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 120, y + 90, { steps: 10 }); + await page.mouse.up(); +} + +test("drawing a rectangle shows it immediately and leaves the screen unselected", async ({ + page, + request, +}) => { + const { designId } = await createDesign(request); + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(String(error))); + try { + await page.goto(appPath(`/design/${designId}?view=overview`), { + waitUntil: "domcontentloaded", + }); + await expect + .poll(async () => page.locator("[data-screen-shell]").count(), { + timeout: 40_000, + }) + .toBeGreaterThan(0); + await page.waitForTimeout(2500); + const before = await previewNodeCount(page); + + await drawRectangle(page); + await page.waitForTimeout(2500); + + expect(pageErrors).toEqual([]); + expect(await previewNodeCount(page)).toBeGreaterThan(before); + await expect(page.locator("[data-frame-selection-box]")).toHaveCount(0); + } finally { + await action(request, "delete-design", { id: designId }).catch(() => {}); + } +}); + +test("duplicating right after drawing copies the rectangle, not its screen", async ({ + page, + request, +}) => { + const { designId } = await createDesign(request); + try { + await page.goto(appPath(`/design/${designId}?view=overview`), { + waitUntil: "domcontentloaded", + }); + await expect + .poll(async () => page.locator("[data-screen-shell]").count(), { + timeout: 40_000, + }) + .toBeGreaterThan(0); + await page.waitForTimeout(2500); + const screensBefore = await page.locator("[data-screen-shell]").count(); + + await drawRectangle(page); + await page.waitForTimeout(2500); + const nodesAfterDraw = await previewNodeCount(page); + + await page.keyboard.press("ControlOrMeta+d"); + await page.waitForTimeout(2500); + + expect(await previewNodeCount(page)).toBeGreaterThan(nodesAfterDraw); + expect(await page.locator("[data-screen-shell]").count()).toBe( + screensBefore, + ); + } finally { + await action(request, "delete-design", { id: designId }).catch(() => {}); + } +});