Skip to content

Commit 99e1ed6

Browse files
committed
make previews consistent for zoom and scroll
1 parent 805d819 commit 99e1ed6

5 files changed

Lines changed: 340 additions & 107 deletions

File tree

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

Lines changed: 189 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,36 @@ import { cn } from '@/lib/core/utils/cn'
77
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88
import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
99
import { PDF_PAGE_SKELETON, PreviewError, resolvePreviewError } from './preview-shared'
10+
import { PreviewToolbar } from './preview-toolbar'
11+
import { bindPreviewWheelZoom } from './preview-wheel-zoom'
1012

1113
const logger = createLogger('DocxPreview')
1214

15+
const DOCX_ZOOM_MIN = 25
16+
const DOCX_ZOOM_MAX = 400
17+
const DOCX_ZOOM_STEP = 20
18+
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
19+
1320
/**
1421
* Fit the rendered docx pages to the host container width using a CSS scale.
1522
* The library renders `<section class="docx">` at the document's natural page
1623
* width (in cm), which overflows narrow panels.
1724
*/
18-
function fitDocxToContainer(host: HTMLElement) {
25+
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
1926
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
2027
if (!wrapper) return
2128
const section = wrapper.querySelector<HTMLElement>('section.docx')
2229
if (!section) return
2330

24-
wrapper.style.transform = ''
25-
wrapper.style.transformOrigin = 'top left'
31+
host.style.minWidth = ''
32+
host.style.minHeight = ''
33+
host.style.width = ''
34+
host.style.display = 'flex'
35+
host.style.flexDirection = 'column'
36+
host.style.alignItems = 'center'
37+
wrapper.style.zoom = ''
2638
wrapper.style.width = ''
39+
wrapper.style.flex = '0 0 auto'
2740
wrapper.style.marginRight = ''
2841
wrapper.style.marginBottom = ''
2942

@@ -34,16 +47,16 @@ function fitDocxToContainer(host: HTMLElement) {
3447
const horizontalPadding =
3548
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
3649
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
37-
const available = host.clientWidth
38-
const scale = Math.min(1, available / naturalWrapperWidth)
39-
40-
if (scale >= 1) return
50+
const available = viewport.clientWidth
51+
const fitScale = Math.min(1, available / naturalWrapperWidth)
52+
const scale = fitScale * (zoomPercent / 100)
53+
const scaledWrapperWidth = naturalWrapperWidth * scale
4154

4255
wrapper.style.width = `${naturalWrapperWidth}px`
43-
wrapper.style.transform = `scale(${scale})`
44-
const naturalHeight = wrapper.offsetHeight
45-
wrapper.style.marginRight = `${(scale - 1) * naturalWrapperWidth}px`
46-
wrapper.style.marginBottom = `${(scale - 1) * naturalHeight}px`
56+
wrapper.style.zoom = String(scale)
57+
host.style.width = `${Math.max(available, scaledWrapperWidth)}px`
58+
host.style.minWidth = `${scaledWrapperWidth}px`
59+
host.style.minHeight = `${wrapper.offsetHeight * scale}px`
4760
}
4861

4962
export const DocxPreview = memo(function DocxPreview({
@@ -56,7 +69,9 @@ export const DocxPreview = memo(function DocxPreview({
5669
streamingContent?: string
5770
}) {
5871
const containerRef = useRef<HTMLDivElement>(null)
72+
const scrollContainerRef = useRef<HTMLDivElement>(null)
5973
const lastSuccessfulHtmlRef = useRef('')
74+
const zoomPercentRef = useRef(100)
6075
const {
6176
data: fileData,
6277
isLoading,
@@ -65,25 +80,106 @@ export const DocxPreview = memo(function DocxPreview({
6580
const [renderError, setRenderError] = useState<string | null>(null)
6681
const [rendering, setRendering] = useState(false)
6782
const [hasRenderedPreview, setHasRenderedPreview] = useState(false)
83+
const [zoomPercent, setZoomPercent] = useState(100)
84+
const [pageCount, setPageCount] = useState(0)
85+
const [currentPage, setCurrentPage] = useState(1)
86+
const [documentRenderVersion, setDocumentRenderVersion] = useState(0)
6887

6988
const applyPostRenderStyling = useCallback(() => {
7089
const container = containerRef.current
71-
if (!container) return
90+
const scrollContainer = scrollContainerRef.current
91+
if (!container || !scrollContainer) return
7292
const wrapper = container.querySelector<HTMLElement>('.docx-wrapper')
7393
if (wrapper) wrapper.style.background = 'transparent'
74-
container.querySelectorAll<HTMLElement>('section.docx').forEach((page) => {
94+
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
95+
pages.forEach((page, index) => {
7596
page.style.boxShadow = 'var(--shadow-medium)'
97+
page.dataset.page = String(index + 1)
7698
})
77-
fitDocxToContainer(container)
99+
setPageCount((previous) => (previous === pages.length ? previous : pages.length))
100+
setCurrentPage((current) => (pages.length > 0 ? Math.min(current, pages.length) : 1))
101+
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
78102
}, [])
79103

80104
useEffect(() => {
105+
const scrollContainer = scrollContainerRef.current
106+
if (!scrollContainer) return
107+
const observer = new ResizeObserver(() => applyPostRenderStyling())
108+
observer.observe(scrollContainer)
109+
return () => observer.disconnect()
110+
}, [applyPostRenderStyling])
111+
112+
const applyZoomAt = useCallback(
113+
(nextZoom: number, anchorX: number, anchorY: number) => {
114+
const scrollContainer = scrollContainerRef.current
115+
if (!scrollContainer) return
116+
117+
const clampedZoom = Math.round(Math.min(Math.max(nextZoom, DOCX_ZOOM_MIN), DOCX_ZOOM_MAX))
118+
const wrapper = containerRef.current?.querySelector<HTMLElement>('.docx-wrapper')
119+
const containerRect = scrollContainer.getBoundingClientRect()
120+
const anchorClientX = containerRect.left + anchorX
121+
const anchorClientY = containerRect.top + anchorY
122+
const beforeRect = wrapper?.getBoundingClientRect()
123+
const anchorRatioX =
124+
beforeRect && beforeRect.width > 0
125+
? (anchorClientX - beforeRect.left) / beforeRect.width
126+
: 0
127+
const anchorRatioY =
128+
beforeRect && beforeRect.height > 0
129+
? (anchorClientY - beforeRect.top) / beforeRect.height
130+
: 0
131+
132+
zoomPercentRef.current = clampedZoom
133+
setZoomPercent(clampedZoom)
134+
applyPostRenderStyling()
135+
136+
const afterRect = wrapper?.getBoundingClientRect()
137+
if (!beforeRect || !afterRect) return
138+
139+
scrollContainer.scrollLeft += afterRect.left + anchorRatioX * afterRect.width - anchorClientX
140+
scrollContainer.scrollTop += afterRect.top + anchorRatioY * afterRect.height - anchorClientY
141+
},
142+
[applyPostRenderStyling]
143+
)
144+
145+
useEffect(() => {
146+
const scrollContainer = scrollContainerRef.current
147+
if (!scrollContainer) return
148+
149+
return bindPreviewWheelZoom(scrollContainer, (event) => {
150+
const rect = scrollContainer.getBoundingClientRect()
151+
applyZoomAt(
152+
zoomPercentRef.current * (1 - event.deltaY * DOCX_ZOOM_WHEEL_SENSITIVITY),
153+
event.clientX - rect.left,
154+
event.clientY - rect.top
155+
)
156+
})
157+
}, [applyZoomAt])
158+
159+
useEffect(() => {
160+
const scrollContainer = scrollContainerRef.current
81161
const container = containerRef.current
82-
if (!container) return
83-
const observer = new ResizeObserver(() => fitDocxToContainer(container))
84-
observer.observe(container)
162+
if (!scrollContainer || !container || pageCount === 0) return
163+
164+
const pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'))
165+
const observer = new IntersectionObserver(
166+
(entries) => {
167+
for (const entry of entries) {
168+
if (entry.isIntersecting) {
169+
const page = Number((entry.target as HTMLElement).dataset.page)
170+
if (page) setCurrentPage(page)
171+
}
172+
}
173+
},
174+
{ root: scrollContainer, threshold: 0.5 }
175+
)
176+
177+
for (const page of pages) {
178+
observer.observe(page)
179+
}
180+
85181
return () => observer.disconnect()
86-
}, [])
182+
}, [pageCount, documentRenderVersion])
87183

88184
useEffect(() => {
89185
if (!containerRef.current || !fileData || streamingContent !== undefined) return
@@ -106,6 +202,7 @@ export const DocxPreview = memo(function DocxPreview({
106202
applyPostRenderStyling()
107203
lastSuccessfulHtmlRef.current = containerRef.current.innerHTML
108204
setHasRenderedPreview(true)
205+
setDocumentRenderVersion((version) => version + 1)
109206
}
110207
} catch (err) {
111208
if (!cancelled) {
@@ -128,6 +225,7 @@ export const DocxPreview = memo(function DocxPreview({
128225

129226
useEffect(() => {
130227
if (streamingContent === undefined || !containerRef.current) return
228+
if (streamingContent.trim().length === 0) return
131229

132230
let cancelled = false
133231
const controller = new AbortController()
@@ -155,6 +253,7 @@ export const DocxPreview = memo(function DocxPreview({
155253

156254
const arrayBuffer = await response.arrayBuffer()
157255
if (cancelled || !containerRef.current) return
256+
if (arrayBuffer.byteLength === 0) return
158257

159258
const { renderAsync } = await import('docx-preview')
160259
if (cancelled || !containerRef.current) return
@@ -170,13 +269,15 @@ export const DocxPreview = memo(function DocxPreview({
170269
applyPostRenderStyling()
171270
lastSuccessfulHtmlRef.current = containerRef.current.innerHTML
172271
setHasRenderedPreview(true)
272+
setDocumentRenderVersion((version) => version + 1)
173273
}
174274
} catch (err) {
175275
if (!cancelled && !(err instanceof DOMException && err.name === 'AbortError')) {
176276
if (containerRef.current && previousHtml) {
177277
containerRef.current.innerHTML = previousHtml
178278
applyPostRenderStyling()
179279
setHasRenderedPreview(true)
280+
setDocumentRenderVersion((version) => version + 1)
180281
}
181282
const msg = toError(err).message || 'Failed to render document'
182283
logger.info('Transient DOCX streaming preview error (suppressed)', { error: msg })
@@ -201,15 +302,78 @@ export const DocxPreview = memo(function DocxPreview({
201302
const showSkeleton =
202303
!hasRenderedPreview && (streamingContent !== undefined || isLoading || rendering)
203304

305+
const scrollToPage = (page: number) => {
306+
const scrollContainer = scrollContainerRef.current
307+
const target = containerRef.current?.querySelector<HTMLElement>(
308+
`section.docx[data-page="${page}"]`
309+
)
310+
if (!scrollContainer || !target) return
311+
312+
if (zoomPercentRef.current !== 100) {
313+
applyZoomAt(100, scrollContainer.clientWidth / 2, scrollContainer.clientHeight / 2)
314+
}
315+
316+
scrollContainer.scrollTo({
317+
top: target.offsetTop - scrollContainer.offsetTop - 16,
318+
behavior: 'smooth',
319+
})
320+
}
321+
204322
return (
205-
<div className='relative h-full w-full overflow-auto bg-[var(--surface-1)]'>
206-
{showSkeleton && (
207-
<div className='absolute inset-0 z-10 bg-[var(--surface-1)]'>{PDF_PAGE_SKELETON}</div>
208-
)}
209-
<div
210-
ref={containerRef}
211-
className={cn('h-full w-full overflow-auto', showSkeleton && 'opacity-0')}
323+
<div className='flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
324+
<PreviewToolbar
325+
navigation={{
326+
current: currentPage,
327+
total: pageCount,
328+
label: 'page',
329+
canPrevious: pageCount > 0 && currentPage > 1,
330+
canNext: pageCount > 0 && currentPage < pageCount,
331+
onPrevious: () => {
332+
const previous = Math.max(1, currentPage - 1)
333+
setCurrentPage(previous)
334+
scrollToPage(previous)
335+
},
336+
onNext: () => {
337+
const next = Math.min(pageCount, currentPage + 1)
338+
setCurrentPage(next)
339+
scrollToPage(next)
340+
},
341+
}}
342+
zoom={{
343+
label: `${zoomPercent}%`,
344+
canZoomOut: zoomPercent > DOCX_ZOOM_MIN,
345+
canZoomIn: zoomPercent < DOCX_ZOOM_MAX,
346+
onReset: () => {
347+
const c = scrollContainerRef.current
348+
applyZoomAt(100, c ? c.clientWidth / 2 : 0, c ? c.clientHeight / 2 : 0)
349+
},
350+
onZoomOut: () => {
351+
const c = scrollContainerRef.current
352+
applyZoomAt(
353+
zoomPercent - DOCX_ZOOM_STEP,
354+
c ? c.clientWidth / 2 : 0,
355+
c ? c.clientHeight / 2 : 0
356+
)
357+
},
358+
onZoomIn: () => {
359+
const c = scrollContainerRef.current
360+
applyZoomAt(
361+
zoomPercent + DOCX_ZOOM_STEP,
362+
c ? c.clientWidth / 2 : 0,
363+
c ? c.clientHeight / 2 : 0
364+
)
365+
},
366+
}}
212367
/>
368+
<div
369+
ref={scrollContainerRef}
370+
className='relative min-h-0 flex-1 overflow-auto bg-[var(--surface-1)]'
371+
>
372+
{showSkeleton && (
373+
<div className='absolute inset-0 z-10 bg-[var(--surface-1)]'>{PDF_PAGE_SKELETON}</div>
374+
)}
375+
<div ref={containerRef} className={cn('min-h-full w-full', showSkeleton && 'opacity-0')} />
376+
</div>
213377
</div>
214378
)
215379
})

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

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { pdfjs, Document as ReactPdfDocument, Page as ReactPdfPage } from 'react
66
import 'react-pdf/dist/Page/TextLayer.css'
77
import { Skeleton } from '@/components/emcn'
88
import { PreviewToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar'
9+
import { bindPreviewWheelZoom } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-wheel-zoom'
910

1011
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
1112
'pdfjs-dist/build/pdf.worker.min.mjs',
@@ -120,9 +121,14 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
120121
}, [])
121122

122123
const scrollToPage = (page: number) => {
124+
const container = containerRef.current
125+
if (container && zoomRef.current !== PDF_ZOOM_DEFAULT) {
126+
applyZoomAt(PDF_ZOOM_DEFAULT, container.clientWidth / 2, container.clientHeight / 2)
127+
}
128+
123129
const wrapper = pageRefs.current[page - 1]
124-
if (wrapper && containerRef.current) {
125-
containerRef.current.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
130+
if (wrapper && container) {
131+
container.scrollTo({ top: wrapper.offsetTop - 16, behavior: 'smooth' })
126132
}
127133
}
128134

@@ -153,20 +159,14 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
153159
const container = containerRef.current
154160
if (!container) return
155161

156-
const onWheel = (e: WheelEvent) => {
157-
if (!e.ctrlKey) return
158-
e.preventDefault()
159-
162+
return bindPreviewWheelZoom(container, (e) => {
160163
const next = Math.min(
161164
PDF_ZOOM_MAX,
162165
Math.max(PDF_ZOOM_MIN, zoomRef.current * (1 - e.deltaY * 0.005))
163166
)
164167
const rect = container.getBoundingClientRect()
165168
applyZoomAt(next, e.clientX - rect.left, e.clientY - rect.top)
166-
}
167-
168-
container.addEventListener('wheel', onWheel, { passive: false })
169-
return () => container.removeEventListener('wheel', onWheel)
169+
})
170170
}, [applyZoomAt])
171171

172172
return (

0 commit comments

Comments
 (0)