Skip to content

Commit c28830e

Browse files
authored
Zoom-on-Scroll Support in Image Viewer (#530)
1 parent fb20387 commit c28830e

5 files changed

Lines changed: 124 additions & 79 deletions

File tree

docs/backend/backend_python/openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,6 +2003,7 @@
20032003
"metadata": {
20042004
"anyOf": [
20052005
{
2006+
"additionalProperties": true,
20062007
"type": "object"
20072008
},
20082009
{

frontend/package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
"react-image-crop": "^11.0.7",
6262
"react-redux": "^9.2.0",
6363
"react-router": "^7.6.2",
64+
"react-zoom-pan-pinch": "^3.7.0",
6465
"tailwind-merge": "^3.3.0",
6566
"tailwindcss": "^4.1.8",
6667
"ts-node": "^10.9.2",
Lines changed: 71 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,79 @@
1+
import React, { useRef, useImperativeHandle, forwardRef } from 'react';
2+
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
13
import { convertFileSrc } from '@tauri-apps/api/core';
2-
import React from 'react';
34

45
interface ImageViewerProps {
56
imagePath: string;
67
alt: string;
7-
scale: number;
8-
position: { x: number; y: number };
98
rotation: number;
10-
isDragging: boolean;
11-
onMouseDown: (e: React.MouseEvent) => void;
12-
onMouseMove: (e: React.MouseEvent) => void;
13-
onMouseUp: () => void;
14-
onMouseLeave: () => void;
15-
onClick?: (e: React.MouseEvent) => void;
9+
resetSignal?: number;
1610
}
1711

18-
export const ImageViewer: React.FC<ImageViewerProps> = ({
19-
imagePath,
20-
alt,
21-
scale,
22-
position,
23-
rotation,
24-
isDragging,
25-
onMouseDown,
26-
onMouseMove,
27-
onMouseUp,
28-
onMouseLeave,
29-
onClick,
30-
}) => {
31-
return (
32-
<div
33-
id="zoomable-image"
34-
onClick={onClick}
35-
onMouseDown={onMouseDown}
36-
onMouseMove={onMouseMove}
37-
onMouseUp={onMouseUp}
38-
onMouseLeave={onMouseLeave}
39-
className="relative flex h-full w-full items-center justify-center overflow-hidden"
40-
>
41-
<img
42-
src={convertFileSrc(imagePath) || '/placeholder.svg'}
43-
alt={alt}
44-
draggable={false}
45-
className="h-full w-full object-contain select-none"
46-
onError={(e) => {
47-
const img = e.target as HTMLImageElement;
48-
img.onerror = null;
49-
img.src = '/placeholder.svg';
50-
}}
51-
style={{
52-
transform: `translate(${position.x}px, ${position.y}px) scale(${scale}) rotate(${rotation}deg)`,
53-
transition: isDragging ? 'none' : 'transform 0.2s ease-in-out',
54-
cursor: isDragging ? 'grabbing' : 'grab',
55-
}}
56-
/>
57-
</div>
58-
);
59-
};
12+
export interface ImageViewerRef {
13+
zoomIn: () => void;
14+
zoomOut: () => void;
15+
reset: () => void;
16+
}
17+
18+
export const ImageViewer = forwardRef<ImageViewerRef, ImageViewerProps>(
19+
({ imagePath, alt, rotation, resetSignal }, ref) => {
20+
const transformRef = useRef<any>(null);
21+
22+
// Expose zoom functions to parent
23+
useImperativeHandle(ref, () => ({
24+
zoomIn: () => transformRef.current?.zoomIn(),
25+
zoomOut: () => transformRef.current?.zoomOut(),
26+
reset: () => transformRef.current?.resetTransform(),
27+
}));
28+
29+
// Reset on signal change
30+
React.useEffect(() => {
31+
transformRef.current?.resetTransform();
32+
}, [resetSignal]);
33+
34+
return (
35+
<TransformWrapper
36+
ref={transformRef}
37+
initialScale={1}
38+
minScale={0.1}
39+
maxScale={8}
40+
centerOnInit
41+
limitToBounds={false}
42+
>
43+
<TransformComponent
44+
wrapperStyle={{
45+
width: '100%',
46+
height: '100%',
47+
overflow: 'visible',
48+
}}
49+
contentStyle={{
50+
width: '100%',
51+
height: '100%',
52+
display: 'flex',
53+
alignItems: 'center',
54+
justifyContent: 'center',
55+
}}
56+
>
57+
<img
58+
src={convertFileSrc(imagePath) || '/placeholder.svg'}
59+
alt={alt}
60+
draggable={false}
61+
className="select-none"
62+
onError={(e) => {
63+
const img = e.target as HTMLImageElement;
64+
img.onerror = null;
65+
img.src = '/placeholder.svg';
66+
}}
67+
style={{
68+
maxWidth: '100%',
69+
maxHeight: '100%',
70+
objectFit: 'contain',
71+
zIndex: 50,
72+
transform: `rotate(${rotation}deg)`,
73+
}}
74+
/>
75+
</TransformComponent>
76+
</TransformWrapper>
77+
);
78+
},
79+
);

frontend/src/components/Media/MediaView.tsx

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback, useMemo } from 'react';
1+
import { useState, useCallback, useMemo, useRef } from 'react';
22
import { useSelector, useDispatch } from 'react-redux';
33
import { MediaViewProps } from '@/types/Media';
44
import { selectCurrentViewIndex } from '@/features/imageSelectors';
@@ -8,14 +8,14 @@ import {
88
previousImage,
99
closeImageView,
1010
} from '@/features/imageSlice';
11-
1211
// Modular components
1312
import { MediaViewControls } from './MediaViewControls';
1413
import { ZoomControls } from './ZoomControls';
1514
import { MediaThumbnails } from './MediaThumbnails';
1615
import { MediaInfoPanel } from './MediaInfoPanel';
1716
import { ImageViewer } from './ImageViewer';
1817
import { NavigationButtons } from './NavigationButtons';
18+
import type { ImageViewerRef } from './ImageViewer';
1919

2020
// Custom hooks
2121
import { useImageViewControls } from '@/hooks/useImageViewControls';
@@ -29,17 +29,20 @@ export function MediaView({ onClose, images, type = 'image' }: MediaViewProps) {
2929
// Redux selectors
3030
const currentViewIndex = useSelector(selectCurrentViewIndex);
3131
const totalImages = images.length;
32+
3233
const currentImage = useMemo(() => {
3334
if (currentViewIndex >= 0 && currentViewIndex < images.length) {
3435
return images[currentViewIndex];
3536
}
3637
return null;
3738
}, [images, currentViewIndex]);
38-
console.log(currentViewIndex);
39+
40+
const imageViewerRef = useRef<ImageViewerRef>(null);
3941

4042
// Local UI state
4143
const [showInfo, setShowInfo] = useState(false);
4244
const [showThumbnails, setShowThumbnails] = useState(false);
45+
const [resetSignal, setResetSignal] = useState(0);
4346

4447
// Custom hooks
4548
const { viewState, handlers } = useImageViewControls();
@@ -69,39 +72,54 @@ export function MediaView({ onClose, images, type = 'image' }: MediaViewProps) {
6972
[dispatch, handlers],
7073
);
7174

72-
// Slideshow functionality
73-
const { isSlideshowActive, toggleSlideshow } = useSlideshow(
74-
totalImages,
75-
handleNextImage,
76-
);
77-
78-
// Toggle functions
7975
const toggleInfo = useCallback(() => {
8076
setShowInfo((prev) => !prev);
8177
}, []);
8278

79+
// Hooks that depend on currentImage but always declared
8380
const handleToggleFavorite = useCallback(() => {
8481
if (currentImage) {
8582
toggleFavorite(currentImage.path);
8683
}
8784
}, [currentImage, toggleFavorite]);
8885

86+
const handleZoomIn = useCallback(() => {
87+
imageViewerRef.current?.zoomIn();
88+
}, []);
89+
90+
const handleZoomOut = useCallback(() => {
91+
imageViewerRef.current?.zoomOut();
92+
}, []);
93+
94+
const handleResetZoom = useCallback(() => {
95+
imageViewerRef.current?.reset();
96+
handlers.resetZoom();
97+
setResetSignal((s) => s + 1);
98+
}, [handlers]);
99+
89100
// Keyboard navigation
90101
useKeyboardNavigation({
91102
onClose: handleClose,
92103
onNext: handleNextImage,
93104
onPrevious: handlePreviousImage,
94-
onZoomIn: handlers.handleZoomIn,
95-
onZoomOut: handlers.handleZoomOut,
105+
onZoomIn: handleZoomIn,
106+
onZoomOut: handleZoomOut,
96107
onRotate: handlers.handleRotate,
97108
onToggleInfo: toggleInfo,
98109
});
99110

111+
// Slideshow functionality
112+
const { isSlideshowActive, toggleSlideshow } = useSlideshow(
113+
totalImages,
114+
handleNextImage,
115+
);
116+
100117
// Early return if no images or invalid index
101118
if (!images.length || currentViewIndex === -1 || !currentImage) {
102119
return null;
103120
}
104121

122+
// Safe variables
105123
const currentImagePath = currentImage.path;
106124
const currentImageAlt = `image-${currentViewIndex}`;
107125

@@ -121,28 +139,18 @@ export function MediaView({ onClose, images, type = 'image' }: MediaViewProps) {
121139

122140
{/* Main viewer area */}
123141
<div
124-
className="relative flex h-full w-full items-center justify-center"
142+
className="relative flex h-full w-full items-center justify-center overflow-visible"
125143
onClick={(e) => {
126144
if (e.target === e.currentTarget) handleClose();
127145
}}
128146
>
129147
{type === 'image' && (
130148
<ImageViewer
149+
ref={imageViewerRef}
131150
imagePath={currentImagePath}
132151
alt={currentImageAlt}
133-
scale={viewState.scale}
134-
position={viewState.position}
135152
rotation={viewState.rotation}
136-
isDragging={viewState.isDragging}
137-
onMouseDown={handlers.handleMouseDown}
138-
onMouseMove={handlers.handleMouseMove}
139-
onMouseUp={handlers.handleMouseUp}
140-
onMouseLeave={handlers.handleMouseUp}
141-
onClick={(e) => {
142-
if (e.target === e.currentTarget) {
143-
handleClose();
144-
}
145-
}}
153+
resetSignal={resetSignal}
146154
/>
147155
)}
148156

@@ -156,10 +164,10 @@ export function MediaView({ onClose, images, type = 'image' }: MediaViewProps) {
156164
{/* Zoom controls */}
157165
{type === 'image' && (
158166
<ZoomControls
159-
onZoomIn={handlers.handleZoomIn}
160-
onZoomOut={handlers.handleZoomOut}
167+
onZoomIn={handleZoomIn}
168+
onZoomOut={handleZoomOut}
161169
onRotate={handlers.handleRotate}
162-
onReset={handlers.resetZoom}
170+
onReset={handleResetZoom}
163171
showThumbnails={showThumbnails}
164172
/>
165173
)}

0 commit comments

Comments
 (0)