diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index ce52af4fbf4..4d0d4b8583d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -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 `
` 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('.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 | 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( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 2989806a928..f20d1762ccf 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -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 + return } if (category === 'video-previewable') { - return + return } 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 + if (error) return if (isLoading && !blobUrl) { return } - return ( -
-
-
🎵
-

{file.name}

+ if (kind === 'audio') { + return ( +
+
+ +

{file.name}

+
+ {blobUrl && ( + // biome-ignore lint/a11y/useMediaCaption: audio from workspace files +
- {blobUrl && ( - // biome-ignore lint/a11y/useMediaCaption: audio from workspace files -
- ) -}) - -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 - - if (isLoading && !blobUrl) { - return + ) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx index 2dea41ff560..13802b174e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pdf-viewer.tsx @@ -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(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 | 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) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx index b161d2f8bce..005218e7aff 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-toolbar.tsx @@ -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 (
-
+
{navigation && }
-
{zoom && }
+
{zoom && }
) } @@ -54,29 +54,21 @@ function PreviewNavigationControls({ }: PreviewNavigationControls) { return ( <> - - + /> + {total > 0 ? `${current} / ${total}` : '0 / 0'} - + /> ) } @@ -92,39 +84,13 @@ function PreviewZoomControls({ return ( <> {onReset && ( - + )} - - - {label} - - + + {label} + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx index acc20afbc9c..3962aaf038d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview.tsx @@ -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 (
-
-
+
+
{sheetNames.map((name, i) => ( - + ))}