diff --git a/app/globals.css b/app/globals.css index 574952a69..34c994559 100644 --- a/app/globals.css +++ b/app/globals.css @@ -69,10 +69,12 @@ @layer base { * { @apply border-border; + overscroll-behavior-x: none; } body { @apply bg-background text-foreground; + overscroll-behavior-x: none; } } @@ -90,4 +92,4 @@ input[type='search']::-moz-search-cancel-button { /* Microsoft Edge */ input[type='search']::-ms-clear { display: none; -} \ No newline at end of file +} diff --git a/app/w/[id]/workflow.tsx b/app/w/[id]/workflow.tsx index e3ffbda48..44467d324 100644 --- a/app/w/[id]/workflow.tsx +++ b/app/w/[id]/workflow.tsx @@ -1,16 +1,217 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { BlockProps } from '../components/block' + +const ZOOM_SPEED = 0.005 +const MIN_ZOOM = 0.5 +const MAX_ZOOM = 2 +const CANVAS_SIZE = 5000 // 5000px x 5000px virtual canvas + export default function Workflow() { + const [blocks, setBlocks] = useState< + (BlockProps & { id: string; position: { x: number; y: number } })[] + >([]) + const [zoom, setZoom] = useState(1) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [isPanning, setIsPanning] = useState(false) + const [startPanPos, setStartPanPos] = useState({ x: 0, y: 0 }) + + // Initialize pan position after mount + useEffect(() => { + const viewportWidth = window.innerWidth - 344 // Account for sidebar + const viewportHeight = window.innerHeight - 56 // Account for header + setPan({ + x: (viewportWidth - CANVAS_SIZE) / 2, + y: (viewportHeight - CANVAS_SIZE) / 2, + }) + }, []) + + const constrainPan = useCallback( + (newPan: { x: number; y: number }, currentZoom: number) => { + // Calculate the visible area dimensions + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight - 56 // Adjust for header height + + // Calculate the scaled canvas size + const scaledCanvasWidth = CANVAS_SIZE * currentZoom + const scaledCanvasHeight = CANVAS_SIZE * currentZoom + + // Calculate the maximum allowed pan values + const maxX = 0 + const minX = viewportWidth - scaledCanvasWidth + const maxY = 0 + const minY = viewportHeight - scaledCanvasHeight + + return { + x: Math.min(maxX, Math.max(minX, newPan.x)), + y: Math.min(maxY, Math.max(minY, newPan.y)), + } + }, + [] + ) + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.dataTransfer.dropEffect = 'copy' + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + + try { + const blockData = JSON.parse( + e.dataTransfer.getData('application/json') + ) as BlockProps + + // Get the canvas element's bounding rectangle + const rect = e.currentTarget.getBoundingClientRect() + + // Calculate the drop position in canvas coordinates + // 1. Get the mouse position relative to the canvas element + // 2. Remove the pan offset (scaled by zoom) + // 3. Scale by zoom to get true canvas coordinates + const mouseX = e.clientX - rect.left + const mouseY = e.clientY - rect.top + + const x = mouseX / zoom + const y = mouseY / zoom + + setBlocks((prev) => [ + ...prev, + { + ...blockData, + id: crypto.randomUUID(), + position: { x, y }, + }, + ]) + } catch (err) { + console.error('Error dropping block:', err) + } + } + + const handleWheel = useCallback( + (e: React.WheelEvent) => { + // Prevent browser zooming + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + const delta = -e.deltaY * ZOOM_SPEED + setZoom((prevZoom) => { + // If we're at max/min zoom and trying to zoom further, return current zoom + if ( + (prevZoom >= MAX_ZOOM && delta > 0) || + (prevZoom <= MIN_ZOOM && delta < 0) + ) { + return prevZoom + } + const newZoom = Math.min( + MAX_ZOOM, + Math.max(MIN_ZOOM, prevZoom + delta) + ) + // Adjust pan when zooming to keep the point under cursor fixed + setPan((prevPan) => constrainPan(prevPan, newZoom)) + return newZoom + }) + } else { + // Regular scrolling for pan + setPan((prevPan) => + constrainPan( + { + x: prevPan.x - e.deltaX, + y: prevPan.y - e.deltaY, + }, + zoom + ) + ) + } + }, + [constrainPan, zoom] + ) + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (e.button === 1 || e.button === 0) { + // Middle mouse or left click + setIsPanning(true) + setStartPanPos({ x: e.clientX - pan.x, y: e.clientY - pan.y }) + } + }, + [pan] + ) + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (isPanning) { + setPan((prevPan) => + constrainPan( + { + x: e.clientX - startPanPos.x, + y: e.clientY - startPanPos.y, + }, + zoom + ) + ) + } + }, + [isPanning, startPanPos, zoom, constrainPan] + ) + + const handleMouseUp = useCallback(() => { + setIsPanning(false) + }, []) + + // Add this useEffect to prevent browser zoom + useEffect(() => { + const preventDefaultZoom = (e: WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault() + } + } + + document.addEventListener('wheel', preventDefaultZoom, { passive: false }) + return () => document.removeEventListener('wheel', preventDefaultZoom) + }, []) + return ( - //
Saved 2 minutes ago
+