|
17 | 17 | *
|
18 | 18 | */
|
19 | 19 |
|
20 |
| -import React, {HTMLProps, useState} from 'react'; |
| 20 | +import React, {HTMLProps, RefObject, useEffect, useRef, useState} from 'react'; |
21 | 21 |
|
22 |
| -import {imageStyle} from './ZoomableImage.style'; |
| 22 | +import {containerStyle, imageStyle} from './ZoomableImage.style'; |
23 | 23 |
|
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 | + }; |
27 | 55 | }
|
28 | 56 |
|
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 | + }; |
30 | 63 | }
|
31 | 64 |
|
32 | 65 | type ZoomableImageProps = HTMLProps<HTMLImageElement>;
|
33 | 66 |
|
34 | 67 | 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 | + |
35 | 75 | const [isZoomEnabled, setIsZoomEnabled] = useState<boolean>(false);
|
36 |
| - const [maxOffset, setMaxOffset] = useState({x: 0, y: 0}); |
37 | 76 |
|
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); |
40 | 79 |
|
41 |
| - if (!checkIfCanZoomImage(element)) { |
42 |
| - setIsZoomEnabled(false); |
| 80 | + const canZoomImage = imageRatio !== 1; |
| 81 | + const zoomScale = isZoomEnabled ? 1 : imageRatio; |
43 | 82 |
|
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; |
50 | 85 |
|
| 86 | + if (!isHTMLImageElement(element)) { |
51 | 87 | return;
|
52 | 88 | }
|
53 | 89 |
|
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 | + } |
56 | 134 |
|
57 | 135 | 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 | + } |
58 | 147 |
|
59 |
| - element.style.width = `${naturalWidth}px`; |
60 |
| - element.style.height = `${naturalHeight}px`; |
| 148 | + if (isZoomEnabled) { |
| 149 | + mouseDownRef.current = true; |
| 150 | + } |
61 | 151 |
|
62 |
| - const {left, top} = element.getBoundingClientRect(); |
| 152 | + requestAnimationFrame(() => { |
| 153 | + element.style.transition = 'transform 0.2s'; |
| 154 | + }); |
63 | 155 |
|
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 | + }); |
67 | 160 |
|
68 |
| - const deltaX = clientX - left; |
69 |
| - const deltaY = clientY - top; |
| 161 | + event.preventDefault(); |
| 162 | + }; |
70 | 163 |
|
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 | + } |
73 | 168 |
|
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; |
77 | 170 |
|
78 |
| - setMaxOffset({x: maxXOffset, y: maxYOffset}); |
| 171 | + if (!isHTMLImageElement(element)) { |
| 172 | + return; |
| 173 | + } |
79 | 174 |
|
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; |
82 | 176 |
|
83 |
| - element.style.transform = `translate(${-xOffset}%, ${-yOffset}%)`; |
| 177 | + requestAnimationFrame(() => { |
| 178 | + element.style.cursor = 'zoom-out'; |
| 179 | + }); |
84 | 180 | };
|
85 | 181 |
|
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; |
89 | 188 |
|
90 |
| - const {naturalWidth, naturalHeight} = element; |
91 |
| - const {left, top} = element.getBoundingClientRect(); |
| 189 | + if (!isHTMLImageElement(element)) { |
| 190 | + return; |
| 191 | + } |
92 | 192 |
|
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 | + } |
97 | 198 |
|
98 |
| - const {x: maxXOffset, y: maxYOffset} = maxOffset; |
| 199 | + requestAnimationFrame(() => { |
| 200 | + element.style.cursor = 'grabbing'; |
| 201 | + }); |
99 | 202 |
|
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); |
102 | 204 |
|
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; |
104 | 216 | }
|
| 217 | + |
| 218 | + const zoomRatio = calculateZoomRatio(imageRef.current); |
| 219 | + setImageRatio(zoomRatio > 1 ? 1 : zoomRatio); |
105 | 220 | };
|
106 | 221 |
|
| 222 | + useEffect(() => { |
| 223 | + window.addEventListener('resize', updateZoomRatio); |
| 224 | + |
| 225 | + return () => { |
| 226 | + window.removeEventListener('resize', updateZoomRatio); |
| 227 | + }; |
| 228 | + }, []); |
| 229 | + |
107 | 230 | 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} |
113 | 238 | 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; |
116 | 250 |
|
117 |
| - const isImageWidthTooLarge = element.naturalWidth > element.offsetWidth; |
118 |
| - const isImageHeightTooLarge = element.naturalHeight > element.offsetHeight; |
| 251 | + if (!isHTMLImageElement(element)) { |
| 252 | + return; |
| 253 | + } |
119 | 254 |
|
120 |
| - if (!isImageHeightTooLarge && !isImageWidthTooLarge) { |
121 |
| - return; |
122 |
| - } |
| 255 | + const zoomRatio = calculateZoomRatio(element); |
| 256 | + const imageScale = zoomRatio > 1 ? 1 : zoomRatio; |
| 257 | + setImageRatio(imageScale); |
123 | 258 |
|
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> |
127 | 265 | );
|
128 | 266 | };
|
0 commit comments