Drag and drop function works with canvas scrolling

This commit is contained in:
Emir Karabeg
2025-01-07 17:37:51 -08:00
parent 1ef494a100
commit ec1d8e0067
6 changed files with 301 additions and 31 deletions

View File

@@ -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;
}
}

View File

@@ -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 (
// <div className="w-full h-screen overflow-hidden">
// <div
// className="w-full h-full bg-[#F5F5F5]"
// style={{
// backgroundImage: `radial-gradient(#D9D9D9 1px, transparent 1px)`,
// backgroundSize: '20px 20px',
// }}
// >
// {/* Canvas content will go here */}
// </div>
// </div>
<></>
<div
className="w-full h-[calc(100vh-56px)] overflow-hidden"
onWheel={handleWheel}
>
<div
className="w-full h-full bg-[#F5F5F5] relative cursor-grab active:cursor-grabbing"
style={{
backgroundImage: `radial-gradient(#D9D9D9 1px, transparent 1px)`,
backgroundSize: '20px 20px',
width: CANVAS_SIZE,
height: CANVAS_SIZE,
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
transformOrigin: '0 0',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{blocks.map((block) => (
<div
key={block.id}
style={{
position: 'absolute',
left: `${block.position.x}px`,
top: `${block.position.y}px`,
transform: 'translate(-50%, -50%)',
backgroundColor: block.bgColor,
width: '160px', // 40 * 4 (w-40)
height: '80px', // 20 * 4 (h-20)
borderRadius: '8px', // rounded-lg
cursor: 'move',
userSelect: 'none',
}}
/>
))}
</div>
</div>
)
}

View File

@@ -15,8 +15,26 @@ export function Block({
type,
bgColor,
}: BlockProps) {
const handleDragStart = (e: React.DragEvent) => {
// Pass block data as JSON string
e.dataTransfer.setData(
'application/json',
JSON.stringify({
type,
title,
description,
imagePath,
bgColor,
})
)
}
return (
<div className="group flex items-center gap-3 rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-accent/50 cursor-pointer">
<div
draggable
onDragStart={handleDragStart}
className="group flex items-center gap-3 rounded-lg border bg-card p-4 shadow-sm transition-colors hover:bg-accent/50 cursor-grab active:cursor-grabbing"
>
<div
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ backgroundColor: bgColor }}

View File

@@ -12,7 +12,7 @@ export function ToolbarTabs({ activeTab, onTabChange }: ToolbarTabsProps) {
<button
onClick={() => onTabChange('basic')}
className={`text-sm font-medium transition-colors hover:text-black ${
activeTab === 'basic' ? 'text-black' : 'text-gray-500'
activeTab === 'basic' ? 'text-black' : 'text-muted-foreground'
}`}
>
Basic
@@ -20,7 +20,7 @@ export function ToolbarTabs({ activeTab, onTabChange }: ToolbarTabsProps) {
<button
onClick={() => onTabChange('advanced')}
className={`text-sm font-medium transition-colors hover:text-black ${
activeTab === 'advanced' ? 'text-black' : 'text-gray-500'
activeTab === 'advanced' ? 'text-black' : 'text-muted-foreground'
}`}
>
Advanced
@@ -28,7 +28,7 @@ export function ToolbarTabs({ activeTab, onTabChange }: ToolbarTabsProps) {
</div>
<div className="relative mt-2">
<div className="absolute bottom-0 h-[1px] w-full bg-gray-200" />
<div className="absolute bottom-0 h-[1px] w-full bg-[#E2E8F0]" />
<div
className="absolute bottom-0 h-[1.5px] bg-black transition-transform duration-200"
style={{

View File

@@ -0,0 +1,56 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { History, Bell, Play } from 'lucide-react'
export function WorkflowControlBar() {
return (
<div className="flex h-16 w-full items-center justify-between bg-background px-6 border-b">
{/* Left Section - Workflow Info */}
<div className="flex flex-col gap-[2px]">
<h2 className="font-semibold text-sm">Workflow 1</h2>
<p className="text-xs text-muted-foreground">Saved 2 minutes ago</p>
</div>
{/* Middle Section - Reserved for future use */}
<div className="flex-1" />
{/* Right Section - Actions */}
<div className="flex items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<History className="h-5 w-5" />
<span className="sr-only">Version History</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>View History</DropdownMenuItem>
<DropdownMenuItem>Compare Versions</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>No new notifications</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button className="gap-2 bg-[#7F2FFF] hover:bg-[#7F2FFF]/90">
<Play fill="currentColor" className="!h-3.5 !w-3.5" />
Run
</Button>
</div>
</div>
)
}

View File

@@ -24,19 +24,9 @@ import {
SheetTrigger,
SheetTitle,
} from '@/components/ui/sheet'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { SearchInput } from './components/search'
import { User } from './components/user'
import Image from 'next/image'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { DesktopToolbar } from './components/desktop-toolbar'
import { WorkflowControlBar } from './components/workflow-control-bar'
export default function WorkspaceLayout({
children,
@@ -49,12 +39,15 @@ export default function WorkspaceLayout({
<div className="flex">
<DesktopNav />
<DesktopToolbar />
<div className="fixed top-0 left-[344px] right-0 z-30">
<WorkflowControlBar />
</div>
</div>
<div className="flex flex-col sm:gap-4 sm:py-4 sm:pl-[344px]">
<header className="sticky top-0 z-30 flex h-14 items-center gap-4 border-b bg-background px-4 sm:static sm:h-auto sm:border-0 sm:bg-transparent sm:px-6">
<div className="flex flex-col sm:gap-4 sm:pt-[56px] sm:pl-[344px]">
<header className="sticky top-0 z-30 flex h-14 items-center border-b bg-background sm:static sm:hidden">
<MobileNav />
</header>
<main className="grid flex-1 items-start gap-2 p-4 sm:px-6 sm:py-0 md:gap-4 bg-muted/40">
<main className="grid flex-1 items-start gap-2 sm:py-0 md:gap-4 bg-muted/40">
{children}
</main>
</div>