返回 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 data-app-overlay=""
74 onClick={handleBackdropClick}
75 role="dialog"
76 aria-modal="true"
77 aria-label={imageName || t("imageViewer.title")}
78 >
79 <button
80 className="image-viewer__close"
81 type="button"
82 onClick={onClose}
83 aria-label={t("imageViewer.closePreview")}
84 >
85 <X size={22} />
86 </button>
87 <div className="image-viewer__content">
88 <img
89 className="image-viewer__image"
90 src={imageUrl}
91 alt={imageName || ""}
92 draggable={false}
93 />
94 {imageName && <div className="image-viewer__name">{imageName}</div>}
95 </div>
96 </div>
97 );
98
99 return createPortal(overlay, portalTarget);
100 }
101
101 lines Plain Text