Add PDF preview to the file preview modal (#23)

Extends the existing fetch-and-bypass-Content-Disposition pattern
already used for text/markdown previews: the PDF's bytes are fetched
into a Blob and handed to the browser's native viewer via a blob: URL,
which carries no HTTP headers of its own. That sidesteps
Content-Disposition: attachment the same way a script-initiated fetch()
already does for text, without needing an <iframe>/<embed> to navigate
to the real file URL directly (which would respect that header and
force a download) -- and without the backend allowlist endpoint this
issue's original scoping assumed would be necessary.

MIME type is forced to application/pdf explicitly rather than trusted
from the upload, since getPreviewKind gates on the .pdf extension alone
(matching its existing behavior for .md/.txt), so a mislabeled file
still renders instead of downloading or erroring. Object URLs are
revoked on unmount/file-change to avoid leaking memory.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:09:10 -06:00
co-authored by Claude Sonnet 5
parent 53d16973fb
commit 73020fa39f
2 changed files with 67 additions and 16 deletions
@@ -20,6 +20,15 @@
overflow: hidden;
}
/* Wider and taller than the text/markdown case -- a PDF needs real room to
be readable, and its own viewer chrome (page controls, zoom) benefits
from more space than a text snippet ever would. */
.file-preview-modal-pdf {
width: min(900px, 100%);
height: min(90vh, 1100px);
max-height: min(90vh, 1100px);
}
.file-preview-header {
display: flex;
align-items: center;
@@ -76,6 +85,22 @@
overflow: auto;
}
/* No padding, and let the iframe fill the available space -- the native
PDF viewer already has its own margins/toolbar, and needs the full body
height, not just its content's natural size. */
.file-preview-body-pdf {
padding: 0;
flex: 1;
min-height: 0;
display: flex;
}
.file-preview-pdf-frame {
flex: 1;
width: 100%;
border: none;
}
.file-preview-loading,
.file-preview-error {
font-size: 0.86rem;
+42 -16
View File
@@ -6,7 +6,7 @@ import type { MessageFileInfo } from '../types'
import { MARKDOWN_OPTIONS } from './MessageContent'
import './FilePreviewModal.css'
export type PreviewKind = 'markdown' | 'text'
export type PreviewKind = 'markdown' | 'text' | 'pdf'
// Deliberately extension-based, not content_type-based: the browser-supplied
// content_type for less-common extensions like .md is inconsistent (often
@@ -16,6 +16,7 @@ export function getPreviewKind(filename: string): PreviewKind | null {
const lower = filename.toLowerCase()
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'
if (lower.endsWith('.txt')) return 'text'
if (lower.endsWith('.pdf')) return 'pdf'
return null
}
@@ -29,35 +30,56 @@ interface FilePreviewModalProps {
export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewModalProps) {
useEscapeKey(onClose)
const [content, setContent] = useState<string | null>(null)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileUrl = getRoomFileUrl(roomId, file.id)
useEffect(() => {
let cancelled = false
let objectUrl: string | null = null
// A plain fetch() read is unaffected by the Content-Disposition:
// attachment header the file-serve endpoint always sends -- that header
// only steers the browser's own navigation/embed rendering, not a
// script-initiated read of the response body. So no separate
// "inline"-flavored endpoint is needed just to preview text.
fetch(fileUrl, { credentials: 'include' })
.then((res) => {
if (!res.ok) throw new Error(`Failed to load file (${res.status})`)
return res.text()
})
.then((text) => {
// "inline"-flavored endpoint is needed just to preview text -- or, for
// PDF, to preview it either: fetching the bytes ourselves and handing
// the browser's native viewer a blob: URL (which carries no HTTP
// headers of its own) sidesteps Content-Disposition the same way,
// without needing an <iframe>/<embed> to navigate to the real file URL
// directly (which *would* respect it and force a download).
async function load() {
const res = await fetch(fileUrl, { credentials: 'include' })
if (!res.ok) throw new Error(`Failed to load file (${res.status})`)
if (cancelled) return
if (kind === 'pdf') {
const blob = await res.blob()
if (cancelled) return
// Force the MIME type explicitly rather than trusting the server's
// reported content_type -- getPreviewKind gates on the .pdf
// extension alone (see its own comment), so a mislabeled upload
// must still render as a PDF here, not download or error.
objectUrl = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
setPdfUrl(objectUrl)
} else {
const text = await res.text()
if (!cancelled) setContent(text)
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load file')
})
}
}
load().catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load file')
})
return () => {
cancelled = true
if (objectUrl) URL.revokeObjectURL(objectUrl)
}
}, [fileUrl])
}, [fileUrl, kind])
return (
<div className="file-preview-overlay" onClick={onClose}>
<div className="file-preview-modal" onClick={(e) => e.stopPropagation()}>
<div
className={`file-preview-modal${kind === 'pdf' ? ' file-preview-modal-pdf' : ''}`}
onClick={(e) => e.stopPropagation()}
>
<div className="file-preview-header">
<span className="file-preview-filename">{file.filename}</span>
<div className="file-preview-actions">
@@ -77,15 +99,19 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
</button>
</div>
</div>
<div className="file-preview-body">
<div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
{error && <p className="file-preview-error">{error}</p>}
{!error && content === null && <p className="file-preview-loading">Loading</p>}
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading</p>}
{!error && content !== null && kind === 'markdown' && (
<div className="message-text file-preview-markdown">
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
</div>
)}
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
{!error && kind === 'pdf' && !pdfUrl && <p className="file-preview-loading">Loading</p>}
{!error && kind === 'pdf' && pdfUrl && (
<iframe src={pdfUrl} title={file.filename} className="file-preview-pdf-frame" />
)}
</div>
</div>
</div>