) => void;
72 | }
73 |
74 | interface CompoundedComponent extends React.FC
{
75 | PreviewGroup: typeof PreviewGroup;
76 | }
77 |
78 | const ImageInternal: CompoundedComponent = props => {
79 | const {
80 | // Misc
81 | prefixCls = 'rc-image',
82 | previewPrefixCls = `${prefixCls}-preview`,
83 |
84 | // Style
85 | rootClassName,
86 | className,
87 | style,
88 |
89 | classNames = {},
90 | styles = {},
91 |
92 | width,
93 | height,
94 |
95 | // Image
96 | src: imgSrc,
97 | alt,
98 | placeholder,
99 | fallback,
100 |
101 | // Preview
102 | preview = true,
103 |
104 | // Events
105 | onClick,
106 | onError,
107 | ...otherProps
108 | } = props;
109 |
110 | const groupContext = useContext(PreviewGroupContext);
111 |
112 | // ========================== Preview ===========================
113 | const canPreview = !!preview;
114 |
115 | const {
116 | src: previewSrc,
117 | open: previewOpen,
118 | onOpenChange: onPreviewOpenChange,
119 | cover,
120 | rootClassName: previewRootClassName,
121 | ...restProps
122 | }: PreviewConfig = preview && typeof preview === 'object' ? preview : {};
123 |
124 | // ============================ Open ============================
125 | const [isShowPreview, setShowPreview] = useMergedState(!!previewOpen, {
126 | value: previewOpen,
127 | });
128 |
129 | const [mousePosition, setMousePosition] = useState(null);
130 |
131 | const triggerPreviewOpen = (nextOpen: boolean) => {
132 | setShowPreview(nextOpen);
133 | onPreviewOpenChange?.(nextOpen);
134 | };
135 |
136 | const onPreviewClose = () => {
137 | triggerPreviewOpen(false);
138 | };
139 |
140 | // ========================= ImageProps =========================
141 | const isCustomPlaceholder = placeholder && placeholder !== true;
142 |
143 | const src = previewSrc ?? imgSrc;
144 | const [getImgRef, srcAndOnload, status] = useStatus({
145 | src: imgSrc,
146 | isCustomPlaceholder,
147 | fallback,
148 | });
149 |
150 | const imgCommonProps = useMemo(
151 | () => {
152 | const obj: ImageElementProps = {};
153 | COMMON_PROPS.forEach((prop: any) => {
154 | if (props[prop] !== undefined) {
155 | obj[prop] = props[prop];
156 | }
157 | });
158 |
159 | return obj;
160 | },
161 | COMMON_PROPS.map(prop => props[prop]),
162 | );
163 |
164 | // ========================== Register ==========================
165 | const registerData: ImageElementProps = useMemo(
166 | () => ({
167 | ...imgCommonProps,
168 | src,
169 | }),
170 | [src, imgCommonProps],
171 | );
172 |
173 | const imageId = useRegisterImage(canPreview, registerData);
174 |
175 | // ========================== Preview ===========================
176 | const onPreview: React.MouseEventHandler = e => {
177 | const rect = (e.target as HTMLDivElement).getBoundingClientRect();
178 | const left = rect.x + rect.width / 2;
179 | const top = rect.y + rect.height / 2;
180 |
181 | if (groupContext) {
182 | groupContext.onPreview(imageId, src, left, top);
183 | } else {
184 | setMousePosition({
185 | x: left,
186 | y: top,
187 | });
188 | triggerPreviewOpen(true);
189 | }
190 |
191 | onClick?.(e);
192 | };
193 |
194 | // =========================== Render ===========================
195 | return (
196 | <>
197 |
209 |
![]()
230 |
231 | {status === 'loading' && (
232 |
233 | {placeholder}
234 |
235 | )}
236 |
237 | {/* Preview Click Mask */}
238 | {cover !== false && canPreview && (
239 |
246 | {cover}
247 |
248 | )}
249 |
250 | {!groupContext && canPreview && (
251 |
267 | )}
268 | >
269 | );
270 | };
271 |
272 | ImageInternal.PreviewGroup = PreviewGroup;
273 |
274 | if (process.env.NODE_ENV !== 'production') {
275 | ImageInternal.displayName = 'Image';
276 | }
277 |
278 | export default ImageInternal;
279 |
--------------------------------------------------------------------------------
/src/Preview/CloseBtn.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | export interface CloseBtnProps {
4 | prefixCls: string;
5 | icon?: React.ReactNode;
6 | onClick: React.MouseEventHandler;
7 | }
8 |
9 | export default function CloseBtn(props: CloseBtnProps) {
10 | const { prefixCls, icon, onClick } = props;
11 |
12 | return (
13 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/Preview/Footer.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import * as React from 'react';
3 | import type { Actions, PreviewProps } from '.';
4 | import type { ImgInfo } from '../Image';
5 | import type { TransformType } from '../hooks/useImageTransform';
6 |
7 | export type FooterSemanticName = 'footer' | 'actions';
8 |
9 | type OperationType =
10 | | 'prev'
11 | | 'next'
12 | | 'flipY'
13 | | 'flipX'
14 | | 'rotateLeft'
15 | | 'rotateRight'
16 | | 'zoomOut'
17 | | 'zoomIn';
18 |
19 | interface RenderOperationParams {
20 | icon: React.ReactNode;
21 | type: OperationType;
22 | disabled?: boolean;
23 | onClick: (e: React.MouseEvent) => void;
24 | }
25 |
26 | export interface FooterProps extends Actions {
27 | prefixCls: string;
28 | showProgress: boolean;
29 | countRender?: PreviewProps['countRender'];
30 | actionsRender?: PreviewProps['actionsRender'];
31 | current: number;
32 | count: number;
33 | showSwitch: boolean;
34 | icons: PreviewProps['icons'];
35 | scale: number;
36 | minScale: number;
37 | maxScale: number;
38 | image: ImgInfo;
39 | transform: TransformType;
40 |
41 | // Style
42 | classNames: Partial>;
43 | styles: Partial>;
44 | }
45 |
46 | export default function Footer(props: FooterProps) {
47 | // 修改解构,添加缺失的属性,并提供默认值
48 | const {
49 | prefixCls,
50 | showProgress,
51 | current,
52 | count,
53 | showSwitch,
54 |
55 | // Style
56 | classNames,
57 | styles,
58 |
59 | // render
60 | icons,
61 | image,
62 | transform,
63 | countRender,
64 | actionsRender,
65 |
66 | // Scale
67 | scale,
68 | minScale,
69 | maxScale,
70 |
71 | // Actions
72 | onActive,
73 | onFlipY,
74 | onFlipX,
75 | onRotateLeft,
76 | onRotateRight,
77 | onZoomOut,
78 | onZoomIn,
79 | onClose,
80 | onReset,
81 | } = props;
82 |
83 | const { left, right, prev, next, flipY, flipX, rotateLeft, rotateRight, zoomOut, zoomIn } = icons;
84 |
85 | // ========================== Render ==========================
86 | // >>>>> Progress
87 | const progressNode = showProgress && (
88 |
89 | {countRender ? countRender(current + 1, count) : {`${current + 1} / ${count}`}}
90 |
91 | );
92 |
93 | // >>>>> Actions
94 | const actionCls = `${prefixCls}-actions-action`;
95 |
96 | const renderOperation = ({ type, disabled, onClick, icon }: RenderOperationParams) => {
97 | return (
98 |
105 | {icon}
106 |
107 | );
108 | };
109 |
110 | const switchPrevNode = showSwitch
111 | ? renderOperation({
112 | icon: prev ?? left,
113 | onClick: () => onActive(-1),
114 | type: 'prev',
115 | disabled: current === 0,
116 | })
117 | : undefined;
118 |
119 | const switchNextNode = showSwitch
120 | ? renderOperation({
121 | icon: next ?? right,
122 | onClick: () => onActive(1),
123 | type: 'next',
124 | disabled: current === count - 1,
125 | })
126 | : undefined;
127 |
128 | const flipYNode = renderOperation({
129 | icon: flipY,
130 | onClick: onFlipY,
131 | type: 'flipY',
132 | });
133 |
134 | const flipXNode = renderOperation({
135 | icon: flipX,
136 | onClick: onFlipX,
137 | type: 'flipX',
138 | });
139 |
140 | const rotateLeftNode = renderOperation({
141 | icon: rotateLeft,
142 | onClick: onRotateLeft,
143 | type: 'rotateLeft',
144 | });
145 |
146 | const rotateRightNode = renderOperation({
147 | icon: rotateRight,
148 | onClick: onRotateRight,
149 | type: 'rotateRight',
150 | });
151 |
152 | const zoomOutNode = renderOperation({
153 | icon: zoomOut,
154 | onClick: onZoomOut,
155 | type: 'zoomOut',
156 | disabled: scale <= minScale,
157 | });
158 |
159 | const zoomInNode = renderOperation({
160 | icon: zoomIn,
161 | onClick: onZoomIn,
162 | type: 'zoomIn',
163 | disabled: scale === maxScale,
164 | });
165 |
166 | const actionsNode = (
167 |
168 | {flipYNode}
169 | {flipXNode}
170 | {rotateLeftNode}
171 | {rotateRightNode}
172 | {zoomOutNode}
173 | {zoomInNode}
174 |
175 | );
176 |
177 | // >>>>> Render
178 | return (
179 |
180 | {progressNode}
181 | {actionsRender
182 | ? actionsRender(actionsNode, {
183 | icons: {
184 | prevIcon: switchPrevNode,
185 | nextIcon: switchNextNode,
186 | flipYIcon: flipYNode,
187 | flipXIcon: flipXNode,
188 | rotateLeftIcon: rotateLeftNode,
189 | rotateRightIcon: rotateRightNode,
190 | zoomOutIcon: zoomOutNode,
191 | zoomInIcon: zoomInNode,
192 | },
193 | actions: {
194 | onActive,
195 | onFlipY,
196 | onFlipX,
197 | onRotateLeft,
198 | onRotateRight,
199 | onZoomOut,
200 | onZoomIn,
201 | onReset,
202 | onClose,
203 | },
204 | transform,
205 | current,
206 | total: count,
207 | image,
208 | })
209 | : actionsNode}
210 |
211 | );
212 | }
213 |
--------------------------------------------------------------------------------
/src/Preview/PrevNext.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import * as React from 'react';
3 | import type { OperationIcons } from '.';
4 |
5 | export interface PrevNextProps {
6 | prefixCls: string;
7 | onActive: (offset: number) => void;
8 | current: number;
9 | count: number;
10 | icons: OperationIcons;
11 | }
12 |
13 | export default function PrevNext(props: PrevNextProps) {
14 | const {
15 | prefixCls,
16 | onActive,
17 | current,
18 | count,
19 | icons: { left, right, prev, next },
20 | } = props;
21 |
22 | const switchCls = `${prefixCls}-switch`;
23 |
24 | return (
25 | <>
26 | onActive(-1)}
31 | >
32 | {prev ?? left}
33 |
34 | onActive(1)}
39 | >
40 | {next ?? right}
41 |
42 | >
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/Preview/index.tsx:
--------------------------------------------------------------------------------
1 | import CSSMotion from '@rc-component/motion';
2 | import Portal, { type PortalProps } from '@rc-component/portal';
3 | import { useEvent } from '@rc-component/util';
4 | import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect';
5 | import KeyCode from '@rc-component/util/lib/KeyCode';
6 | import classnames from 'classnames';
7 | import React, { useContext, useEffect, useRef, useState } from 'react';
8 | import { PreviewGroupContext } from '../context';
9 | import type { TransformAction, TransformType } from '../hooks/useImageTransform';
10 | import useImageTransform from '../hooks/useImageTransform';
11 | import useMouseEvent from '../hooks/useMouseEvent';
12 | import useStatus from '../hooks/useStatus';
13 | import useTouchEvent from '../hooks/useTouchEvent';
14 | import type { ImgInfo } from '../Image';
15 | import { BASE_SCALE_RATIO } from '../previewConfig';
16 | import CloseBtn from './CloseBtn';
17 | import Footer, { type FooterSemanticName } from './Footer';
18 | import PrevNext from './PrevNext';
19 |
20 | // Note: if you want to add `action`,
21 | // pls contact @zombieJ or @thinkasany first.
22 | export type PreviewSemanticName = 'root' | 'mask' | 'body' | FooterSemanticName;
23 |
24 | export interface OperationIcons {
25 | rotateLeft?: React.ReactNode;
26 | rotateRight?: React.ReactNode;
27 | zoomIn?: React.ReactNode;
28 | zoomOut?: React.ReactNode;
29 | close?: React.ReactNode;
30 | prev?: React.ReactNode;
31 | next?: React.ReactNode;
32 | /** @deprecated Please use `prev` instead */
33 | left?: React.ReactNode;
34 | /** @deprecated Please use `next` instead */
35 | right?: React.ReactNode;
36 | flipX?: React.ReactNode;
37 | flipY?: React.ReactNode;
38 | }
39 |
40 | export interface Actions {
41 | onActive: (offset: number) => void;
42 | onFlipY: () => void;
43 | onFlipX: () => void;
44 | onRotateLeft: () => void;
45 | onRotateRight: () => void;
46 | onZoomOut: () => void;
47 | onZoomIn: () => void;
48 | onClose: () => void;
49 | onReset: () => void;
50 | }
51 |
52 | export type ToolbarRenderInfoType = {
53 | icons: {
54 | prevIcon?: React.ReactNode;
55 | nextIcon?: React.ReactNode;
56 | flipYIcon: React.ReactNode;
57 | flipXIcon: React.ReactNode;
58 | rotateLeftIcon: React.ReactNode;
59 | rotateRightIcon: React.ReactNode;
60 | zoomOutIcon: React.ReactNode;
61 | zoomInIcon: React.ReactNode;
62 | };
63 | actions: Actions;
64 | transform: TransformType;
65 | current: number;
66 | total: number;
67 | image: ImgInfo;
68 | };
69 |
70 | export interface InternalPreviewConfig {
71 | // Semantic
72 | /** Better to use `classNames.root` instead */
73 | rootClassName?: string;
74 |
75 | // Image
76 | src?: string;
77 | alt?: string;
78 |
79 | // Scale
80 | scaleStep?: number;
81 | minScale?: number;
82 | maxScale?: number;
83 |
84 | // Display
85 | motionName?: string;
86 | open?: boolean;
87 | getContainer?: PortalProps['getContainer'];
88 | zIndex?: number;
89 | afterOpenChange?: (open: boolean) => void;
90 |
91 | // Operation
92 | movable?: boolean;
93 | icons?: OperationIcons;
94 | closeIcon?: React.ReactNode;
95 |
96 | onTransform?: (info: { transform: TransformType; action: TransformAction }) => void;
97 |
98 | // Render
99 | countRender?: (current: number, total: number) => React.ReactNode;
100 | imageRender?: (
101 | originalNode: React.ReactElement,
102 | info: { transform: TransformType; current?: number; image: ImgInfo },
103 | ) => React.ReactNode;
104 | actionsRender?: (
105 | originalNode: React.ReactElement,
106 | info: ToolbarRenderInfoType,
107 | ) => React.ReactNode;
108 | }
109 |
110 | export interface PreviewProps extends InternalPreviewConfig {
111 | // Misc
112 | prefixCls: string;
113 |
114 | classNames?: Partial>;
115 | styles?: Partial>;
116 |
117 | // Origin image Info
118 | imageInfo?: {
119 | width: number | string;
120 | height: number | string;
121 | };
122 | fallback?: string;
123 |
124 | // Preview image
125 | imgCommonProps?: React.ImgHTMLAttributes;
126 | width?: string | number;
127 | height?: string | number;
128 |
129 | // Pagination
130 | current?: number;
131 | count?: number;
132 | onChange?: (current: number, prev: number) => void;
133 |
134 | // Events
135 | onClose?: () => void;
136 |
137 | // Display
138 | mousePosition: null | { x: number; y: number };
139 | }
140 |
141 | interface PreviewImageProps extends React.ImgHTMLAttributes {
142 | fallback?: string;
143 | imgRef: React.MutableRefObject;
144 | }
145 |
146 | const PreviewImage: React.FC = ({ fallback, src, imgRef, ...props }) => {
147 | const [getImgRef, srcAndOnload] = useStatus({
148 | src,
149 | fallback,
150 | });
151 |
152 | return (
153 |
{
155 | imgRef.current = ref;
156 | getImgRef(ref);
157 | }}
158 | {...props}
159 | {...srcAndOnload}
160 | />
161 | );
162 | };
163 |
164 | const Preview: React.FC = props => {
165 | const {
166 | prefixCls,
167 | rootClassName,
168 | src,
169 | alt,
170 | imageInfo,
171 | fallback,
172 | movable = true,
173 | onClose,
174 | open,
175 | afterOpenChange,
176 | icons = {},
177 | closeIcon,
178 | getContainer,
179 | current = 0,
180 | count = 1,
181 | countRender,
182 | scaleStep = 0.5,
183 | minScale = 1,
184 | maxScale = 50,
185 | motionName = 'fade',
186 | imageRender,
187 | imgCommonProps,
188 | actionsRender,
189 | onTransform,
190 | onChange,
191 | classNames = {},
192 | styles = {},
193 | mousePosition,
194 | zIndex,
195 | } = props;
196 |
197 | const imgRef = useRef();
198 | const groupContext = useContext(PreviewGroupContext);
199 | const showLeftOrRightSwitches = groupContext && count > 1;
200 | const showOperationsProgress = groupContext && count >= 1;
201 |
202 | // ======================== Transform =========================
203 | const [enableTransition, setEnableTransition] = useState(true);
204 | const { transform, resetTransform, updateTransform, dispatchZoomChange } = useImageTransform(
205 | imgRef,
206 | minScale,
207 | maxScale,
208 | onTransform,
209 | );
210 | const { isMoving, onMouseDown, onWheel } = useMouseEvent(
211 | imgRef,
212 | movable,
213 | open,
214 | scaleStep,
215 | transform,
216 | updateTransform,
217 | dispatchZoomChange,
218 | );
219 | const { isTouching, onTouchStart, onTouchMove, onTouchEnd } = useTouchEvent(
220 | imgRef,
221 | movable,
222 | open,
223 | minScale,
224 | transform,
225 | updateTransform,
226 | dispatchZoomChange,
227 | );
228 | const { rotate, scale } = transform;
229 |
230 | useEffect(() => {
231 | if (!enableTransition) {
232 | setEnableTransition(true);
233 | }
234 | }, [enableTransition]);
235 |
236 | useEffect(() => {
237 | if (!open) {
238 | resetTransform('close');
239 | }
240 | }, [open]);
241 |
242 | // ========================== Image ===========================
243 | const onDoubleClick = (event: React.MouseEvent) => {
244 | if (open) {
245 | if (scale !== 1) {
246 | updateTransform({ x: 0, y: 0, scale: 1 }, 'doubleClick');
247 | } else {
248 | dispatchZoomChange(
249 | BASE_SCALE_RATIO + scaleStep,
250 | 'doubleClick',
251 | event.clientX,
252 | event.clientY,
253 | );
254 | }
255 | }
256 | };
257 |
258 | const imgNode = (
259 |
282 | );
283 |
284 | const image = {
285 | url: src,
286 | alt,
287 | ...imageInfo,
288 | };
289 |
290 | // ======================== Operation =========================
291 | // >>>>> Actions
292 | const onZoomIn = () => {
293 | dispatchZoomChange(BASE_SCALE_RATIO + scaleStep, 'zoomIn');
294 | };
295 |
296 | const onZoomOut = () => {
297 | dispatchZoomChange(BASE_SCALE_RATIO / (BASE_SCALE_RATIO + scaleStep), 'zoomOut');
298 | };
299 |
300 | const onRotateRight = () => {
301 | updateTransform({ rotate: rotate + 90 }, 'rotateRight');
302 | };
303 |
304 | const onRotateLeft = () => {
305 | updateTransform({ rotate: rotate - 90 }, 'rotateLeft');
306 | };
307 |
308 | const onFlipX = () => {
309 | updateTransform({ flipX: !transform.flipX }, 'flipX');
310 | };
311 |
312 | const onFlipY = () => {
313 | updateTransform({ flipY: !transform.flipY }, 'flipY');
314 | };
315 |
316 | const onReset = () => {
317 | resetTransform('reset');
318 | };
319 |
320 | const onActive = (offset: number) => {
321 | const nextCurrent = current + offset;
322 |
323 | if (nextCurrent >= 0 && nextCurrent <= count - 1) {
324 | setEnableTransition(false);
325 | resetTransform(offset < 0 ? 'prev' : 'next');
326 | onChange?.(nextCurrent, current);
327 | }
328 | };
329 |
330 | // >>>>> Effect: Keyboard
331 | const onKeyDown = useEvent((event: KeyboardEvent) => {
332 | if (open) {
333 | const { keyCode } = event;
334 |
335 | if (keyCode === KeyCode.ESC) {
336 | onClose?.();
337 | }
338 |
339 | if (showLeftOrRightSwitches) {
340 | if (keyCode === KeyCode.LEFT) {
341 | onActive(-1);
342 | } else if (keyCode === KeyCode.RIGHT) {
343 | onActive(1);
344 | }
345 | }
346 | }
347 | });
348 |
349 | useEffect(() => {
350 | if (open) {
351 | window.addEventListener('keydown', onKeyDown);
352 |
353 | return () => {
354 | window.removeEventListener('keydown', onKeyDown);
355 | };
356 | }
357 | }, [open]);
358 |
359 | // ======================= Lock Scroll ========================
360 | const [lockScroll, setLockScroll] = useState(false);
361 |
362 | React.useEffect(() => {
363 | if (open) {
364 | setLockScroll(true);
365 | }
366 | }, [open]);
367 |
368 | const onVisibleChanged = (nextVisible: boolean) => {
369 | if (!nextVisible) {
370 | setLockScroll(false);
371 | }
372 | afterOpenChange?.(nextVisible);
373 | };
374 |
375 | // ========================== Portal ==========================
376 | const [portalRender, setPortalRender] = useState(false);
377 | useLayoutEffect(() => {
378 | if (open) {
379 | setPortalRender(true);
380 | }
381 | }, [open]);
382 |
383 | // ========================== Render ==========================
384 | const bodyStyle: React.CSSProperties = {
385 | ...styles.body,
386 | };
387 | if (mousePosition) {
388 | bodyStyle.transformOrigin = `${mousePosition.x}px ${mousePosition.y}px`;
389 | }
390 |
391 | return (
392 |
393 |
401 | {({ className: motionClassName, style: motionStyle }) => {
402 | const mergedStyle = {
403 | ...styles.root,
404 | ...motionStyle,
405 | };
406 |
407 | if (zIndex) {
408 | mergedStyle.zIndex = zIndex;
409 | }
410 |
411 | return (
412 |
418 | {/* Mask */}
419 |
424 |
425 | {/* Body */}
426 |
427 | {/* Preview Image */}
428 | {imageRender
429 | ? imageRender(imgNode, {
430 | transform,
431 | image,
432 | ...(groupContext ? { current } : {}),
433 | })
434 | : imgNode}
435 |
436 |
437 | {/* Close Button */}
438 | {closeIcon !== false && closeIcon !== null && (
439 |
444 | )}
445 |
446 | {/* Switch prev or next */}
447 | {showLeftOrRightSwitches && (
448 |
455 | )}
456 |
457 | {/* Footer */}
458 |
488 |
489 | );
490 | }}
491 |
492 |
493 | );
494 | };
495 |
496 | export default Preview;
497 |
--------------------------------------------------------------------------------
/src/PreviewGroup.tsx:
--------------------------------------------------------------------------------
1 | import useMergedState from '@rc-component/util/lib/hooks/useMergedState';
2 | import * as React from 'react';
3 | import { useState } from 'react';
4 | import type { ImgInfo } from './Image';
5 | import type { InternalPreviewConfig, PreviewProps, PreviewSemanticName } from './Preview';
6 | import Preview from './Preview';
7 | import { PreviewGroupContext } from './context';
8 | import type { TransformType } from './hooks/useImageTransform';
9 | import usePreviewItems from './hooks/usePreviewItems';
10 | import type { ImageElementProps, OnGroupPreview } from './interface';
11 |
12 | export interface GroupPreviewConfig extends InternalPreviewConfig {
13 | current?: number;
14 | // Similar to InternalPreviewConfig but has additional current
15 | imageRender?: (
16 | originalNode: React.ReactElement,
17 | info: { transform: TransformType; current: number; image: ImgInfo },
18 | ) => React.ReactNode;
19 | onOpenChange?: (value: boolean, info: { current: number }) => void;
20 | onChange?: (current: number, prevCurrent: number) => void;
21 | }
22 |
23 | export interface PreviewGroupProps {
24 | previewPrefixCls?: string;
25 | classNames?: {
26 | popup?: Partial>;
27 | };
28 |
29 | styles?: {
30 | popup?: Partial>;
31 | };
32 |
33 | icons?: PreviewProps['icons'];
34 | items?: (string | ImageElementProps)[];
35 | fallback?: string;
36 | preview?: boolean | GroupPreviewConfig;
37 | children?: React.ReactNode;
38 | }
39 |
40 | const Group: React.FC = ({
41 | previewPrefixCls = 'rc-image-preview',
42 | classNames,
43 | styles,
44 | children,
45 | icons = {},
46 | items,
47 | preview,
48 | fallback,
49 | }) => {
50 | const {
51 | open: previewOpen,
52 | onOpenChange,
53 | current: currentIndex,
54 | onChange,
55 | ...restProps
56 | } = preview && typeof preview === 'object' ? preview : ({} as GroupPreviewConfig);
57 |
58 | // ========================== Items ===========================
59 | const [mergedItems, register, fromItems] = usePreviewItems(items);
60 |
61 | // ========================= Preview ==========================
62 | // >>> Index
63 | const [current, setCurrent] = useMergedState(0, {
64 | value: currentIndex,
65 | });
66 |
67 | const [keepOpenIndex, setKeepOpenIndex] = useState(false);
68 |
69 | // >>> Image
70 | const { src, ...imgCommonProps } = mergedItems[current]?.data || {};
71 | // >>> Visible
72 | const [isShowPreview, setShowPreview] = useMergedState(!!previewOpen, {
73 | value: previewOpen,
74 | onChange: val => {
75 | onOpenChange?.(val, { current });
76 | },
77 | });
78 |
79 | // >>> Position
80 | const [mousePosition, setMousePosition] = useState(null);
81 |
82 | const onPreviewFromImage = React.useCallback(
83 | (id, imageSrc, mouseX, mouseY) => {
84 | const index = fromItems
85 | ? mergedItems.findIndex(item => item.data.src === imageSrc)
86 | : mergedItems.findIndex(item => item.id === id);
87 |
88 | setCurrent(index < 0 ? 0 : index);
89 |
90 | setShowPreview(true);
91 | setMousePosition({ x: mouseX, y: mouseY });
92 |
93 | setKeepOpenIndex(true);
94 | },
95 | [mergedItems, fromItems],
96 | );
97 |
98 | // Reset current when reopen
99 | React.useEffect(() => {
100 | if (isShowPreview) {
101 | if (!keepOpenIndex) {
102 | setCurrent(0);
103 | }
104 | } else {
105 | setKeepOpenIndex(false);
106 | }
107 | }, [isShowPreview]);
108 |
109 | // ========================== Events ==========================
110 | const onInternalChange: GroupPreviewConfig['onChange'] = (next, prev) => {
111 | setCurrent(next);
112 |
113 | onChange?.(next, prev);
114 | };
115 |
116 | const onPreviewClose = () => {
117 | setShowPreview(false);
118 | setMousePosition(null);
119 | };
120 |
121 | // ========================= Context ==========================
122 | const previewGroupContext = React.useMemo(
123 | () => ({ register, onPreview: onPreviewFromImage }),
124 | [register, onPreviewFromImage],
125 | );
126 |
127 | // ========================== Render ==========================
128 | return (
129 |
130 | {children}
131 |
148 |
149 | );
150 | };
151 |
152 | export default Group;
153 |
--------------------------------------------------------------------------------
/src/common.ts:
--------------------------------------------------------------------------------
1 | import type { ImageElementProps } from './interface';
2 |
3 | export const COMMON_PROPS: (keyof Omit)[] = [
4 | 'crossOrigin',
5 | 'decoding',
6 | 'draggable',
7 | 'loading',
8 | 'referrerPolicy',
9 | 'sizes',
10 | 'srcSet',
11 | 'useMap',
12 | 'alt',
13 | ];
14 |
--------------------------------------------------------------------------------
/src/context.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import type { OnGroupPreview, RegisterImage } from './interface';
3 |
4 | export interface PreviewGroupContextProps {
5 | register: RegisterImage;
6 | onPreview: OnGroupPreview;
7 | }
8 |
9 | export const PreviewGroupContext = React.createContext(null);
10 |
--------------------------------------------------------------------------------
/src/getFixScaleEleTransPosition.ts:
--------------------------------------------------------------------------------
1 | import { getClientSize } from "./util";
2 |
3 | function fixPoint(key: 'x' | 'y', start: number, width: number, clientWidth: number) {
4 | const startAddWidth = start + width;
5 | const offsetStart = (width - clientWidth) / 2;
6 |
7 | if (width > clientWidth) {
8 | if (start > 0) {
9 | return {
10 | [key]: offsetStart,
11 | };
12 | }
13 | if (start < 0 && startAddWidth < clientWidth) {
14 | return {
15 | [key]: -offsetStart,
16 | };
17 | }
18 | } else if (start < 0 || startAddWidth > clientWidth) {
19 | return {
20 | [key]: start < 0 ? offsetStart : -offsetStart,
21 | };
22 | }
23 | return {};
24 | }
25 |
26 | /**
27 | * Fix positon x,y point when
28 | *
29 | * Ele width && height < client
30 | * - Back origin
31 | *
32 | * - Ele width | height > clientWidth | clientHeight
33 | * - left | top > 0 -> Back 0
34 | * - left | top + width | height < clientWidth | clientHeight -> Back left | top + width | height === clientWidth | clientHeight
35 | *
36 | * Regardless of other
37 | */
38 | export default function getFixScaleEleTransPosition(
39 | width: number,
40 | height: number,
41 | left: number,
42 | top: number,
43 | ): null | { x: number; y: number } {
44 | const { width: clientWidth, height: clientHeight } = getClientSize();
45 |
46 | let fixPos = null;
47 |
48 | if (width <= clientWidth && height <= clientHeight) {
49 | fixPos = {
50 | x: 0,
51 | y: 0,
52 | };
53 | } else if (width > clientWidth || height > clientHeight) {
54 | fixPos = {
55 | ...fixPoint('x', left, width, clientWidth),
56 | ...fixPoint('y', top, height, clientHeight),
57 | };
58 | }
59 |
60 | return fixPos;
61 | }
62 |
--------------------------------------------------------------------------------
/src/hooks/useImageTransform.ts:
--------------------------------------------------------------------------------
1 | import { getClientSize } from '../util';
2 | import isEqual from '@rc-component/util/lib/isEqual';
3 | import raf from '@rc-component/util/lib/raf';
4 | import { useRef, useState } from 'react';
5 |
6 | export type TransformType = {
7 | x: number;
8 | y: number;
9 | rotate: number;
10 | scale: number;
11 | flipX: boolean;
12 | flipY: boolean;
13 | };
14 |
15 | export type TransformAction =
16 | | 'flipY'
17 | | 'flipX'
18 | | 'rotateLeft'
19 | | 'rotateRight'
20 | | 'zoomIn'
21 | | 'zoomOut'
22 | | 'close'
23 | | 'prev'
24 | | 'next'
25 | | 'wheel'
26 | | 'doubleClick'
27 | | 'move'
28 | | 'dragRebound'
29 | | 'touchZoom'
30 | | 'reset';
31 |
32 | export type UpdateTransformFunc = (
33 | newTransform: Partial,
34 | action: TransformAction,
35 | ) => void;
36 |
37 | export type DispatchZoomChangeFunc = (
38 | ratio: number,
39 | action: TransformAction,
40 | centerX?: number,
41 | centerY?: number,
42 | isTouch?: boolean,
43 | ) => void;
44 |
45 | const initialTransform = {
46 | x: 0,
47 | y: 0,
48 | rotate: 0,
49 | scale: 1,
50 | flipX: false,
51 | flipY: false,
52 | };
53 |
54 | export default function useImageTransform(
55 | imgRef: React.MutableRefObject,
56 | minScale: number,
57 | maxScale: number,
58 | onTransform: (info: { transform: TransformType; action: TransformAction }) => void,
59 | ) {
60 | const frame = useRef(null);
61 | const queue = useRef([]);
62 | const [transform, setTransform] = useState(initialTransform);
63 |
64 | const resetTransform = (action: TransformAction) => {
65 | setTransform(initialTransform);
66 | if (!isEqual(initialTransform, transform)) {
67 | onTransform?.({ transform: initialTransform, action });
68 | }
69 | };
70 |
71 | /** Direct update transform */
72 | const updateTransform: UpdateTransformFunc = (newTransform, action) => {
73 | if (frame.current === null) {
74 | queue.current = [];
75 | frame.current = raf(() => {
76 | setTransform(preState => {
77 | let memoState: any = preState;
78 | queue.current.forEach(queueState => {
79 | memoState = { ...memoState, ...queueState };
80 | });
81 | frame.current = null;
82 |
83 | onTransform?.({ transform: memoState, action });
84 | return memoState;
85 | });
86 | });
87 | }
88 | queue.current.push({
89 | ...transform,
90 | ...newTransform,
91 | });
92 | };
93 |
94 | /** Scale according to the position of centerX and centerY */
95 | const dispatchZoomChange: DispatchZoomChangeFunc = (
96 | ratio,
97 | action,
98 | centerX?,
99 | centerY?,
100 | isTouch?,
101 | ) => {
102 | const { width, height, offsetWidth, offsetHeight, offsetLeft, offsetTop } = imgRef.current;
103 |
104 | let newRatio = ratio;
105 | let newScale = transform.scale * ratio;
106 | if (newScale > maxScale) {
107 | newScale = maxScale;
108 | newRatio = maxScale / transform.scale;
109 | } else if (newScale < minScale) {
110 | // For mobile interactions, allow scaling down to the minimum scale.
111 | newScale = isTouch ? newScale : minScale;
112 | newRatio = newScale / transform.scale;
113 | }
114 |
115 | /** Default center point scaling */
116 | const mergedCenterX = centerX ?? innerWidth / 2;
117 | const mergedCenterY = centerY ?? innerHeight / 2;
118 |
119 | const diffRatio = newRatio - 1;
120 | /** Deviation calculated from image size */
121 | const diffImgX = diffRatio * width * 0.5;
122 | const diffImgY = diffRatio * height * 0.5;
123 | /** The difference between the click position and the edge of the document */
124 | const diffOffsetLeft = diffRatio * (mergedCenterX - transform.x - offsetLeft);
125 | const diffOffsetTop = diffRatio * (mergedCenterY - transform.y - offsetTop);
126 | /** Final positioning */
127 | let newX = transform.x - (diffOffsetLeft - diffImgX);
128 | let newY = transform.y - (diffOffsetTop - diffImgY);
129 |
130 | /**
131 | * When zooming the image
132 | * When the image size is smaller than the width and height of the window, the position is initialized
133 | */
134 | if (ratio < 1 && newScale === 1) {
135 | const mergedWidth = offsetWidth * newScale;
136 | const mergedHeight = offsetHeight * newScale;
137 | const { width: clientWidth, height: clientHeight } = getClientSize();
138 | if (mergedWidth <= clientWidth && mergedHeight <= clientHeight) {
139 | newX = 0;
140 | newY = 0;
141 | }
142 | }
143 |
144 | updateTransform(
145 | {
146 | x: newX,
147 | y: newY,
148 | scale: newScale,
149 | },
150 | action,
151 | );
152 | };
153 |
154 | return {
155 | transform,
156 | resetTransform,
157 | updateTransform,
158 | dispatchZoomChange,
159 | };
160 | }
161 |
--------------------------------------------------------------------------------
/src/hooks/useMouseEvent.ts:
--------------------------------------------------------------------------------
1 | import { warning } from '@rc-component/util/lib/warning';
2 | import type React from 'react';
3 | import { useEffect, useRef, useState } from 'react';
4 | import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition';
5 | import { BASE_SCALE_RATIO, WHEEL_MAX_SCALE_RATIO } from '../previewConfig';
6 | import type {
7 | DispatchZoomChangeFunc,
8 | TransformType,
9 | UpdateTransformFunc,
10 | } from './useImageTransform';
11 |
12 | export default function useMouseEvent(
13 | imgRef: React.MutableRefObject,
14 | movable: boolean,
15 | open: boolean,
16 | scaleStep: number,
17 | transform: TransformType,
18 | updateTransform: UpdateTransformFunc,
19 | dispatchZoomChange: DispatchZoomChangeFunc,
20 | ) {
21 | const { rotate, scale, x, y } = transform;
22 |
23 | const [isMoving, setMoving] = useState(false);
24 | const startPositionInfo = useRef({
25 | diffX: 0,
26 | diffY: 0,
27 | transformX: 0,
28 | transformY: 0,
29 | });
30 |
31 | const onMouseDown: React.MouseEventHandler = event => {
32 | // Only allow main button
33 | if (!movable || event.button !== 0) return;
34 | event.preventDefault();
35 | event.stopPropagation();
36 | startPositionInfo.current = {
37 | diffX: event.pageX - x,
38 | diffY: event.pageY - y,
39 | transformX: x,
40 | transformY: y,
41 | };
42 | setMoving(true);
43 | };
44 |
45 | const onMouseMove = (event: MouseEvent) => {
46 | if (open && isMoving) {
47 | updateTransform(
48 | {
49 | x: event.pageX - startPositionInfo.current.diffX,
50 | y: event.pageY - startPositionInfo.current.diffY,
51 | },
52 | 'move',
53 | );
54 | }
55 | };
56 |
57 | const onMouseUp = () => {
58 | if (open && isMoving) {
59 | setMoving(false);
60 |
61 | /** No need to restore the position when the picture is not moved, So as not to interfere with the click */
62 | const { transformX, transformY } = startPositionInfo.current;
63 | const hasChangedPosition = x !== transformX && y !== transformY;
64 | if (!hasChangedPosition) return;
65 |
66 | const width = imgRef.current.offsetWidth * scale;
67 | const height = imgRef.current.offsetHeight * scale;
68 | // eslint-disable-next-line @typescript-eslint/no-shadow
69 | const { left, top } = imgRef.current.getBoundingClientRect();
70 | const isRotate = rotate % 180 !== 0;
71 |
72 | const fixState = getFixScaleEleTransPosition(
73 | isRotate ? height : width,
74 | isRotate ? width : height,
75 | left,
76 | top,
77 | );
78 |
79 | if (fixState) {
80 | updateTransform({ ...fixState }, 'dragRebound');
81 | }
82 | }
83 | };
84 |
85 | const onWheel = (event: React.WheelEvent) => {
86 | if (!open || event.deltaY == 0) return;
87 | // Scale ratio depends on the deltaY size
88 | const scaleRatio = Math.abs(event.deltaY / 100);
89 | // Limit the maximum scale ratio
90 | const mergedScaleRatio = Math.min(scaleRatio, WHEEL_MAX_SCALE_RATIO);
91 | // Scale the ratio each time
92 | let ratio = BASE_SCALE_RATIO + mergedScaleRatio * scaleStep;
93 | if (event.deltaY > 0) {
94 | ratio = BASE_SCALE_RATIO / ratio;
95 | }
96 | dispatchZoomChange(ratio, 'wheel', event.clientX, event.clientY);
97 | };
98 |
99 | useEffect(() => {
100 | if (movable) {
101 | window.addEventListener('mouseup', onMouseUp, false);
102 | window.addEventListener('mousemove', onMouseMove, false);
103 |
104 | try {
105 | // Resolve if in iframe lost event
106 | /* istanbul ignore next */
107 | if (window.top !== window.self) {
108 | window.top.addEventListener('mouseup', onMouseUp, false);
109 | window.top.addEventListener('mousemove', onMouseMove, false);
110 | }
111 | } catch (error) {
112 | /* istanbul ignore next */
113 | warning(false, `[rc-image] ${error}`);
114 | }
115 | }
116 |
117 | return () => {
118 | window.removeEventListener('mouseup', onMouseUp);
119 | window.removeEventListener('mousemove', onMouseMove);
120 | // /* istanbul ignore next */
121 | window.top?.removeEventListener('mouseup', onMouseUp);
122 | // /* istanbul ignore next */
123 | window.top?.removeEventListener('mousemove', onMouseMove);
124 | };
125 | }, [open, isMoving, x, y, rotate, movable]);
126 |
127 | return {
128 | isMoving,
129 | onMouseDown,
130 | onMouseMove,
131 | onMouseUp,
132 | onWheel,
133 | };
134 | }
135 |
--------------------------------------------------------------------------------
/src/hooks/usePreviewItems.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import type { PreviewGroupProps } from '../PreviewGroup';
3 | import { COMMON_PROPS } from '../common';
4 | import type {
5 | ImageElementProps,
6 | InternalItem,
7 | PreviewImageElementProps,
8 | RegisterImage,
9 | } from '../interface';
10 |
11 | export type Items = Omit[];
12 |
13 | /**
14 | * Merge props provided `items` or context collected images
15 | */
16 | export default function usePreviewItems(
17 | items?: PreviewGroupProps['items'],
18 | ): [items: Items, registerImage: RegisterImage, fromItems: boolean] {
19 | // Context collection image data
20 | const [images, setImages] = React.useState>({});
21 |
22 | const registerImage = React.useCallback((id, data) => {
23 | setImages(imgs => ({
24 | ...imgs,
25 | [id]: data,
26 | }));
27 |
28 | return () => {
29 | setImages(imgs => {
30 | const cloneImgs = { ...imgs };
31 | delete cloneImgs[id];
32 | return cloneImgs;
33 | });
34 | };
35 | }, []);
36 |
37 | // items
38 | const mergedItems = React.useMemo(() => {
39 | // use `items` first
40 | if (items) {
41 | return items.map(item => {
42 | if (typeof item === 'string') {
43 | return { data: { src: item } };
44 | }
45 | const data: ImageElementProps = {};
46 | Object.keys(item).forEach(key => {
47 | if (['src', ...COMMON_PROPS].includes(key)) {
48 | data[key] = item[key];
49 | }
50 | });
51 | return { data };
52 | });
53 | }
54 |
55 | // use registered images secondly
56 | return Object.keys(images).reduce((total: Items, id) => {
57 | const { canPreview, data } = images[id];
58 | if (canPreview) {
59 | total.push({ data, id });
60 | }
61 | return total;
62 | }, []);
63 | }, [items, images]);
64 |
65 | return [mergedItems, registerImage, !!items];
66 | }
67 |
--------------------------------------------------------------------------------
/src/hooks/useRegisterImage.ts:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { PreviewGroupContext } from '../context';
3 | import type { ImageElementProps } from '../interface';
4 |
5 | let uid = 0;
6 |
7 | export default function useRegisterImage(canPreview: boolean, data: ImageElementProps) {
8 | const [id] = React.useState(() => {
9 | uid += 1;
10 | return String(uid);
11 | });
12 | const groupContext = React.useContext(PreviewGroupContext);
13 |
14 | const registerData = {
15 | data,
16 | canPreview,
17 | };
18 |
19 | // Keep order start
20 | // Resolve https://github.com/ant-design/ant-design/issues/28881
21 | // Only need unRegister when component unMount
22 | React.useEffect(() => {
23 | if (groupContext) {
24 | return groupContext.register(id, registerData);
25 | }
26 | }, []);
27 |
28 | React.useEffect(() => {
29 | if (groupContext) {
30 | groupContext.register(id, registerData);
31 | }
32 | }, [canPreview, data]);
33 |
34 | return id;
35 | }
36 |
--------------------------------------------------------------------------------
/src/hooks/useStatus.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { isImageValid } from '../util';
3 |
4 | type ImageStatus = 'normal' | 'error' | 'loading';
5 |
6 | export default function useStatus({
7 | src,
8 | isCustomPlaceholder,
9 | fallback,
10 | }: {
11 | src: string;
12 | isCustomPlaceholder?: boolean;
13 | fallback?: string;
14 | }) {
15 | const [status, setStatus] = useState(isCustomPlaceholder ? 'loading' : 'normal');
16 | const isLoaded = useRef(false);
17 | const isError = status === 'error';
18 |
19 | // https://github.com/react-component/image/pull/187
20 | useEffect(() => {
21 | let isCurrentSrc = true;
22 | isImageValid(src).then(isValid => {
23 | // https://github.com/ant-design/ant-design/issues/44948
24 | // If src changes, the previous setStatus should not be triggered
25 | if (!isValid && isCurrentSrc) {
26 | setStatus('error');
27 | }
28 | });
29 | return () => {
30 | isCurrentSrc = false;
31 | };
32 | }, [src]);
33 |
34 | useEffect(() => {
35 | if (isCustomPlaceholder && !isLoaded.current) {
36 | setStatus('loading');
37 | } else if (isError) {
38 | setStatus('normal');
39 | }
40 | }, [src]);
41 |
42 | const onLoad = () => {
43 | setStatus('normal');
44 | };
45 |
46 | const getImgRef = (img?: HTMLImageElement) => {
47 | isLoaded.current = false;
48 | if (status === 'loading' && img?.complete && (img.naturalWidth || img.naturalHeight)) {
49 | isLoaded.current = true;
50 | onLoad();
51 | }
52 | };
53 |
54 | const srcAndOnload = isError && fallback ? { src: fallback } : { onLoad, src };
55 |
56 | return [getImgRef, srcAndOnload, status] as const;
57 | }
58 |
--------------------------------------------------------------------------------
/src/hooks/useTouchEvent.ts:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { useEffect, useRef, useState } from 'react';
3 | import getFixScaleEleTransPosition from '../getFixScaleEleTransPosition';
4 | import type {
5 | DispatchZoomChangeFunc,
6 | TransformType,
7 | UpdateTransformFunc,
8 | } from './useImageTransform';
9 |
10 | type Point = {
11 | x: number;
12 | y: number;
13 | };
14 |
15 | type TouchPointInfoType = {
16 | point1: Point;
17 | point2: Point;
18 | eventType: string;
19 | };
20 |
21 | function getDistance(a: Point, b: Point) {
22 | const x = a.x - b.x;
23 | const y = a.y - b.y;
24 | return Math.hypot(x, y);
25 | }
26 |
27 | function getCenter(oldPoint1: Point, oldPoint2: Point, newPoint1: Point, newPoint2: Point) {
28 | // Calculate the distance each point has moved
29 | const distance1 = getDistance(oldPoint1, newPoint1);
30 | const distance2 = getDistance(oldPoint2, newPoint2);
31 |
32 | // If both distances are 0, return the original points
33 | if (distance1 === 0 && distance2 === 0) {
34 | return [oldPoint1.x, oldPoint1.y];
35 | }
36 |
37 | // Calculate the ratio of the distances
38 | const ratio = distance1 / (distance1 + distance2);
39 |
40 | // Calculate the new center point based on the ratio
41 | const x = oldPoint1.x + ratio * (oldPoint2.x - oldPoint1.x);
42 | const y = oldPoint1.y + ratio * (oldPoint2.y - oldPoint1.y);
43 |
44 | return [x, y];
45 | }
46 |
47 | export default function useTouchEvent(
48 | imgRef: React.MutableRefObject,
49 | movable: boolean,
50 | open: boolean,
51 | minScale: number,
52 | transform: TransformType,
53 | updateTransform: UpdateTransformFunc,
54 | dispatchZoomChange: DispatchZoomChangeFunc,
55 | ) {
56 | const { rotate, scale, x, y } = transform;
57 |
58 | const [isTouching, setIsTouching] = useState(false);
59 | const touchPointInfo = useRef({
60 | point1: { x: 0, y: 0 },
61 | point2: { x: 0, y: 0 },
62 | eventType: 'none',
63 | });
64 |
65 | const updateTouchPointInfo = (values: Partial) => {
66 | touchPointInfo.current = {
67 | ...touchPointInfo.current,
68 | ...values,
69 | };
70 | };
71 |
72 | const onTouchStart = (event: React.TouchEvent) => {
73 | if (!movable) return;
74 | event.stopPropagation();
75 | setIsTouching(true);
76 |
77 | const { touches = [] } = event;
78 | if (touches.length > 1) {
79 | // touch zoom
80 | updateTouchPointInfo({
81 | point1: { x: touches[0].clientX, y: touches[0].clientY },
82 | point2: { x: touches[1].clientX, y: touches[1].clientY },
83 | eventType: 'touchZoom',
84 | });
85 | } else {
86 | // touch move
87 | updateTouchPointInfo({
88 | point1: {
89 | x: touches[0].clientX - x,
90 | y: touches[0].clientY - y,
91 | },
92 | eventType: 'move',
93 | });
94 | }
95 | };
96 |
97 | const onTouchMove = (event: React.TouchEvent) => {
98 | const { touches = [] } = event;
99 | const { point1, point2, eventType } = touchPointInfo.current;
100 |
101 | if (touches.length > 1 && eventType === 'touchZoom') {
102 | // touch zoom
103 | const newPoint1 = {
104 | x: touches[0].clientX,
105 | y: touches[0].clientY,
106 | };
107 | const newPoint2 = {
108 | x: touches[1].clientX,
109 | y: touches[1].clientY,
110 | };
111 | const [centerX, centerY] = getCenter(point1, point2, newPoint1, newPoint2);
112 | const ratio = getDistance(newPoint1, newPoint2) / getDistance(point1, point2);
113 |
114 | dispatchZoomChange(ratio, 'touchZoom', centerX, centerY, true);
115 | updateTouchPointInfo({
116 | point1: newPoint1,
117 | point2: newPoint2,
118 | eventType: 'touchZoom',
119 | });
120 | } else if (eventType === 'move') {
121 | // touch move
122 | updateTransform(
123 | {
124 | x: touches[0].clientX - point1.x,
125 | y: touches[0].clientY - point1.y,
126 | },
127 | 'move',
128 | );
129 | updateTouchPointInfo({ eventType: 'move' });
130 | }
131 | };
132 |
133 | const onTouchEnd = () => {
134 | if (!open) return;
135 |
136 | if (isTouching) {
137 | setIsTouching(false);
138 | }
139 |
140 | updateTouchPointInfo({ eventType: 'none' });
141 |
142 | if (minScale > scale) {
143 | /** When the scaling ratio is less than the minimum scaling ratio, reset the scaling ratio */
144 | return updateTransform({ x: 0, y: 0, scale: minScale }, 'touchZoom');
145 | }
146 |
147 | const width = imgRef.current.offsetWidth * scale;
148 | const height = imgRef.current.offsetHeight * scale;
149 | // eslint-disable-next-line @typescript-eslint/no-shadow
150 | const { left, top } = imgRef.current.getBoundingClientRect();
151 | const isRotate = rotate % 180 !== 0;
152 |
153 | const fixState = getFixScaleEleTransPosition(
154 | isRotate ? height : width,
155 | isRotate ? width : height,
156 | left,
157 | top,
158 | );
159 |
160 | if (fixState) {
161 | updateTransform({ ...fixState }, 'dragRebound');
162 | }
163 | };
164 |
165 | useEffect(() => {
166 | const preventDefault = (e: TouchEvent) => {
167 | e.preventDefault();
168 | };
169 |
170 | if (open && movable) {
171 | window.addEventListener('touchmove', preventDefault, {
172 | passive: false,
173 | });
174 | }
175 | return () => {
176 | window.removeEventListener('touchmove', preventDefault);
177 | };
178 | }, [open, movable]);
179 |
180 | return {
181 | isTouching,
182 | onTouchStart,
183 | onTouchMove,
184 | onTouchEnd,
185 | };
186 | }
187 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import Image from './Image';
2 |
3 | export * from './Image';
4 | export default Image;
5 |
--------------------------------------------------------------------------------
/src/interface.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Used for PreviewGroup passed image data
3 | */
4 | export type ImageElementProps = Pick<
5 | React.ImgHTMLAttributes,
6 | | 'src'
7 | | 'crossOrigin'
8 | | 'decoding'
9 | | 'draggable'
10 | | 'loading'
11 | | 'referrerPolicy'
12 | | 'sizes'
13 | | 'srcSet'
14 | | 'useMap'
15 | | 'alt'
16 | >;
17 |
18 | export type PreviewImageElementProps = {
19 | data: ImageElementProps;
20 | canPreview: boolean;
21 | };
22 |
23 | export type InternalItem = PreviewImageElementProps & {
24 | id?: string;
25 | };
26 |
27 | export type RegisterImage = (id: string, data: PreviewImageElementProps) => VoidFunction;
28 |
29 | export type OnGroupPreview = (id: string, imageSrc: string, mouseX: number, mouseY: number) => void;
30 |
--------------------------------------------------------------------------------
/src/previewConfig.ts:
--------------------------------------------------------------------------------
1 | /** Scale the ratio base */
2 | export const BASE_SCALE_RATIO = 1;
3 | /** The maximum zoom ratio when the mouse zooms in, adjustable */
4 | export const WHEEL_MAX_SCALE_RATIO = 1;
5 |
--------------------------------------------------------------------------------
/src/util.ts:
--------------------------------------------------------------------------------
1 | export function isImageValid(src: string) {
2 | return new Promise(resolve => {
3 | if (!src) {
4 | resolve(false);
5 | return;
6 | }
7 | const img = document.createElement('img');
8 | img.onerror = () => resolve(false);
9 | img.onload = () => resolve(true);
10 | img.src = src;
11 | });
12 | }
13 |
14 | // ============================= Legacy =============================
15 | export function getClientSize() {
16 | const width = document.documentElement.clientWidth;
17 | const height = window.innerHeight || document.documentElement.clientHeight;
18 | return {
19 | width,
20 | height,
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/tests/__snapshots__/basic.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Basic snapshot 1`] = `
4 |
8 |

13 |
16 |
17 | `;
18 |
--------------------------------------------------------------------------------
/tests/basic.test.tsx:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/react';
2 | import React from 'react';
3 | import Image from '../src';
4 |
5 | describe('Basic', () => {
6 | it('snapshot', () => {
7 | const { asFragment } = render(
8 | ,
12 | );
13 |
14 | expect(asFragment().firstChild).toMatchSnapshot();
15 | });
16 |
17 | it('With click', () => {
18 | const onClickMock = jest.fn();
19 | const { container } = render(
20 | ,
24 | );
25 |
26 | fireEvent.click(container.querySelector('.rc-image'));
27 |
28 | expect(onClickMock).toHaveBeenCalledTimes(1);
29 | });
30 |
31 | it('With click when disable preview', () => {
32 | const onClickMock = jest.fn();
33 | const { container } = render(
34 | ,
39 | );
40 |
41 | fireEvent.click(container.querySelector('.rc-image'));
42 |
43 | expect(onClickMock).toHaveBeenCalledTimes(1);
44 | });
45 |
46 | it('className and style props should work on img element', () => {
47 | const { container } = render(
48 | ,
55 | );
56 | const img = container.querySelector('img');
57 | expect(img).toHaveClass('img');
58 | expect(img).toHaveStyle({ objectFit: 'cover' });
59 | });
60 |
61 | it('classNames.root and styles.root should work on image wrapper element', () => {
62 | const { container } = render(
63 | ,
74 | );
75 | const wrapperElement = container.firstChild;
76 | expect(wrapperElement).toHaveClass('bamboo');
77 | expect(wrapperElement).toHaveStyle({ objectFit: 'cover' });
78 | });
79 |
80 | // https://github.com/ant-design/ant-design/issues/36680
81 | it('preview mask should be hidden when image has style { display: "none" }', () => {
82 | const { container } = render(
83 | ,
90 | );
91 | const maskElement = container.querySelector('.rc-image-cover');
92 | expect(maskElement).toHaveStyle({ display: 'none' });
93 | });
94 | it('preview zIndex should pass', () => {
95 | const { baseElement } = render(
96 | ,
100 | );
101 | const operationsElement = baseElement.querySelector('.rc-image-preview');
102 | expect(operationsElement).toHaveStyle({ zIndex: 9999 });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/tests/controlled.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, fireEvent, render } from '@testing-library/react';
2 | import React from 'react';
3 | import Image from '../src';
4 |
5 | describe('Controlled', () => {
6 | beforeEach(() => {
7 | jest.useFakeTimers();
8 | });
9 |
10 | afterEach(() => {
11 | jest.useRealTimers();
12 | });
13 | it('With previewVisible', () => {
14 | const { rerender } = render(
15 | ,
19 | );
20 |
21 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
22 |
23 | rerender(
24 | ,
28 | );
29 |
30 | act(() => {
31 | jest.runAllTimers();
32 | });
33 |
34 | expect(document.querySelector('.rc-image-preview')).toBeFalsy();
35 | });
36 |
37 | it('controlled current in group', () => {
38 | const { rerender } = render(
39 |
40 |
41 |
42 |
43 | ,
44 | );
45 |
46 | fireEvent.click(document.querySelector('.first-img'));
47 |
48 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src2');
49 |
50 | rerender(
51 |
52 |
53 |
54 |
55 | ,
56 | );
57 |
58 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src3');
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/tests/fallback.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, fireEvent, render } from '@testing-library/react';
2 | import React from 'react';
3 | import Image from '../src';
4 |
5 | global.lastResolve = null;
6 |
7 | jest.mock('../src/util', () => {
8 | const { isImageValid, ...rest } = jest.requireActual('../src/util');
9 |
10 | return {
11 | ...rest,
12 | isImageValid: () =>
13 | new Promise(resolve => {
14 | global.lastResolve = resolve;
15 |
16 | setTimeout(() => {
17 | resolve(false);
18 | }, 1000);
19 | }),
20 | };
21 | });
22 |
23 | describe('Fallback', () => {
24 | beforeEach(() => {
25 | global.lastResolve = null;
26 | jest.useFakeTimers();
27 | });
28 |
29 | afterEach(() => {
30 | jest.useRealTimers();
31 | });
32 |
33 | const fallback = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
34 |
35 | it('Fallback correct', async () => {
36 | const { container } = render();
37 |
38 | await act(async () => {
39 | jest.runAllTimers();
40 | await Promise.resolve();
41 | });
42 |
43 | expect(container.querySelector('img').src).toEqual(fallback);
44 | });
45 |
46 | it('PreviewGroup Fallback correct', async () => {
47 | const { container } = render(
48 |
49 |
50 | ,
51 | );
52 |
53 | fireEvent.click(container.querySelector('.rc-image-img'));
54 |
55 | await act(async () => {
56 | jest.runAllTimers();
57 | await Promise.resolve();
58 | });
59 |
60 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', fallback);
61 | });
62 |
63 | it('should not show preview', async () => {
64 | const { container } = render();
65 |
66 | fireEvent.error(container.querySelector('.rc-image-img'));
67 | await act(async () => {
68 | jest.runAllTimers();
69 | await Promise.resolve();
70 | });
71 |
72 | expect(container.querySelector('.rc-image-mask')).toBeFalsy();
73 | });
74 |
75 | it('should change image, not error', async () => {
76 | const { container, rerender } = render(
77 | ,
82 | );
83 |
84 | rerender(
85 | ,
90 | );
91 |
92 | // New Image should pass
93 | await act(async () => {
94 | await global.lastResolve(true);
95 | });
96 |
97 | // Origin one should failed
98 | await act(async () => {
99 | jest.runAllTimers();
100 | await Promise.resolve();
101 | });
102 |
103 | expect(container.querySelector('.rc-image-img')).toHaveAttribute(
104 | 'src',
105 | 'https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*NZuwQp_vcIQAAAAAAAAAAABkARQnAQ',
106 | );
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/tests/placeholder.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, fireEvent, render } from '@testing-library/react';
2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
3 | import React from 'react';
4 | import Image from '../src';
5 |
6 | describe('Placeholder', () => {
7 | beforeEach(() => {
8 | jest.useFakeTimers();
9 | });
10 |
11 | afterEach(() => {
12 | jest.useRealTimers();
13 | });
14 |
15 | it('Default placeholder', () => {
16 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
17 | const { container } = render();
18 |
19 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy();
20 | expect(container.querySelector('.rc-image-img-placeholder')).toHaveAttribute('src', src);
21 | });
22 |
23 | it('Set correct', () => {
24 | const placeholder = 'placeholder';
25 | const { container } = render(
26 | ,
30 | );
31 | expect(container.querySelector('.rc-image-placeholder').textContent).toBe(placeholder);
32 |
33 | fireEvent.load(container.querySelector('.rc-image-img'));
34 | act(() => {
35 | jest.runAllTimers();
36 | });
37 |
38 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy();
39 | });
40 |
41 | it('Hide placeholder when load from cache', () => {
42 | const domSpy = spyElementPrototypes(HTMLImageElement, {
43 | complete: {
44 | get: () => true,
45 | },
46 | naturalWidth: {
47 | get: () => 1004,
48 | },
49 | naturalHeight: {
50 | get: () => 986,
51 | },
52 | });
53 |
54 | const { container } = render(
55 | >}
58 | />,
59 | );
60 |
61 | expect(container.querySelector('.rc-image-placeholder')).toBeFalsy();
62 |
63 | domSpy.mockRestore();
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/tests/preview.test.tsx:
--------------------------------------------------------------------------------
1 | import CloseOutlined from '@ant-design/icons/CloseOutlined';
2 | import LeftOutlined from '@ant-design/icons/LeftOutlined';
3 | import RightOutlined from '@ant-design/icons/RightOutlined';
4 | import RotateLeftOutlined from '@ant-design/icons/RotateLeftOutlined';
5 | import RotateRightOutlined from '@ant-design/icons/RotateRightOutlined';
6 | import ZoomInOutlined from '@ant-design/icons/ZoomInOutlined';
7 | import ZoomOutOutlined from '@ant-design/icons/ZoomOutOutlined';
8 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
9 | import { act, createEvent, fireEvent, render } from '@testing-library/react';
10 | import React from 'react';
11 |
12 | jest.mock('../src/Preview', () => {
13 | const MockPreview = (props: any) => {
14 | global._previewProps = props;
15 |
16 | let Preview = jest.requireActual('../src/Preview');
17 | Preview = Preview.default || Preview;
18 |
19 | return ;
20 | };
21 |
22 | return MockPreview;
23 | });
24 |
25 | import Image from '../src';
26 |
27 | describe('Preview', () => {
28 | beforeEach(() => {
29 | jest.useFakeTimers();
30 | });
31 |
32 | afterEach(() => {
33 | jest.useRealTimers();
34 | });
35 |
36 | const fireMouseEvent = (
37 | eventName: 'mouseDown' | 'mouseMove' | 'mouseUp',
38 | element: Element | Window,
39 | info: {
40 | pageX?: number;
41 | pageY?: number;
42 | button?: number;
43 | } = {},
44 | ) => {
45 | const event = createEvent[eventName](element);
46 | Object.keys(info).forEach(key => {
47 | Object.defineProperty(event, key, {
48 | get: () => info[key],
49 | });
50 | });
51 |
52 | act(() => {
53 | fireEvent(element, event);
54 | });
55 |
56 | act(() => {
57 | jest.runAllTimers();
58 | });
59 | };
60 |
61 | it('Show preview and close', () => {
62 | const onPreviewCloseMock = jest.fn();
63 | const { container } = render(
64 | ,
70 | );
71 |
72 | // Click Image
73 | fireEvent.click(container.querySelector('.rc-image'));
74 | expect(onPreviewCloseMock).toHaveBeenCalledWith(true);
75 |
76 | act(() => {
77 | jest.runAllTimers();
78 | });
79 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
80 |
81 | // Click Mask
82 | onPreviewCloseMock.mockReset();
83 | fireEvent.click(document.querySelector('.rc-image-preview-mask'));
84 | expect(onPreviewCloseMock).toHaveBeenCalledWith(false);
85 |
86 | // Click Image again
87 | fireEvent.click(container.querySelector('.rc-image'));
88 | act(() => {
89 | jest.runAllTimers();
90 | });
91 |
92 | // Click Close Button
93 | onPreviewCloseMock.mockReset();
94 | fireEvent.click(document.querySelector('.rc-image-preview-close'));
95 | expect(onPreviewCloseMock).toHaveBeenCalledWith(false);
96 | });
97 |
98 | it('Unmount', () => {
99 | const { container, unmount } = render(
100 | ,
101 | );
102 |
103 | fireEvent.click(container.querySelector('.rc-image'));
104 | act(() => {
105 | jest.runAllTimers();
106 | });
107 |
108 | expect(() => {
109 | unmount();
110 | }).not.toThrow();
111 | });
112 |
113 | it('Rotate', () => {
114 | const { container } = render(
115 | ,
116 | );
117 |
118 | fireEvent.click(container.querySelector('.rc-image'));
119 | act(() => {
120 | jest.runAllTimers();
121 | });
122 |
123 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[3]);
124 | act(() => {
125 | jest.runAllTimers();
126 | });
127 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
128 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(90deg)',
129 | });
130 |
131 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[2]);
132 | act(() => {
133 | jest.runAllTimers();
134 | });
135 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
136 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
137 | });
138 | });
139 |
140 | it('Flip', () => {
141 | const { container } = render(
142 | ,
143 | );
144 |
145 | fireEvent.click(container.querySelector('.rc-image'));
146 | act(() => {
147 | jest.runAllTimers();
148 | });
149 |
150 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[1]);
151 | act(() => {
152 | jest.runAllTimers();
153 | });
154 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
155 | transform: 'translate3d(0px, 0px, 0) scale3d(-1, 1, 1) rotate(0deg)',
156 | });
157 |
158 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[0]);
159 | act(() => {
160 | jest.runAllTimers();
161 | });
162 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
163 | transform: 'translate3d(0px, 0px, 0) scale3d(-1, -1, 1) rotate(0deg)',
164 | });
165 |
166 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[1]);
167 | act(() => {
168 | jest.runAllTimers();
169 | });
170 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
171 | transform: 'translate3d(0px, 0px, 0) scale3d(1, -1, 1) rotate(0deg)',
172 | });
173 |
174 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[0]);
175 | act(() => {
176 | jest.runAllTimers();
177 | });
178 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
179 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
180 | });
181 | });
182 |
183 | it('Scale', () => {
184 | const { container } = render(
185 | ,
186 | );
187 |
188 | fireEvent.click(container.querySelector('.rc-image'));
189 | act(() => {
190 | jest.runAllTimers();
191 | });
192 |
193 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]);
194 | act(() => {
195 | jest.runAllTimers();
196 | });
197 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
198 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)',
199 | });
200 |
201 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]);
202 | act(() => {
203 | jest.runAllTimers();
204 | });
205 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
206 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
207 | });
208 |
209 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]);
210 | act(() => {
211 | jest.runAllTimers();
212 | });
213 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
214 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)',
215 | });
216 |
217 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]);
218 | act(() => {
219 | jest.runAllTimers();
220 | });
221 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
222 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
223 | });
224 |
225 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
226 | deltaY: -50,
227 | });
228 |
229 | act(() => {
230 | jest.runAllTimers();
231 | });
232 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
233 | transform: 'translate3d(0px, 0px, 0) scale3d(1.25, 1.25, 1) rotate(0deg)',
234 | });
235 |
236 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
237 | deltaY: 50,
238 | });
239 | act(() => {
240 | jest.runAllTimers();
241 | });
242 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
243 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
244 | });
245 |
246 | for (let i = 0; i < 50; i++) {
247 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
248 | deltaY: -100,
249 | });
250 | act(() => {
251 | jest.runAllTimers();
252 | });
253 | }
254 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
255 | transform: 'translate3d(0px, 0px, 0) scale3d(50, 50, 1) rotate(0deg)',
256 | });
257 | });
258 |
259 | it('scaleStep = 1', () => {
260 | const { container } = render(
261 | ,
267 | );
268 |
269 | fireEvent.click(container.querySelector('.rc-image'));
270 | act(() => {
271 | jest.runAllTimers();
272 | });
273 |
274 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]);
275 | act(() => {
276 | jest.runAllTimers();
277 | });
278 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
279 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
280 | });
281 |
282 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]);
283 | act(() => {
284 | jest.runAllTimers();
285 | });
286 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
287 | transform: 'translate3d(-512px, -384px, 0) scale3d(2, 2, 1) rotate(0deg)',
288 | });
289 |
290 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[4]);
291 | act(() => {
292 | jest.runAllTimers();
293 | });
294 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
295 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
296 | });
297 |
298 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
299 | deltaY: -50,
300 | });
301 |
302 | act(() => {
303 | jest.runAllTimers();
304 | });
305 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
306 | transform: 'translate3d(0px, 0px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)',
307 | });
308 |
309 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
310 | deltaY: 50,
311 | });
312 | act(() => {
313 | jest.runAllTimers();
314 | });
315 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
316 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
317 | });
318 | });
319 |
320 | it('Reset scale on double click', () => {
321 | const { container } = render(
322 | ,
323 | );
324 |
325 | fireEvent.click(container.querySelector('.rc-image'));
326 | act(() => {
327 | jest.runAllTimers();
328 | });
329 |
330 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[5]);
331 | act(() => {
332 | jest.runAllTimers();
333 | });
334 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
335 | transform: 'translate3d(-256px, -192px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)',
336 | });
337 |
338 | fireEvent.dblClick(document.querySelector('.rc-image-preview-img'));
339 | act(() => {
340 | jest.runAllTimers();
341 | });
342 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
343 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
344 | });
345 | });
346 |
347 | it('Reset position on double click', () => {
348 | const { container } = render(
349 | ,
350 | );
351 |
352 | fireEvent.click(container.querySelector('.rc-image'));
353 | act(() => {
354 | jest.runAllTimers();
355 | });
356 |
357 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
358 | pageX: 0,
359 | pageY: 0,
360 | button: 0,
361 | });
362 | fireMouseEvent('mouseMove', window, {
363 | pageX: 50,
364 | pageY: 50,
365 | });
366 |
367 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
368 | transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)',
369 | });
370 |
371 | fireEvent.dblClick(document.querySelector('.rc-image-preview-img'));
372 | act(() => {
373 | jest.runAllTimers();
374 | });
375 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
376 | transform: 'translate3d(75px, 75px, 0) scale3d(1.5, 1.5, 1) rotate(0deg)',
377 | });
378 | });
379 |
380 | it('Mouse Event', () => {
381 | const clientWidthMock = jest
382 | .spyOn(document.documentElement, 'clientWidth', 'get')
383 | .mockImplementation(() => 1080);
384 |
385 | let offsetWidth = 200;
386 | let offsetHeight = 100;
387 | let left = 0;
388 | let top = 0;
389 |
390 | const imgEleMock = spyElementPrototypes(HTMLImageElement, {
391 | offsetWidth: {
392 | get: () => offsetWidth,
393 | },
394 | offsetHeight: {
395 | get: () => offsetHeight,
396 | },
397 | getBoundingClientRect: () => {
398 | return { left, top };
399 | },
400 | });
401 | const { container } = render(
402 | ,
403 | );
404 |
405 | fireEvent.click(container.querySelector('.rc-image'));
406 |
407 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
408 | pageX: 0,
409 | pageY: 0,
410 | button: 2,
411 | });
412 | expect(document.querySelector('.rc-image-preview-moving')).toBeFalsy();
413 |
414 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
415 | pageX: 0,
416 | pageY: 0,
417 | button: 0,
418 | });
419 | expect(document.querySelector('.rc-image-preview-moving')).toBeTruthy();
420 |
421 | fireMouseEvent('mouseMove', window, {
422 | pageX: 50,
423 | pageY: 50,
424 | });
425 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
426 | transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)',
427 | });
428 |
429 | fireMouseEvent('mouseUp', window);
430 |
431 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
432 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
433 | });
434 |
435 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
436 | pageX: 0,
437 | pageY: 0,
438 | button: 0,
439 | });
440 |
441 | fireMouseEvent('mouseUp', window);
442 |
443 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
444 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
445 | });
446 |
447 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
448 | pageX: 0,
449 | pageY: 0,
450 | button: 0,
451 | });
452 |
453 | fireMouseEvent('mouseMove', window, {
454 | pageX: 1,
455 | pageY: 1,
456 | });
457 |
458 | left = 100;
459 | top = 100;
460 | offsetWidth = 2000;
461 | offsetHeight = 1000;
462 | fireMouseEvent('mouseUp', window);
463 |
464 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
465 | transform: 'translate3d(460px, 116px, 0) scale3d(1, 1, 1) rotate(0deg)',
466 | });
467 |
468 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
469 | pageX: 0,
470 | pageY: 0,
471 | button: 0,
472 | });
473 |
474 | left = -200;
475 | top = -200;
476 | offsetWidth = 2000;
477 | offsetHeight = 1000;
478 |
479 | fireMouseEvent('mouseUp', window);
480 |
481 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
482 | transform: 'translate3d(460px, 116px, 0) scale3d(1, 1, 1) rotate(0deg)',
483 | });
484 |
485 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
486 | pageX: 0,
487 | pageY: 0,
488 | button: 0,
489 | });
490 |
491 | fireMouseEvent('mouseMove', window, {
492 | pageX: 1,
493 | pageY: 1,
494 | });
495 |
496 | left = -200;
497 | top = -200;
498 | offsetWidth = 1000;
499 | offsetHeight = 500;
500 |
501 | fireMouseEvent('mouseUp', window);
502 |
503 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
504 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
505 | });
506 |
507 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
508 | pageX: 0,
509 | pageY: 0,
510 | button: 0,
511 | });
512 |
513 | fireMouseEvent('mouseMove', window, {
514 | pageX: 1,
515 | pageY: 1,
516 | });
517 |
518 | left = -200;
519 | top = -200;
520 | offsetWidth = 1200;
521 | offsetHeight = 600;
522 | fireMouseEvent('mouseUp', window);
523 |
524 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
525 | transform: 'translate3d(-60px, -84px, 0) scale3d(1, 1, 1) rotate(0deg)',
526 | });
527 |
528 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
529 | pageX: 0,
530 | pageY: 0,
531 | button: 0,
532 | });
533 |
534 | fireMouseEvent('mouseMove', window, {
535 | pageX: 1,
536 | pageY: 1,
537 | });
538 |
539 | left = -200;
540 | top = -200;
541 | offsetWidth = 1000;
542 | offsetHeight = 900;
543 | fireMouseEvent('mouseUp', window);
544 |
545 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
546 | transform: 'translate3d(-40px, -66px, 0) scale3d(1, 1, 1) rotate(0deg)',
547 | });
548 |
549 | fireEvent.wheel(document.querySelector('.rc-image-preview-img'), {
550 | deltaY: -50,
551 | });
552 |
553 | fireMouseEvent('mouseDown', document.querySelector('.rc-image-preview-img'), {
554 | pageX: 0,
555 | pageY: 0,
556 | button: 0,
557 | });
558 |
559 | fireMouseEvent('mouseMove', window, {
560 | pageX: 1,
561 | pageY: 1,
562 | });
563 |
564 | fireMouseEvent('mouseUp', window);
565 |
566 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
567 | transform: 'translate3d(-85px, -65px, 0) scale3d(1.25, 1.25, 1) rotate(0deg)',
568 | });
569 |
570 | // Clear
571 | clientWidthMock.mockRestore();
572 | imgEleMock.mockRestore();
573 | jest.restoreAllMocks();
574 | });
575 |
576 | it('PreviewGroup render', () => {
577 | const { container } = render(
578 | ,
581 | rotateRight: ,
582 | zoomIn: ,
583 | zoomOut: ,
584 | close: ,
585 | left: ,
586 | right: ,
587 | }}
588 | >
589 |
593 |
597 | ,
598 | );
599 |
600 | fireEvent.click(container.querySelector('.rc-image'));
601 | act(() => {
602 | jest.runAllTimers();
603 | });
604 |
605 | expect(document.querySelectorAll('.rc-image-preview-actions-action')).toHaveLength(6);
606 | });
607 |
608 | it('preview placeholder', () => {
609 | render(
610 | ,
619 | );
620 |
621 | expect(document.querySelector('.rc-image-cover').textContent).toEqual('Bamboo Is Light');
622 | expect(document.querySelector('.rc-image-cover')).toHaveClass('bamboo');
623 | });
624 |
625 | it('previewSrc', () => {
626 | const src =
627 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png?x-oss-process=image/auto-orient,1/resize,p_10/quality,q_10';
628 | const previewSrc =
629 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
630 | const { container } = render();
631 |
632 | expect(container.querySelector('.rc-image-img')).toHaveAttribute('src', src);
633 | expect(document.querySelector('.rc-image-preview')).toBeFalsy();
634 |
635 | fireEvent.click(container.querySelector('.rc-image'));
636 | act(() => {
637 | jest.runAllTimers();
638 | });
639 |
640 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
641 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', previewSrc);
642 | });
643 |
644 | it('Customize preview props', () => {
645 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
646 | render(
647 | ,
655 | );
656 |
657 | expect(global._previewProps).toEqual(
658 | expect.objectContaining({
659 | motionName: 'abc',
660 | }),
661 | );
662 |
663 | expect(document.querySelector('.rc-image-preview-close')).toBeFalsy();
664 | });
665 |
666 | it('Customize Group preview props', () => {
667 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
668 | render(
669 |
670 |
671 | ,
672 | );
673 |
674 | expect(global._previewProps).toEqual(
675 | expect.objectContaining({
676 | motionName: 'abc',
677 | }),
678 | );
679 | });
680 |
681 | it('add rootClassName should be correct', () => {
682 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
683 | const { container } = render();
684 |
685 | expect(container.querySelector('.rc-image.custom-className')).toBeTruthy();
686 | });
687 |
688 | it('add rootClassName should be correct when open preview', () => {
689 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
690 | const previewSrc =
691 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
692 |
693 | const { container } = render(
694 | ,
695 | );
696 | expect(container.querySelector('.rc-image.custom-className .rc-image-img')).toHaveAttribute(
697 | 'src',
698 | src,
699 | );
700 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeFalsy();
701 |
702 | fireEvent.click(container.querySelector('.rc-image'));
703 | act(() => {
704 | jest.runAllTimers();
705 | });
706 |
707 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeTruthy();
708 | expect(document.querySelector('.rc-image-preview.custom-className img')).toHaveAttribute(
709 | 'src',
710 | previewSrc,
711 | );
712 | expect(document.querySelectorAll('.custom-className')).toHaveLength(2);
713 | });
714 |
715 | it('preview.rootClassName should be correct', () => {
716 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
717 | render(
718 | ,
725 | );
726 |
727 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeTruthy();
728 | });
729 |
730 | it('rootClassName on both side but classNames.root on single side', () => {
731 | const src = 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
732 | render(
733 | ,
756 | );
757 |
758 | expect(document.querySelectorAll('.both')).toHaveLength(2);
759 | expect(document.querySelectorAll('.rc-image.image-root')).toHaveLength(1);
760 | expect(document.querySelectorAll('.rc-image-preview.preview-root')).toHaveLength(1);
761 |
762 | expect(document.querySelector('.rc-image.image-root')).toHaveStyle({
763 | color: 'red',
764 | });
765 | expect(document.querySelector('.rc-image.image-root')).not.toHaveStyle({
766 | background: 'green',
767 | });
768 | expect(document.querySelector('.rc-image-preview.preview-root')).toHaveStyle({
769 | background: 'green',
770 | });
771 | expect(document.querySelector('.rc-image-preview.preview-root')).not.toHaveStyle({
772 | color: 'red',
773 | });
774 | });
775 |
776 | it('if async src set should be correct', () => {
777 | const src =
778 | 'https://gw.alipayobjects.com/mdn/rms_08e378/afts/img/A*P0S-QIRUbsUAAAAAAAAAAABkARQnAQ';
779 | const AsyncImage = ({ src: imgSrc }) => {
780 | const normalSrc =
781 | 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png';
782 | return (
783 |
784 |
785 |
786 |
787 | );
788 | };
789 |
790 | const { container, rerender } = render();
791 | rerender();
792 |
793 | fireEvent.click(container.querySelector('.rc-image'));
794 |
795 | act(() => {
796 | jest.runAllTimers();
797 | });
798 |
799 | expect(document.querySelector('.rc-image-preview img')).toHaveAttribute('src', src);
800 |
801 | expect(document.querySelector('.rc-image-preview-switch-prev')).toHaveClass(
802 | 'rc-image-preview-switch-disabled',
803 | );
804 | });
805 |
806 | it('pass img common props to previewed image', () => {
807 | const { container } = render(
808 | ,
812 | );
813 |
814 | fireEvent.click(container.querySelector('.rc-image'));
815 | act(() => {
816 | jest.runAllTimers();
817 | });
818 |
819 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute(
820 | 'referrerPolicy',
821 | 'no-referrer',
822 | );
823 | });
824 |
825 | describe('actionsRender', () => {
826 | it('single', () => {
827 | const printImage = jest.fn();
828 | const { container } = render(
829 | {
836 | printImage(image);
837 | return (
838 | <>
839 | actions.onFlipY()}>
840 | {icons.flipYIcon}
841 |
842 | actions.onFlipX()}>
843 | {icons.flipXIcon}
844 |
845 | actions.onZoomIn()}>
846 | {icons.zoomInIcon}
847 |
848 | actions.onZoomOut()}>
849 | {icons.zoomOutIcon}
850 |
851 | actions.onRotateLeft()}>
852 | {icons.rotateLeftIcon}
853 |
854 | actions.onRotateRight()}>
855 | {icons.rotateRightIcon}
856 |
857 | actions.onReset()}>
858 | reset
859 |
860 | >
861 | );
862 | },
863 | }}
864 | />,
865 | );
866 |
867 | fireEvent.click(container.querySelector('.rc-image'));
868 | act(() => {
869 | jest.runAllTimers();
870 | });
871 |
872 | expect(printImage).toHaveBeenCalledWith({
873 | alt: 'alt',
874 | height: 200,
875 | url: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png',
876 | width: 200,
877 | });
878 | // flipY
879 | fireEvent.click(document.getElementById('flipY'));
880 | act(() => {
881 | jest.runAllTimers();
882 | });
883 | fireEvent.click(document.getElementById('flipX'));
884 | act(() => {
885 | jest.runAllTimers();
886 | });
887 | fireEvent.click(document.getElementById('zoomIn'));
888 | act(() => {
889 | jest.runAllTimers();
890 | });
891 | fireEvent.click(document.getElementById('rotateLeft'));
892 | act(() => {
893 | jest.runAllTimers();
894 | });
895 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
896 | transform: 'translate3d(-256px, -192px, 0) scale3d(-1.5, -1.5, 1) rotate(-90deg)',
897 | });
898 |
899 | // reset
900 | fireEvent.click(document.getElementById('reset'));
901 | act(() => {
902 | jest.runAllTimers();
903 | });
904 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
905 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
906 | });
907 | });
908 |
909 | it('switch', () => {
910 | const onChange = jest.fn();
911 | render(
912 | {
928 | return (
929 | <>
930 | {icons.prevIcon}
931 | {icons.nextIcon}
932 | actions.onActive(-1)}>
933 | Prev
934 |
935 | actions.onActive(1)}>
936 | Next
937 |
938 | >
939 | );
940 | },
941 | onChange,
942 | }}
943 | />,
944 | );
945 |
946 | // Origin Node
947 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-prev'));
948 | expect(onChange).toHaveBeenCalledWith(0, 1);
949 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-next'));
950 | expect(onChange).toHaveBeenCalledWith(2, 1);
951 |
952 | // Customize
953 | onChange.mockReset();
954 | fireEvent.click(document.getElementById('left'));
955 | expect(onChange).toHaveBeenCalledWith(0, 1);
956 |
957 | fireEvent.click(document.getElementById('right'));
958 | expect(onChange).toHaveBeenCalledWith(2, 1);
959 | });
960 | });
961 |
962 | it('onTransform should be triggered when transform change', () => {
963 | const onTransform = jest.fn();
964 | const { container } = render(
965 | ,
969 | );
970 |
971 | fireEvent.click(container.querySelector('.rc-image'));
972 | act(() => {
973 | jest.runAllTimers();
974 | });
975 |
976 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
977 |
978 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-flipY'));
979 | act(() => {
980 | jest.runAllTimers();
981 | });
982 |
983 | expect(onTransform).toHaveBeenCalledTimes(1);
984 | expect(onTransform).toHaveBeenCalledWith({
985 | transform: {
986 | flipY: true,
987 | flipX: false,
988 | rotate: 0,
989 | scale: 1,
990 | x: 0,
991 | y: 0,
992 | },
993 | action: 'flipY',
994 | });
995 | });
996 |
997 | it('imageRender', () => {
998 | const { container } = render(
999 | (
1003 |
1008 | ),
1009 | }}
1010 | />,
1011 | );
1012 |
1013 | fireEvent.click(container.querySelector('.rc-image'));
1014 | act(() => {
1015 | jest.runAllTimers();
1016 | });
1017 |
1018 | expect(document.querySelector('video')).toBeTruthy();
1019 | });
1020 |
1021 | it('should be closed when press esc after click portal', () => {
1022 | const onOpenChange = jest.fn();
1023 | const afterOpenChange = jest.fn();
1024 | const { container } = render(
1025 | ,
1032 | );
1033 |
1034 | fireEvent.click(container.querySelector('.rc-image'));
1035 | act(() => {
1036 | jest.runAllTimers();
1037 | });
1038 |
1039 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
1040 |
1041 | expect(onOpenChange).toHaveBeenCalledWith(true);
1042 | expect(afterOpenChange).toHaveBeenCalledWith(true);
1043 |
1044 | fireEvent.click(document.querySelector('.rc-image-preview-actions'));
1045 |
1046 | fireEvent.keyDown(window, { key: 'Escape', keyCode: 27 });
1047 |
1048 | expect(onOpenChange).toHaveBeenCalledWith(false);
1049 | expect(afterOpenChange).toHaveBeenCalledWith(false);
1050 |
1051 | expect(onOpenChange).toHaveBeenCalledTimes(2);
1052 | expect(afterOpenChange).toHaveBeenCalledTimes(2);
1053 | });
1054 |
1055 | it('not modify preview image size', () => {
1056 | render(
1057 | ,
1065 | );
1066 |
1067 | act(() => {
1068 | jest.runAllTimers();
1069 | });
1070 |
1071 | const previewImg = document.querySelector('.rc-image-preview img');
1072 | expect(previewImg).not.toHaveAttribute('width');
1073 | expect(previewImg).not.toHaveAttribute('height');
1074 | });
1075 | it('support classnames and styles', () => {
1076 | const customClassnames = {
1077 | cover: 'custom-cover',
1078 | popup: {
1079 | mask: 'custom-mask',
1080 | actions: 'custom-actions',
1081 | root: 'custom-root',
1082 | },
1083 | };
1084 | const customStyles = {
1085 | cover: { color: 'red' },
1086 | popup: {
1087 | mask: { color: 'red' },
1088 | actions: { backgroundColor: 'blue' },
1089 | root: { border: '1px solid green' },
1090 | },
1091 | };
1092 | const { baseElement } = render(
1093 | ,
1103 | );
1104 |
1105 | const cover = document.querySelector('.rc-image-cover');
1106 | const mask = document.querySelector('.rc-image-preview-mask');
1107 | const actions = baseElement.querySelector('.rc-image-preview-actions');
1108 | expect(cover).toHaveClass(customClassnames.cover);
1109 | expect(cover).toHaveStyle(customStyles.cover);
1110 | expect(mask).toHaveClass(customClassnames.popup.mask);
1111 | expect(mask).toHaveStyle(customStyles.popup.mask);
1112 | expect(actions).toHaveClass(customClassnames.popup.actions);
1113 | expect(actions).toHaveStyle(customStyles.popup.actions);
1114 | expect(baseElement.querySelector('.rc-image-preview')).toHaveClass(customClassnames.popup.root);
1115 | expect(baseElement.querySelector('.rc-image-preview')).toHaveStyle(customStyles.popup.root);
1116 | });
1117 | });
1118 |
--------------------------------------------------------------------------------
/tests/previewGroup.test.tsx:
--------------------------------------------------------------------------------
1 | import KeyCode from '@rc-component/util/lib/KeyCode';
2 | import { act, fireEvent, render } from '@testing-library/react';
3 | import React from 'react';
4 | import Image from '../src';
5 |
6 | describe('PreviewGroup', () => {
7 | beforeEach(() => {
8 | jest.useFakeTimers();
9 | });
10 |
11 | afterEach(() => {
12 | jest.useRealTimers();
13 | });
14 |
15 | it('onChange should be called', () => {
16 | const onChange = jest.fn();
17 | const onOpenChange = jest.fn();
18 | const afterOpenChange = jest.fn();
19 | const { container } = render(
20 |
21 |
22 |
23 |
24 |
25 | ,
26 | );
27 |
28 | fireEvent.click(container.querySelector('.firstImg'));
29 | act(() => {
30 | jest.runAllTimers();
31 | });
32 | expect(onChange).not.toHaveBeenCalled();
33 | expect(onOpenChange).toHaveBeenCalledWith(true, { current: 0 });
34 | expect(afterOpenChange).toHaveBeenCalledWith(true);
35 |
36 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
37 | act(() => {
38 | jest.runAllTimers();
39 | });
40 | expect(onChange).toHaveBeenCalledWith(1, 0);
41 |
42 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
43 | act(() => {
44 | jest.runAllTimers();
45 | });
46 | expect(onChange).toHaveBeenCalledWith(2, 1);
47 | });
48 |
49 | it('items should works', () => {
50 | const { rerender } = render(
51 |
52 |
53 | ,
54 | );
55 |
56 | fireEvent.click(document.querySelector('.first-img'));
57 |
58 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src2');
59 |
60 | rerender(
61 |
62 |
63 | ,
64 | );
65 |
66 | fireEvent.click(document.querySelector('.first-img'));
67 |
68 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src3');
69 | });
70 |
71 | it('Mount and UnMount', () => {
72 | const { container, unmount } = render(
73 |
74 |
75 |
76 | ,
77 | );
78 |
79 | fireEvent.click(container.querySelector('.rc-image'));
80 |
81 | act(() => {
82 | jest.runAllTimers();
83 | });
84 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
85 |
86 | const previewProgressElement = document.querySelector('.rc-image-preview-progress');
87 |
88 | expect(previewProgressElement).toBeTruthy();
89 | expect(previewProgressElement.textContent).toEqual('1 / 2');
90 |
91 | expect(() => {
92 | unmount();
93 | }).not.toThrow();
94 | });
95 |
96 | it('Disable preview', () => {
97 | const { container } = render(
98 |
99 |
100 | ,
101 | );
102 |
103 | fireEvent.click(container.querySelector('.rc-image'));
104 | act(() => {
105 | jest.runAllTimers();
106 | });
107 |
108 | expect(document.querySelector('.rc-image-preview')).toBeFalsy();
109 | });
110 |
111 | it('Preview with Custom Preview Property', () => {
112 | const { container } = render(
113 | `current:${current} / total:${total}`,
116 | }}
117 | >
118 |
119 |
120 |
121 | ,
122 | );
123 |
124 | fireEvent.click(container.querySelector('.rc-image'));
125 | act(() => {
126 | jest.runAllTimers();
127 | });
128 |
129 | const previewProgressElement = document.querySelector('.rc-image-preview-progress');
130 |
131 | expect(previewProgressElement).toBeTruthy();
132 | expect(previewProgressElement.textContent).toEqual('current:1 / total:3');
133 | });
134 |
135 | it('Switch', () => {
136 | const previewProgressElementPath = '.rc-image-preview-progress';
137 | const { container } = render(
138 |
139 |
140 |
141 |
142 | ,
143 | );
144 |
145 | fireEvent.click(container.querySelector('.rc-image'));
146 | act(() => {
147 | jest.runAllTimers();
148 | });
149 |
150 | expect(
151 | document.querySelector('.rc-image-preview-switch-prev.rc-image-preview-switch-disabled'),
152 | ).toBeTruthy();
153 | expect(document.querySelector(previewProgressElementPath).textContent).toEqual('1 / 2');
154 |
155 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
156 | act(() => {
157 | jest.runAllTimers();
158 | });
159 |
160 | expect(
161 | document.querySelector('.rc-image-preview-switch-next.rc-image-preview-switch-disabled'),
162 | ).toBeTruthy();
163 | expect(document.querySelector(previewProgressElementPath).textContent).toEqual('2 / 2');
164 |
165 | fireEvent.click(document.querySelector('.rc-image-preview-switch-prev'));
166 | act(() => {
167 | jest.runAllTimers();
168 | });
169 |
170 | expect(
171 | document.querySelector('.rc-image-preview-switch-prev.rc-image-preview-switch-disabled'),
172 | ).toBeTruthy();
173 |
174 | fireEvent.keyDown(window, { keyCode: KeyCode.RIGHT });
175 | act(() => {
176 | jest.runAllTimers();
177 | });
178 |
179 | expect(
180 | document.querySelector('.rc-image-preview-switch-next.rc-image-preview-switch-disabled'),
181 | ).toBeTruthy();
182 |
183 | fireEvent.keyDown(window, { keyCode: KeyCode.LEFT });
184 | act(() => {
185 | jest.runAllTimers();
186 | });
187 |
188 | expect(
189 | document.querySelector('.rc-image-preview-switch-prev.rc-image-preview-switch-disabled'),
190 | ).toBeTruthy();
191 | });
192 |
193 | it('With Controlled', () => {
194 | const { rerender } = render(
195 |
196 |
197 | ,
198 | );
199 |
200 | expect(document.querySelector('.rc-image-preview')).toBeTruthy();
201 |
202 | rerender(
203 |
204 |
205 | ,
206 | );
207 | act(() => {
208 | jest.runAllTimers();
209 | });
210 |
211 | expect(document.querySelector('.rc-image-preview')).toBeFalsy();
212 | });
213 |
214 | it('should show error img', () => {
215 | render(
216 |
217 |
218 | ,
219 | );
220 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'errorsrc');
221 | });
222 |
223 | it('should reset transform when switch', () => {
224 | const { container } = render(
225 |
226 |
227 |
228 | ,
229 | );
230 |
231 | fireEvent.click(container.querySelector('.rc-image'));
232 | act(() => {
233 | jest.runAllTimers();
234 | });
235 |
236 | fireEvent.click(document.querySelectorAll('.rc-image-preview-actions-action')[3]);
237 | act(() => {
238 | jest.runAllTimers();
239 | });
240 |
241 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
242 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(90deg)',
243 | });
244 |
245 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
246 | act(() => {
247 | jest.runAllTimers();
248 | });
249 |
250 | expect(document.querySelector('.rc-image-preview-img')).toHaveStyle({
251 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
252 | });
253 | });
254 |
255 | it('pass img common props to previewed image', () => {
256 | const { container } = render(
257 |
258 |
259 |
260 | ,
261 | );
262 |
263 | fireEvent.click(container.querySelector('.rc-image'));
264 | act(() => {
265 | jest.runAllTimers();
266 | });
267 |
268 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute(
269 | 'referrerPolicy',
270 | 'no-referrer',
271 | );
272 |
273 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
274 | act(() => {
275 | jest.runAllTimers();
276 | });
277 |
278 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute(
279 | 'referrerPolicy',
280 | 'origin',
281 | );
282 | });
283 |
284 | it('album mode', () => {
285 | const { container } = render(
286 |
287 |
288 | ,
289 | );
290 |
291 | expect(container.querySelectorAll('.rc-image')).toHaveLength(1);
292 |
293 | fireEvent.click(container.querySelector('.rc-image'));
294 | act(() => {
295 | jest.runAllTimers();
296 | });
297 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src1');
298 |
299 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
300 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src2');
301 |
302 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
303 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src3');
304 | });
305 |
306 | it('album mode: object item', () => {
307 | const { container } = render(
308 |
321 |
322 | ,
323 | );
324 |
325 | expect(container.querySelectorAll('.rc-image')).toHaveLength(1);
326 |
327 | fireEvent.click(container.querySelector('.rc-image'));
328 | act(() => {
329 | jest.runAllTimers();
330 | });
331 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src1');
332 |
333 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
334 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src2');
335 |
336 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
337 | expect(document.querySelector('.rc-image-preview-img')).toHaveAttribute('src', 'src3');
338 | });
339 |
340 | it('should keep order', async () => {
341 | const Demo = ({ firstUrl }: { firstUrl: string }) => {
342 | return (
343 |
344 |
345 |
346 |
347 | );
348 | };
349 |
350 | const { rerender } = render();
351 |
352 | // Open preview
353 | expect(document.querySelector('.rc-image-preview-progress').textContent).toEqual('1 / 2');
354 | expect(document.querySelector('.rc-image-preview img')!.src).toEqual(
355 | 'http://first/img.png',
356 | );
357 |
358 | // Modify URL should keep order
359 | rerender();
360 |
361 | expect(document.querySelector('.rc-image-preview-progress').textContent).toEqual('1 / 2');
362 | expect(document.querySelector('.rc-image-preview img')!.src).toEqual(
363 | 'http://second/img.png',
364 | );
365 | });
366 |
367 | it('onTransform should be triggered when switch', () => {
368 | const onTransform = jest.fn();
369 | render(
370 | ,
384 | );
385 | fireEvent.click(document.querySelector('.rc-image-preview-actions-action-flipY'));
386 | act(() => {
387 | jest.runAllTimers();
388 | });
389 | expect(onTransform).toHaveBeenCalledTimes(1);
390 | expect(onTransform).toHaveBeenLastCalledWith({
391 | transform: {
392 | flipY: true,
393 | flipX: false,
394 | rotate: 0,
395 | scale: 1,
396 | x: 0,
397 | y: 0,
398 | },
399 | action: 'flipY',
400 | });
401 | fireEvent.click(document.querySelector('.rc-image-preview-switch-next'));
402 | act(() => {
403 | jest.runAllTimers();
404 | });
405 | expect(onTransform).toHaveBeenCalledTimes(2);
406 | expect(onTransform).toHaveBeenLastCalledWith({
407 | transform: {
408 | flipY: false,
409 | flipX: false,
410 | rotate: 0,
411 | scale: 1,
412 | x: 0,
413 | y: 0,
414 | },
415 | action: 'next',
416 | });
417 | });
418 |
419 | it('preview.rootClassName should be correct', () => {
420 | render(
421 | ,
432 | );
433 |
434 | expect(document.querySelector('.rc-image-preview.custom-className')).toBeTruthy();
435 | });
436 | });
437 |
--------------------------------------------------------------------------------
/tests/previewTouch.test.tsx:
--------------------------------------------------------------------------------
1 | import { act, fireEvent, render } from '@testing-library/react';
2 | import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
3 | import React from 'react';
4 | import Image from '../src';
5 |
6 | describe('Touch Events', () => {
7 | beforeEach(() => {
8 | jest.useFakeTimers();
9 | });
10 |
11 | afterEach(() => {
12 | jest.useRealTimers();
13 | });
14 |
15 | it('touch move', () => {
16 | const { container } = render(
17 | ,
18 | );
19 |
20 | fireEvent.click(container.querySelector('.rc-image'));
21 |
22 | const previewImgDom = document.querySelector('.rc-image-preview-img');
23 |
24 | fireEvent.touchStart(previewImgDom, {
25 | touches: [{ clientX: 0, clientY: 0 }],
26 | });
27 | fireEvent.touchMove(previewImgDom, {
28 | touches: [{ clientX: 50, clientY: 50 }],
29 | });
30 |
31 | act(() => {
32 | jest.runAllTimers();
33 | });
34 |
35 | expect(previewImgDom).toHaveStyle({
36 | transform: 'translate3d(50px, 50px, 0) scale3d(1, 1, 1) rotate(0deg)',
37 | // Disable transition during image movement
38 | transitionDuration: '0s',
39 | });
40 |
41 | fireEvent.touchEnd(previewImgDom);
42 |
43 | act(() => {
44 | jest.runAllTimers();
45 | });
46 |
47 | // Correct the position when the image moves out of the current window
48 | expect(previewImgDom).toHaveStyle({
49 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
50 | transitionDuration: undefined,
51 | });
52 | });
53 |
54 | it('touch zoom', () => {
55 | const { container } = render(
56 | ,
57 | );
58 |
59 | fireEvent.click(container.querySelector('.rc-image'));
60 |
61 | const previewImgDom = document.querySelector('.rc-image-preview-img');
62 |
63 | fireEvent.touchStart(previewImgDom, {
64 | touches: [
65 | { clientX: 40, clientY: 40 },
66 | { clientX: 60, clientY: 60 },
67 | ],
68 | });
69 | fireEvent.touchMove(previewImgDom, {
70 | touches: [
71 | { clientX: 30, clientY: 30 },
72 | { clientX: 70, clientY: 70 },
73 | ],
74 | });
75 |
76 | act(() => {
77 | jest.runAllTimers();
78 | });
79 |
80 | expect(previewImgDom).toHaveStyle({
81 | transform: 'translate3d(-50px, -50px, 0) scale3d(2, 2, 1) rotate(0deg)',
82 | // Disable transition during image zooming
83 | transitionDuration: '0s',
84 | });
85 |
86 | fireEvent.touchEnd(previewImgDom);
87 |
88 | expect(previewImgDom).toHaveStyle({
89 | transform: 'translate3d(-50px, -50px, 0) scale3d(2, 2, 1) rotate(0deg)',
90 | transitionDuration: undefined,
91 | });
92 | });
93 |
94 | it('Calculation of the center point during image scaling', () => {
95 | const imgEleMock = spyElementPrototypes(HTMLImageElement, {
96 | width: { get: () => 375 },
97 | height: { get: () => 368 },
98 | offsetWidth: { get: () => 375 },
99 | offsetHeight: { get: () => 368 },
100 | offsetLeft: { get: () => 0 },
101 | offsetTop: { get: () => 149 },
102 | });
103 |
104 | const { container } = render(
105 | ,
106 | );
107 |
108 | fireEvent.click(container.querySelector('.rc-image'));
109 |
110 | const previewImgDom = document.querySelector('.rc-image-preview-img');
111 |
112 | fireEvent.touchStart(previewImgDom, {
113 | touches: [
114 | { clientX: 40, clientY: 40 },
115 | { clientX: 60, clientY: 60 },
116 | ],
117 | });
118 | fireEvent.touchMove(previewImgDom, {
119 | touches: [
120 | { clientX: 10, clientY: 10 },
121 | { clientX: 70, clientY: 70 },
122 | ],
123 | });
124 |
125 | act(() => {
126 | jest.runAllTimers();
127 | });
128 |
129 | expect(previewImgDom).toHaveStyle({
130 | transform: 'translate3d(265px, 556px, 0) scale3d(3, 3, 1) rotate(0deg)',
131 | });
132 |
133 | // Cover the test when the movement distance of both points is 0
134 | fireEvent.touchMove(previewImgDom, {
135 | touches: [
136 | { clientX: 10, clientY: 10 },
137 | { clientX: 70, clientY: 70 },
138 | ],
139 | });
140 |
141 | imgEleMock.mockRestore();
142 | });
143 |
144 | it('The scale needs to be reset when the image is scaled to less than minScale', () => {
145 | const { container } = render(
146 | ,
147 | );
148 |
149 | fireEvent.click(container.querySelector('.rc-image'));
150 |
151 | const previewImgDom = document.querySelector('.rc-image-preview-img');
152 |
153 | // The scale needs to be reset when the image is scaled to less than minScale
154 | fireEvent.touchStart(previewImgDom, {
155 | touches: [
156 | { clientX: 20, clientY: 40 },
157 | { clientX: 20, clientY: 60 },
158 | ],
159 | });
160 | fireEvent.touchMove(previewImgDom, {
161 | touches: [
162 | { clientX: 20, clientY: 45 },
163 | { clientX: 20, clientY: 55 },
164 | ],
165 | });
166 |
167 | act(() => {
168 | jest.runAllTimers();
169 | });
170 |
171 | fireEvent.touchEnd(previewImgDom);
172 |
173 | act(() => {
174 | jest.runAllTimers();
175 | });
176 |
177 | expect(previewImgDom).toHaveStyle({
178 | transform: 'translate3d(0px, 0px, 0) scale3d(1, 1, 1) rotate(0deg)',
179 | });
180 | });
181 | });
182 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "moduleResolution": "node",
5 | "baseUrl": "./",
6 | "jsx": "react",
7 | "declaration": true,
8 | "skipLibCheck": true,
9 | "esModuleInterop": true,
10 | "paths": {
11 | "@/*": ["src/*"],
12 | "@@/*": [".dumi/tmp/*"],
13 | "@rc-component/image": ["src/index.ts"]
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/typings.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.css';
2 | declare module '*.less';
3 |
--------------------------------------------------------------------------------
/update-example.js:
--------------------------------------------------------------------------------
1 | /*
2 | 用于 dumi 改造使用,
3 | 可用于将 examples 的文件批量修改为 demo 引入形式,
4 | 其他项目根据具体情况使用。
5 | */
6 |
7 | const fs = require('fs');
8 | const glob = require('glob');
9 |
10 | const suffix = '.tsx';
11 |
12 | const paths = glob.sync(`./docs/examples/*${suffix}`);
13 |
14 | paths.forEach(path => {
15 | const name = path.split('/').pop().split('.')[0];
16 | fs.writeFile(
17 | `./docs/demo/${name}.md`,
18 | `## ${name}
19 |
20 |
21 | `,
22 | 'utf8',
23 | function (error) {
24 | if (error) {
25 | console.log(error);
26 | return false;
27 | }
28 | console.log(`${name} 更新成功~`);
29 | },
30 | );
31 | });
32 |
--------------------------------------------------------------------------------