"use client"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { Check, Copy, Download, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { docIconFor, formatBytes } from "@/lib/doc-attachments"; import { apiUrl } from "@/lib/api"; import { type FilePreviewSource, previewKindFor, resolveSourceUrl, } from "./previewerFor"; // Heavy renderers are lazy so opening the drawer with a small image doesn't // pay the cost of loading the markdown / code-highlight chunks. const PdfPreview = dynamic(() => import("./previewers/PdfPreview")); const ImagePreview = dynamic(() => import("./previewers/ImagePreview")); const SvgPreview = dynamic(() => import("./previewers/SvgPreview")); const MarkdownPreview = dynamic(() => import("./previewers/MarkdownPreview")); const TextPreview = dynamic(() => import("./previewers/TextPreview")); const DocxPreview = dynamic(() => import("./previewers/DocxPreview")); const XlsxPreview = dynamic(() => import("./previewers/XlsxPreview")); const OfficeTextPreview = dynamic( () => import("./previewers/OfficeTextPreview"), ); const FallbackPreview = dynamic(() => import("./previewers/FallbackPreview")); const ANIM_MS = 220; interface FilePreviewDrawerProps { open: boolean; source: FilePreviewSource | null; onClose: () => void; } /** * Right-side slide-in drawer that previews a chat attachment. * * Design notes * ──────────── * • No backdrop. The drawer sits alongside the chat so the user can still * read messages or send replies — closer to Claude desktop's "side panel" * than a modal dialog. * • The shell is **always mounted**, parked off-screen at translate-x-full. * That way every open is a single class flip and CSS handles the rest — * no double-render, no requestAnimationFrame dance, no delay before the * slide starts. Only the body content (header + preview) is conditionally * rendered, latched during the exit transition so it doesn't tear. * • Renderers are lazy so opening a small image doesn't drag in markdown / * syntax-highlight chunks. */ export default function FilePreviewDrawer({ open, source, onClose, }: FilePreviewDrawerProps) { const { t } = useTranslation(); // Latch the most recently shown source so the body keeps rendering during // the slide-out transition. const [renderedSource, setRenderedSource] = useState(null); // The HEAVY preview body (markdown / syntax-highlight) is gated on this // flag and only mounts AFTER the slide-in finishes. The chat-page's // padding-right transition lives on the main thread, and so does // react-markdown's reconciliation — if both run at once, the markdown // work eats every frame and the chat squeeze visibly stalls. Deferring // the body keeps the main thread free for the slide animations. const [bodyMounted, setBodyMounted] = useState(false); const closeBtnRef = useRef(null); // Mirror `source` synchronously DURING render. React commits a single // render where both `renderedSource` and the outer slide class flip // together — that single paint also fires the chat-page's padding // transition, so the drawer slide and the chat squeeze launch in // lock-step (no one-frame gap from a useEffect-driven latch). // // We also reset `bodyMounted` here so a switch to a new source doesn't // momentarily render the heavy body before the deferring effect kicks in. if (open && source && source !== renderedSource) { setRenderedSource(source); setBodyMounted(false); } // After close, defer the body unmount until the slide-out animation has // had time to play. The cleanup cancels the pending unmount if the user // re-opens before it fires. useEffect(() => { if (!open && renderedSource) { const timer = setTimeout(() => setRenderedSource(null), ANIM_MS); return () => clearTimeout(timer); } }, [open, renderedSource]); // After the slide-in completes, mount the heavy body. Done in an effect // so the timer is scoped to the open lifecycle (cleanup cancels it if // the user closes before it fires). useEffect(() => { if (open && renderedSource && !bodyMounted) { const timer = setTimeout(() => setBodyMounted(true), ANIM_MS); return () => clearTimeout(timer); } }, [open, renderedSource, bodyMounted]); // `visible` derives directly from `open`, so it flips in the very same // render where the parent's `previewSource` flipped — same instant the // chat's padding-right transition starts. const visible = open; // Focus the close button on open so keyboard users can immediately ESC. useEffect(() => { if (visible) closeBtnRef.current?.focus(); }, [visible]); // ESC closes (only while visible — listening on document is fine since the // drawer is global). useEffect(() => { if (!visible) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); onClose(); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [visible, onClose]); const previewUrl = renderedSource ? resolveSourceUrl(renderedSource, apiUrl) : null; const downloadUrl = previewUrl; const previewKind = renderedSource ? previewKindFor(renderedSource) : null; const [copied, setCopied] = useState(false); const handleCopy = useCallback(async () => { if (!downloadUrl) return; try { await navigator.clipboard.writeText(downloadUrl); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { // Clipboard rejected (insecure context / permission). Silently noop — // the user can still right-click the download link. } }, [downloadUrl]); const filename = renderedSource?.filename || t("Attachment"); const spec = docIconFor(filename); const HeaderIcon = spec.Icon; const sizeLabel = renderedSource?.size ? formatBytes(renderedSource.size) : ""; return (
{renderedSource && ( <> {/* Header */}
{filename}
{sizeLabel ? `${spec.label} · ${sizeLabel}` : spec.label}
{downloadUrl && ( )} {downloadUrl && ( )}
{/* Body — mounted only after the slide-in animation is done so its (potentially expensive) markdown / syntax-highlight render can't steal main-thread frames from the chat squeeze. */}
{bodyMounted && ( )}
)}
); } // Memoised so the close-time re-render of the outer drawer does NOT reconcile // the heavy markdown / syntax-highlight tree underneath (which can take // 10–50 ms with math + code blocks and was the visible "stutter" before the // slide-out started). With stable source/previewUrl/kind props, memo bails // the entire body subtree. const PreviewBody = memo(function PreviewBody({ source, previewUrl, kind, }: { source: FilePreviewSource; previewUrl: string | null; kind: ReturnType | null; }) { const filename = source.filename; const rawExtractedTextUrl = source.extractedTextUrl; let extractedTextUrl: string | null = null; if (rawExtractedTextUrl) { extractedTextUrl = rawExtractedTextUrl.startsWith("http") || rawExtractedTextUrl.startsWith("blob:") ? rawExtractedTextUrl : apiUrl(rawExtractedTextUrl); } // Office docs lean on extracted_text and degrade gracefully via the // OfficeTextPreview, even when previewUrl is missing (legacy messages). if (kind === "office-text") { return ( ); } // Everything else needs a fetchable URL. Without one we fall back. if (!previewUrl) { return ; } switch (kind) { case "pdf": return ; case "docx": return ; case "xlsx": return ; case "image": return ; case "svg": return ; case "markdown": return (
); case "code": case "text": return (
); case "fallback": default: return ; } }); PreviewBody.displayName = "PreviewBody";