mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
Drag and drop function works with canvas scrolling
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
56
app/w/components/workflow-control-bar.tsx
Normal file
56
app/w/components/workflow-control-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user