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 917c15ca56..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 @@ -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'; @@ -11,12 +12,13 @@ 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); 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); @@ -58,11 +60,14 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => { style={{ transform: `translate(${frame.position.x}px, ${frame.position.y}px)` }} > - + -
+
- + {isConnecting && !hasTimedOut && ( 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..fcd26ebc64 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/top-bar/helpers.ts @@ -0,0 +1,99 @@ +import type { EditorEngine } from '@/components/store/editor/engine'; +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; + let isDragActive = false; + + // 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) => { + 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 = dx / scale; + const deltaY = dy / 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 fb0b0d5ab8..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,15 +8,17 @@ 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( - ({ frame }: { frame: Frame }) => { + ({ frame, isInDragSelection = false }: { frame: Frame; isInDragSelection?: boolean }) => { const editorEngine = useEditorEngine(); const isSelected = editorEngine.frames.isSelected(frame.id); 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); }; @@ -133,6 +117,7 @@ export const TopBar = observer( className={cn( 'bg-blend-multiply hover:shadow 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={{ backgroundColor: 'rgba(255, 255, 255, 0.04)', 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 f6d506706a..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); @@ -301,7 +302,8 @@ export const FrameComponent = observer( className={cn( 'backdrop-blur-sm transition outline outline-4', isActiveBranch && 'outline-teal-400', - isActiveBranch && !isSelected && 'outline-teal-600', + 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" 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 32ac3c4400..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 @@ -3,13 +3,16 @@ 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 } 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'; +import { getFramesInSelection, getSelectedFrameData } from './selection-utils'; const ZOOM_SENSITIVITY = 0.006; const PAN_SENSITIVITY = 0.52; @@ -25,12 +28,72 @@ 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 [framesInSelection, setFramesInSelection] = useState>(new Set()); const handleCanvasMouseDown = (event: React.MouseEvent) => { if (event.target !== containerRef.current) { return; } - editorEngine.clearUI(); + + // 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(); + editorEngine.frames.deselectAll(); + } + } else if (event.button === 0) { + // Only clear UI for left clicks that don't start drag selection + editorEngine.clearUI(); + } + }; + + const updateFramesInSelection = useCallback((start: { x: number; y: number }, end: { x: number; y: number }) => { + 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 }; const handleZoom = useCallback( @@ -156,9 +219,46 @@ 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]); + + // Global mouseup listener to handle drag termination outside canvas + useEffect(() => { + if (isDragSelecting) { + const handleGlobalMouseUp = (event: MouseEvent) => { + 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; + } + }; + + window.addEventListener('mouseup', handleGlobalMouseUp); + return () => window.removeEventListener('mouseup', handleGlobalMouseUp); + } + }, [isDragSelecting, dragSelectStart, dragSelectEnd, position, scale, editorEngine]); return ( @@ -166,11 +266,30 @@ export const Canvas = observer(() => { ref={containerRef} className="overflow-hidden bg-background-onlook flex flex-grow relative" onMouseDown={handleCanvasMouseDown} + onMouseMove={handleCanvasMouseMove} + onMouseUp={handleCanvasMouseUp} + onMouseLeave={(e) => { + // Only terminate drag if no mouse button is pressed + // 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/overlay/drag-select.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx new file mode 100644 index 0000000000..a91b33a446 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_components/canvas/overlay/drag-select.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { colors } from '@onlook/ui/tokens'; +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 ( +
+ ); +}); 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 diff --git a/apps/web/client/src/components/store/editor/state/index.ts b/apps/web/client/src/components/store/editor/state/index.ts index e6613a5f8c..23667f8265 100644 --- a/apps/web/client/src/components/store/editor/state/index.ts +++ b/apps/web/client/src/components/store/editor/state/index.ts @@ -15,6 +15,7 @@ export class StateManager { publishOpen = false; leftPanelLocked = false; canvasPanning = false; + isDragSelecting = false; editorMode: EditorMode = EditorMode.DESIGN; leftPanelTab: LeftPanelTabValue | null = null;