mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) => {
|
||||
|
||||
+16
-50
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user