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 70c3ac9a99..cfae210046 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 @@ -1025,6 +1025,7 @@ const DocxPreview = memo(function DocxPreview({ file: WorkspaceFileRecord workspaceId: string }) { + const viewportRef = useRef(null) const containerRef = useRef(null) const { data: fileData, @@ -1032,6 +1033,40 @@ const DocxPreview = memo(function DocxPreview({ error: fetchError, } = useWorkspaceFileBinary(workspaceId, file.id, file.key) const [renderError, setRenderError] = useState(null) + const [docxScale, setDocxScale] = useState(1) + const [scaledSize, setScaledSize] = useState<{ width: number; height: number } | null>(null) + + const updateDocxScale = useCallback(() => { + const viewport = viewportRef.current + const container = containerRef.current + if (!viewport || !container) return + + const intrinsicWidth = container.scrollWidth + const intrinsicHeight = container.scrollHeight + if (intrinsicWidth === 0 || intrinsicHeight === 0) return + + const viewportStyle = window.getComputedStyle(viewport) + const paddingX = + Number.parseFloat(viewportStyle.paddingLeft) + Number.parseFloat(viewportStyle.paddingRight) + const availableWidth = Math.max(viewport.clientWidth - paddingX, 0) + const nextScale = availableWidth > 0 ? Math.min(1, availableWidth / intrinsicWidth) : 1 + + setDocxScale((prev) => (Math.abs(prev - nextScale) < 0.001 ? prev : nextScale)) + setScaledSize((prev) => { + const next = { + width: intrinsicWidth * nextScale, + height: intrinsicHeight * nextScale, + } + if ( + prev && + Math.abs(prev.width - next.width) < 1 && + Math.abs(prev.height - next.height) < 1 + ) { + return prev + } + return next + }) + }, []) useEffect(() => { if (!containerRef.current || !fileData) return @@ -1042,12 +1077,18 @@ const DocxPreview = memo(function DocxPreview({ try { const { renderAsync } = await import('docx-preview') if (cancelled || !containerRef.current) return + setRenderError(null) + setDocxScale(1) + setScaledSize(null) containerRef.current.innerHTML = '' await renderAsync(fileData, containerRef.current, undefined, { inWrapper: true, ignoreWidth: false, ignoreHeight: false, }) + if (!cancelled) { + requestAnimationFrame(updateDocxScale) + } } catch (err) { if (!cancelled) { const msg = err instanceof Error ? err.message : 'Failed to render document' @@ -1061,13 +1102,57 @@ const DocxPreview = memo(function DocxPreview({ return () => { cancelled = true } - }, [fileData]) + }, [fileData, updateDocxScale]) + + useEffect(() => { + const viewport = viewportRef.current + const container = containerRef.current + if (!viewport || !container) return + + updateDocxScale() + + const resizeObserver = new ResizeObserver(() => { + updateDocxScale() + }) + + resizeObserver.observe(viewport) + resizeObserver.observe(container) + + return () => { + resizeObserver.disconnect() + } + }, [fileData, updateDocxScale]) const error = resolvePreviewError(fetchError, renderError) if (error) return if (isLoading) return DOCUMENT_SKELETON - return
+ return ( +
+
+
+
+
+
+
+ ) }) const pptxSlideCache = new Map() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index 24a74a9265..3bc527a378 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -4,6 +4,7 @@ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRe import { useRouter } from 'next/navigation' import rehypeSlug from 'rehype-slug' import remarkBreaks from 'remark-breaks' +import remarkGfm from 'remark-gfm' import { Streamdown } from 'streamdown' import 'streamdown/styles.css' import { Checkbox } from '@/components/emcn' @@ -74,7 +75,7 @@ export const PreviewPanel = memo(function PreviewPanel({ return null }) -const REMARK_PLUGINS = [remarkBreaks] +const REMARK_PLUGINS = [remarkGfm, remarkBreaks] const REHYPE_PLUGINS = [rehypeSlug] /** @@ -404,12 +405,80 @@ const MarkdownPreview = memo(function MarkdownPreview({ ) }) +const HTML_PREVIEW_BASE_URL = 'about:srcdoc' + +const HTML_PREVIEW_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + 'img-src data: blob:', + 'font-src data:', + 'media-src data: blob:', + "connect-src 'none'", + "form-action 'none'", + "frame-src 'none'", + "child-src 'none'", + "object-src 'none'", +].join('; ') + +const HTML_PREVIEW_BOOTSTRAP = `` + +function buildHtmlPreviewDocument(content: string): string { + const headInjection = [ + '', + ``, + ``, + HTML_PREVIEW_BOOTSTRAP, + ].join('') + + if (/]/i.test(content)) { + return content.replace(/]*)?>/i, (match) => `${match}${headInjection}`) + } + + if (/]/i.test(content)) { + return content.replace(/]*)?>/i, (match) => `${match}${headInjection}`) + } + + return `${headInjection}${content}` +} + const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) { + // Run inline HTML/JS in an isolated iframe while blocking any navigation + // that would replace the preview with another document. + const wrappedContent = useMemo(() => buildHtmlPreviewDocument(content), [content]) + return (