Skip to content

Commit a90cb18

Browse files
committed
fix autozoom
1 parent 45d61c1 commit a90cb18

3 files changed

Lines changed: 147 additions & 34 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const ImagePreview = memo(function ImagePreview({ file }: { file: Workspa
88
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
99

1010
return (
11-
<ZoomablePreview className='flex flex-1'>
11+
<ZoomablePreview className='flex flex-1' contentClassName='h-full w-full'>
1212
<img
1313
src={serveUrl}
1414
alt={file.name}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -394,13 +394,15 @@ const MermaidDiagram = memo(function MermaidDiagram({
394394
}
395395

396396
if (svg && renderedDefinition === trimmedDefinition) {
397-
const diagram = (
398-
<div className='max-h-full max-w-full' dangerouslySetInnerHTML={{ __html: svg }} />
399-
)
397+
const diagram = <div dangerouslySetInnerHTML={{ __html: svg }} />
400398

401399
if (zoomable) {
402400
return (
403-
<ZoomablePreview className={zoomClassName ?? 'my-4 h-[420px] rounded-lg'}>
401+
<ZoomablePreview
402+
className={zoomClassName ?? 'my-4 h-[420px] rounded-lg'}
403+
initialScale='fit'
404+
resetKey={renderedDefinition}
405+
>
404406
{diagram}
405407
</ZoomablePreview>
406408
)
@@ -415,7 +417,7 @@ const MermaidDiagram = memo(function MermaidDiagram({
415417
return <MermaidSourcePreview definition={definition} isRendering={isRendering} />
416418
}
417419

418-
if (!trimmedDefinition || !svg) {
420+
if (!trimmedDefinition || !svg || renderedDefinition !== trimmedDefinition) {
419421
return <MermaidCodeBlockSkeleton />
420422
}
421423
return null
@@ -987,7 +989,7 @@ function SvgPreview({ content }: { content: string }) {
987989
const wrappedContent = `<!DOCTYPE html><html><head><style>body{margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:transparent;}svg{max-width:100%;max-height:100vh;}</style></head><body>${content}</body></html>`
988990

989991
return (
990-
<ZoomablePreview className='h-full'>
992+
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
991993
<iframe
992994
srcDoc={wrappedContent}
993995
sandbox=''

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/zoomable-preview.tsx

Lines changed: 138 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import type { MouseEvent, ReactNode } from 'react'
4-
import { useCallback, useEffect, useRef, useState } from 'react'
4+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
55
import { ZoomIn, ZoomOut } from 'lucide-react'
66
import { Button } from '@/components/emcn'
77
import { cn } from '@/lib/core/utils/cn'
@@ -10,6 +10,7 @@ const ZOOM_MIN = 0.25
1010
const ZOOM_MAX = 4
1111
const ZOOM_WHEEL_SENSITIVITY = 0.005
1212
const ZOOM_BUTTON_FACTOR = 1.2
13+
const FIT_PADDING = 48
1314

1415
const clampZoom = (zoom: number) => Math.min(Math.max(zoom, ZOOM_MIN), ZOOM_MAX)
1516

@@ -18,44 +19,104 @@ interface Offset {
1819
y: number
1920
}
2021

22+
interface Size {
23+
width: number
24+
height: number
25+
}
26+
2127
interface ZoomablePreviewProps {
2228
children: ReactNode
2329
className?: string
2430
contentClassName?: string
31+
initialScale?: 'actual' | 'fit'
32+
resetKey?: string | number
33+
}
34+
35+
function getElementSize(element: HTMLElement | null): Size {
36+
if (!element) return { width: 0, height: 0 }
37+
return {
38+
width: element.offsetWidth,
39+
height: element.offsetHeight,
40+
}
2541
}
2642

27-
function clampOffset(container: HTMLDivElement | null, offset: Offset, zoom: number): Offset {
28-
if (!container) return offset
43+
function getFitZoom(container: Size, content: Size): number {
44+
if (container.width <= 0 || container.height <= 0 || content.width <= 0 || content.height <= 0) {
45+
return 1
46+
}
2947

30-
const maxX = Math.max(0, (container.clientWidth * zoom - container.clientWidth) / 2)
31-
const maxY = Math.max(0, (container.clientHeight * zoom - container.clientHeight) / 2)
48+
const availableWidth = Math.max(1, container.width - FIT_PADDING)
49+
const availableHeight = Math.max(1, container.height - FIT_PADDING)
50+
return clampZoom(Math.min(1, availableWidth / content.width, availableHeight / content.height))
51+
}
52+
53+
function clampOffset(container: Size, content: Size, offset: Offset, zoom: number): Offset {
54+
if (container.width <= 0 || container.height <= 0 || content.width <= 0 || content.height <= 0) {
55+
return offset
56+
}
57+
58+
const scaledWidth = content.width * zoom
59+
const scaledHeight = content.height * zoom
60+
const maxX = Math.max(0, (scaledWidth - container.width) / 2)
61+
const maxY = Math.max(0, (scaledHeight - container.height) / 2)
3262

3363
return {
3464
x: Math.min(Math.max(offset.x, -maxX), maxX),
3565
y: Math.min(Math.max(offset.y, -maxY), maxY),
3666
}
3767
}
3868

39-
export function ZoomablePreview({ children, className, contentClassName }: ZoomablePreviewProps) {
69+
export function ZoomablePreview({
70+
children,
71+
className,
72+
contentClassName,
73+
initialScale = 'actual',
74+
resetKey,
75+
}: ZoomablePreviewProps) {
4076
const [zoom, setZoom] = useState(1)
4177
const [offset, setOffset] = useState({ x: 0, y: 0 })
78+
const [containerSize, setContainerSize] = useState<Size>({ width: 0, height: 0 })
79+
const [contentSize, setContentSize] = useState<Size>({ width: 0, height: 0 })
4280
const containerRef = useRef<HTMLDivElement>(null)
81+
const contentRef = useRef<HTMLDivElement>(null)
4382
const isDragging = useRef(false)
4483
const dragStart = useRef({ x: 0, y: 0 })
4584
const offsetAtDragStart = useRef({ x: 0, y: 0 })
85+
const hasInteractedRef = useRef(false)
4686
const zoomRef = useRef(zoom)
4787
const offsetRef = useRef(offset)
88+
const containerSizeRef = useRef(containerSize)
89+
const contentSizeRef = useRef(contentSize)
4890
zoomRef.current = zoom
4991
offsetRef.current = offset
92+
containerSizeRef.current = containerSize
93+
contentSizeRef.current = contentSize
5094

5195
const applyZoom = useCallback((nextZoom: number) => {
5296
zoomRef.current = nextZoom
5397
setZoom(nextZoom)
54-
setOffset((currentOffset) => clampOffset(containerRef.current, currentOffset, nextZoom))
98+
setOffset((currentOffset) =>
99+
clampOffset(containerSizeRef.current, contentSizeRef.current, currentOffset, nextZoom)
100+
)
55101
}, [])
56102

57-
const zoomIn = () => applyZoom(clampZoom(zoom * ZOOM_BUTTON_FACTOR))
58-
const zoomOut = () => applyZoom(clampZoom(zoom / ZOOM_BUTTON_FACTOR))
103+
const fitToView = useCallback(() => {
104+
hasInteractedRef.current = false
105+
const nextZoom =
106+
initialScale === 'fit' ? getFitZoom(containerSizeRef.current, contentSizeRef.current) : 1
107+
zoomRef.current = nextZoom
108+
setZoom(nextZoom)
109+
setOffset({ x: 0, y: 0 })
110+
}, [initialScale])
111+
112+
const zoomIn = () => {
113+
hasInteractedRef.current = true
114+
applyZoom(clampZoom(zoom * ZOOM_BUTTON_FACTOR))
115+
}
116+
const zoomOut = () => {
117+
hasInteractedRef.current = true
118+
applyZoom(clampZoom(zoom / ZOOM_BUTTON_FACTOR))
119+
}
59120

60121
useEffect(() => {
61122
const el = containerRef.current
@@ -64,11 +125,14 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
64125
const onWheel = (e: WheelEvent) => {
65126
e.preventDefault()
66127
if (e.ctrlKey || e.metaKey) {
128+
hasInteractedRef.current = true
67129
applyZoom(clampZoom(zoomRef.current * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)))
68130
} else {
131+
hasInteractedRef.current = true
69132
setOffset((currentOffset) =>
70133
clampOffset(
71-
el,
134+
containerSizeRef.current,
135+
contentSizeRef.current,
72136
{
73137
x: currentOffset.x - e.deltaX,
74138
y: currentOffset.y - e.deltaY,
@@ -83,19 +147,56 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
83147
return () => el.removeEventListener('wheel', onWheel)
84148
}, [applyZoom])
85149

86-
useEffect(() => {
87-
const el = containerRef.current
88-
if (!el) return
150+
useLayoutEffect(() => {
151+
const updateSizes = () => {
152+
setContainerSize(getElementSize(containerRef.current))
153+
setContentSize(getElementSize(contentRef.current))
154+
}
155+
updateSizes()
156+
157+
const container = containerRef.current
158+
const content = contentRef.current
159+
if (!container || !content) return
89160

90161
const observer = new ResizeObserver(() => {
91-
setOffset((currentOffset) => clampOffset(el, currentOffset, zoomRef.current))
162+
updateSizes()
92163
})
93-
observer.observe(el)
164+
observer.observe(container)
165+
observer.observe(content)
94166
return () => observer.disconnect()
95167
}, [])
96168

169+
useLayoutEffect(() => {
170+
if (
171+
containerSize.width <= 0 ||
172+
containerSize.height <= 0 ||
173+
contentSize.width <= 0 ||
174+
contentSize.height <= 0
175+
) {
176+
return
177+
}
178+
179+
const nextZoom =
180+
initialScale === 'fit' && !hasInteractedRef.current
181+
? getFitZoom(containerSize, contentSize)
182+
: zoomRef.current
183+
zoomRef.current = nextZoom
184+
setZoom(nextZoom)
185+
setOffset((currentOffset) => clampOffset(containerSize, contentSize, currentOffset, nextZoom))
186+
}, [containerSize, contentSize, initialScale])
187+
188+
useLayoutEffect(() => {
189+
hasInteractedRef.current = false
190+
const nextZoom =
191+
initialScale === 'fit' ? getFitZoom(containerSizeRef.current, contentSizeRef.current) : 1
192+
zoomRef.current = nextZoom
193+
setZoom(nextZoom)
194+
setOffset({ x: 0, y: 0 })
195+
}, [initialScale, resetKey])
196+
97197
const handleMouseDown = (e: MouseEvent) => {
98198
if (e.button !== 0) return
199+
hasInteractedRef.current = true
99200
isDragging.current = true
100201
dragStart.current = { x: e.clientX, y: e.clientY }
101202
offsetAtDragStart.current = offsetRef.current
@@ -107,7 +208,8 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
107208
if (!isDragging.current) return
108209
setOffset(
109210
clampOffset(
110-
containerRef.current,
211+
containerSizeRef.current,
212+
contentSizeRef.current,
111213
{
112214
x: offsetAtDragStart.current.x + (e.clientX - dragStart.current.x),
113215
y: offsetAtDragStart.current.y + (e.clientY - dragStart.current.y),
@@ -131,22 +233,31 @@ export function ZoomablePreview({ children, className, contentClassName }: Zooma
131233
onMouseUp={handleMouseUp}
132234
onMouseLeave={handleMouseUp}
133235
>
134-
<div
135-
className={cn(
136-
'pointer-events-none absolute inset-0 flex items-center justify-center',
137-
contentClassName
138-
)}
139-
style={{
140-
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
141-
transformOrigin: 'center center',
142-
}}
143-
>
144-
{children}
236+
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
237+
<div
238+
ref={contentRef}
239+
className={cn('flex items-center justify-center', contentClassName)}
240+
style={{
241+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
242+
transformOrigin: 'center center',
243+
}}
244+
>
245+
{children}
246+
</div>
145247
</div>
146248
<div
147249
className='absolute right-4 bottom-4 flex items-center gap-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1 shadow-card'
148250
onMouseDown={(e) => e.stopPropagation()}
149251
>
252+
<Button
253+
variant='ghost'
254+
size='sm'
255+
onClick={fitToView}
256+
className='h-6 px-2 text-[11px]'
257+
aria-label={initialScale === 'fit' ? 'Fit to view' : 'Reset zoom'}
258+
>
259+
{initialScale === 'fit' ? 'Fit' : 'Reset'}
260+
</Button>
150261
<Button
151262
variant='ghost'
152263
size='sm'

0 commit comments

Comments
 (0)