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)
This commit is contained in:
Waleed
2026-06-12 11:55:01 -07:00
committed by GitHub
parent 37e7121406
commit bc92a2cc49
5 changed files with 108 additions and 119 deletions
@@ -16,11 +16,14 @@ const DOCX_ZOOM_MIN = 25
const DOCX_ZOOM_MAX = 400
const DOCX_ZOOM_STEP = 20
const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005
const DOCX_RESIZE_DEBOUNCE_MS = 150
/**
* Fit the rendered docx pages to the host container width using a CSS scale.
* The library renders `<section class="docx">` at the document's natural page
* width (in cm), which overflows narrow panels.
* width (in cm), which overflows narrow panels. 100% zoom means fit-to-width —
* pages upscale past their natural print size in wide panels (CSS zoom of HTML
* stays crisp), matching the PDF preview's semantics.
*/
function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercent: number) {
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
@@ -48,7 +51,7 @@ function fitDocxToContainer(host: HTMLElement, viewport: HTMLElement, zoomPercen
Number.parseFloat(wrapperStyle.paddingLeft) + Number.parseFloat(wrapperStyle.paddingRight)
const naturalWrapperWidth = naturalPageWidth + horizontalPadding
const available = viewport.clientWidth
const fitScale = Math.min(1, available / naturalWrapperWidth)
const fitScale = available / naturalWrapperWidth
const scale = fitScale * (zoomPercent / 100)
const scaledWrapperWidth = naturalWrapperWidth * scale
@@ -95,12 +98,25 @@ export const DocxPreview = memo(function DocxPreview({
fitDocxToContainer(container, scrollContainer, zoomPercentRef.current)
}, [])
/**
* Resize refits are debounced: each one re-queries the rendered pages and
* recomputes the fit scale, so per-tick refits during a panel-divider drag
* would thrash layout continuously (the initial fit is applied directly by
* the render path, not this observer). Mirrors the PDF preview's debounce.
*/
useEffect(() => {
const scrollContainer = scrollContainerRef.current
if (!scrollContainer) return
const observer = new ResizeObserver(() => applyPostRenderStyling())
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(() => {
clearTimeout(debounce)
debounce = setTimeout(() => applyPostRenderStyling(), DOCX_RESIZE_DEBOUNCE_MS)
})
observer.observe(scrollContainer)
return () => observer.disconnect()
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [applyPostRenderStyling])
const applyZoomAt = useCallback(
@@ -2,6 +2,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { Music } from 'lucide-react'
import dynamic from 'next/dynamic'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { getFileExtension } from '@/lib/uploads/utils/file-utils'
@@ -102,11 +103,11 @@ export function FileViewer({
}
if (category === 'audio-previewable') {
return <AudioPreview key={file.id} file={file} workspaceId={workspaceId} />
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='audio' />
}
if (category === 'video-previewable') {
return <VideoPreview key={file.id} file={file} workspaceId={workspaceId} />
return <MediaPreview key={file.id} file={file} workspaceId={workspaceId} kind='video' />
}
if (category === 'docx-previewable') {
@@ -176,12 +177,21 @@ function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) {
return { fileData, isLoading, error, blobUrl, replaceBlobUrl }
}
const AudioPreview = memo(function AudioPreview({
const MEDIA_FALLBACK_MIME = { audio: 'audio/mpeg', video: 'video/mp4' } as const
/**
* Shared blob-backed preview for audio and video files — the fetch, blob-URL
* lifecycle, and error/loading handling are identical; only the rendered
* player differs.
*/
const MediaPreview = memo(function MediaPreview({
file,
workspaceId,
kind,
}: {
file: WorkspaceFileRecord
workspaceId: string
kind: 'audio' | 'video'
}) {
const {
fileData,
@@ -193,55 +203,31 @@ const AudioPreview = memo(function AudioPreview({
useEffect(() => {
if (!fileData) return
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'audio/mpeg' })))
}, [file.type, fileData, replaceBlobUrl])
replaceBlobUrl(
URL.createObjectURL(new Blob([fileData], { type: file.type || MEDIA_FALLBACK_MIME[kind] }))
)
}, [file.type, fileData, kind, replaceBlobUrl])
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
if (error) return <PreviewError label='audio' error={error} />
if (error) return <PreviewError label={kind} error={error} />
if (isLoading && !blobUrl) {
return <PreviewLoadingFrame className='h-full' tone='surface' />
}
return (
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
<div className='flex flex-col items-center gap-2 text-center'>
<div className='text-[32px]'>🎵</div>
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
if (kind === 'audio') {
return (
<div className='flex h-full flex-col items-center justify-center gap-4 bg-[var(--surface-1)] p-8'>
<div className='flex flex-col items-center gap-2 text-center'>
<Music className='size-[32px] text-[var(--text-muted)]' strokeWidth={1.5} />
<p className='font-medium text-[14px] text-[var(--text-primary)]'>{file.name}</p>
</div>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
)}
</div>
{blobUrl && (
// biome-ignore lint/a11y/useMediaCaption: audio from workspace files
<audio src={blobUrl} controls className='w-full max-w-[480px]' />
)}
</div>
)
})
const VideoPreview = memo(function VideoPreview({
file,
workspaceId,
}: {
file: WorkspaceFileRecord
workspaceId: string
}) {
const {
fileData,
isLoading,
error: fetchError,
blobUrl,
replaceBlobUrl,
} = useBlobUrl(workspaceId, file.id, file.key)
useEffect(() => {
if (!fileData) return
replaceBlobUrl(URL.createObjectURL(new Blob([fileData], { type: file.type || 'video/mp4' })))
}, [file.type, fileData, replaceBlobUrl])
const error = blobUrl !== null ? null : resolvePreviewError(fetchError, null)
if (error) return <PreviewError label='video' error={error} />
if (isLoading && !blobUrl) {
return <PreviewLoadingFrame className='h-full' tone='surface' />
)
}
return (
@@ -28,8 +28,8 @@ const PDF_ZOOM_MIN = 0.5
const PDF_ZOOM_MAX = 3
const PDF_ZOOM_DEFAULT = 1
const PDF_ZOOM_STEP = 1.25
const PDF_PAGE_MAX_WIDTH = 816
const PDF_VIEWER_PADDING = 24
const PDF_RESIZE_DEBOUNCE_MS = 150
export type PdfDocumentSource =
| { kind: 'url'; url: string }
@@ -66,25 +66,53 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P
const [loadError, setLoadError] = useState<string | null>(null)
const sourceValue = source.kind === 'url' ? source.url : source.buffer
/**
* The buffer copy (`slice(0)`) is load-bearing: pdf.js transfers — and
* detaches — the ArrayBuffer it receives to its worker, so handing over the
* caller's buffer would leave it unusable on the next render or remount.
*/
const file = useMemo(
() => (source.kind === 'url' ? source.url : { data: new Uint8Array(source.buffer.slice(0)) }),
[sourceValue]
)
/**
* The first non-zero measurement applies immediately so the document renders
* without delay (a hidden container reports zero width and must not consume
* the immediate slot); subsequent ones (panel-divider drags) are debounced
* because every pageWidth change makes pdf.js re-rasterise all page canvases
* — per-tick updates during a drag would re-render the whole document
* continuously.
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
let hasMeasured = false
let debounce: ReturnType<typeof setTimeout> | undefined
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
const { width } = entry.contentRect
if (!hasMeasured) {
if (width <= 0) return
hasMeasured = true
setContainerWidth(width)
return
}
clearTimeout(debounce)
debounce = setTimeout(() => setContainerWidth(width), PDF_RESIZE_DEBOUNCE_MS)
})
observer.observe(container)
return () => observer.disconnect()
return () => {
clearTimeout(debounce)
observer.disconnect()
}
}, [])
const pageWidth =
containerWidth > 0
? Math.min(containerWidth - 2 * PDF_VIEWER_PADDING, PDF_PAGE_MAX_WIDTH)
: undefined
/**
* 100% zoom fits the page to the panel width (pdf.js re-renders the canvas
* at the target width, so upscaling past the page's natural print size
* stays crisp). Matches the DOCX preview's fit-to-width semantics.
*/
const pageWidth = containerWidth > 0 ? containerWidth - 2 * PDF_VIEWER_PADDING : undefined
pageWidthRef.current = pageWidth
const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Chip } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface PreviewNavigationControls {
@@ -31,14 +31,14 @@ export function PreviewToolbar({ navigation, zoom, className }: PreviewToolbarPr
return (
<div
className={cn(
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-3 py-1.5',
'flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1',
className
)}
>
<div className='flex items-center gap-1'>
<div className='flex items-center'>
{navigation && <PreviewNavigationControls {...navigation} />}
</div>
<div className='flex items-center gap-1'>{zoom && <PreviewZoomControls {...zoom} />}</div>
<div className='flex items-center'>{zoom && <PreviewZoomControls {...zoom} />}</div>
</div>
)
}
@@ -54,29 +54,21 @@ function PreviewNavigationControls({
}: PreviewNavigationControls) {
return (
<>
<Button
variant='ghost'
size='sm'
<Chip
leftIcon={ChevronLeft}
onClick={onPrevious}
disabled={!canPrevious}
className='size-6 p-0 text-[var(--text-icon)]'
aria-label={`Previous ${label}`}
>
<ChevronLeft className='size-[14px]' />
</Button>
<span className='min-w-[5rem] text-center text-[12px] text-[var(--text-secondary)]'>
/>
<span className='min-w-[4.5rem] text-center text-[var(--text-body)] text-sm'>
{total > 0 ? `${current} / ${total}` : '0 / 0'}
</span>
<Button
variant='ghost'
size='sm'
<Chip
leftIcon={ChevronRight}
onClick={onNext}
disabled={!canNext}
className='size-6 p-0 text-[var(--text-icon)]'
aria-label={`Next ${label}`}
>
<ChevronRight className='size-[14px]' />
</Button>
/>
</>
)
}
@@ -92,39 +84,13 @@ function PreviewZoomControls({
return (
<>
{onReset && (
<Button
variant='ghost'
size='sm'
onClick={onReset}
className='h-6 px-2 text-[11px]'
aria-label='Reset zoom'
>
<Chip onClick={onReset} aria-label='Reset zoom'>
Reset
</Button>
</Chip>
)}
<Button
variant='ghost'
size='sm'
onClick={onZoomOut}
disabled={!canZoomOut}
className='size-6 p-0 text-[var(--text-icon)]'
aria-label='Zoom out'
>
<ZoomOut className='size-[14px]' />
</Button>
<span className='min-w-[3rem] text-center text-[12px] text-[var(--text-secondary)]'>
{label}
</span>
<Button
variant='ghost'
size='sm'
onClick={onZoomIn}
disabled={!canZoomIn}
className='size-6 p-0 text-[var(--text-icon)]'
aria-label='Zoom in'
>
<ZoomIn className='size-[14px]' />
</Button>
<Chip leftIcon={ZoomOut} onClick={onZoomOut} disabled={!canZoomOut} aria-label='Zoom out' />
<span className='min-w-[3.25rem] text-center text-[var(--text-body)] text-sm'>{label}</span>
<Chip leftIcon={ZoomIn} onClick={onZoomIn} disabled={!canZoomIn} aria-label='Zoom in' />
</>
)
}
@@ -4,8 +4,7 @@ import { memo, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import type { WorkBook } from 'xlsx'
import { Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { Chip } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
import { DataTable } from './data-table'
import { PreviewError, PreviewLoadingFrame, resolvePreviewError } from './preview-shared'
@@ -115,23 +114,17 @@ export const XlsxPreview = memo(function XlsxPreview({
return (
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='flex shrink-0 items-center justify-between border-[var(--border)] border-b bg-[var(--surface-1)]'>
<div className='flex gap-0'>
<div className='flex shrink-0 items-center border-[var(--border)] border-b bg-[var(--surface-1)] px-2 py-1'>
<div className='flex items-center overflow-x-auto'>
{sheetNames.map((name, i) => (
<Button
<Chip
key={name}
variant='ghost'
size='sm'
active={i === activeSheet}
onClick={() => setActiveSheet(i)}
className={cn(
'rounded-none px-3 py-1.5 text-[12px]',
i === activeSheet
? 'border-[var(--brand-secondary)] border-b-2 font-medium text-[var(--text-primary)]'
: 'text-[var(--text-muted)] hover:text-[var(--text-secondary)]'
)}
className='shrink-0'
>
{name}
</Button>
</Chip>
))}
</div>
</div>