From 62d12a07735e89a9e7b99467c82a6c3c3b4884d2 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 11:30:05 -0700 Subject: [PATCH 1/4] 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 --- .../components/file-viewer/docx-preview.tsx | 6 +- .../components/file-viewer/file-viewer.tsx | 3 +- .../components/file-viewer/pdf-viewer.tsx | 11 ++-- .../file-viewer/preview-toolbar.tsx | 66 +++++-------------- .../components/file-viewer/xlsx-preview.tsx | 21 ++---- 5 files changed, 35 insertions(+), 72 deletions(-) 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..c7715de540a 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 @@ -20,7 +20,9 @@ const DOCX_ZOOM_WHEEL_SENSITIVITY = 0.005 /** * 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 +50,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 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..ae3ec151af4 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' @@ -206,7 +207,7 @@ const AudioPreview = memo(function AudioPreview({ return (
-
🎵
+

{file.name}

{blobUrl && ( 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..0f060e17be6 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,7 +28,6 @@ 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 export type PdfDocumentSource = @@ -81,10 +80,12 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P return () => 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) => ( - + ))}
From 53d0c673b5060d252e6c3d9d89171e93b320254a Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 11:37:39 -0700 Subject: [PATCH 2/4] 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. --- .../components/file-viewer/pdf-viewer.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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 0f060e17be6..e9618f0b40f 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 @@ -29,6 +29,7 @@ const PDF_ZOOM_MAX = 3 const PDF_ZOOM_DEFAULT = 1 const PDF_ZOOM_STEP = 1.25 const PDF_VIEWER_PADDING = 24 +const PDF_RESIZE_DEBOUNCE_MS = 150 export type PdfDocumentSource = | { kind: 'url'; url: string } @@ -70,14 +71,32 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P [sourceValue] ) + /** + * The first measurement applies immediately so the document renders without + * delay; 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) { + 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() + } }, []) /** From 8aea876a678dd004c8db2a90f88676df520728e8 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 11:43:17 -0700 Subject: [PATCH 3/4] 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. --- .../files/components/file-viewer/pdf-viewer.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 e9618f0b40f..a26477688f2 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 @@ -72,10 +72,12 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P ) /** - * The first measurement applies immediately so the document renders without - * delay; 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. + * 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 @@ -85,6 +87,7 @@ export const PdfViewerCore = memo(function PdfViewerCore({ source, filename }: P const observer = new ResizeObserver(([entry]) => { const { width } = entry.contentRect if (!hasMeasured) { + if (width <= 0) return hasMeasured = true setContainerWidth(width) return From 6a33064431b506ba9cb543387349745f684e0d62 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 12 Jun 2026 11:51:01 -0700 Subject: [PATCH 4/4] =?UTF-8?q?improvement(files):=20module=20cleanup=20?= =?UTF-8?q?=E2=80=94=20dedupe=20media=20previews,=20debounce=20docx=20refi?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../components/file-viewer/docx-preview.tsx | 18 ++++- .../components/file-viewer/file-viewer.tsx | 73 ++++++++----------- .../components/file-viewer/pdf-viewer.tsx | 5 ++ 3 files changed, 50 insertions(+), 46 deletions(-) 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 c7715de540a..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,6 +16,7 @@ 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. @@ -97,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 ae3ec151af4..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 @@ -103,11 +103,11 @@ export function FileViewer({ } if (category === 'audio-previewable') { - return + return } if (category === 'video-previewable') { - return + return } if (category === 'docx-previewable') { @@ -177,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, @@ -194,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 a26477688f2..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 @@ -66,6 +66,11 @@ 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]