This commit is contained in:
Siddharth Ganesan
2026-04-10 15:54:53 -07:00
parent 22cd5e4814
commit 0f6c16dc64
3 changed files with 166 additions and 5 deletions

View File

@@ -1025,6 +1025,7 @@ const DocxPreview = memo(function DocxPreview({
file: WorkspaceFileRecord
workspaceId: string
}) {
const viewportRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(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<string | null>(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 <PreviewError label='document' error={error} />
if (isLoading) return DOCUMENT_SKELETON
return <div ref={containerRef} className='h-full w-full overflow-auto bg-white' />
return (
<div ref={viewportRef} className='h-full overflow-auto bg-[var(--surface-1)] p-4 sm:p-6'>
<div className='flex min-h-full justify-center'>
<div
className='shrink-0'
style={
scaledSize
? {
width: scaledSize.width,
minHeight: scaledSize.height,
}
: undefined
}
>
<div
ref={containerRef}
className='origin-top'
style={{
transform: `scale(${docxScale})`,
transformOrigin: 'top center',
}}
/>
</div>
</div>
</div>
)
})
const pptxSlideCache = new Map<string, string[]>()

View File

@@ -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 = `<script>
(() => {
const allowHref = (href) => href.startsWith('#') || /^\\s*javascript:/i.test(href)
document.addEventListener(
'click',
(event) => {
if (!(event.target instanceof Element)) return
const anchor = event.target.closest('a[href]')
if (!(anchor instanceof HTMLAnchorElement)) return
const href = anchor.getAttribute('href') || ''
if (allowHref(href)) return
event.preventDefault()
},
true
)
document.addEventListener(
'submit',
(event) => {
event.preventDefault()
},
true
)
})()
</script>`
function buildHtmlPreviewDocument(content: string): string {
const headInjection = [
'<meta charset="utf-8">',
`<base href="${HTML_PREVIEW_BASE_URL}">`,
`<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`,
HTML_PREVIEW_BOOTSTRAP,
].join('')
if (/<head[\s>]/i.test(content)) {
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${headInjection}`)
}
if (/<html[\s>]/i.test(content)) {
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${headInjection}</head>`)
}
return `<!DOCTYPE html><html><head>${headInjection}</head><body>${content}</body></html>`
}
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 (
<div className='h-full overflow-hidden'>
<iframe
srcDoc={content}
sandbox='allow-same-origin'
srcDoc={wrappedContent}
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
className='h-full w-full border-0 bg-white'
/>

View File

@@ -2507,6 +2507,13 @@ export function useChat(
return
}
// A live SSE `complete` event is already terminal. Finalize immediately so follow-up
// sends do not get spuriously queued behind an already-finished response.
if (streamResult.sawComplete) {
finalize()
return
}
await resumeOrFinalize({
streamId: streamIdRef.current || userMessageId,
assistantId,