fix(files): zoom file viewer content, not the browser page (#4741)

* fix(files): zoom file viewer content, not the browser page

* fix(files): use effect lifecycle for SVG blob URL to survive strict mode
This commit is contained in:
Waleed
2026-05-26 12:32:49 -07:00
committed by GitHub
parent 3b18d3ba0e
commit bc99c457b9
3 changed files with 72 additions and 36 deletions
@@ -1071,15 +1071,21 @@ const HtmlPreview = memo(function HtmlPreview({ content }: { content: string })
})
function SvgPreview({ content }: { content: string }) {
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>`
const [blobUrl, setBlobUrl] = useState('')
useEffect(() => {
const url = URL.createObjectURL(new Blob([content], { type: 'image/svg+xml' }))
setBlobUrl(url)
return () => URL.revokeObjectURL(url)
}, [content])
return (
<ZoomablePreview className='h-full' contentClassName='h-full w-full'>
<iframe
srcDoc={wrappedContent}
sandbox=''
title='SVG Preview'
className='h-full w-full border-0'
<img
src={blobUrl}
alt='SVG preview'
className='max-h-full max-w-full select-none object-contain'
draggable={false}
/>
</ZoomablePreview>
)
@@ -1,17 +1,39 @@
interface BindPreviewWheelZoomOptions {
/**
* Called for non-modifier wheel events (two-finger scroll). When provided,
* the container's native scrolling is suppressed and the consumer drives
* pan via `deltaX` / `deltaY`. Use for transform-based viewers (e.g. image)
* where the content is not a real scroll container.
*/
onPan?: (event: WheelEvent) => void
}
/**
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview scroll containers.
* Bind browser pinch/ctrl-wheel zoom and horizontal wheel gestures for preview
* scroll containers. Trackpad pinch fires `wheel` with `ctrlKey=true`; without
* a non-passive native listener the browser falls back to page zoom. `metaKey`
* is also accepted so Cmd+scroll zooms on macOS, matching Figma/tldraw/Excalidraw.
*/
export function bindPreviewWheelZoom(
container: HTMLElement,
onZoom: (event: WheelEvent) => void
onZoom: (event: WheelEvent) => void,
options: BindPreviewWheelZoomOptions = {}
): () => void {
const { onPan } = options
const onWheel = (event: WheelEvent) => {
if (event.ctrlKey) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault()
onZoom(event)
return
}
if (onPan) {
event.preventDefault()
onPan(event)
return
}
const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0
if (horizontalDelta === 0 || container.scrollWidth <= container.clientWidth) return
@@ -1,9 +1,10 @@
'use client'
import type { MouseEvent, ReactNode, WheelEvent } from 'react'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
import type { MouseEvent, ReactNode } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@/lib/core/utils/cn'
import { PreviewToolbar } from './preview-toolbar'
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
const ZOOM_MIN = 0.25
const ZOOM_MAX = 4
@@ -133,31 +134,39 @@ export function ZoomablePreview({
applyZoom(clampZoom(zoom / ZOOM_BUTTON_FACTOR))
}
const handleWheel = (e: WheelEvent<HTMLDivElement>) => {
e.preventDefault()
if (e.ctrlKey || e.metaKey) {
hasInteractedRef.current = true
const rect = e.currentTarget.getBoundingClientRect()
applyZoom(
clampZoom(zoomRef.current * Math.exp(-e.deltaY * ZOOM_WHEEL_SENSITIVITY)),
e.clientX - rect.left,
e.clientY - rect.top
)
} else {
hasInteractedRef.current = true
setOffset((currentOffset) =>
clampOffset(
containerSizeRef.current,
contentSizeRef.current,
{
x: currentOffset.x - e.deltaX,
y: currentOffset.y - e.deltaY,
},
zoomRef.current
useEffect(() => {
const viewport = viewportRef.current
if (!viewport) return
return bindPreviewWheelZoom(
viewport,
(event) => {
hasInteractedRef.current = true
const rect = viewport.getBoundingClientRect()
applyZoom(
clampZoom(zoomRef.current * Math.exp(-event.deltaY * ZOOM_WHEEL_SENSITIVITY)),
event.clientX - rect.left,
event.clientY - rect.top
)
)
}
}
},
{
onPan: (event) => {
hasInteractedRef.current = true
setOffset((currentOffset) =>
clampOffset(
containerSizeRef.current,
contentSizeRef.current,
{
x: currentOffset.x - event.deltaX,
y: currentOffset.y - event.deltaY,
},
zoomRef.current
)
)
},
}
)
}, [applyZoom])
useLayoutEffect(() => {
const updateSizes = () => {
@@ -257,7 +266,6 @@ export function ZoomablePreview({
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
>
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div