Skip to content

Commit 530e2ac

Browse files
committed
added util for fit to zoom that accounts for sidebar, terminal, and panel
1 parent e59e2fd commit 530e2ac

File tree

4 files changed

+207
-14
lines changed

4 files changed

+207
-14
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/action-bar/action-bar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,17 @@ import {
1919
} from '@/components/emcn'
2020
import { useSession } from '@/lib/auth/auth-client'
2121
import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
22+
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
2223
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
2324
import { useCanvasModeStore } from '@/stores/canvas-mode'
2425
import { useGeneralStore } from '@/stores/settings/general'
2526
import { useUndoRedoStore } from '@/stores/undo-redo'
2627
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2728

2829
export function ActionBar() {
29-
const { zoomIn, zoomOut, fitView } = useReactFlow()
30+
const reactFlowInstance = useReactFlow()
31+
const { zoomIn, zoomOut } = reactFlowInstance
32+
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
3033
const { mode, setMode } = useCanvasModeStore()
3134
const { undo, redo } = useCollaborativeWorkflow()
3235
const showActionBar = useGeneralStore((s) => s.showActionBar)
@@ -61,7 +64,7 @@ export function ActionBar() {
6164
return (
6265
<>
6366
<div
64-
className='fixed bottom-[calc(var(--terminal-height)+12px)] left-[calc(var(--sidebar-width)+12px)] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px] shadow-sm transition-[left,bottom] duration-100 ease-out'
67+
className='fixed bottom-[calc(var(--terminal-height)+16px)] left-[calc(var(--sidebar-width)+16px)] z-10 flex h-[36px] items-center gap-[2px] rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] p-[4px] shadow-sm transition-[left,bottom] duration-100 ease-out'
6568
onContextMenu={handleContextMenu}
6669
>
6770
<Tooltip.Root>
@@ -149,7 +152,7 @@ export function ActionBar() {
149152
<Button
150153
variant='ghost'
151154
className='h-[28px] w-[28px] p-0'
152-
onClick={() => fitView({ padding: 0.3, duration: 300 })}
155+
onClick={() => fitViewToBounds({ padding: 0.1, duration: 300 })}
153156
>
154157
<Expand className='h-[16px] w-[16px]' />
155158
</Button>

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-auto-layout.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { useReactFlow } from 'reactflow'
44
import type { AutoLayoutOptions } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
55
import { applyAutoLayoutAndUpdateStore as applyAutoLayoutStandalone } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/auto-layout-utils'
6+
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
67

78
export type { AutoLayoutOptions }
89

@@ -16,7 +17,8 @@ const logger = createLogger('useAutoLayout')
1617
* Note: This hook requires a ReactFlowProvider ancestor.
1718
*/
1819
export function useAutoLayout(workflowId: string | null) {
19-
const { fitView } = useReactFlow()
20+
const reactFlowInstance = useReactFlow()
21+
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
2022

2123
const applyAutoLayoutAndUpdateStore = useCallback(
2224
async (options: AutoLayoutOptions = {}) => {
@@ -38,7 +40,7 @@ export function useAutoLayout(workflowId: string | null) {
3840
if (result.success) {
3941
logger.info('Auto layout completed successfully')
4042
requestAnimationFrame(() => {
41-
fitView({ padding: 0.8, duration: 600 })
43+
fitViewToBounds({ padding: 0.15, duration: 600 })
4244
})
4345
} else {
4446
logger.error('Auto layout failed:', result.error)
@@ -52,7 +54,7 @@ export function useAutoLayout(workflowId: string | null) {
5254
error: error instanceof Error ? error.message : 'Unknown error',
5355
}
5456
}
55-
}, [applyAutoLayoutAndUpdateStore, fitView])
57+
}, [applyAutoLayoutAndUpdateStore, fitViewToBounds])
5658

5759
return {
5860
applyAutoLayoutAndUpdateStore,

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'
6363
import { getBlock } from '@/blocks'
6464
import { isAnnotationOnlyBlock } from '@/executor/constants'
6565
import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
66+
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
6667
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
6768
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
6869
import { useCanvasModeStore } from '@/stores/canvas-mode'
@@ -225,7 +226,9 @@ const WorkflowContent = React.memo(() => {
225226

226227
const params = useParams()
227228
const router = useRouter()
228-
const { screenToFlowPosition, getNodes, setNodes, fitView, getIntersectingNodes } = useReactFlow()
229+
const reactFlowInstance = useReactFlow()
230+
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
231+
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
229232
const { emitCursorUpdate } = useSocket()
230233

231234
const workspaceId = params.workspaceId as string
@@ -1502,29 +1505,29 @@ const WorkflowContent = React.memo(() => {
15021505
foundNodes: changedNodes.length,
15031506
})
15041507
requestAnimationFrame(() => {
1505-
fitView({
1508+
fitViewToBounds({
15061509
nodes: changedNodes,
15071510
duration: 600,
1508-
padding: 0.3,
1511+
padding: 0.1,
15091512
minZoom: 0.5,
15101513
maxZoom: 1.0,
15111514
})
15121515
})
15131516
} else {
15141517
logger.info('Diff ready - no changed nodes found, fitting all')
15151518
requestAnimationFrame(() => {
1516-
fitView({ padding: 0.3, duration: 600 })
1519+
fitViewToBounds({ padding: 0.1, duration: 600 })
15171520
})
15181521
}
15191522
} else {
15201523
logger.info('Diff ready - no changed blocks, fitting all')
15211524
requestAnimationFrame(() => {
1522-
fitView({ padding: 0.3, duration: 600 })
1525+
fitViewToBounds({ padding: 0.1, duration: 600 })
15231526
})
15241527
}
15251528
}
15261529
prevDiffReadyRef.current = isDiffReady
1527-
}, [isDiffReady, diffAnalysis, fitView, getNodes])
1530+
}, [isDiffReady, diffAnalysis, fitViewToBounds, getNodes])
15281531

15291532
/** Displays trigger warning notifications. */
15301533
useEffect(() => {
@@ -3308,9 +3311,9 @@ const WorkflowContent = React.memo(() => {
33083311
edgeTypes={edgeTypes}
33093312
onDrop={effectivePermissions.canEdit ? onDrop : undefined}
33103313
onDragOver={effectivePermissions.canEdit ? onDragOver : undefined}
3311-
onInit={(instance) => {
3314+
onInit={() => {
33123315
requestAnimationFrame(() => {
3313-
instance.fitView(reactFlowFitViewOptions)
3316+
fitViewToBounds({ padding: 0.1, maxZoom: 1.0 })
33143317
setIsCanvasReady(true)
33153318
})
33163319
}}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { useCallback } from 'react'
2+
import type { Node, ReactFlowInstance } from 'reactflow'
3+
4+
interface VisibleBounds {
5+
width: number
6+
height: number
7+
offsetLeft: number
8+
offsetRight: number
9+
offsetBottom: number
10+
}
11+
12+
/**
13+
* Gets the visible canvas bounds accounting for sidebar, terminal, and panel overlays.
14+
* Works correctly regardless of whether the ReactFlow container extends under the sidebar or not.
15+
*/
16+
function getVisibleCanvasBounds(): VisibleBounds {
17+
const style = getComputedStyle(document.documentElement)
18+
19+
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
20+
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
21+
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
22+
23+
const flowContainer = document.querySelector('.react-flow')
24+
if (!flowContainer) {
25+
return {
26+
width: window.innerWidth - sidebarWidth - panelWidth,
27+
height: window.innerHeight - terminalHeight,
28+
offsetLeft: sidebarWidth,
29+
offsetRight: panelWidth,
30+
offsetBottom: terminalHeight,
31+
}
32+
}
33+
34+
const rect = flowContainer.getBoundingClientRect()
35+
36+
// Calculate actual visible area in screen coordinates
37+
// This works regardless of whether the container extends under overlays
38+
const visibleLeft = Math.max(rect.left, sidebarWidth)
39+
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
40+
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
41+
42+
// Calculate visible dimensions and offsets relative to the container
43+
const visibleWidth = Math.max(0, visibleRight - visibleLeft)
44+
const visibleHeight = Math.max(0, visibleBottom - rect.top)
45+
46+
return {
47+
width: visibleWidth,
48+
height: visibleHeight,
49+
offsetLeft: visibleLeft - rect.left,
50+
offsetRight: rect.right - visibleRight,
51+
offsetBottom: rect.bottom - visibleBottom,
52+
}
53+
}
54+
55+
/**
56+
* Gets the center of the visible canvas in screen coordinates.
57+
*/
58+
function getVisibleCanvasCenter(): { x: number; y: number } {
59+
const style = getComputedStyle(document.documentElement)
60+
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
61+
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
62+
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
63+
64+
const flowContainer = document.querySelector('.react-flow')
65+
if (!flowContainer) {
66+
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
67+
const visibleHeight = window.innerHeight - terminalHeight
68+
return {
69+
x: sidebarWidth + visibleWidth / 2,
70+
y: visibleHeight / 2,
71+
}
72+
}
73+
74+
const rect = flowContainer.getBoundingClientRect()
75+
76+
// Calculate actual visible area in screen coordinates
77+
const visibleLeft = Math.max(rect.left, sidebarWidth)
78+
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
79+
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
80+
81+
return {
82+
x: (visibleLeft + visibleRight) / 2,
83+
y: (rect.top + visibleBottom) / 2,
84+
}
85+
}
86+
87+
interface FitViewToBoundsOptions {
88+
padding?: number
89+
maxZoom?: number
90+
minZoom?: number
91+
duration?: number
92+
nodes?: Node[]
93+
}
94+
95+
/**
96+
* Hook providing canvas viewport utilities that account for sidebar, panel, and terminal overlays.
97+
*/
98+
export function useCanvasViewport(reactFlowInstance: ReactFlowInstance | null) {
99+
/**
100+
* Gets the center of the visible canvas in flow coordinates.
101+
*/
102+
const getViewportCenter = useCallback(() => {
103+
if (!reactFlowInstance) {
104+
return { x: 0, y: 0 }
105+
}
106+
107+
const center = getVisibleCanvasCenter()
108+
return reactFlowInstance.screenToFlowPosition(center)
109+
}, [reactFlowInstance])
110+
111+
/**
112+
* Fits the view to show all nodes within the visible canvas bounds,
113+
* accounting for sidebar, panel, and terminal overlays.
114+
* @param padding - Fraction of viewport to leave as margin (0.1 = 10% on each side)
115+
*/
116+
const fitViewToBounds = useCallback(
117+
(options: FitViewToBoundsOptions = {}) => {
118+
if (!reactFlowInstance) return
119+
120+
const {
121+
padding = 0.1,
122+
maxZoom = 1,
123+
minZoom = 0.1,
124+
duration = 300,
125+
nodes: targetNodes,
126+
} = options
127+
128+
const nodes = targetNodes ?? reactFlowInstance.getNodes()
129+
if (nodes.length === 0) {
130+
return
131+
}
132+
133+
const bounds = getVisibleCanvasBounds()
134+
135+
// Calculate node bounds
136+
let minX = Number.POSITIVE_INFINITY
137+
let minY = Number.POSITIVE_INFINITY
138+
let maxX = Number.NEGATIVE_INFINITY
139+
let maxY = Number.NEGATIVE_INFINITY
140+
141+
nodes.forEach((node) => {
142+
const nodeWidth = node.width ?? 200
143+
const nodeHeight = node.height ?? 100
144+
145+
minX = Math.min(minX, node.position.x)
146+
minY = Math.min(minY, node.position.y)
147+
maxX = Math.max(maxX, node.position.x + nodeWidth)
148+
maxY = Math.max(maxY, node.position.y + nodeHeight)
149+
})
150+
151+
const contentWidth = maxX - minX
152+
const contentHeight = maxY - minY
153+
154+
// Apply padding as fraction of viewport (matches ReactFlow's fitView behavior)
155+
const availableWidth = bounds.width * (1 - padding * 2)
156+
const availableHeight = bounds.height * (1 - padding * 2)
157+
158+
// Calculate zoom to fit content in available area
159+
const zoomX = availableWidth / contentWidth
160+
const zoomY = availableHeight / contentHeight
161+
const zoom = Math.max(minZoom, Math.min(maxZoom, Math.min(zoomX, zoomY)))
162+
163+
// Calculate center of content in flow coordinates
164+
const contentCenterX = minX + contentWidth / 2
165+
const contentCenterY = minY + contentHeight / 2
166+
167+
// Calculate viewport position to center content in visible area
168+
// Account for sidebar offset on the left
169+
const visibleCenterX = bounds.offsetLeft + bounds.width / 2
170+
const visibleCenterY = bounds.height / 2
171+
172+
const x = visibleCenterX - contentCenterX * zoom
173+
const y = visibleCenterY - contentCenterY * zoom
174+
175+
reactFlowInstance.setViewport({ x, y, zoom }, { duration })
176+
},
177+
[reactFlowInstance]
178+
)
179+
180+
return {
181+
getViewportCenter,
182+
fitViewToBounds,
183+
getVisibleCanvasBounds,
184+
}
185+
}

0 commit comments

Comments
 (0)