From d20e7c661fc0b6d5bff4cea44909ba676584f6d8 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Tue, 16 Sep 2025 22:16:10 -0700 Subject: [PATCH 01/13] feat: add drag-to-select for multiple webframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement drag selection functionality allowing users to select multiple webframes by clicking and dragging on the canvas, similar to Figma and tldraw. - Add DragSelectOverlay component for visual selection rectangle - Implement drag selection logic in Canvas component with proper coordinate transformation - Add visual feedback with teal outline for selected frames - Support multi-select with Shift key modifier - Integrate with existing frame selection system for chat context 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../[id]/_components/canvas/frame/index.tsx | 6 +- .../project/[id]/_components/canvas/index.tsx | 97 ++++++++++++++++++- .../canvas/overlay/drag-select.tsx | 34 +++++++ 3 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index 917c15ca56..e8420cbe05 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -17,6 +17,7 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => { const [isResizing, setIsResizing] = useState(false); const [reloadKey, setReloadKey] = useState(0); const [hasTimedOut, setHasTimedOut] = useState(false); + const isSelected = editorEngine.frames.isSelected(frame.id); // Check if sandbox is connecting for this frame's branch const branchData = editorEngine.branches.getBranchDataById(frame.branchId); @@ -60,7 +61,10 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => { -
+
diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx index 32ac3c4400..82b9a400a1 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx @@ -4,10 +4,11 @@ import { useEditorEngine } from '@/components/store/editor'; import { EditorAttributes } from '@onlook/constants'; import { EditorMode } from '@onlook/models'; import { observer } from 'mobx-react-lite'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Frames } from './frames'; import { HotkeysArea } from './hotkeys'; import { Overlay } from './overlay'; +import { DragSelectOverlay } from './overlay/drag-select'; import { PanOverlay } from './overlay/pan'; import { RecenterCanvasButton } from './recenter-canvas-button'; @@ -25,12 +26,94 @@ export const Canvas = observer(() => { const containerRef = useRef(null); const scale = editorEngine.canvas.scale; const position = editorEngine.canvas.position; + const [isDragSelecting, setIsDragSelecting] = useState(false); + const [dragSelectStart, setDragSelectStart] = useState({ x: 0, y: 0 }); + const [dragSelectEnd, setDragSelectEnd] = useState({ x: 0, y: 0 }); const handleCanvasMouseDown = (event: React.MouseEvent) => { if (event.target !== containerRef.current) { return; } - editorEngine.clearUI(); + + // Start drag selection only in design mode and when not holding middle mouse button + if (editorEngine.state.editorMode === EditorMode.DESIGN && event.button === 0) { + const rect = containerRef.current.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + setIsDragSelecting(true); + setDragSelectStart({ x, y }); + setDragSelectEnd({ x, y }); + + // Clear existing selections if not shift-clicking + if (!event.shiftKey) { + editorEngine.clearUI(); + editorEngine.frames.deselectAll(); + } + } else { + editorEngine.clearUI(); + } + }; + + const handleCanvasMouseMove = (event: React.MouseEvent) => { + if (!isDragSelecting || !containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + setDragSelectEnd({ x, y }); + }; + + const handleCanvasMouseUp = (event: React.MouseEvent) => { + if (!isDragSelecting) { + return; + } + + // Calculate which frames are within the selection rectangle + const selectionRect = { + left: Math.min(dragSelectStart.x, dragSelectEnd.x), + top: Math.min(dragSelectStart.y, dragSelectEnd.y), + right: Math.max(dragSelectStart.x, dragSelectEnd.x), + bottom: Math.max(dragSelectStart.y, dragSelectEnd.y), + }; + + // Convert selection rect to canvas coordinates + const canvasSelectionRect = { + left: (selectionRect.left - position.x) / scale, + top: (selectionRect.top - position.y) / scale, + right: (selectionRect.right - position.x) / scale, + bottom: (selectionRect.bottom - position.y) / scale, + }; + + // Find all frames that intersect with the selection rectangle + const allFrames = editorEngine.frames.getAll(); + const selectedFrames = allFrames.filter(frameData => { + const frame = frameData.frame; + const frameLeft = frame.position.x; + const frameTop = frame.position.y; + const frameRight = frame.position.x + frame.dimension.width; + const frameBottom = frame.position.y + frame.dimension.height; + + // Check if frame intersects with selection rectangle + return !( + frameLeft > canvasSelectionRect.right || + frameRight < canvasSelectionRect.left || + frameTop > canvasSelectionRect.bottom || + frameBottom < canvasSelectionRect.top + ); + }); + + // Select the frames if any were found in the selection + if (selectedFrames.length > 0) { + editorEngine.frames.select( + selectedFrames.map(fd => fd.frame), + event.shiftKey // multiselect if shift is held + ); + } + + setIsDragSelecting(false); }; const handleZoom = useCallback( @@ -166,11 +249,21 @@ export const Canvas = observer(() => { ref={containerRef} className="overflow-hidden bg-background-onlook flex flex-grow relative" onMouseDown={handleCanvasMouseDown} + onMouseMove={handleCanvasMouseMove} + onMouseUp={handleCanvasMouseUp} + onMouseLeave={handleCanvasMouseUp} >
+ diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx new file mode 100644 index 0000000000..ea21a3b650 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { observer } from 'mobx-react-lite'; + +interface DragSelectOverlayProps { + startX: number; + startY: number; + endX: number; + endY: number; + isSelecting: boolean; +} + +export const DragSelectOverlay = observer(({ startX, startY, endX, endY, isSelecting }: DragSelectOverlayProps) => { + if (!isSelecting) { + return null; + } + + const left = Math.min(startX, endX); + const top = Math.min(startY, endY); + const width = Math.abs(endX - startX); + const height = Math.abs(endY - startY); + + return ( +
+ ); +}); \ No newline at end of file From 67dbb86632c8915657f7254f2aa185862676a666 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Tue, 16 Sep 2025 22:22:35 -0700 Subject: [PATCH 02/13] style: make drag selection border thinner Changed from border-2 (2px) to border (1px) for a more subtle appearance --- .../app/project/[id]/_components/canvas/overlay/drag-select.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx index ea21a3b650..41480f2edb 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx @@ -22,7 +22,7 @@ export const DragSelectOverlay = observer(({ startX, startY, endX, endY, isSelec return (
Date: Tue, 16 Sep 2025 22:23:37 -0700 Subject: [PATCH 03/13] style: use Onlook's custom blue-500 color for drag selection Replace Tailwind's blue-500 with Onlook's design system colors from @onlook/ui/tokens --- .../project/[id]/_components/canvas/overlay/drag-select.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx index 41480f2edb..fcdaa38402 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx @@ -1,5 +1,6 @@ 'use client'; +import { colors } from '@onlook/ui/tokens'; import { observer } from 'mobx-react-lite'; interface DragSelectOverlayProps { @@ -22,12 +23,14 @@ export const DragSelectOverlay = observer(({ startX, startY, endX, endY, isSelec return (
); From 0497fedbe751d6d52416bb72cdcf819a45f9350f Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Tue, 16 Sep 2025 22:26:50 -0700 Subject: [PATCH 04/13] fix: address performance and logic issues in drag selection - Add throttling to mouse move handler (16ms/~60fps) to prevent performance issues - Fix mouse leave handler to only terminate drag when no button is pressed - Fix logic error that was clearing UI on right/middle clicks - Add proper cleanup for throttled function Addresses review feedback from Graphite --- .../project/[id]/_components/canvas/index.tsx | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx index 82b9a400a1..e84b1b66f0 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx @@ -3,6 +3,7 @@ import { useEditorEngine } from '@/components/store/editor'; import { EditorAttributes } from '@onlook/constants'; import { EditorMode } from '@onlook/models'; +import { throttle } from 'lodash'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Frames } from './frames'; @@ -35,7 +36,7 @@ export const Canvas = observer(() => { return; } - // Start drag selection only in design mode and when not holding middle mouse button + // Start drag selection only in design mode and left mouse button if (editorEngine.state.editorMode === EditorMode.DESIGN && event.button === 0) { const rect = containerRef.current.getBoundingClientRect(); const x = event.clientX - rect.left; @@ -50,21 +51,25 @@ export const Canvas = observer(() => { editorEngine.clearUI(); editorEngine.frames.deselectAll(); } - } else { + } else if (event.button === 0) { + // Only clear UI for left clicks that don't start drag selection editorEngine.clearUI(); } }; - const handleCanvasMouseMove = (event: React.MouseEvent) => { - if (!isDragSelecting || !containerRef.current) { - return; - } - - const rect = containerRef.current.getBoundingClientRect(); - const x = event.clientX - rect.left; - const y = event.clientY - rect.top; - setDragSelectEnd({ x, y }); - }; + const handleCanvasMouseMove = useCallback( + throttle((event: React.MouseEvent) => { + if (!isDragSelecting || !containerRef.current) { + return; + } + + const rect = containerRef.current.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + setDragSelectEnd({ x, y }); + }, 16), // ~60fps + [isDragSelecting] + ); const handleCanvasMouseUp = (event: React.MouseEvent) => { if (!isDragSelecting) { @@ -239,9 +244,10 @@ export const Canvas = observer(() => { div.removeEventListener('wheel', handleWheel); div.removeEventListener('mousedown', middleMouseButtonDown); div.removeEventListener('mouseup', middleMouseButtonUp); + handleCanvasMouseMove.cancel?.(); // Clean up throttled function }; } - }, [handleWheel, middleMouseButtonDown, middleMouseButtonUp]); + }, [handleWheel, middleMouseButtonDown, middleMouseButtonUp, handleCanvasMouseMove]); return ( @@ -251,7 +257,12 @@ export const Canvas = observer(() => { onMouseDown={handleCanvasMouseDown} onMouseMove={handleCanvasMouseMove} onMouseUp={handleCanvasMouseUp} - onMouseLeave={handleCanvasMouseUp} + onMouseLeave={(e) => { + // Only terminate drag if no mouse button is pressed + if (e.buttons === 0) { + handleCanvasMouseUp(e); + } + }} >
From 1320b58ff9584086dcddefa4a3f507671dedc2a5 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Tue, 16 Sep 2025 22:34:57 -0700 Subject: [PATCH 05/13] feat: improve drag selection visual feedback and prevent element hover - Add real-time teal outline feedback for frames during drag selection - Prevent element hover outlines from showing while drag selecting - Add isDragSelecting state to prevent hover events during drag - Show frames with semi-transparent teal outline as they enter selection area - Frames now show visual feedback immediately during drag, not just after --- .../[id]/_components/canvas/frame/gesture.tsx | 4 ++ .../[id]/_components/canvas/frame/index.tsx | 5 +- .../[id]/_components/canvas/frames.tsx | 8 ++- .../project/[id]/_components/canvas/index.tsx | 57 ++++++++++++++++++- .../components/store/editor/state/index.ts | 1 + 5 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/gesture.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/gesture.tsx index 865376b3bf..5eb34f7299 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/gesture.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/gesture.tsx @@ -93,6 +93,10 @@ export const GestureScreen = observer(({ frame, isResizing }: { frame: Frame, is const throttledMouseMove = useMemo( () => throttle(async (e: React.MouseEvent) => { + // Skip hover events during drag selection + if (editorEngine.state.isDragSelecting) { + return; + } if (editorEngine.move.shouldDrag) { await editorEngine.move.drag(e, getRelativeMousePosition); diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index e8420cbe05..2ecb261f5c 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -11,7 +11,7 @@ import { RightClickMenu } from './right-click'; import { TopBar } from './top-bar'; import { FrameComponent, type IFrameView } from './view'; -export const FrameView = observer(({ frame }: { frame: Frame }) => { +export const FrameView = observer(({ frame, isInDragSelection = false }: { frame: Frame; isInDragSelection?: boolean }) => { const editorEngine = useEditorEngine(); const iFrameRef = useRef(null); const [isResizing, setIsResizing] = useState(false); @@ -62,8 +62,9 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => {
diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frames.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frames.tsx index 0dad5adfdf..6219e3db50 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frames.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frames.tsx @@ -5,14 +5,18 @@ import type { FrameData } from '@/components/store/editor/frames'; import { observer } from 'mobx-react-lite'; import { FrameView } from './frame'; -export const Frames = observer(() => { +export const Frames = observer(({ framesInDragSelection }: { framesInDragSelection: Set }) => { const editorEngine = useEditorEngine(); const frames = editorEngine.frames.getAll(); return (
{frames.map((frame: FrameData) => ( - + ))}
); diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx index e84b1b66f0..59095e49dc 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx @@ -30,6 +30,7 @@ export const Canvas = observer(() => { const [isDragSelecting, setIsDragSelecting] = useState(false); const [dragSelectStart, setDragSelectStart] = useState({ x: 0, y: 0 }); const [dragSelectEnd, setDragSelectEnd] = useState({ x: 0, y: 0 }); + const [framesInSelection, setFramesInSelection] = useState>(new Set()); const handleCanvasMouseDown = (event: React.MouseEvent) => { if (event.target !== containerRef.current) { @@ -45,6 +46,10 @@ export const Canvas = observer(() => { setIsDragSelecting(true); setDragSelectStart({ x, y }); setDragSelectEnd({ x, y }); + setFramesInSelection(new Set()); + + // Set a flag in the editor engine to suppress hover effects + editorEngine.state.isDragSelecting = true; // Clear existing selections if not shift-clicking if (!event.shiftKey) { @@ -57,6 +62,49 @@ export const Canvas = observer(() => { } }; + const updateFramesInSelection = useCallback((start: { x: number; y: number }, end: { x: number; y: number }) => { + const selectionRect = { + left: Math.min(start.x, end.x), + top: Math.min(start.y, end.y), + right: Math.max(start.x, end.x), + bottom: Math.max(start.y, end.y), + }; + + // Convert selection rect to canvas coordinates + const canvasSelectionRect = { + left: (selectionRect.left - position.x) / scale, + top: (selectionRect.top - position.y) / scale, + right: (selectionRect.right - position.x) / scale, + bottom: (selectionRect.bottom - position.y) / scale, + }; + + // Find all frames that intersect with the selection rectangle + const allFrames = editorEngine.frames.getAll(); + const intersectingFrameIds = new Set(); + + allFrames.forEach(frameData => { + const frame = frameData.frame; + const frameLeft = frame.position.x; + const frameTop = frame.position.y; + const frameRight = frame.position.x + frame.dimension.width; + const frameBottom = frame.position.y + frame.dimension.height; + + // Check if frame intersects with selection rectangle + const intersects = !( + frameLeft > canvasSelectionRect.right || + frameRight < canvasSelectionRect.left || + frameTop > canvasSelectionRect.bottom || + frameBottom < canvasSelectionRect.top + ); + + if (intersects) { + intersectingFrameIds.add(frame.id); + } + }); + + setFramesInSelection(intersectingFrameIds); + }, [position, scale, editorEngine.frames]); + const handleCanvasMouseMove = useCallback( throttle((event: React.MouseEvent) => { if (!isDragSelecting || !containerRef.current) { @@ -67,8 +115,11 @@ export const Canvas = observer(() => { const x = event.clientX - rect.left; const y = event.clientY - rect.top; setDragSelectEnd({ x, y }); + + // Update frames in selection for visual feedback + updateFramesInSelection(dragSelectStart, { x, y }); }, 16), // ~60fps - [isDragSelecting] + [isDragSelecting, dragSelectStart, updateFramesInSelection] ); const handleCanvasMouseUp = (event: React.MouseEvent) => { @@ -119,6 +170,8 @@ export const Canvas = observer(() => { } setIsDragSelecting(false); + setFramesInSelection(new Set()); + editorEngine.state.isDragSelecting = false; }; const handleZoom = useCallback( @@ -265,7 +318,7 @@ export const Canvas = observer(() => { }} >
- +
Date: Tue, 16 Sep 2025 22:48:00 -0700 Subject: [PATCH 06/13] style: enhance drag selection visual feedback with proper teal colors - Use Onlook's teal color tokens for consistent styling - Selected frames show teal-400 outline and text (unchanged) - Frames in drag selection show teal-500 outline and text (one tier darker) - Remove opacity change, use solid colors for clarity - Pass isInDragSelection prop through to TopBar for text color changes --- .../src/app/project/[id]/_components/canvas/frame/index.tsx | 6 +++--- .../project/[id]/_components/canvas/frame/top-bar/index.tsx | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index 2ecb261f5c..4110790c1c 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -2,6 +2,7 @@ import { useEditorEngine } from '@/components/store/editor'; import { type Frame } from '@onlook/models'; import { Icons } from '@onlook/ui/icons'; import { toast } from '@onlook/ui/sonner'; +import { colors } from '@onlook/ui/tokens'; import { debounce } from 'lodash'; import { observer } from 'mobx-react-lite'; import { useEffect, useRef, useState } from 'react'; @@ -59,12 +60,11 @@ export const FrameView = observer(({ frame, isInDragSelection = false }: { frame style={{ transform: `translate(${frame.position.x}px, ${frame.position.y}px)` }} > - +
diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx index 54ce5d1cf2..5041b85dca 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx @@ -11,7 +11,7 @@ import { BranchDisplay } from './branch'; import { PageSelector } from './page-selector'; export const TopBar = observer( - ({ frame }: { frame: Frame }) => { + ({ frame, isInDragSelection = false }: { frame: Frame; isInDragSelection?: boolean }) => { const editorEngine = useEditorEngine(); const isSelected = editorEngine.frames.isSelected(frame.id); const topBarRef = useRef(null); @@ -133,6 +133,7 @@ export const TopBar = observer( className={cn( 'rounded-lg bg-background-primary/10 hover:shadow h-6 m-auto flex flex-row items-center backdrop-blur-lg overflow-hidden relative shadow-sm border-input text-foreground-secondary group-hover:text-foreground cursor-grab active:cursor-grabbing', isSelected && 'text-teal-400 fill-teal-400', + !isSelected && isInDragSelection && 'text-teal-500 fill-teal-500', )} style={{ height: `${28 / editorEngine.canvas.scale}px`, From a41bcab4de381a837a9888725a14be8e16d43d09 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Tue, 16 Sep 2025 23:56:26 -0700 Subject: [PATCH 07/13] fix: change iframe outer border to teal during drag selection - Pass isInDragSelection prop through FrameView to FrameComponent - Add teal-500 outline to iframe when frame is in drag selection - Ensures the gray outer border changes to teal when dragged over --- .../src/app/project/[id]/_components/canvas/frame/index.tsx | 2 +- .../src/app/project/[id]/_components/canvas/frame/view.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index 4110790c1c..dddeeffb3c 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -67,7 +67,7 @@ export const FrameView = observer(({ frame, isInDragSelection = false }: { frame borderRadius: '4px', }}> - + {isConnecting && !hasTimedOut && ( diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/view.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/view.tsx index f1ffb930ee..c77e308b6e 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/view.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/view.tsx @@ -54,10 +54,11 @@ const createSafeFallbackMethods = (): PromisifiedPendpalChildMethods => { interface FrameViewProps extends IframeHTMLAttributes { frame: Frame; reloadIframe?: () => void; + isInDragSelection?: boolean; } export const FrameComponent = observer( - forwardRef(({ frame, reloadIframe, ...props }, ref) => { + forwardRef(({ frame, reloadIframe, isInDragSelection = false, ...props }, ref) => { const editorEngine = useEditorEngine(); const iframeRef = useRef(null); const zoomLevel = useRef(1); @@ -302,6 +303,7 @@ export const FrameComponent = observer( 'backdrop-blur-sm transition outline outline-4', isActiveBranch && 'outline-teal-400', isActiveBranch && !isSelected && 'outline-dashed', + !isActiveBranch && isInDragSelection && 'outline-teal-500', )} src={frame.url} sandbox="allow-modals allow-forms allow-same-origin allow-scripts allow-popups allow-downloads" From da04fd0b284fa35c26505b006f7b6d3b6fa82274 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Wed, 17 Sep 2025 13:20:52 -0400 Subject: [PATCH 08/13] Update colors --- .../project/[id]/_components/canvas/overlay/drag-select.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx index fcdaa38402..a91b33a446 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx @@ -29,9 +29,9 @@ export const DragSelectOverlay = observer(({ startX, startY, endX, endY, isSelec top: `${top}px`, width: `${width}px`, height: `${height}px`, - border: `1px solid ${colors.blue[500]}`, - backgroundColor: `${colors.blue[500]}1A`, // 10% opacity (1A in hex) + border: `1px solid ${colors.teal[300]}`, + backgroundColor: `${colors.teal[300]}1A`, // 10% opacity (1A in hex) }} /> ); -}); \ No newline at end of file +}); From 990fffa5886186b39912d28dd25b2ce1ab3afaa8 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Wed, 17 Sep 2025 10:22:04 -0700 Subject: [PATCH 09/13] fix: add global mouseup listener for reliable drag termination - Add window-level mouseup event listener when drag selecting starts - Ensures drag selection properly terminates even when mouse released outside canvas - Prevents application from getting stuck in drag selecting state - Addresses Graphite review feedback about edge case handling --- .../project/[id]/_components/canvas/index.tsx | 107 ++++++++++-------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx index 59095e49dc..b82ebbdeef 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx @@ -123,55 +123,8 @@ export const Canvas = observer(() => { ); const handleCanvasMouseUp = (event: React.MouseEvent) => { - if (!isDragSelecting) { - return; - } - - // Calculate which frames are within the selection rectangle - const selectionRect = { - left: Math.min(dragSelectStart.x, dragSelectEnd.x), - top: Math.min(dragSelectStart.y, dragSelectEnd.y), - right: Math.max(dragSelectStart.x, dragSelectEnd.x), - bottom: Math.max(dragSelectStart.y, dragSelectEnd.y), - }; - - // Convert selection rect to canvas coordinates - const canvasSelectionRect = { - left: (selectionRect.left - position.x) / scale, - top: (selectionRect.top - position.y) / scale, - right: (selectionRect.right - position.x) / scale, - bottom: (selectionRect.bottom - position.y) / scale, - }; - - // Find all frames that intersect with the selection rectangle - const allFrames = editorEngine.frames.getAll(); - const selectedFrames = allFrames.filter(frameData => { - const frame = frameData.frame; - const frameLeft = frame.position.x; - const frameTop = frame.position.y; - const frameRight = frame.position.x + frame.dimension.width; - const frameBottom = frame.position.y + frame.dimension.height; - - // Check if frame intersects with selection rectangle - return !( - frameLeft > canvasSelectionRect.right || - frameRight < canvasSelectionRect.left || - frameTop > canvasSelectionRect.bottom || - frameBottom < canvasSelectionRect.top - ); - }); - - // Select the frames if any were found in the selection - if (selectedFrames.length > 0) { - editorEngine.frames.select( - selectedFrames.map(fd => fd.frame), - event.shiftKey // multiselect if shift is held - ); - } - - setIsDragSelecting(false); - setFramesInSelection(new Set()); - editorEngine.state.isDragSelecting = false; + // Mouse up is now handled by the global listener in useEffect + // This function is kept for consistency but the logic is in the global handler }; const handleZoom = useCallback( @@ -301,6 +254,62 @@ export const Canvas = observer(() => { }; } }, [handleWheel, middleMouseButtonDown, middleMouseButtonUp, handleCanvasMouseMove]); + + // Global mouseup listener to handle drag termination outside canvas + useEffect(() => { + if (isDragSelecting) { + const handleGlobalMouseUp = (event: MouseEvent) => { + // Calculate which frames are within the selection rectangle + const selectionRect = { + left: Math.min(dragSelectStart.x, dragSelectEnd.x), + top: Math.min(dragSelectStart.y, dragSelectEnd.y), + right: Math.max(dragSelectStart.x, dragSelectEnd.x), + bottom: Math.max(dragSelectStart.y, dragSelectEnd.y), + }; + + // Convert selection rect to canvas coordinates + const canvasSelectionRect = { + left: (selectionRect.left - position.x) / scale, + top: (selectionRect.top - position.y) / scale, + right: (selectionRect.right - position.x) / scale, + bottom: (selectionRect.bottom - position.y) / scale, + }; + + // Find all frames that intersect with the selection rectangle + const allFrames = editorEngine.frames.getAll(); + const selectedFrames = allFrames.filter(frameData => { + const frame = frameData.frame; + const frameLeft = frame.position.x; + const frameTop = frame.position.y; + const frameRight = frame.position.x + frame.dimension.width; + const frameBottom = frame.position.y + frame.dimension.height; + + // Check if frame intersects with selection rectangle + return !( + frameLeft > canvasSelectionRect.right || + frameRight < canvasSelectionRect.left || + frameTop > canvasSelectionRect.bottom || + frameBottom < canvasSelectionRect.top + ); + }); + + // Select the frames if any were found in the selection + if (selectedFrames.length > 0) { + editorEngine.frames.select( + selectedFrames.map(fd => fd.frame), + event.shiftKey // multiselect if shift is held + ); + } + + setIsDragSelecting(false); + setFramesInSelection(new Set()); + editorEngine.state.isDragSelecting = false; + }; + + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + } + }, [isDragSelecting, dragSelectStart, dragSelectEnd, position, scale, editorEngine]); return ( From 027714de3060a2302186e50806317ae700532399 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 17 Sep 2025 14:32:02 -0700 Subject: [PATCH 10/13] handle click --- .../canvas/frame/top-bar/helpers.ts | 87 +++++++++++++++++++ .../canvas/frame/top-bar/index.tsx | 78 +++++++---------- 2 files changed, 118 insertions(+), 47 deletions(-) create mode 100644 apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts new file mode 100644 index 0000000000..2551a93b89 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts @@ -0,0 +1,87 @@ +import type { EditorEngine } from '@/components/store/editor'; +import type { Frame } from '@onlook/models'; + +export interface MouseMoveHandlerOptions { + editorEngine: EditorEngine; + selectedFrames: Frame[]; + clearElements: () => void; +} + +export function createMouseMoveHandler( + startEvent: React.MouseEvent, + options: MouseMoveHandlerOptions +) { + const { editorEngine, selectedFrames, clearElements } = options; + + startEvent.preventDefault(); + startEvent.stopPropagation(); + clearElements(); + + const startX = startEvent.clientX; + const startY = startEvent.clientY; + + // Store initial positions for all selected frames + const initialFramePositions = selectedFrames.map(frame => ({ + id: frame.id, + startPosition: { x: frame.position.x, y: frame.position.y }, + dimension: frame.dimension + })); + + const handleMove = async (e: MouseEvent) => { + clearElements(); + const scale = editorEngine.canvas.scale; + const deltaX = (e.clientX - startX) / scale; + const deltaY = (e.clientY - startY) / scale; + + // Update all selected frames + for (const frameData of initialFramePositions) { + let newPosition = { + x: frameData.startPosition.x + deltaX, + y: frameData.startPosition.y + deltaY, + }; + + // Apply snapping if enabled (only for the primary frame to avoid conflicts) + if (editorEngine.snap.config.enabled && !e.ctrlKey && !e.metaKey && frameData === initialFramePositions[0]) { + const snapTarget = editorEngine.snap.calculateSnapTarget( + frameData.id, + newPosition, + frameData.dimension + ); + + if (snapTarget) { + // Apply the snap offset to all frames + const snapDeltaX = snapTarget.position.x - newPosition.x; + const snapDeltaY = snapTarget.position.y - newPosition.y; + + for (const otherFrameData of initialFramePositions) { + const adjustedPosition = { + x: otherFrameData.startPosition.x + deltaX + snapDeltaX, + y: otherFrameData.startPosition.y + deltaY + snapDeltaY, + }; + editorEngine.frames.updateAndSaveToStorage(otherFrameData.id, { position: adjustedPosition }); + } + + editorEngine.snap.showSnapLines(snapTarget.snapLines); + return; + } else { + editorEngine.snap.hideSnapLines(); + } + } else if (frameData === initialFramePositions[0]) { + editorEngine.snap.hideSnapLines(); + } + + editorEngine.frames.updateAndSaveToStorage(frameData.id, { position: newPosition }); + } + }; + + const endMove = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + editorEngine.snap.hideSnapLines(); + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', endMove); + }; + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', endMove); +} \ No newline at end of file diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx index 025c170f8c..09a66e1c4a 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/index.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { HoverOnlyTooltip } from '../../../editor-bar/hover-tooltip'; import { BranchDisplay } from './branch'; +import { createMouseMoveHandler } from './helpers'; import { PageSelector } from './page-selector'; export const TopBar = observer( @@ -17,6 +18,7 @@ export const TopBar = observer( const topBarRef = useRef(null); const toolBarRef = useRef(null); const [shouldShowExternalLink, setShouldShowExternalLink] = useState(true); + const mouseDownRef = useRef<{ x: number; y: number; time: number } | null>(null); useEffect(() => { const calculateVisibility = () => { @@ -54,56 +56,20 @@ export const TopBar = observer( }, [isSelected, editorEngine.canvas.scale, frame.dimension.width]); const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - clearElements(); - - const startX = e.clientX; - const startY = e.clientY; - const startPositionX = frame.position.x; - const startPositionY = frame.position.y; - - const handleMove = async (e: MouseEvent) => { - clearElements(); - const scale = editorEngine.canvas.scale; - const deltaX = (e.clientX - startX) / scale; - const deltaY = (e.clientY - startY) / scale; - - let newPosition = { - x: startPositionX + deltaX, - y: startPositionY + deltaY, - }; - - if (editorEngine.snap.config.enabled && !e.ctrlKey && !e.metaKey) { - const snapTarget = editorEngine.snap.calculateSnapTarget( - frame.id, - newPosition, - frame.dimension - ); - - if (snapTarget) { - newPosition = snapTarget.position; - editorEngine.snap.showSnapLines(snapTarget.snapLines); - } else { - editorEngine.snap.hideSnapLines(); - } - } else { - editorEngine.snap.hideSnapLines(); - } - - editorEngine.frames.updateAndSaveToStorage(frame.id, { position: newPosition }); + mouseDownRef.current = { + x: e.clientX, + y: e.clientY, + time: Date.now() }; - const endMove = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - editorEngine.snap.hideSnapLines(); - window.removeEventListener('mousemove', handleMove); - window.removeEventListener('mouseup', endMove); - }; + const selectedFrames = editorEngine.frames.selected.map(frameData => frameData.frame); + const framesToMove = selectedFrames.length > 0 ? selectedFrames : [frame]; - window.addEventListener('mousemove', handleMove); - window.addEventListener('mouseup', endMove); + createMouseMoveHandler(e, { + editorEngine, + selectedFrames: framesToMove, + clearElements + }); }; const clearElements = () => { @@ -124,6 +90,24 @@ export const TopBar = observer( }; const handleClick = (e: React.MouseEvent) => { + if (!mouseDownRef.current) { + return; + } + + const currentTime = Date.now(); + const timeDiff = currentTime - mouseDownRef.current.time; + const distance = Math.sqrt( + Math.pow(e.clientX - mouseDownRef.current.x, 2) + + Math.pow(e.clientY - mouseDownRef.current.y, 2) + ); + + // Don't register click if it was a long hold (>200ms) or significant movement (>5px) + if (timeDiff > 200 || distance > 5) { + mouseDownRef.current = null; + return; + } + + mouseDownRef.current = null; editorEngine.frames.select([frame], e.shiftKey); }; From 4087cb6b5f24c163e2fcf8d6f90558316dfb2976 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 17 Sep 2025 14:33:30 -0700 Subject: [PATCH 11/13] fix types --- .../[id]/_components/canvas/frame/top-bar/helpers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts index 2551a93b89..fe270fcf65 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts @@ -1,4 +1,4 @@ -import type { EditorEngine } from '@/components/store/editor'; +import type { EditorEngine } from '@/components/store/editor/engine'; import type { Frame } from '@onlook/models'; export interface MouseMoveHandlerOptions { @@ -12,14 +12,14 @@ export function createMouseMoveHandler( options: MouseMoveHandlerOptions ) { const { editorEngine, selectedFrames, clearElements } = options; - + startEvent.preventDefault(); startEvent.stopPropagation(); clearElements(); const startX = startEvent.clientX; const startY = startEvent.clientY; - + // Store initial positions for all selected frames const initialFramePositions = selectedFrames.map(frame => ({ id: frame.id, @@ -52,7 +52,7 @@ export function createMouseMoveHandler( // Apply the snap offset to all frames const snapDeltaX = snapTarget.position.x - newPosition.x; const snapDeltaY = snapTarget.position.y - newPosition.y; - + for (const otherFrameData of initialFramePositions) { const adjustedPosition = { x: otherFrameData.startPosition.x + deltaX + snapDeltaX, @@ -60,7 +60,7 @@ export function createMouseMoveHandler( }; editorEngine.frames.updateAndSaveToStorage(otherFrameData.id, { position: adjustedPosition }); } - + editorEngine.snap.showSnapLines(snapTarget.snapLines); return; } else { From 6aa6cc38be91dc4d90b1dad2cd2f56fed7edeb50 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 17 Sep 2025 15:45:59 -0700 Subject: [PATCH 12/13] add drag deadzone --- .../canvas/frame/top-bar/helpers.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts index fe270fcf65..fcd26ebc64 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts @@ -19,6 +19,7 @@ export function createMouseMoveHandler( const startX = startEvent.clientX; const startY = startEvent.clientY; + let isDragActive = false; // Store initial positions for all selected frames const initialFramePositions = selectedFrames.map(frame => ({ @@ -28,10 +29,21 @@ export function createMouseMoveHandler( })); const handleMove = async (e: MouseEvent) => { - clearElements(); + const dx = e.clientX - startX; + const dy = e.clientY - startY; + + // Check deadzone - only start dragging after 5px movement + if (!isDragActive) { + if (dx * dx + dy * dy <= 25) { + return; // Still within deadzone + } + isDragActive = true; + clearElements(); + } + const scale = editorEngine.canvas.scale; - const deltaX = (e.clientX - startX) / scale; - const deltaY = (e.clientY - startY) / scale; + const deltaX = dx / scale; + const deltaY = dy / scale; // Update all selected frames for (const frameData of initialFramePositions) { From f9bf313b74121dfd6ecd24fc691d0f5be3360ead Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 17 Sep 2025 15:50:22 -0700 Subject: [PATCH 13/13] update drag --- .../project/[id]/_components/canvas/index.tsx | 147 ++++++------------ .../_components/canvas/selection-utils.ts | 101 ++++++++++++ 2 files changed, 151 insertions(+), 97 deletions(-) create mode 100644 apps/web/client/src/app/project/[id]/_components/canvas/selection-utils.ts diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx index b82ebbdeef..8e3638d45c 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/index.tsx @@ -12,6 +12,7 @@ import { Overlay } from './overlay'; import { DragSelectOverlay } from './overlay/drag-select'; import { PanOverlay } from './overlay/pan'; import { RecenterCanvasButton } from './recenter-canvas-button'; +import { getFramesInSelection, getSelectedFrameData } from './selection-utils'; const ZOOM_SENSITIVITY = 0.006; const PAN_SENSITIVITY = 0.52; @@ -36,21 +37,21 @@ export const Canvas = observer(() => { if (event.target !== containerRef.current) { return; } - + // Start drag selection only in design mode and left mouse button if (editorEngine.state.editorMode === EditorMode.DESIGN && event.button === 0) { const rect = containerRef.current.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; - + setIsDragSelecting(true); setDragSelectStart({ x, y }); setDragSelectEnd({ x, y }); setFramesInSelection(new Set()); - + // Set a flag in the editor engine to suppress hover effects editorEngine.state.isDragSelecting = true; - + // Clear existing selections if not shift-clicking if (!event.shiftKey) { editorEngine.clearUI(); @@ -61,67 +62,35 @@ export const Canvas = observer(() => { editorEngine.clearUI(); } }; - + const updateFramesInSelection = useCallback((start: { x: number; y: number }, end: { x: number; y: number }) => { - const selectionRect = { - left: Math.min(start.x, end.x), - top: Math.min(start.y, end.y), - right: Math.max(start.x, end.x), - bottom: Math.max(start.y, end.y), - }; - - // Convert selection rect to canvas coordinates - const canvasSelectionRect = { - left: (selectionRect.left - position.x) / scale, - top: (selectionRect.top - position.y) / scale, - right: (selectionRect.right - position.x) / scale, - bottom: (selectionRect.bottom - position.y) / scale, - }; - - // Find all frames that intersect with the selection rectangle - const allFrames = editorEngine.frames.getAll(); - const intersectingFrameIds = new Set(); - - allFrames.forEach(frameData => { - const frame = frameData.frame; - const frameLeft = frame.position.x; - const frameTop = frame.position.y; - const frameRight = frame.position.x + frame.dimension.width; - const frameBottom = frame.position.y + frame.dimension.height; - - // Check if frame intersects with selection rectangle - const intersects = !( - frameLeft > canvasSelectionRect.right || - frameRight < canvasSelectionRect.left || - frameTop > canvasSelectionRect.bottom || - frameBottom < canvasSelectionRect.top - ); - - if (intersects) { - intersectingFrameIds.add(frame.id); - } - }); - - setFramesInSelection(intersectingFrameIds); - }, [position, scale, editorEngine.frames]); - + const intersectingFrameIds = getFramesInSelection( + editorEngine, + start, + end, + position, + scale + ); + setFramesInSelection(new Set(intersectingFrameIds)); + }, [position, scale, editorEngine]); + const handleCanvasMouseMove = useCallback( throttle((event: React.MouseEvent) => { if (!isDragSelecting || !containerRef.current) { return; } - + const rect = containerRef.current.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; setDragSelectEnd({ x, y }); - + // Update frames in selection for visual feedback updateFramesInSelection(dragSelectStart, { x, y }); }, 16), // ~60fps [isDragSelecting, dragSelectStart, updateFramesInSelection] ); - + const handleCanvasMouseUp = (event: React.MouseEvent) => { // Mouse up is now handled by the global listener in useEffect // This function is kept for consistency but the logic is in the global handler @@ -254,58 +223,38 @@ export const Canvas = observer(() => { }; } }, [handleWheel, middleMouseButtonDown, middleMouseButtonUp, handleCanvasMouseMove]); - + // Global mouseup listener to handle drag termination outside canvas useEffect(() => { if (isDragSelecting) { const handleGlobalMouseUp = (event: MouseEvent) => { - // Calculate which frames are within the selection rectangle - const selectionRect = { - left: Math.min(dragSelectStart.x, dragSelectEnd.x), - top: Math.min(dragSelectStart.y, dragSelectEnd.y), - right: Math.max(dragSelectStart.x, dragSelectEnd.x), - bottom: Math.max(dragSelectStart.y, dragSelectEnd.y), - }; - - // Convert selection rect to canvas coordinates - const canvasSelectionRect = { - left: (selectionRect.left - position.x) / scale, - top: (selectionRect.top - position.y) / scale, - right: (selectionRect.right - position.x) / scale, - bottom: (selectionRect.bottom - position.y) / scale, - }; - - // Find all frames that intersect with the selection rectangle - const allFrames = editorEngine.frames.getAll(); - const selectedFrames = allFrames.filter(frameData => { - const frame = frameData.frame; - const frameLeft = frame.position.x; - const frameTop = frame.position.y; - const frameRight = frame.position.x + frame.dimension.width; - const frameBottom = frame.position.y + frame.dimension.height; - - // Check if frame intersects with selection rectangle - return !( - frameLeft > canvasSelectionRect.right || - frameRight < canvasSelectionRect.left || - frameTop > canvasSelectionRect.bottom || - frameBottom < canvasSelectionRect.top - ); - }); - - // Select the frames if any were found in the selection - if (selectedFrames.length > 0) { - editorEngine.frames.select( - selectedFrames.map(fd => fd.frame), - event.shiftKey // multiselect if shift is held + try { + // Get frames that intersect with the selection rectangle + const selectedFrames = getSelectedFrameData( + editorEngine, + dragSelectStart, + dragSelectEnd, + position, + scale ); + + // Select the frames if any were found in the selection + if (selectedFrames.length > 0) { + editorEngine.frames.select( + selectedFrames.map(fd => fd.frame), + event.shiftKey // multiselect if shift is held + ); + } + } catch (error) { + console.warn('Error during drag selection:', error); + } finally { + // Always clean up drag selection state, even if selection fails + setIsDragSelecting(false); + setFramesInSelection(new Set()); + editorEngine.state.isDragSelecting = false; } - - setIsDragSelecting(false); - setFramesInSelection(new Set()); - editorEngine.state.isDragSelecting = false; }; - + window.addEventListener('mouseup', handleGlobalMouseUp); return () => window.removeEventListener('mouseup', handleGlobalMouseUp); } @@ -321,8 +270,12 @@ export const Canvas = observer(() => { onMouseUp={handleCanvasMouseUp} onMouseLeave={(e) => { // Only terminate drag if no mouse button is pressed - if (e.buttons === 0) { - handleCanvasMouseUp(e); + // Note: The global mouseup listener will handle the actual cleanup + // This is just an additional safety check for when mouse leaves without buttons pressed + if (e.buttons === 0 && isDragSelecting) { + setIsDragSelecting(false); + setFramesInSelection(new Set()); + editorEngine.state.isDragSelecting = false; } }} > diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/selection-utils.ts b/apps/web/client/src/app/project/[id]/_components/canvas/selection-utils.ts new file mode 100644 index 0000000000..9be50e6ec1 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/canvas/selection-utils.ts @@ -0,0 +1,101 @@ +import type { EditorEngine } from '@/components/store/editor/engine'; + +export interface SelectionRect { + left: number; + top: number; + right: number; + bottom: number; +} + +export interface CanvasPosition { + x: number; + y: number; +} + +/** + * Calculates which frames intersect with a selection rectangle + * @param editorEngine - The editor engine instance + * @param dragStart - Start position of drag selection in canvas coordinates + * @param dragEnd - End position of drag selection in canvas coordinates + * @param canvasPosition - Current canvas position + * @param canvasScale - Current canvas scale + * @returns Array of frame IDs that intersect with the selection rectangle + */ +export function getFramesInSelection( + editorEngine: EditorEngine, + dragStart: { x: number; y: number }, + dragEnd: { x: number; y: number }, + canvasPosition: CanvasPosition, + canvasScale: number +): string[] { + const selectionRect = { + left: Math.min(dragStart.x, dragEnd.x), + top: Math.min(dragStart.y, dragEnd.y), + right: Math.max(dragStart.x, dragEnd.x), + bottom: Math.max(dragStart.y, dragEnd.y), + }; + + // Convert selection rect to canvas coordinates + const canvasSelectionRect = { + left: (selectionRect.left - canvasPosition.x) / canvasScale, + top: (selectionRect.top - canvasPosition.y) / canvasScale, + right: (selectionRect.right - canvasPosition.x) / canvasScale, + bottom: (selectionRect.bottom - canvasPosition.y) / canvasScale, + }; + + // Find all frames that intersect with the selection rectangle + const allFrames = editorEngine.frames.getAll(); + const intersectingFrameIds: string[] = []; + + allFrames.forEach(frameData => { + const frame = frameData.frame; + const frameLeft = frame.position.x; + const frameTop = frame.position.y; + const frameRight = frame.position.x + frame.dimension.width; + const frameBottom = frame.position.y + frame.dimension.height; + + // Check if frame intersects with selection rectangle + const intersects = !( + frameLeft > canvasSelectionRect.right || + frameRight < canvasSelectionRect.left || + frameTop > canvasSelectionRect.bottom || + frameBottom < canvasSelectionRect.top + ); + + if (intersects) { + intersectingFrameIds.push(frame.id); + } + }); + + return intersectingFrameIds; +} + +/** + * Gets the actual frame objects for intersecting frames (used for final selection) + * @param editorEngine - The editor engine instance + * @param dragStart - Start position of drag selection in canvas coordinates + * @param dragEnd - End position of drag selection in canvas coordinates + * @param canvasPosition - Current canvas position + * @param canvasScale - Current canvas scale + * @returns Array of frame data objects that intersect with the selection rectangle + */ +export function getSelectedFrameData( + editorEngine: EditorEngine, + dragStart: { x: number; y: number }, + dragEnd: { x: number; y: number }, + canvasPosition: CanvasPosition, + canvasScale: number +) { + const intersectingFrameIds = getFramesInSelection( + editorEngine, + dragStart, + dragEnd, + canvasPosition, + canvasScale + ); + + const allFrames = editorEngine.frames.getAll(); + return allFrames.filter(frameData => + intersectingFrameIds.includes(frameData.frame.id) + ); +} \ No newline at end of file