返回 DeepSeek-Reasonix
ImageViewer.tsx
根目录 / desktop / frontend / src / components / ImageViewer.tsx
1 import { useCallback, useEffect, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { X } from "lucide-react";
4 import { useT } from "../lib/i18n";
5
6 export interface ImageViewerProps {
7 open: boolean;
8 /** data-URL of the image to display */
9 imageUrl: string;
10 /** optional filename shown at the bottom */
11 imageName?: string;
12 onClose: () => void;
13 }
14
15 /**
16 * ImageViewer renders a full-size image preview in a portal overlay.
17 *
18 * It follows the same portal pattern as MermaidDiagram fullscreen:
19 * the overlay is portaled into .chat-pane (fallback document.body) so it
20 * covers the chat area without being clipped by overflow containers.
21 */
22 export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
23 const t = useT();
24 const [portalTarget, setPortalTarget] = useState<Element | null>(null);
25 const [visible, setVisible] = useState(false);
26
27 // Resolve portal target when opening.
28 useEffect(() => {
29 if (!open) {
30 setVisible(false);
31 setPortalTarget(null);
32 return;
33 }
34 const target = document.querySelector(".chat-pane") ?? document.body;
35 setPortalTarget(target);
36 // Trigger enter animation on the next frame.
37 const raf = requestAnimationFrame(() => setVisible(true));
38 return () => cancelAnimationFrame(raf);
39 }, [open]);
40
41 // Close on Escape.
42 useEffect(() => {
43 if (!open) return;
44 const onKeyDown = (event: KeyboardEvent) => {
45 if (event.key === "Escape") onClose();
46 };
47 document.addEventListener("keydown", onKeyDown);
48 return () => document.removeEventListener("keydown", onKeyDown);
49 }, [open, onClose]);
50
51 // Prevent body scroll while open.
52 useEffect(() => {
53 if (!open) return;
54 const prev = document.body.style.overflow;
55 document.body.style.overflow = "hidden";
56 return () => {
57 document.body.style.overflow = prev;
58 };
59 }, [open]);
60
61 const handleBackdropClick = useCallback(
62 (e: React.MouseEvent) => {
63 if (e.target === e.currentTarget) onClose();
64 },
65 [onClose],
66 );
67
68 if (!open || !portalTarget || !imageUrl) return null;
69
70 const overlay = (
71 <div
72 className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
73 onClick={handleBackdropClick}
74 role="dialog"
75 aria-modal="true"
76 aria-label={imageName || t("imageViewer.title")}
77 >
78 <button
79 className="image-viewer__close"
80 type="button"
81 onClick={onClose}
82 aria-label={t("imageViewer.closePreview")}
83 >
84 <X size={22} />
85 </button>
86 <div className="image-viewer__content">
87 <img
88 className="image-viewer__image"
89 src={imageUrl}
90 alt={imageName || ""}
91 draggable={false}
92 />
93 {imageName && <div className="image-viewer__name">{imageName}</div>}
94 </div>
95 </div>
96 );
97
98 return createPortal(overlay, portalTarget);
99 }
100
100 lines Plain Text