Skip to content

Commit 0f6c16d

Browse files
committed
Fixes
1 parent 22cd5e4 commit 0f6c16d

File tree

3 files changed

+166
-5
lines changed

3 files changed

+166
-5
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,13 +1025,48 @@ const DocxPreview = memo(function DocxPreview({
10251025
file: WorkspaceFileRecord
10261026
workspaceId: string
10271027
}) {
1028+
const viewportRef = useRef<HTMLDivElement>(null)
10281029
const containerRef = useRef<HTMLDivElement>(null)
10291030
const {
10301031
data: fileData,
10311032
isLoading,
10321033
error: fetchError,
10331034
} = useWorkspaceFileBinary(workspaceId, file.id, file.key)
10341035
const [renderError, setRenderError] = useState<string | null>(null)
1036+
const [docxScale, setDocxScale] = useState(1)
1037+
const [scaledSize, setScaledSize] = useState<{ width: number; height: number } | null>(null)
1038+
1039+
const updateDocxScale = useCallback(() => {
1040+
const viewport = viewportRef.current
1041+
const container = containerRef.current
1042+
if (!viewport || !container) return
1043+
1044+
const intrinsicWidth = container.scrollWidth
1045+
const intrinsicHeight = container.scrollHeight
1046+
if (intrinsicWidth === 0 || intrinsicHeight === 0) return
1047+
1048+
const viewportStyle = window.getComputedStyle(viewport)
1049+
const paddingX =
1050+
Number.parseFloat(viewportStyle.paddingLeft) + Number.parseFloat(viewportStyle.paddingRight)
1051+
const availableWidth = Math.max(viewport.clientWidth - paddingX, 0)
1052+
const nextScale = availableWidth > 0 ? Math.min(1, availableWidth / intrinsicWidth) : 1
1053+
1054+
setDocxScale((prev) => (Math.abs(prev - nextScale) < 0.001 ? prev : nextScale))
1055+
setScaledSize((prev) => {
1056+
const next = {
1057+
width: intrinsicWidth * nextScale,
1058+
height: intrinsicHeight * nextScale,
1059+
}
1060+
if (
1061+
prev &&
1062+
Math.abs(prev.width - next.width) < 1 &&
1063+
Math.abs(prev.height - next.height) < 1
1064+
) {
1065+
return prev
1066+
}
1067+
return next
1068+
})
1069+
}, [])
10351070

10361071
useEffect(() => {
10371072
if (!containerRef.current || !fileData) return
@@ -1042,12 +1077,18 @@ const DocxPreview = memo(function DocxPreview({
10421077
try {
10431078
const { renderAsync } = await import('docx-preview')
10441079
if (cancelled || !containerRef.current) return
1080+
setRenderError(null)
1081+
setDocxScale(1)
1082+
setScaledSize(null)
10451083
containerRef.current.innerHTML = ''
10461084
await renderAsync(fileData, containerRef.current, undefined, {
10471085
inWrapper: true,
10481086
ignoreWidth: false,
10491087
ignoreHeight: false,
10501088
})
1089+
if (!cancelled) {
1090+
requestAnimationFrame(updateDocxScale)
1091+
}
10511092
} catch (err) {
10521093
if (!cancelled) {
10531094
const msg = err instanceof Error ? err.message : 'Failed to render document'
@@ -1061,13 +1102,57 @@ const DocxPreview = memo(function DocxPreview({
10611102
return () => {
10621103
cancelled = true
10631104
}
1064-
}, [fileData])
1105+
}, [fileData, updateDocxScale])
1106+
1107+
useEffect(() => {
1108+
const viewport = viewportRef.current
1109+
const container = containerRef.current
1110+
if (!viewport || !container) return
1111+
1112+
updateDocxScale()
1113+
1114+
const resizeObserver = new ResizeObserver(() => {
1115+
updateDocxScale()
1116+
})
1117+
1118+
resizeObserver.observe(viewport)
1119+
resizeObserver.observe(container)
1120+
1121+
return () => {
1122+
resizeObserver.disconnect()
1123+
}
1124+
}, [fileData, updateDocxScale])
10651125

10661126
const error = resolvePreviewError(fetchError, renderError)
10671127
if (error) return <PreviewError label='document' error={error} />
10681128
if (isLoading) return DOCUMENT_SKELETON
10691129

1070-
return <div ref={containerRef} className='h-full w-full overflow-auto bg-white' />
1130+
return (
1131+
<div ref={viewportRef} className='h-full overflow-auto bg-[var(--surface-1)] p-4 sm:p-6'>
1132+
<div className='flex min-h-full justify-center'>
1133+
<div
1134+
className='shrink-0'
1135+
style={
1136+
scaledSize
1137+
? {
1138+
width: scaledSize.width,
1139+
minHeight: scaledSize.height,
1140+
}
1141+
: undefined
1142+
}
1143+
>
1144+
<div
1145+
ref={containerRef}
1146+
className='origin-top'
1147+
style={{
1148+
transform: `scale(${docxScale})`,
1149+
transformOrigin: 'top center',
1150+
}}
1151+
/>
1152+
</div>
1153+
</div>
1154+
</div>
1155+
)
10711156
})
10721157

10731158
const pptxSlideCache = new Map<string, string[]>()

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRe
44
import { useRouter } from 'next/navigation'
55
import rehypeSlug from 'rehype-slug'
66
import remarkBreaks from 'remark-breaks'
7+
import remarkGfm from 'remark-gfm'
78
import { Streamdown } from 'streamdown'
89
import 'streamdown/styles.css'
910
import { Checkbox } from '@/components/emcn'
@@ -74,7 +75,7 @@ export const PreviewPanel = memo(function PreviewPanel({
7475
return null
7576
})
7677

77-
const REMARK_PLUGINS = [remarkBreaks]
78+
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
7879
const REHYPE_PLUGINS = [rehypeSlug]
7980

8081
/**
@@ -404,12 +405,80 @@ const MarkdownPreview = memo(function MarkdownPreview({
404405
)
405406
})
406407

408+
const HTML_PREVIEW_BASE_URL = 'about:srcdoc'
409+
410+
const HTML_PREVIEW_CSP = [
411+
"default-src 'none'",
412+
"script-src 'unsafe-inline'",
413+
"style-src 'unsafe-inline'",
414+
'img-src data: blob:',
415+
'font-src data:',
416+
'media-src data: blob:',
417+
"connect-src 'none'",
418+
"form-action 'none'",
419+
"frame-src 'none'",
420+
"child-src 'none'",
421+
"object-src 'none'",
422+
].join('; ')
423+
424+
const HTML_PREVIEW_BOOTSTRAP = `<script>
425+
(() => {
426+
const allowHref = (href) => href.startsWith('#') || /^\\s*javascript:/i.test(href)
427+
428+
document.addEventListener(
429+
'click',
430+
(event) => {
431+
if (!(event.target instanceof Element)) return
432+
const anchor = event.target.closest('a[href]')
433+
if (!(anchor instanceof HTMLAnchorElement)) return
434+
const href = anchor.getAttribute('href') || ''
435+
if (allowHref(href)) return
436+
event.preventDefault()
437+
},
438+
true
439+
)
440+
441+
document.addEventListener(
442+
'submit',
443+
(event) => {
444+
event.preventDefault()
445+
},
446+
true
447+
)
448+
449+
})()
450+
</script>`
451+
452+
function buildHtmlPreviewDocument(content: string): string {
453+
const headInjection = [
454+
'<meta charset="utf-8">',
455+
`<base href="${HTML_PREVIEW_BASE_URL}">`,
456+
`<meta http-equiv="Content-Security-Policy" content="${HTML_PREVIEW_CSP}">`,
457+
HTML_PREVIEW_BOOTSTRAP,
458+
].join('')
459+
460+
if (/<head[\s>]/i.test(content)) {
461+
return content.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${headInjection}`)
462+
}
463+
464+
if (/<html[\s>]/i.test(content)) {
465+
return content.replace(/<html(\s[^>]*)?>/i, (match) => `${match}<head>${headInjection}</head>`)
466+
}
467+
468+
return `<!DOCTYPE html><html><head>${headInjection}</head><body>${content}</body></html>`
469+
}
470+
407471
const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) {
472+
// Run inline HTML/JS in an isolated iframe while blocking any navigation
473+
// that would replace the preview with another document.
474+
const wrappedContent = useMemo(() => buildHtmlPreviewDocument(content), [content])
475+
408476
return (
409477
<div className='h-full overflow-hidden'>
410478
<iframe
411-
srcDoc={content}
412-
sandbox='allow-same-origin'
479+
srcDoc={wrappedContent}
480+
sandbox='allow-scripts'
481+
referrerPolicy='no-referrer'
413482
title='HTML Preview'
414483
className='h-full w-full border-0 bg-white'
415484
/>

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2507,6 +2507,13 @@ export function useChat(
25072507
return
25082508
}
25092509

2510+
// A live SSE `complete` event is already terminal. Finalize immediately so follow-up
2511+
// sends do not get spuriously queued behind an already-finished response.
2512+
if (streamResult.sawComplete) {
2513+
finalize()
2514+
return
2515+
}
2516+
25102517
await resumeOrFinalize({
25112518
streamId: streamIdRef.current || userMessageId,
25122519
assistantId,

0 commit comments

Comments
 (0)