Skip to content

Commit 0e2bc7e

Browse files
committed
fix(ui): share file viewer trackpad zoom with lightbox
1 parent f519ebf commit 0e2bc7e

10 files changed

Lines changed: 214 additions & 15 deletions

File tree

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
'use client'
22

33
import { memo, useCallback, useEffect, useRef, useState } from 'react'
4-
import { cn } from '@sim/emcn'
4+
import { bindPreviewWheelZoom, cn } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { toError } from '@sim/utils/errors'
77
import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety'
88
import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard'
99
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
1010
import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared'
1111
import { PreviewToolbar } from './preview-toolbar'
12-
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
1312
import { useDocPreviewBinary } from './use-doc-preview-binary'
1413

1514
const logger = createLogger('DocxPreview')

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
99
import { createLogger } from '@sim/logger'
1010
import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react-pdf'
1111
import 'react-pdf/dist/Page/TextLayer.css'
12+
import { bindPreviewWheelZoom } from '@sim/emcn'
1213
import { PREVIEW_LOADING_OVERLAY } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared'
1314
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
14-
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
1515

1616
/**
1717
* The worker runs in its own context that browser-polyfills cannot reach, so

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
'use client'
22

33
import { memo, useCallback, useEffect, useRef, useState } from 'react'
4+
import { bindPreviewWheelZoom } from '@sim/emcn'
45
import { createLogger } from '@sim/logger'
56
import { toError } from '@sim/utils/errors'
67
import { openSimPptxViewer, type SimPptxViewerHandle } from '@/lib/pptx-renderer/sim-pptx-viewer'
78
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
8-
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
99

1010
const logger = createLogger('PptxSandboxHost')
1111

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useRef } from 'react'
4-
import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
4+
import { bindPreviewHorizontalWheel } from '@sim/emcn'
55

66
/**
77
* Ref callback that gives a preview scroll container horizontal wheel scrolling.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
import type { MouseEvent, ReactNode } from 'react'
44
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
5-
import { cn } from '@sim/emcn'
5+
import { bindPreviewWheelZoom, cn } from '@sim/emcn'
66
import { PreviewToolbar } from './preview-toolbar'
7-
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
87

98
const ZOOM_MIN = 0.25
109
const ZOOM_MAX = 4
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { Lightbox } from '@sim/emcn'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
9+
vi.mock('next/navigation', () => ({ usePathname: () => '/introduction' }))
10+
11+
let root: Root
12+
let container: HTMLDivElement
13+
14+
function button(label: string): HTMLButtonElement {
15+
const element = document.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
16+
if (!element) throw new Error(`Missing button: ${label}`)
17+
return element
18+
}
19+
20+
function media(): HTMLImageElement {
21+
const element = document.querySelector<HTMLImageElement>('[role="dialog"] img')
22+
if (!element) throw new Error('Missing lightbox image')
23+
return element
24+
}
25+
26+
function wheel(init: WheelEventInit): WheelEvent {
27+
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, ...init })
28+
act(() => media().dispatchEvent(event))
29+
return event
30+
}
31+
32+
async function click(label: string) {
33+
await act(async () => button(label).click())
34+
}
35+
36+
describe('Lightbox interactions', () => {
37+
beforeEach(async () => {
38+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
39+
container = document.createElement('div')
40+
document.body.appendChild(container)
41+
root = createRoot(container)
42+
await act(async () =>
43+
root.render(
44+
<Lightbox src='/image.png' alt='Example image'>
45+
<button type='button' aria-label='Open image'>
46+
Preview
47+
</button>
48+
</Lightbox>
49+
)
50+
)
51+
await click('Open image')
52+
})
53+
54+
afterEach(async () => {
55+
await act(async () => root.unmount())
56+
container.remove()
57+
document.body.replaceChildren()
58+
document.body.removeAttribute('style')
59+
vi.restoreAllMocks()
60+
})
61+
62+
it.each([{ ctrlKey: true }, { metaKey: true }])(
63+
'zooms the media in both directions and cancels page zoom for %j',
64+
(modifier) => {
65+
const zoomIn = wheel({ ...modifier, deltaY: -40 })
66+
expect(zoomIn.defaultPrevented).toBe(true)
67+
expect(Number(media().style.zoom)).toBeCloseTo(Math.exp(0.2))
68+
69+
const zoomOut = wheel({ ...modifier, deltaY: 40 })
70+
expect(zoomOut.defaultPrevented).toBe(true)
71+
expect(Number(media().style.zoom)).toBeCloseTo(1)
72+
}
73+
)
74+
75+
it('leaves ordinary vertical scrolling available without changing zoom', () => {
76+
const event = wheel({ deltaY: 40 })
77+
expect(event.defaultPrevented).toBe(false)
78+
expect(media().style.zoom).toBe('1')
79+
})
80+
81+
it('keeps toolbar controls open, clamps zoom, and resets to fit', async () => {
82+
await click('Zoom in')
83+
expect(media().style.zoom).toBe('1.25')
84+
await click('Zoom out')
85+
expect(media().style.zoom).toBe('1')
86+
87+
wheel({ ctrlKey: true, deltaY: -10000 })
88+
expect(media().style.zoom).toBe('4')
89+
expect(button('Zoom in').disabled).toBe(true)
90+
wheel({ ctrlKey: true, deltaY: 10000 })
91+
expect(media().style.zoom).toBe('0.25')
92+
expect(button('Zoom out').disabled).toBe(true)
93+
await click('Reset zoom (25%)')
94+
expect(media().style.zoom).toBe('1')
95+
})
96+
97+
it('preserves the point beneath the gesture when zoom changes', () => {
98+
const frame = button('Close media viewer')
99+
const viewport = frame.parentElement?.parentElement
100+
if (!viewport) throw new Error('Missing viewport')
101+
const getBounds = vi.spyOn(frame, 'getBoundingClientRect')
102+
getBounds.mockReturnValueOnce(new DOMRect(100, 100, 400, 300))
103+
getBounds.mockReturnValueOnce(new DOMRect(100, 100, 800, 600))
104+
105+
wheel({ ctrlKey: true, deltaY: -Math.log(2) / 0.005, clientX: 200, clientY: 175 })
106+
107+
expect(viewport.scrollLeft).toBeCloseTo(100)
108+
expect(viewport.scrollTop).toBeCloseTo(75)
109+
})
110+
111+
it('closes on the image, restores focus, and binds gestures again after reopening', async () => {
112+
const oldMedia = media()
113+
wheel({ ctrlKey: true, deltaY: -40 })
114+
await click('Close media viewer')
115+
expect(document.querySelector('[role="dialog"]')).toBeNull()
116+
await vi.waitFor(() => expect(document.activeElement).toBe(button('Open image')))
117+
const detachedWheel = new WheelEvent('wheel', {
118+
bubbles: true,
119+
cancelable: true,
120+
ctrlKey: true,
121+
deltaY: -40,
122+
})
123+
oldMedia.dispatchEvent(detachedWheel)
124+
expect(detachedWheel.defaultPrevented).toBe(false)
125+
126+
await click('Open image')
127+
expect(media().style.zoom).toBe('1')
128+
expect(wheel({ ctrlKey: true, deltaY: -40 }).defaultPrevented).toBe(true)
129+
expect(Number(media().style.zoom)).toBeGreaterThan(1)
130+
})
131+
132+
it('dismisses on the viewport background and Escape', async () => {
133+
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')
134+
await act(async () => dialog?.click())
135+
expect(document.querySelector('[role="dialog"]')).toBeNull()
136+
await click('Open image')
137+
await act(async () => {
138+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
139+
})
140+
expect(document.querySelector('[role="dialog"]')).toBeNull()
141+
})
142+
})

packages/emcn/src/components/lightbox/lightbox.tsx

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

3-
import { type ReactElement, useLayoutEffect, useRef, useState } from 'react'
3+
import { type ReactElement, useCallback, useLayoutEffect, useRef, useState } from 'react'
44
import {
5+
bindPreviewWheelZoom,
56
Chip,
67
chipFieldSurfaceClass,
78
cn,
@@ -25,8 +26,16 @@ export interface LightboxProps {
2526
const ZOOM_MIN = 0.25
2627
const ZOOM_MAX = 4
2728
const ZOOM_STEP = 0.25
29+
const ZOOM_WHEEL_SENSITIVITY = 0.005
2830
const MEDIA_CLASS = 'block h-auto max-h-[calc(100dvh-6rem)] w-auto max-w-[92vw] object-contain'
2931

32+
interface ZoomAnchor {
33+
clientX: number
34+
clientY: number
35+
fractionX: number
36+
fractionY: number
37+
}
38+
3039
function centerViewport(viewport: HTMLDivElement | null) {
3140
if (!viewport) return
3241
viewport.scrollLeft = (viewport.scrollWidth - viewport.clientWidth) / 2
@@ -46,17 +55,64 @@ function centerViewport(viewport: HTMLDivElement | null) {
4655
*/
4756
export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }: LightboxProps) {
4857
const viewportRef = useRef<HTMLDivElement>(null)
58+
const mediaFrameRef = useRef<HTMLButtonElement>(null)
4959
const controlsRef = useRef<HTMLDivElement>(null)
60+
const zoomAnchorRef = useRef<ZoomAnchor | null>(null)
5061
const [open, setOpen] = useState(false)
5162
const [zoom, setZoom] = useState(1)
5263

5364
function handleOpenChange(nextOpen: boolean) {
54-
if (nextOpen) setZoom(1)
65+
if (nextOpen) {
66+
zoomAnchorRef.current = null
67+
setZoom(1)
68+
}
5569
setOpen(nextOpen)
5670
}
5771

72+
function handleControlZoom(nextZoom: number) {
73+
zoomAnchorRef.current = null
74+
setZoom(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextZoom)))
75+
}
76+
77+
const attachViewport = useCallback((viewport: HTMLDivElement | null) => {
78+
viewportRef.current = viewport
79+
if (!viewport) return
80+
81+
const unbind = bindPreviewWheelZoom(viewport, (event) => {
82+
const frame = mediaFrameRef.current?.getBoundingClientRect()
83+
if (frame && frame.width > 0 && frame.height > 0) {
84+
zoomAnchorRef.current = {
85+
clientX: event.clientX,
86+
clientY: event.clientY,
87+
fractionX: (event.clientX - frame.left) / frame.width,
88+
fractionY: (event.clientY - frame.top) / frame.height,
89+
}
90+
}
91+
setZoom((current) =>
92+
Math.min(
93+
ZOOM_MAX,
94+
Math.max(ZOOM_MIN, current * Math.exp(-event.deltaY * ZOOM_WHEEL_SENSITIVITY))
95+
)
96+
)
97+
})
98+
99+
return () => {
100+
unbind()
101+
viewportRef.current = null
102+
}
103+
}, [])
104+
58105
useLayoutEffect(() => {
59-
centerViewport(viewportRef.current)
106+
const viewport = viewportRef.current
107+
const anchor = zoomAnchorRef.current
108+
const frame = mediaFrameRef.current?.getBoundingClientRect()
109+
if (viewport && anchor && frame) {
110+
viewport.scrollLeft += frame.left + anchor.fractionX * frame.width - anchor.clientX
111+
viewport.scrollTop += frame.top + anchor.fractionY * frame.height - anchor.clientY
112+
} else {
113+
centerViewport(viewport)
114+
}
115+
zoomAnchorRef.current = null
60116
}, [open, zoom])
61117

62118
return (
@@ -72,10 +128,11 @@ export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }:
72128
if (!controlsRef.current?.contains(event.target as Node)) setOpen(false)
73129
}}
74130
>
75-
<div ref={viewportRef} className='min-h-0 flex-1 overflow-auto overscroll-contain'>
131+
<div ref={attachViewport} className='min-h-0 flex-1 overflow-auto overscroll-contain'>
76132
<div className='flex min-h-full w-max min-w-full items-center justify-center'>
77133
<ModalClose asChild>
78134
<button
135+
ref={mediaFrameRef}
79136
type='button'
80137
aria-label='Close media viewer'
81138
className='m-1 block shrink-0 cursor-pointer overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--bg)] p-0 shadow-[var(--shadow-overlay)] outline-none'
@@ -122,11 +179,11 @@ export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }:
122179
leftIcon={Minus}
123180
aria-label='Zoom out'
124181
disabled={zoom <= ZOOM_MIN}
125-
onClick={() => setZoom((current) => Math.max(ZOOM_MIN, current - ZOOM_STEP))}
182+
onClick={() => handleControlZoom(zoom - ZOOM_STEP)}
126183
/>
127184
<Chip
128185
aria-label={`Reset zoom (${Math.round(zoom * 100)}%)`}
129-
onClick={() => setZoom(1)}
186+
onClick={() => handleControlZoom(1)}
130187
className='min-w-16 text-center tabular-nums'
131188
>
132189
{Math.round(zoom * 100)}%
@@ -135,7 +192,7 @@ export function Lightbox({ children, src, alt, type = 'image', startTime = 0 }:
135192
leftIcon={Plus}
136193
aria-label='Zoom in'
137194
disabled={zoom >= ZOOM_MAX}
138-
onClick={() => setZoom((current) => Math.min(ZOOM_MAX, current + ZOOM_STEP))}
195+
onClick={() => handleControlZoom(zoom + ZOOM_STEP)}
139196
/>
140197
</div>
141198
</ModalContent>

packages/emcn/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ export { useScrollEdges } from './hooks/use-scroll-edges'
5151
export * from './icons'
5252
export { cn } from './lib/cn'
5353
export { handleKeyboardActivation, isKeyboardActivation } from './lib/keyboard'
54+
export { bindPreviewHorizontalWheel, bindPreviewWheelZoom } from './lib/preview-wheel-zoom'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.test.ts renamed to packages/emcn/src/lib/preview-wheel-zoom.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* of the way otherwise, and — unlike the zooming variant — leave ctrl/cmd+wheel to the browser
88
* so page zoom still works over a table.
99
*/
10+
11+
import { bindPreviewHorizontalWheel } from '@sim/emcn'
1012
import { beforeEach, describe, expect, it } from 'vitest'
11-
import { bindPreviewHorizontalWheel } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
1213

1314
/** jsdom does no layout, so scrollWidth/clientWidth are stubbed to model an overflowing container. */
1415
function makeContainer({ scrollWidth = 2000, clientWidth = 1000 } = {}): HTMLElement {

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom.ts renamed to packages/emcn/src/lib/preview-wheel-zoom.ts

File renamed without changes.

0 commit comments

Comments
 (0)