Skip to content

Commit 78faf2e

Browse files
tdesign-botRSS1102
authored andcommitted
feat(ImageViewer): 支持视口之外图片向中心缩放
1 parent a0b8809 commit 78faf2e

5 files changed

Lines changed: 233 additions & 47 deletions

File tree

packages/common

packages/components/image-viewer/ImageViewerModal.tsx

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1+
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
22
import classNames from 'classnames';
33
import { isArray, isFunction } from 'lodash-es';
44
import {
@@ -20,14 +20,45 @@ import { ImageModalMini } from './ImageViewerMini';
2020
import useIconMap from './hooks/useIconMap';
2121
import useIndex from './hooks/useIndex';
2222
import useMirror from './hooks/useMirror';
23-
import usePosition from './hooks/usePosition';
23+
import usePosition, { PositionType } from './hooks/usePosition';
2424
import useRotate from './hooks/useRotate';
25-
import useScale from './hooks/useScale';
25+
import useScale, { type UseScaleOptions } from './hooks/useScale';
2626

2727
import type { TNode } from '../common';
2828
import type { ImageViewerProps } from './ImageViewer';
2929
import type { ImageInfo, ImageScale, ImageViewerScale, TdImageViewerProps } from './type';
3030

31+
/** 向中心缩放动画时长(ms) */
32+
const ZOOM_TO_CENTER_DURATION = 150;
33+
34+
/** 检测图片是否超出视口 */
35+
const isImageExceedsViewport = (container: HTMLDivElement, modalBox: HTMLDivElement): boolean => {
36+
const containerRect = container.getBoundingClientRect();
37+
const modalRect = modalBox.getBoundingClientRect();
38+
return (
39+
modalRect.left < containerRect.left ||
40+
modalRect.right > containerRect.right ||
41+
modalRect.top < containerRect.top ||
42+
modalRect.bottom > containerRect.bottom
43+
);
44+
};
45+
46+
/** ImageModalItem 暴露给父组件的接口 */
47+
export interface ImageModalItemRef {
48+
/** modal-box 容器 DOM 引用 */
49+
modalBoxRef: React.RefObject<HTMLDivElement>;
50+
/** 当前位移 */
51+
position: PositionType;
52+
/** 设置位移 */
53+
setPosition: React.Dispatch<React.SetStateAction<PositionType>>;
54+
/** 重置位移 */
55+
resetPosition: () => void;
56+
/** 是否正在拖拽 */
57+
isDragging: boolean;
58+
/** 设置是否正在缩放向中心动画 */
59+
setIsZoomingToCenter: React.Dispatch<React.SetStateAction<boolean>>;
60+
}
61+
3162
const ImageError = ({ errorText }: { errorText: string }) => {
3263
const { classPrefix } = useConfig();
3364
const { ImageErrorIcon } = useGlobalIcon({ ImageErrorIcon: TdImageErrorIcon });
@@ -55,7 +86,7 @@ interface ImageModalItemProps {
5586
}
5687

5788
// 单个弹窗实例
58-
export const ImageModalItem: React.FC<ImageModalItemProps> = ({
89+
export const ImageModalItem = React.forwardRef<ImageModalItemRef, ImageModalItemProps>(({
5990
rotateZ,
6091
scale,
6192
src,
@@ -65,14 +96,17 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
6596
imageReferrerpolicy,
6697
isSvg,
6798
innerClassName,
68-
}) => {
99+
}, ref) => {
69100
const { classPrefix } = useConfig();
70101

71102
const imgRef = useRef<HTMLImageElement>(null);
72103
const svgRef = useRef<HTMLDivElement>(null);
104+
const modalBoxRef = useRef<HTMLDivElement>(null);
73105

74106
const [loaded, setLoaded] = useState(false);
75107
const [error, setError] = useState(false);
108+
// 是否正在进行向中心缩放动画
109+
const [isZoomingToCenter, setIsZoomingToCenter] = useState(false);
76110

77111
const imgStyle = {
78112
transform: `rotateZ(${rotateZ}deg) scale(${scale})`,
@@ -86,9 +120,23 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
86120
if (isSvg) return svgRef;
87121
return imgRef;
88122
}, [isSvg]);
89-
const { position } = usePosition(displayRef);
123+
const { position, setPosition, resetPosition, isDragging } = usePosition(displayRef);
90124
const preImgStyle = { transform: `rotateZ(${rotateZ}deg) scale(${scale})`, display: !loaded ? 'block' : 'none' };
91-
const boxStyle = { transform: `translate(${position[0]}px, ${position[1]}px) scale(${mirror}, 1)` };
125+
// 只在非拖拽且正在向中心缩放时启用 transition
126+
const boxStyle: React.CSSProperties = {
127+
transform: `translate(${position[0]}px, ${position[1]}px) scale(${mirror}, 1)`,
128+
...(isZoomingToCenter && !isDragging ? { transition: `transform ${ZOOM_TO_CENTER_DURATION}ms ease-out` } : {}),
129+
};
130+
131+
// 暴露内部状态,供父组件在缩放时读写 position
132+
useImperativeHandle(ref, () => ({
133+
modalBoxRef,
134+
position,
135+
setPosition,
136+
resetPosition,
137+
isDragging,
138+
setIsZoomingToCenter,
139+
}), [position, resetPosition, isDragging]);
92140

93141
const createSvgShadow = async (url: string) => {
94142
const response = await fetch(url);
@@ -148,7 +196,7 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
148196

149197
return (
150198
<div className={classNames(`${classPrefix}-image-viewer__modal-pic`, innerClassName)}>
151-
<div className={`${classPrefix}-image-viewer__modal-box`} style={boxStyle}>
199+
<div ref={modalBoxRef} className={`${classPrefix}-image-viewer__modal-box`} style={boxStyle}>
152200
{error && <ImageError errorText={errorText} />}
153201
{/* 预览图 */}
154202
{!error && !!preSrc && preSrcImagePreviewUrl && (
@@ -188,7 +236,9 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
188236
</div>
189237
</div>
190238
);
191-
};
239+
});
240+
241+
ImageModalItem.displayName = 'ImageModalItem';
192242

193243
// 旋转角度单位
194244
const ROTATE_COUNT = 90;
@@ -442,13 +492,68 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
442492

443493
const { next, prev } = useIndex(props, images);
444494
const { rotateZ, onResetRotate, onRotate } = useRotate();
445-
const { scale, onZoom, onZoomOut, onResetScale } = useScale(imageScale, visible);
446495
const { mirror, onResetMirror, onMirror } = useMirror();
447496

497+
const containerRef = useRef<HTMLDivElement>(null);
498+
const imageItemRef = useRef<ImageModalItemRef>(null);
499+
500+
// 自定义滚轮缩放处理:超出视口时向中心收敛
501+
const handleWheelZoom = useCallback<NonNullable<UseScaleOptions['onWheelZoom']>>(
502+
(e, { onZoomOut }) => {
503+
const isZoomingOut = e.deltaY > 0;
504+
// 仅处理缩小场景
505+
if (!isZoomingOut) return false;
506+
507+
const container = containerRef.current;
508+
const modalBox = imageItemRef.current?.modalBoxRef?.current;
509+
// 图片未超出视口时,使用默认逻辑
510+
if (!container || !modalBox || !isImageExceedsViewport(container, modalBox)) {
511+
return false;
512+
}
513+
514+
// 超出视口:以视口中心为基准,向中心收敛
515+
const currentPosition = imageItemRef.current?.position ?? [0, 0];
516+
const result = onZoomOut({
517+
mouseOffsetX: 0,
518+
mouseOffsetY: 0,
519+
currentTranslate: {
520+
translateX: currentPosition[0],
521+
translateY: currentPosition[1],
522+
},
523+
});
524+
525+
if (result?.newTranslate) {
526+
// 启用向中心缩放的 transition 动画
527+
imageItemRef.current?.setIsZoomingToCenter?.(true);
528+
imageItemRef.current?.setPosition?.([result.newTranslate.translateX, result.newTranslate.translateY]);
529+
530+
// 动画结束后关闭 transition(使用 once: true 自动移除监听器)
531+
modalBox.addEventListener(
532+
'transitionend',
533+
() => imageItemRef.current?.setIsZoomingToCenter?.(false),
534+
{ once: true },
535+
);
536+
537+
// 兜底:防止 transitionend 未触发(如动画被中断)
538+
setTimeout(() => {
539+
imageItemRef.current?.setIsZoomingToCenter?.(false);
540+
}, ZOOM_TO_CENTER_DURATION + 50);
541+
}
542+
543+
return true; // 已处理,不执行默认逻辑
544+
},
545+
[],
546+
);
547+
548+
const { scale, onZoom, onZoomOut, onResetScale } = useScale(imageScale, visible, {
549+
onWheelZoom: handleWheelZoom,
550+
});
551+
448552
const onReset = useCallback(() => {
449553
onResetScale();
450554
onResetRotate();
451555
onResetMirror();
556+
imageItemRef.current?.resetPosition?.();
452557
}, [onResetMirror, onResetScale, onResetRotate]);
453558

454559
const onKeyDown = useCallback(
@@ -537,6 +642,7 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
537642

538643
return (
539644
<div
645+
ref={containerRef}
540646
className={classNames(
541647
`${classPrefix}-image-viewer-preview-image`,
542648
{
@@ -594,6 +700,7 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
594700
/>
595701
{closeNode}
596702
<ImageModalItem
703+
ref={imageItemRef}
597704
innerClassName={innerClassName}
598705
scale={scale}
599706
rotateZ={rotateZ}

packages/components/image-viewer/hooks/usePosition.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState } from 'react';
1+
import { useCallback, useRef, useState } from 'react';
22
import useMouseEvent from '../../hooks/useMouseEvent';
33

44
export type PositionType = [number, number];
@@ -11,12 +11,14 @@ const usePosition = (imgRef: React.RefObject<HTMLDivElement>, options?: Position
1111
const { initPosition = [0, 0] } = options || {};
1212

1313
const [position, setPosition] = useState<PositionType>(initPosition);
14+
const [isDragging, setIsDragging] = useState(false);
1415
const lastScreenPositionRef = useRef<{ x: number; y: number } | null>(null);
1516

1617
useMouseEvent(imgRef, {
1718
onDown: (e) => {
1819
const { screenX, screenY } = e;
1920
lastScreenPositionRef.current = { x: screenX, y: screenY };
21+
setIsDragging(true);
2022
},
2123
onMove: (e) => {
2224
if (!lastScreenPositionRef.current) return;
@@ -30,11 +32,19 @@ const usePosition = (imgRef: React.RefObject<HTMLDivElement>, options?: Position
3032
},
3133
onUp: () => {
3234
lastScreenPositionRef.current = null;
35+
setIsDragging(false);
3336
},
3437
});
3538

39+
const resetPosition = useCallback(() => {
40+
setPosition(initPosition);
41+
}, [initPosition]);
42+
3643
return {
3744
position,
45+
setPosition,
46+
resetPosition,
47+
isDragging,
3848
};
3949
};
4050

0 commit comments

Comments
 (0)