Skip to content

Commit bc92a2c

Browse files
authored
improvement(files): fit-width previews and chip-chrome viewer controls (#5002)
* improvement(files): fit-width previews and chip-chrome viewer controls - PDF and DOCX previews now treat 100% zoom as fit-to-width instead of capping at the page's natural print size, removing the dead gutters in wide panels (pdf.js re-renders the canvas at the target width and DOCX uses CSS zoom, so both stay crisp) - PreviewToolbar page/zoom controls move from 24px ghost Buttons with off-token labels to canonical emcn chips (icon-only Chip pills, text-sm --text-body value labels) - XLSX sheet tabs move from underline-style ghost Buttons to a chip cluster using the active pill state - Audio preview swaps the music emoji for the lucide Music icon on design-system tokens * improvement(files): debounce PDF panel-resize re-rasterisation With fit-to-width every pageWidth change re-rasterises all page canvases, so per-tick updates during a panel-divider drag re-rendered the document continuously. First measurement still applies immediately. * fix(files): don't let a zero-width first measurement consume the immediate resize slot A hidden container reports zero width from the ResizeObserver; treating that as the initial measurement pushed the real first width onto the debounce path and delayed initial render. * improvement(files): module cleanup — dedupe media previews, debounce docx refits - Merge the near-identical AudioPreview/VideoPreview into one MediaPreview (shared fetch/blob-URL/error/loading path; only the player differs) - Debounce docx resize refits the same way the PDF preview debounces width measurements (the initial fit comes from the render path, not the observer) - Document the load-bearing buffer copy in pdf-viewer (pdf.js transfers and detaches the ArrayBuffer it receives)
1 parent 37e7121 commit bc92a2c

5 files changed

Lines changed: 108 additions & 119 deletions

File tree

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ const DOCX_ZOOM_MIN = 25
1616
const DOCX_ZOOM_MAX = 400
1717
const DOCX_ZOOM_STEP = 20
1818
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
19+
const DOCX_RESIZE_DEBOUNCE_MS = 150
1920

2021
/**
2122
* Fit the rendered docx pages to the host container width using a CSS scale.
2223
* The library renders `<section class="docx">` at the document's natural page
23-
* width (in cm), which overflows narrow panels.
24+
* width (in cm), which overflows narrow panels. 100% zoom means fit-to-width —
25+
* pages upscale past their natural print size in wide panels (CSS zoom of HTML
26+
* stays crisp), matching the PDF preview's semantics.
2427
*/
2528
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
2629
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
@@ -48,7 +51,7 @@ function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercen
4851
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
4952
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
5053
const available = viewport.clientWidth
51-
const fitScale = Math.min(1, available / naturalWrapperWidth)
54+
const fitScale = available / naturalWrapperWidth
5255
const scale = fitScale * (zoomPercent / 100)
5356
const scaledWrapperWidth = naturalWrapperWidth * scale
5457

@@ -95,12 +98,25 @@ export const DocxPreview = memo(function DocxPreview({
9598
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
9699
}, [])
97100

101+
/**
102+
* Resize refits are debounced: each one re-queries the rendered pages and
103+
* recomputes the fit scale, so per-tick refits during a panel-divider drag
104+
* would thrash layout continuously (the initial fit is applied directly by
105+
* the render path, not this observer). Mirrors the PDF preview's debounce.
106+
*/
98107
useEffect(() => {
99108
const scrollContainer = scrollContainerRef.current
100109
if (!scrollContainer) return
101-
const observer = new ResizeObserver(() => applyPostRenderStyling())
110+
let debounce: ReturnType<typeof setTimeout> | undefined
111+
const observer = new ResizeObserver(() => {
112+
clearTimeout(debounce)
113+
debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS)
114+
})
102115
observer.observe(scrollContainer)
103-
return () => observer.disconnect()
116+
return () => {
117+
clearTimeout(debounce)
118+
observer.disconnect()
119+
}
104120
}, [applyPostRenderStyling])
105121

106122
const applyZoomAt = useCallback(

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

Lines changed: 30 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { Music } from 'lucide-react'
56
import dynamic from 'next/dynamic'
67
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
78
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -102,11 +103,11 @@ export function FileViewer({
102103
}
103104

104105
if (category === 'audio-previewable') {
105-
return <AudioPreview key={file.id} file={file} workspaceId={workspaceId} />
106+
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='audio' />
106107
}
107108

108109
if (category === 'video-previewable') {
109-
return <VideoPreview key={file.id} file={file} workspaceId={workspaceId} />
110+
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='video' />
110111
}
111112

112113
if (category === 'docx-previewable') {
@@ -176,12 +177,21 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
176177
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
177178
}
178179

179-
const AudioPreview = memo(function AudioPreview({
180+
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
181+
182+
/**
183+
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
184+
* lifecycle, and error/loading handling are identical; only the rendered
185+
* player differs.
186+
*/
187+
const MediaPreview = memo(function MediaPreview({
180188
file,
181189
workspaceId,
190+
kind,
182191
}: {
183192
file: WorkspaceFileRecord
184193
workspaceId: string
194+
kind: 'audio' | 'video'
185195
}) {
186196
const {
187197
fileData,
@@ -193,55 +203,31 @@ const AudioPreview = memo(function AudioPreview({
193203

194204
useEffect(() => {
195205
if (!fileData) return
196-
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'audio/mpeg' })))
197-
}, [file.type, fileData, replaceBlobUrl])
206+
replaceBlobUrl(
207+
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
208+
)
209+
}, [file.type, fileData, kind, replaceBlobUrl])
198210

199211
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
200-
if (error) return <PreviewError label='audio' error={error} />
212+
if (error) return <PreviewError label={kind} error={error} />
201213

202214
if (isLoading && !blobUrl) {
203215
return <PreviewLoadingFrame className='h-full' tone='surface' />
204216
}
205217

206-
return (
207-
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
208-
<div className='flex flex-col items-center gap-2 text-center'>
209-
<div className='text-[32px]'>🎵</div>
210-
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
218+
if (kind === 'audio') {
219+
return (
220+
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
221+
<div className='flex flex-col items-center gap-2 text-center'>
222+
<Music className='size-[32px] text-[var(--text-muted)]' strokeWidth={1.5} />
223+
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
224+
</div>
225+
{blobUrl && (
226+
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
227+
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
228+
)}
211229
</div>
212-
{blobUrl && (
213-
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
214-
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
215-
)}
216-
</div>
217-
)
218-
})
219-
220-
const VideoPreview = memo(function VideoPreview({
221-
file,
222-
workspaceId,
223-
}: {
224-
file: WorkspaceFileRecord
225-
workspaceId: string
226-
}) {
227-
const {
228-
fileData,
229-
isLoading,
230-
error: fetchError,
231-
blobUrl,
232-
replaceBlobUrl,
233-
} = useBlobUrl(workspaceId, file.id, file.key)
234-
235-
useEffect(() => {
236-
if (!fileData) return
237-
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'video/mp4' })))
238-
}, [file.type, fileData, replaceBlobUrl])
239-
240-
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
241-
if (error) return <PreviewError label='video' error={error} />
242-
243-
if (isLoading && !blobUrl) {
244-
return <PreviewLoadingFrame className='h-full' tone='surface' />
230+
)
245231
}
246232

247233
return (

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

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ const PDF_ZOOM_MIN = 0.5
2828
const PDF_ZOOM_MAX = 3
2929
const PDF_ZOOM_DEFAULT = 1
3030
const PDF_ZOOM_STEP = 1.25
31-
const PDF_PAGE_MAX_WIDTH = 816
3231
const PDF_VIEWER_PADDING = 24
32+
const PDF_RESIZE_DEBOUNCE_MS = 150
3333

3434
export type PdfDocumentSource =
3535
| { kind: 'url'; url: string }
@@ -66,25 +66,53 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
6666
const [loadError, setLoadError] = useState<string | null>(null)
6767

6868
const sourceValue = source.kind === 'url' ? source.url : source.buffer
69+
/**
70+
* The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and
71+
* detaches — the ArrayBuffer it receives to its worker, so handing over the
72+
* caller's buffer would leave it unusable on the next render or remount.
73+
*/
6974
const file = useMemo(
7075
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
7176
[sourceValue]
7277
)
7378

79+
/**
80+
* The first non-zero measurement applies immediately so the document renders
81+
* without delay (a hidden container reports zero width and must not consume
82+
* the immediate slot); subsequent ones (panel-divider drags) are debounced
83+
* because every pageWidth change makes pdf.js re-rasterise all page canvases
84+
* — per-tick updates during a drag would re-render the whole document
85+
* continuously.
86+
*/
7487
useEffect(() => {
7588
const container = containerRef.current
7689
if (!container) return
90+
let hasMeasured = false
91+
let debounce: ReturnType<typeof setTimeout> | undefined
7792
const observer = new ResizeObserver(([entry]) => {
78-
setContainerWidth(entry.contentRect.width)
93+
const { width } = entry.contentRect
94+
if (!hasMeasured) {
95+
if (width <= 0) return
96+
hasMeasured = true
97+
setContainerWidth(width)
98+
return
99+
}
100+
clearTimeout(debounce)
101+
debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS)
79102
})
80103
observer.observe(container)
81-
return () => observer.disconnect()
104+
return () => {
105+
clearTimeout(debounce)
106+
observer.disconnect()
107+
}
82108
}, [])
83109

84-
const pageWidth =
85-
containerWidth > 0
86-
? Math.min(containerWidth - 2 * PDF_VIEWER_PADDING, PDF_PAGE_MAX_WIDTH)
87-
: undefined
110+
/**
111+
* 100% zoom fits the page to the panel width (pdf.js re-renders the canvas
112+
* at the target width, so upscaling past the page's natural print size
113+
* stays crisp). Matches the DOCX preview's fit-to-width semantics.
114+
*/
115+
const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined
88116
pageWidthRef.current = pageWidth
89117

90118
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
Lines changed: 16 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
2-
import { Button } from '@/components/emcn'
2+
import { Chip } from '@/components/emcn'
33
import { cn } from '@/lib/core/utils/cn'
44

55
interface PreviewNavigationControls {
@@ -31,14 +31,14 @@ export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarPr
3131
return (
3232
<div
3333
className={cn(
34-
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-3 py-1.5',
34+
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1',
3535
className
3636
)}
3737
>
38-
<div className='flex items-center gap-1'>
38+
<div className='flex items-center'>
3939
{navigation && <PreviewNavigationControls {...navigation} />}
4040
</div>
41-
<div className='flex items-center gap-1'>{zoom && <PreviewZoomControls {...zoom} />}</div>
41+
<div className='flex items-center'>{zoom && <PreviewZoomControls {...zoom} />}</div>
4242
</div>
4343
)
4444
}
@@ -54,29 +54,21 @@ function PreviewNavigationControls({
5454
}: PreviewNavigationControls) {
5555
return (
5656
<>
57-
<Button
58-
variant='ghost'
59-
size='sm'
57+
<Chip
58+
leftIcon={ChevronLeft}
6059
onClick={onPrevious}
6160
disabled={!canPrevious}
62-
className='size-6 p-0 text-[var(--text-icon)]'
6361
aria-label={`Previous ${label}`}
64-
>
65-
<ChevronLeft className='size-[14px]' />
66-
</Button>
67-
<span className='min-w-[5rem] text-center text-[12px] text-[var(--text-secondary)]'>
62+
/>
63+
<span className='min-w-[4.5rem] text-center text-[var(--text-body)] text-sm'>
6864
{total > 0 ? `${current} / ${total}` : '0 / 0'}
6965
</span>
70-
<Button
71-
variant='ghost'
72-
size='sm'
66+
<Chip
67+
leftIcon={ChevronRight}
7368
onClick={onNext}
7469
disabled={!canNext}
75-
className='size-6 p-0 text-[var(--text-icon)]'
7670
aria-label={`Next ${label}`}
77-
>
78-
<ChevronRight className='size-[14px]' />
79-
</Button>
71+
/>
8072
</>
8173
)
8274
}
@@ -92,39 +84,13 @@ function PreviewZoomControls({
9284
return (
9385
<>
9486
{onReset && (
95-
<Button
96-
variant='ghost'
97-
size='sm'
98-
onClick={onReset}
99-
className='h-6 px-2 text-[11px]'
100-
aria-label='Reset zoom'
101-
>
87+
<Chip onClick={onReset} aria-label='Reset zoom'>
10288
Reset
103-
</Button>
89+
</Chip>
10490
)}
105-
<Button
106-
variant='ghost'
107-
size='sm'
108-
onClick={onZoomOut}
109-
disabled={!canZoomOut}
110-
className='size-6 p-0 text-[var(--text-icon)]'
111-
aria-label='Zoom out'
112-
>
113-
<ZoomOut className='size-[14px]' />
114-
</Button>
115-
<span className='min-w-[3rem] text-center text-[12px] text-[var(--text-secondary)]'>
116-
{label}
117-
</span>
118-
<Button
119-
variant='ghost'
120-
size='sm'
121-
onClick={onZoomIn}
122-
disabled={!canZoomIn}
123-
className='size-6 p-0 text-[var(--text-icon)]'
124-
aria-label='Zoom in'
125-
>
126-
<ZoomIn className='size-[14px]' />
127-
</Button>
91+
<Chip leftIcon={ZoomOut} onClick={onZoomOut} disabled={!canZoomOut} aria-label='Zoom out' />
92+
<span className='min-w-[3.25rem] text-center text-[var(--text-body)] text-sm'>{label}</span>
93+
<Chip leftIcon={ZoomIn} onClick={onZoomIn} disabled={!canZoomIn} aria-label='Zoom in' />
12894
</>
12995
)
13096
}

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { memo, useEffect, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
55
import { toError } from '@sim/utils/errors'
66
import type { WorkBook } from 'xlsx'
7-
import { Button } from '@/components/emcn'
8-
import { cn } from '@/lib/core/utils/cn'
7+
import { Chip } from '@/components/emcn'
98
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
109
import { DataTable } from './data-table'
1110
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
@@ -115,23 +114,17 @@ export const XlsxPreview = memo(function XlsxPreview({
115114

116115
return (
117116
<div className='flex flex-1 flex-col overflow-hidden'>
118-
<div className='flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)]'>
119-
<div className='flex gap-0'>
117+
<div className='flex shrink-0 items-center border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1'>
118+
<div className='flex items-center overflow-x-auto'>
120119
{sheetNames.map((name, i) => (
121-
<Button
120+
<Chip
122121
key={name}
123-
variant='ghost'
124-
size='sm'
122+
active={i === activeSheet}
125123
onClick={() => setActiveSheet(i)}
126-
className={cn(
127-
'rounded-none px-3 py-1.5 text-[12px]',
128-
i === activeSheet
129-
? 'border-[var(--brand-secondary)] border-b-2 font-medium text-[var(--text-primary)]'
130-
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
131-
)}
124+
className='shrink-0'
132125
>
133126
{name}
134-
</Button>
127+
</Chip>
135128
))}
136129
</div>
137130
</div>

0 commit comments

Comments
 (0)