Private
Public Access
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:
@@ -20,6 +20,15 @@
|
|||||||
overflow: hidden;
|
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 {
|
.file-preview-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -76,6 +85,22 @@
|
|||||||
overflow: auto;
|
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-loading,
|
||||||
.file-preview-error {
|
.file-preview-error {
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type { MessageFileInfo } from '../types'
|
|||||||
import { MARKDOWN_OPTIONS } from './MessageContent'
|
import { MARKDOWN_OPTIONS } from './MessageContent'
|
||||||
import './FilePreviewModal.css'
|
import './FilePreviewModal.css'
|
||||||
|
|
||||||
export type PreviewKind = 'markdown' | 'text'
|
export type PreviewKind = 'markdown' | 'text' | 'pdf'
|
||||||
|
|
||||||
// Deliberately extension-based, not content_type-based: the browser-supplied
|
// Deliberately extension-based, not content_type-based: the browser-supplied
|
||||||
// content_type for less-common extensions like .md is inconsistent (often
|
// 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()
|
const lower = filename.toLowerCase()
|
||||||
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'
|
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'markdown'
|
||||||
if (lower.endsWith('.txt')) return 'text'
|
if (lower.endsWith('.txt')) return 'text'
|
||||||
|
if (lower.endsWith('.pdf')) return 'pdf'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,35 +30,56 @@ interface FilePreviewModalProps {
|
|||||||
export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewModalProps) {
|
export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewModalProps) {
|
||||||
useEscapeKey(onClose)
|
useEscapeKey(onClose)
|
||||||
const [content, setContent] = useState<string | null>(null)
|
const [content, setContent] = useState<string | null>(null)
|
||||||
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const fileUrl = getRoomFileUrl(roomId, file.id)
|
const fileUrl = getRoomFileUrl(roomId, file.id)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
let objectUrl: string | null = null
|
||||||
// A plain fetch() read is unaffected by the Content-Disposition:
|
// A plain fetch() read is unaffected by the Content-Disposition:
|
||||||
// attachment header the file-serve endpoint always sends -- that header
|
// attachment header the file-serve endpoint always sends -- that header
|
||||||
// only steers the browser's own navigation/embed rendering, not a
|
// only steers the browser's own navigation/embed rendering, not a
|
||||||
// script-initiated read of the response body. So no separate
|
// script-initiated read of the response body. So no separate
|
||||||
// "inline"-flavored endpoint is needed just to preview text.
|
// "inline"-flavored endpoint is needed just to preview text -- or, for
|
||||||
fetch(fileUrl, { credentials: 'include' })
|
// PDF, to preview it either: fetching the bytes ourselves and handing
|
||||||
.then((res) => {
|
// 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 (!res.ok) throw new Error(`Failed to load file (${res.status})`)
|
||||||
return res.text()
|
if (cancelled) return
|
||||||
})
|
if (kind === 'pdf') {
|
||||||
.then((text) => {
|
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)
|
if (!cancelled) setContent(text)
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
}
|
||||||
|
load().catch((err) => {
|
||||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load file')
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load file')
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl)
|
||||||
}
|
}
|
||||||
}, [fileUrl])
|
}, [fileUrl, kind])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="file-preview-overlay" onClick={onClose}>
|
<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">
|
<div className="file-preview-header">
|
||||||
<span className="file-preview-filename">{file.filename}</span>
|
<span className="file-preview-filename">{file.filename}</span>
|
||||||
<div className="file-preview-actions">
|
<div className="file-preview-actions">
|
||||||
@@ -77,15 +99,19 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 && <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' && (
|
{!error && content !== null && kind === 'markdown' && (
|
||||||
<div className="message-text file-preview-markdown">
|
<div className="message-text file-preview-markdown">
|
||||||
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
|
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
|
{!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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user