Skip to content
Source
Sign inSign up
Open History to see the last commit

PreviewImageContent.tsx

134 lines4.8 KB

1import { useLayoutEffect, useRef, useState } from "react";
2import { MaximizeIcon, MinusIcon, PlusIcon, ScanIcon } from "lucide-react";
3import { ExpandedImageDialog } from "~/components/chat/ExpandedImageDialog";
4import { observeResize } from "~/lib/observeResize";
5import { AttachmentImage } from "../media/AttachmentImage";
6import { FileSurfaceAction, FileSurfaceFailure } from "./fileSurfaceChrome";
7​
8export default function PreviewImageContent(props: {
9 readonly src: string;
10 readonly name: string;
11 readonly mimeType?: string;
12 readonly onError?: () => void;
13}) {
14 const [failed, setFailed] = useState(false);
15 const [expanded, setExpanded] = useState(false);
16 const [natural, setNatural] = useState({ width: 0, height: 0 });
17 const [viewport, setViewport] = useState({ width: 0, height: 0 });
18 const [zoom, setZoom] = useState<number | null>(null);
19 const ref = useRef<HTMLDivElement>(null);
20 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Measure the image viewport before paint and observe resizes to keep fit-to-view scaling accurate.
21 useLayoutEffect(() => {
22 const node = ref.current;
23 if (!node) return;
24 const update = () => setViewport({ width: node.clientWidth, height: node.clientHeight });
25 update();
26 return observeResize(node, update);
27 }, []);
28 const fit = natural.width
29 ? Math.min(
30 1,
31 Math.max(1, viewport.width - 32) / natural.width,
32 Math.max(1, viewport.height - 32) / natural.height,
33 )
34 : 1;
35 const scale = zoom ?? fit;
36 const changeZoom = (factor: number) => setZoom(Math.max(0.05, Math.min(8, scale * factor)));
37 if (failed) return <FileSurfaceFailure message="Unable to load image." />;
38 return (
39 <>
40 <div className="flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border/60 px-3 py-1 text-xs text-muted-foreground">
41 <span className="tabular-nums">
42 {natural.width
43 ? `${natural.width.toLocaleString()} × ${natural.height.toLocaleString()}`
44 : "Loading image…"}
45 </span>
46 <div className="flex items-center gap-1">
47 <FileSurfaceAction
48 label="Zoom out"
49 disabled={!natural.width || scale <= 0.05}
50 onPress={() => changeZoom(1 / 1.25)}
51 >
52 <MinusIcon className="size-3.5" />
53 </FileSurfaceAction>
54 <span className="min-w-10 text-center tabular-nums">{Math.round(scale * 100)}%</span>
55 <FileSurfaceAction
56 label="Zoom in"
57 disabled={!natural.width || scale >= 8}
58 onPress={() => changeZoom(1.25)}
59 >
60 <PlusIcon className="size-3.5" />
61 </FileSurfaceAction>
62 <FileSurfaceAction
63 label="Fit image"
64 pressed={zoom === null}
65 onPress={() => setZoom(null)}
66 >
67 <ScanIcon className="size-3.5" />
68 </FileSurfaceAction>
69 <FileSurfaceAction
70 label="Actual image size"
71 pressed={zoom === 1}
72 onPress={() => setZoom(1)}
73 >
74 <MaximizeIcon className="size-3.5" />
75 </FileSurfaceAction>
76 </div>
77 </div>
78 <div ref={ref} className="min-h-0 flex-1 overflow-auto">
79 <div
80 className="flex min-h-full min-w-full items-center justify-center p-4"
81 style={{
82 width: natural.width ? natural.width * scale + 32 : undefined,
83 height: natural.height ? natural.height * scale + 32 : undefined,
84 }}
85 >
86 <button
87 type="button"
88 aria-label={`Zoom ${props.name}`}
89 className="shrink-0 cursor-zoom-in"
90 onClick={() => setExpanded(true)}
91 >
92 <AttachmentImage
93 name={props.name}
94 mimeType={props.mimeType}
95 src={props.src}
96 alt={props.name}
97 className="block max-w-none"
98 style={
99 natural.width
100 ? { width: natural.width * scale, height: natural.height * scale }
101 : { maxWidth: "100%" }
102 }
103 onLoad={(event) =>
104 setNatural({
105 width: event.currentTarget.naturalWidth,
106 height: event.currentTarget.naturalHeight,
107 })
108 }
109 onError={() => {
110 setFailed(true);
111 props.onError?.();
112 }}
113 />
114 </button>
115 </div>
116 </div>
117 {expanded ? (
118 <ExpandedImageDialog
119 preview={{
120 images: [
121 {
122 src: props.src,
123 name: props.name,
124 ...(props.mimeType ? { mimeType: props.mimeType } : {}),
125 },
126 ],
127 index: 0,
128 }}
129 onClose={() => setExpanded(false)}
130 />
131 ) : null}
132 </>
133 );