Files
desktop/app/providers/ImageViewerProvider/ImageViewer.tsx
rosetta 83f38dc63f 'init'
2026-01-30 05:01:05 +02:00

173 lines
6.8 KiB
TypeScript

import { Flex, Overlay, Text } from "@mantine/core";
import { ImageToView } from "./ImageViewerProvider";
import { useState } from "react";
import { IconChevronLeft, IconChevronRight, IconImageInPicture, IconX } from "@tabler/icons-react";
import { useRosettaColors } from "@/app/hooks/useRosettaColors";
import { useEffect } from "react";
import { useContextMenu } from "../ContextMenuProvider/useContextMenu";
import { convertJpegBlobToPngBlob, createBlobFromBase64Image } from "@/app/utils/utils";
interface ImageViewerProps {
images: ImageToView[];
initialSlide: number;
onClose: () => void;
}
export function ImageViewer(props : ImageViewerProps) {
const [slide, setSlide] = useState(props.initialSlide);
const imageToRender = props.images[slide];
const colors = useRosettaColors();
const openContextMenu = useContextMenu();
const [pos, setPos] = useState({ x: 0, y: 0, scale: 1 });
const [isDragging, setIsDragging] = useState(false);
const [wasDragging, setWasDragging] = useState(false);
const [dragStart, setDragStart] = useState<{x: number, y: number} | null>(null);
const isNextSlideAvailable = slide + 1 <= props.images.length - 1;
const isPrevSlideAvailable = slide - 1 >= 0;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "ArrowLeft" && isPrevSlideAvailable) {
prevSlide(e);
}
if (e.key === "ArrowRight" && isNextSlideAvailable) {
nextSlide(e);
}
if (e.key === "Escape") {
props.onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [slide, isPrevSlideAvailable, isNextSlideAvailable, props.onClose]);
useEffect(() => {
setPos({ x: 0, y: 0, scale: 1 });
}, [slide]);
const nextSlide = (e : any) => {
e.stopPropagation();
if(slide + 1 > props.images.length - 1) {
return;
}
setSlide(slide + 1);
}
const prevSlide = (e : any) => {
e.stopPropagation();
if(slide - 1 < 0) {
return;
}
setSlide(slide - 1);
}
const onContextMenuImg = async () => {
let blob = await convertJpegBlobToPngBlob(
createBlobFromBase64Image(imageToRender.src)
);
openContextMenu([
{
label: 'Copy Image',
action: async () => {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
]);
},
icon: <IconImageInPicture size={14}></IconImageInPicture>
}
]);
}
// Wheel zoom (zoom to cursor)
const onWheel = (e: React.WheelEvent<HTMLImageElement>) => {
//e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const prevScale = pos.scale;
let newScale = prevScale - e.deltaY * 0.004; // ускоренное увеличение
newScale = Math.max(0.2, Math.min(5, newScale));
const offsetX = mouseX - pos.x;
const offsetY = mouseY - pos.y;
const newX = mouseX - (offsetX * newScale) / prevScale;
const newY = mouseY - (offsetY * newScale) / prevScale;
setPos({ ...pos, scale: newScale, x: newX, y: newY });
};
// Drag logic
const onMouseDown = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
setWasDragging(false);
setDragStart({ x: e.clientX - pos.x, y: e.clientY - pos.y });
};
const onMouseMove = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
if (!isDragging || !dragStart) return;
setPos({ ...pos, x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
setWasDragging(true);
};
const onMouseUp = (e: React.MouseEvent<HTMLImageElement | HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
setDragStart(null);
};
return (
<Overlay
onClick={() => {
if (!isDragging && !wasDragging) props.onClose();
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
backgroundOpacity={0.7}
>
<Flex direction={'column'} h={'100%'} justify={'space-around'}>
<Flex justify={'flex-end'} p={'md'}>
<IconX size={30} stroke={1} color={'white'} style={{cursor: 'pointer'}} onClick={props.onClose}>
</IconX>
</Flex>
<Flex justify={'center'} align={'center'} gap={'sm'}>
<IconChevronLeft onClick={prevSlide} size={30} color={isPrevSlideAvailable ? 'white' : colors.chevrons.disabled} style={{cursor: 'pointer', userSelect: 'none'}}></IconChevronLeft>
<img
onContextMenu={() => onContextMenuImg()}
src={imageToRender.src}
style={{
maxWidth: '70vw',
maxHeight: '70vh',
borderRadius: 8,
userSelect: 'none',
cursor: isDragging ? 'grabbing' : 'grab',
transformOrigin: '0 0',
transform: `translate(${pos.x}px, ${pos.y}px) scale(${pos.scale})`,
}}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
draggable={false}
/>
<IconChevronRight size={30} onClick={nextSlide} color={isNextSlideAvailable ? 'white' : colors.chevrons.disabled} style={{cursor: 'pointer', userSelect: 'none'}}></IconChevronRight>
</Flex>
<Flex justify={'center'} gap={'lg'} align={'center'} direction={'row'}>
<Text size={'sm'} c={'white'}>
{slide + 1} of {props.images.length}
</Text>
{imageToRender.timestamp &&
<Text size={'sm'} c={'dimmed'}>{new Date(imageToRender.timestamp).toLocaleString()}</Text>
}
</Flex>
</Flex>
</Overlay>
);
}