Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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,
Expand All @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Comment thread
waleedlatif1 marked this conversation as resolved.
})
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
Comment thread
waleedlatif1 marked this conversation as resolved.

const applyZoomAt = useCallback((next: number, anchorX: number, anchorY: number) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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>
)
}
Expand All @@ -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>
/>
</>
)
}
Expand All @@ -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' />
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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>
Expand Down
Loading