@@ -7,23 +7,36 @@ import { cn } from '@/lib/core/utils/cn'
77import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files'
99import { PDF_PAGE_SKELETON , PreviewError , resolvePreviewError } from './preview-shared'
10+ import { PreviewToolbar } from './preview-toolbar'
11+ import { bindPreviewWheelZoom } from './preview-wheel-zoom'
1012
1113const 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
4962export 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} )
0 commit comments