Skip to content

Commit 2f97782

Browse files
feat(undo-redo): undo/redo for canvas editing (#1392)
* feat(undo-redo): support undo-redo on canvas * fix zoom live subscribe * progress * fix subflows * progress * fix subflow logic * pruning stacks * centralize unique naming logic * fix type issues * address greptile comments * remove timeouts
1 parent 7cb303e commit 2f97782

File tree

14 files changed

+2635
-86
lines changed

14 files changed

+2635
-86
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
'use client'
2+
3+
import { Minus, Plus, Redo2, Undo2 } from 'lucide-react'
4+
import { useReactFlow, useStore } from 'reactflow'
5+
import { Button } from '@/components/ui/button'
6+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
7+
import { useSession } from '@/lib/auth-client'
8+
import { cn } from '@/lib/utils'
9+
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
10+
import { useGeneralStore } from '@/stores/settings/general/store'
11+
import { useUndoRedoStore } from '@/stores/undo-redo'
12+
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
13+
14+
export function FloatingControls() {
15+
const { zoomIn, zoomOut } = useReactFlow()
16+
// Subscribe to React Flow store so zoom % live-updates while zooming
17+
const zoom = useStore((s: any) =>
18+
Array.isArray(s.transform) ? s.transform[2] : s.viewport?.zoom
19+
)
20+
const { undo, redo } = useCollaborativeWorkflow()
21+
const { showFloatingControls } = useGeneralStore()
22+
const { activeWorkflowId } = useWorkflowRegistry()
23+
const { data: session } = useSession()
24+
const userId = session?.user?.id || 'unknown'
25+
const stacks = useUndoRedoStore((s) => s.stacks)
26+
27+
const undoRedoSizes = (() => {
28+
const key = activeWorkflowId && userId ? `${activeWorkflowId}:${userId}` : ''
29+
const stack = (key && stacks[key]) || { undo: [], redo: [] }
30+
return { undoSize: stack.undo.length, redoSize: stack.redo.length }
31+
})()
32+
const currentZoom = Math.round(((zoom as number) || 1) * 100)
33+
34+
if (!showFloatingControls) return null
35+
36+
const handleZoomIn = () => {
37+
zoomIn({ duration: 200 })
38+
}
39+
40+
const handleZoomOut = () => {
41+
zoomOut({ duration: 200 })
42+
}
43+
44+
return (
45+
<div className='-translate-x-1/2 fixed bottom-6 left-1/2 z-10'>
46+
<div className='flex items-center gap-1 rounded-[14px] border bg-card/95 p-1 shadow-lg backdrop-blur-sm'>
47+
<Tooltip>
48+
<TooltipTrigger asChild>
49+
<Button
50+
variant='ghost'
51+
size='icon'
52+
onClick={handleZoomOut}
53+
disabled={currentZoom <= 10}
54+
className={cn(
55+
'h-9 w-9 rounded-[10px]',
56+
'hover:bg-muted/80',
57+
'disabled:cursor-not-allowed disabled:opacity-50'
58+
)}
59+
>
60+
<Minus className='h-4 w-4' />
61+
</Button>
62+
</TooltipTrigger>
63+
<TooltipContent>Zoom Out</TooltipContent>
64+
</Tooltip>
65+
66+
<div className='flex w-12 items-center justify-center font-medium text-muted-foreground text-sm'>
67+
{currentZoom}%
68+
</div>
69+
70+
<Tooltip>
71+
<TooltipTrigger asChild>
72+
<Button
73+
variant='ghost'
74+
size='icon'
75+
onClick={handleZoomIn}
76+
disabled={currentZoom >= 200}
77+
className={cn(
78+
'h-9 w-9 rounded-[10px]',
79+
'hover:bg-muted/80',
80+
'disabled:cursor-not-allowed disabled:opacity-50'
81+
)}
82+
>
83+
<Plus className='h-4 w-4' />
84+
</Button>
85+
</TooltipTrigger>
86+
<TooltipContent>Zoom In</TooltipContent>
87+
</Tooltip>
88+
89+
<div className='mx-1 h-6 w-px bg-border' />
90+
91+
<Tooltip>
92+
<TooltipTrigger asChild>
93+
<Button
94+
variant='ghost'
95+
size='icon'
96+
onClick={undo}
97+
disabled={undoRedoSizes.undoSize === 0}
98+
className={cn(
99+
'h-9 w-9 rounded-[10px]',
100+
'hover:bg-muted/80',
101+
'disabled:cursor-not-allowed disabled:opacity-50'
102+
)}
103+
>
104+
<Undo2 className='h-4 w-4' />
105+
</Button>
106+
</TooltipTrigger>
107+
<TooltipContent>
108+
<div className='text-center'>
109+
<p>Undo</p>
110+
<p className='text-muted-foreground text-xs'>Cmd+Z</p>
111+
</div>
112+
</TooltipContent>
113+
</Tooltip>
114+
115+
<Tooltip>
116+
<TooltipTrigger asChild>
117+
<Button
118+
variant='ghost'
119+
size='icon'
120+
onClick={redo}
121+
disabled={undoRedoSizes.redoSize === 0}
122+
className={cn(
123+
'h-9 w-9 rounded-[10px]',
124+
'hover:bg-muted/80',
125+
'disabled:cursor-not-allowed disabled:opacity-50'
126+
)}
127+
>
128+
<Redo2 className='h-4 w-4' />
129+
</Button>
130+
</TooltipTrigger>
131+
<TooltipContent>
132+
<div className='text-center'>
133+
<p>Redo</p>
134+
<p className='text-muted-foreground text-xs'>Cmd+Shift+Z</p>
135+
</div>
136+
</TooltipContent>
137+
</Tooltip>
138+
</div>
139+
</div>
140+
)
141+
}

0 commit comments

Comments
 (0)