Skip to content

Commit fcd4d41

Browse files
authored
feat: Zoomable image improvements (#17431)
* feat: Zoomable image improvements * dragging image fix * zoom image on click improvements * move container styles to external file * add check if is image element * add image element checks
1 parent bcc2282 commit fcd4d41

File tree

4 files changed

+235
-79
lines changed

4 files changed

+235
-79
lines changed

src/script/components/ZoomableImage/ZoomableImage.style.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,17 @@
1919

2020
import {CSSObject} from '@emotion/react';
2121

22-
export const imageStyle = (isZoomEnabled: boolean): CSSObject => {
23-
return {
24-
cursor: isZoomEnabled ? 'zoom-out !important' : 'default',
25-
...(isZoomEnabled
26-
? {
27-
transition: 'transform 0.3s linear',
28-
willChange: 'transform',
29-
}
30-
: {
31-
maxWidth: '100%',
32-
height: '100%',
33-
objectFit: 'scale-down',
34-
}),
35-
};
22+
export const imageStyle: CSSObject = {
23+
userSelect: 'none',
24+
flexShrink: 0,
25+
maxWidth: 'none',
26+
height: 'auto',
27+
};
28+
29+
export const containerStyle: CSSObject = {
30+
width: '100%',
31+
height: '100%',
32+
display: 'flex',
33+
alignItems: 'center',
34+
justifyContent: 'center',
3635
};

src/script/components/ZoomableImage/ZoomableImage.tsx

Lines changed: 202 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -17,112 +17,250 @@
1717
*
1818
*/
1919

20-
import React, {HTMLProps, useState} from 'react';
20+
import React, {HTMLProps, RefObject, useEffect, useRef, useState} from 'react';
2121

22-
import {imageStyle} from './ZoomableImage.style';
22+
import {containerStyle, imageStyle} from './ZoomableImage.style';
2323

24-
function checkIfCanZoomImage(element: HTMLImageElement) {
25-
if (element.naturalWidth <= element.width && element.naturalHeight <= element.height) {
26-
return false;
24+
import {isHTMLImageElement} from '../../guards/HTMLElement';
25+
26+
type Offset = {
27+
x: number;
28+
y: number;
29+
};
30+
31+
const DEFAULT_OFFSET: Offset = {x: 0, y: 0};
32+
33+
function calculateZoomRatio(element: HTMLImageElement) {
34+
const parentElement = element.parentElement;
35+
36+
if (!parentElement) {
37+
return 1;
38+
}
39+
40+
const {offsetWidth: parentOffsetWidth, offsetHeight: parentOffsetHeight} = parentElement;
41+
const {naturalWidth, naturalHeight} = element;
42+
43+
const widthRatio = parentOffsetWidth / naturalWidth;
44+
const heightRatio = parentOffsetHeight / naturalHeight;
45+
return Math.min(widthRatio, heightRatio);
46+
}
47+
48+
// if we will add more image zooming, we need to pass 2 props, for check if is image is zoomed and imageScale
49+
function calculateMaxOffset(containerRef: RefObject<HTMLDivElement>, imgRef: RefObject<HTMLImageElement>) {
50+
if (!containerRef.current || !imgRef.current) {
51+
return {
52+
maxXOffset: 0,
53+
maxYOffset: 0,
54+
};
2755
}
2856

29-
return element.naturalWidth !== element.offsetWidth || element.naturalHeight !== element.offsetHeight;
57+
const containerRect = containerRef.current.getBoundingClientRect();
58+
59+
return {
60+
maxXOffset: (containerRect.width - imgRef.current.naturalWidth) / 2,
61+
maxYOffset: (containerRect.height - imgRef.current.naturalHeight) / 2,
62+
};
3063
}
3164

3265
type ZoomableImageProps = HTMLProps<HTMLImageElement>;
3366

3467
export const ZoomableImage = (props: ZoomableImageProps) => {
68+
const imageRef = useRef<HTMLImageElement | null>(null);
69+
const containerRef = useRef<HTMLDivElement | null>(null);
70+
const [imageRatio, setImageRatio] = useState(1);
71+
72+
const draggingRef = useRef(false);
73+
const mouseDownRef = useRef(false);
74+
3575
const [isZoomEnabled, setIsZoomEnabled] = useState<boolean>(false);
36-
const [maxOffset, setMaxOffset] = useState({x: 0, y: 0});
3776

38-
const onButtonClick = (event: React.MouseEvent<HTMLImageElement>) => {
39-
const element = event.target as HTMLImageElement;
77+
const [translateOffset, setTranslateOffset] = useState<Offset>(DEFAULT_OFFSET);
78+
const [startOffset, setStartOffset] = useState<Offset>(DEFAULT_OFFSET);
4079

41-
if (!checkIfCanZoomImage(element)) {
42-
setIsZoomEnabled(false);
80+
const canZoomImage = imageRatio !== 1;
81+
const zoomScale = isZoomEnabled ? 1 : imageRatio;
4382

44-
if (isZoomEnabled) {
45-
element.style.width = ``;
46-
element.style.height = ``;
47-
element.style.transform = ``;
48-
return;
49-
}
83+
const handleMouseClick = (event: React.MouseEvent<HTMLDivElement>) => {
84+
const element = event.target;
5085

86+
if (!isHTMLImageElement(element)) {
5187
return;
5288
}
5389

54-
const {naturalWidth, naturalHeight, offsetWidth, offsetHeight, parentElement} = element;
55-
const {clientX, clientY} = event;
90+
if (!canZoomImage && !isZoomEnabled) {
91+
return;
92+
}
93+
94+
if (draggingRef.current) {
95+
draggingRef.current = false;
96+
return;
97+
}
98+
99+
if (isZoomEnabled) {
100+
setStartOffset(DEFAULT_OFFSET);
101+
setTranslateOffset(DEFAULT_OFFSET);
102+
draggingRef.current = false;
103+
104+
setTimeout(() => {
105+
requestAnimationFrame(() => {
106+
element.style.transition = '';
107+
});
108+
}, 300);
109+
}
110+
111+
if (!draggingRef.current && !isZoomEnabled) {
112+
if (!imageRef.current) {
113+
return;
114+
}
115+
116+
requestAnimationFrame(() => {
117+
element.style.transition = 'transform 0.2s';
118+
element.style.cursor = isZoomEnabled ? 'zoom-in' : 'zoom-out';
119+
});
120+
121+
const {maxXOffset, maxYOffset} = calculateMaxOffset(containerRef, imageRef);
122+
123+
const imageRect = imageRef.current.getBoundingClientRect();
124+
const imageCenterY = imageRef.current.naturalHeight / 2;
125+
const imageCenterX = imageRef.current.naturalWidth / 2;
126+
const currentPosX = (event.clientX - imageRect.left) / imageRatio - imageCenterX;
127+
const currentPosY = (event.clientY - imageRect.top) / imageRatio - imageCenterY;
128+
129+
setTranslateOffset({
130+
x: Math.max(maxXOffset, Math.min(-currentPosX, -maxXOffset)),
131+
y: Math.max(maxYOffset, Math.min(-currentPosY, -maxYOffset)),
132+
});
133+
}
56134

57135
setIsZoomEnabled(prevState => !prevState);
136+
};
137+
138+
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
139+
if (!canZoomImage && !isZoomEnabled) {
140+
return;
141+
}
142+
const element = event.target;
143+
144+
if (!isHTMLImageElement(element)) {
145+
return;
146+
}
58147

59-
element.style.width = `${naturalWidth}px`;
60-
element.style.height = `${naturalHeight}px`;
148+
if (isZoomEnabled) {
149+
mouseDownRef.current = true;
150+
}
61151

62-
const {left, top} = element.getBoundingClientRect();
152+
requestAnimationFrame(() => {
153+
element.style.transition = 'transform 0.2s';
154+
});
63155

64-
const parentElementHeight = parentElement?.offsetHeight || offsetHeight;
65-
const imageCenterX = naturalWidth / 2;
66-
const imageCenterY = naturalHeight / 2;
156+
setStartOffset({
157+
x: event.clientX - translateOffset.x,
158+
y: event.clientY - translateOffset.y,
159+
});
67160

68-
const deltaX = clientX - left;
69-
const deltaY = clientY - top;
161+
event.preventDefault();
162+
};
70163

71-
const isImageNaturalHeightLargerThanHeight = element.naturalHeight >= element.height;
72-
const isImageNaturalWidthLargerThanWidth = element.naturalWidth > element.width;
164+
const handleMouseUp = (event: React.MouseEvent<HTMLDivElement>) => {
165+
if (!isZoomEnabled && !mouseDownRef.current) {
166+
return;
167+
}
73168

74-
const maxXOffset = isImageNaturalWidthLargerThanWidth ? ((naturalWidth - offsetWidth) / 2 / naturalWidth) * 100 : 0;
75-
const calculatedYOffset = ((naturalHeight - parentElementHeight) / 2 / naturalHeight) * 100;
76-
const maxYOffset = isImageNaturalHeightLargerThanHeight ? (calculatedYOffset >= 0 ? calculatedYOffset : 0) : 0;
169+
const element = event.target;
77170

78-
setMaxOffset({x: maxXOffset, y: maxYOffset});
171+
if (!isHTMLImageElement(element)) {
172+
return;
173+
}
79174

80-
const xOffset = Math.min(Math.max(((deltaX - imageCenterX) / naturalWidth) * 100, -maxXOffset), maxXOffset);
81-
const yOffset = Math.min(Math.max(((deltaY - imageCenterY) / naturalHeight) * 100, -maxYOffset), maxYOffset);
175+
mouseDownRef.current = false;
82176

83-
element.style.transform = `translate(${-xOffset}%, ${-yOffset}%)`;
177+
requestAnimationFrame(() => {
178+
element.style.cursor = 'zoom-out';
179+
});
84180
};
85181

86-
const handleMouseMove = (event: React.MouseEvent<HTMLImageElement>) => {
87-
if (isZoomEnabled) {
88-
const element = event.target as HTMLImageElement;
182+
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
183+
if (!isZoomEnabled || !mouseDownRef.current || !containerRef.current || !imageRef.current) {
184+
return;
185+
}
186+
187+
const element = event.target;
89188

90-
const {naturalWidth, naturalHeight} = element;
91-
const {left, top} = element.getBoundingClientRect();
189+
if (!isHTMLImageElement(element)) {
190+
return;
191+
}
92192

93-
const mouseX = event.clientX - left;
94-
const mouseY = event.clientY - top;
95-
const centerX = naturalWidth / 2;
96-
const centerY = naturalHeight / 2;
193+
draggingRef.current = true;
194+
195+
if (element.style.transition) {
196+
element.style.transition = '';
197+
}
97198

98-
const {x: maxXOffset, y: maxYOffset} = maxOffset;
199+
requestAnimationFrame(() => {
200+
element.style.cursor = 'grabbing';
201+
});
99202

100-
const xOffset = Math.min(Math.max(((mouseX - centerX) / naturalWidth) * 100, -maxXOffset), maxXOffset);
101-
const yOffset = Math.min(Math.max(((mouseY - centerY) / naturalHeight) * 100, -maxYOffset), maxYOffset);
203+
const {maxXOffset, maxYOffset} = calculateMaxOffset(containerRef, imageRef);
102204

103-
element.style.transform = `translate(${-xOffset}%, ${-yOffset}%)`;
205+
setTranslateOffset({
206+
x: Math.max(maxXOffset, Math.min(event.clientX - startOffset.x, -maxXOffset)),
207+
y: Math.max(maxYOffset, Math.min(event.clientY - startOffset.y, -maxYOffset)),
208+
});
209+
210+
event.preventDefault();
211+
};
212+
213+
const updateZoomRatio = () => {
214+
if (!imageRef.current) {
215+
return;
104216
}
217+
218+
const zoomRatio = calculateZoomRatio(imageRef.current);
219+
setImageRatio(zoomRatio > 1 ? 1 : zoomRatio);
105220
};
106221

222+
useEffect(() => {
223+
window.addEventListener('resize', updateZoomRatio);
224+
225+
return () => {
226+
window.removeEventListener('resize', updateZoomRatio);
227+
};
228+
}, []);
229+
107230
return (
108-
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-noninteractive-element-interactions,jsx-a11y/alt-text
109-
<img
110-
{...props}
111-
css={imageStyle(isZoomEnabled)}
112-
onClick={onButtonClick}
231+
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
232+
<div
233+
ref={containerRef}
234+
css={containerStyle}
235+
onClick={handleMouseClick}
236+
onMouseDown={handleMouseDown}
237+
onMouseUp={handleMouseUp}
113238
onMouseMove={handleMouseMove}
114-
onLoad={event => {
115-
const element = event.target as HTMLImageElement;
239+
>
240+
<img
241+
{...props}
242+
alt={props.alt}
243+
ref={imageRef}
244+
css={imageStyle}
245+
style={{
246+
transform: `translate3d(${translateOffset.x}px, ${translateOffset.y}px, 0) scale(${zoomScale}, ${zoomScale})`,
247+
}}
248+
onLoad={event => {
249+
const element = event.target;
116250

117-
const isImageWidthTooLarge = element.naturalWidth > element.offsetWidth;
118-
const isImageHeightTooLarge = element.naturalHeight > element.offsetHeight;
251+
if (!isHTMLImageElement(element)) {
252+
return;
253+
}
119254

120-
if (!isImageHeightTooLarge && !isImageWidthTooLarge) {
121-
return;
122-
}
255+
const zoomRatio = calculateZoomRatio(element);
256+
const imageScale = zoomRatio > 1 ? 1 : zoomRatio;
257+
setImageRatio(imageScale);
123258

124-
element.style.cursor = checkIfCanZoomImage(element) ? 'zoom-in' : '';
125-
}}
126-
/>
259+
element.width = element.naturalWidth;
260+
element.height = element.naturalHeight;
261+
element.style.cursor = zoomRatio < 1 ? 'zoom-in' : '';
262+
}}
263+
/>
264+
</div>
127265
);
128266
};

src/script/guards/HTMLElement.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2024 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*
18+
*/
19+
20+
export const isHTMLImageElement = (element: any): element is HTMLImageElement => element.nodeName === 'IMG';

src/style/content/conversation/detail-view.less

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
width: 100%;
6565

6666
max-width: 84%;
67-
max-height: 84%;
6867

6968
flex: 1 1;
7069
contain: strict;

0 commit comments

Comments
 (0)