Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -60,7 +61,10 @@ export const FrameView = observer(({ frame }: { frame: Frame }) => {
<RightClickMenu>
<TopBar frame={frame} />
</RightClickMenu>
<div className="relative">
<div className="relative" style={{
outline: isSelected ? '2px solid rgb(94, 234, 212)' : 'none',
borderRadius: '4px',
}}>
<ResizeHandles frame={frame} setIsResizing={setIsResizing} />
<FrameComponent key={reloadKey} frame={frame} reloadIframe={reloadIframe} ref={iFrameRef} />
<GestureScreen frame={frame} isResizing={isResizing} />
Expand Down
110 changes: 107 additions & 3 deletions apps/web/client/src/app/project/[id]/_components/canvas/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
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';

Expand All @@ -25,12 +27,98 @@ export const Canvas = observer(() => {
const containerRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
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 });

// 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 handleCanvasMouseMove = useCallback(
throttle((event: React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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(
Expand Down Expand Up @@ -156,21 +244,37 @@ 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 (
<HotkeysArea>
<div
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
if (e.buttons === 0) {
handleCanvasMouseUp(e);
}
}}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current onMouseLeave handler only terminates drag selection when no mouse button is pressed (e.buttons === 0), but doesn't handle the case where the user drags outside the canvas and then releases the mouse button elsewhere. This could leave the application in a perpetual drag selecting state.

Consider adding a global mouse up event listener when drag selecting starts:

useEffect(() => {
  if (isDragSelecting) {
    const handleGlobalMouseUp = () => {
      setIsDragSelecting(false);
    };
    
    window.addEventListener('mouseup', handleGlobalMouseUp);
    return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
  }
}, [isDragSelecting]);

This would ensure the drag selection state is properly reset regardless of where the mouse button is released.

Spotted by Diamond

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

>
<div id={EditorAttributes.CANVAS_CONTAINER_ID} style={transformStyle}>
<Frames />
</div>
<RecenterCanvasButton />
<DragSelectOverlay
startX={dragSelectStart.x}
startY={dragSelectStart.y}
endX={dragSelectEnd.x}
endY={dragSelectEnd.y}
isSelecting={isDragSelecting}
/>
<Overlay />
<PanOverlay
clampPosition={(position: { x: number; y: number }) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div
className="absolute pointer-events-none"
style={{
left: `${left}px`,
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)
}}
/>
);
});