Skip to content

Commit 36a1ea9

Browse files
committed
feat(ImageViewer): 支持视口之外图片向中心缩放
- 重构 useScale hook,新增 ZoomOptions/ZoomResult 接口,支持基于缩放中心的位移补偿算法 - 重构 usePosition hook,暴露 setPosition 和 resetPosition 接口 - ImageModalItem 改为 forwardRef,通过 useImperativeHandle 暴露内部状态 - ImageModal 实现滚轮缩放时视口外图片向中心收敛逻辑 - 缩放操作添加 50ms 节流优化 参考: Tencent/tdesign-vue-next#6406
1 parent 16b0764 commit 36a1ea9

File tree

3 files changed

+244
-47
lines changed

3 files changed

+244
-47
lines changed

packages/components/image-viewer/ImageViewerModal.tsx

Lines changed: 110 additions & 7 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,26 @@ 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';
2525
import useScale 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+
/** ImageModalItem 暴露给父组件的接口 */
32+
export interface ImageModalItemRef {
33+
/** modal-box 容器 DOM 引用 */
34+
modalBoxRef: React.RefObject<HTMLDivElement>;
35+
/** 当前位移 */
36+
position: PositionType;
37+
/** 设置位移 */
38+
setPosition: React.Dispatch<React.SetStateAction<PositionType>>;
39+
/** 重置位移 */
40+
resetPosition: () => void;
41+
}
42+
3143
const ImageError = ({ errorText }: { errorText: string }) => {
3244
const { classPrefix } = useConfig();
3345
const { ImageErrorIcon } = useGlobalIcon({ ImageErrorIcon: TdImageErrorIcon });
@@ -55,7 +67,7 @@ interface ImageModalItemProps {
5567
}
5668

5769
// 单个弹窗实例
58-
export const ImageModalItem: React.FC<ImageModalItemProps> = ({
70+
export const ImageModalItem = React.forwardRef<ImageModalItemRef, ImageModalItemProps>(({
5971
rotateZ,
6072
scale,
6173
src,
@@ -65,11 +77,12 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
6577
imageReferrerpolicy,
6678
isSvg,
6779
innerClassName,
68-
}) => {
80+
}, ref) => {
6981
const { classPrefix } = useConfig();
7082

7183
const imgRef = useRef<HTMLImageElement>(null);
7284
const svgRef = useRef<HTMLDivElement>(null);
85+
const modalBoxRef = useRef<HTMLDivElement>(null);
7386

7487
const [loaded, setLoaded] = useState(false);
7588
const [error, setError] = useState(false);
@@ -86,10 +99,18 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
8699
if (isSvg) return svgRef;
87100
return imgRef;
88101
}, [isSvg]);
89-
const { position } = usePosition(displayRef);
102+
const { position, setPosition, resetPosition } = usePosition(displayRef);
90103
const preImgStyle = { transform: `rotateZ(${rotateZ}deg) scale(${scale})`, display: !loaded ? 'block' : 'none' };
91104
const boxStyle = { transform: `translate(${position[0]}px, ${position[1]}px) scale(${mirror}, 1)` };
92105

106+
// 暴露内部状态,供父组件在缩放时读写 position
107+
useImperativeHandle(ref, () => ({
108+
modalBoxRef,
109+
position,
110+
setPosition,
111+
resetPosition,
112+
}), [position, setPosition, resetPosition]);
113+
93114
const createSvgShadow = async (url: string) => {
94115
const response = await fetch(url);
95116
if (!response.ok) {
@@ -148,7 +169,7 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
148169

149170
return (
150171
<div className={classNames(`${classPrefix}-image-viewer__modal-pic`, innerClassName)}>
151-
<div className={`${classPrefix}-image-viewer__modal-box`} style={boxStyle}>
172+
<div ref={modalBoxRef} className={`${classPrefix}-image-viewer__modal-box`} style={boxStyle}>
152173
{error && <ImageError errorText={errorText} />}
153174
{/* 预览图 */}
154175
{!error && !!preSrc && preSrcImagePreviewUrl && (
@@ -188,7 +209,9 @@ export const ImageModalItem: React.FC<ImageModalItemProps> = ({
188209
</div>
189210
</div>
190211
);
191-
};
212+
});
213+
214+
ImageModalItem.displayName = 'ImageModalItem';
192215

193216
// 旋转角度单位
194217
const ROTATE_COUNT = 90;
@@ -445,10 +468,14 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
445468
const { scale, onZoom, onZoomOut, onResetScale } = useScale(imageScale, visible);
446469
const { mirror, onResetMirror, onMirror } = useMirror();
447470

471+
const containerRef = useRef<HTMLDivElement>(null);
472+
const imageItemRef = useRef<ImageModalItemRef>(null);
473+
448474
const onReset = useCallback(() => {
449475
onResetScale();
450476
onResetRotate();
451477
onResetMirror();
478+
imageItemRef.current?.resetPosition?.();
452479
}, [onResetMirror, onResetScale, onResetRotate]);
453480

454481
const onKeyDown = useCallback(
@@ -469,6 +496,80 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
469496
[next, onClose, prev, onZoom, onZoomOut, closeOnEscKeydown],
470497
);
471498

499+
// 检测图片是否超出视口
500+
const isImageExceedsViewport = useCallback(
501+
(container: HTMLDivElement, modalBox: HTMLDivElement): boolean => {
502+
const containerRect = container.getBoundingClientRect();
503+
const modalRect = modalBox.getBoundingClientRect();
504+
return (
505+
modalRect.left < containerRect.left ||
506+
modalRect.right > containerRect.right ||
507+
modalRect.top < containerRect.top ||
508+
modalRect.bottom > containerRect.bottom
509+
);
510+
},
511+
[],
512+
);
513+
514+
// 滚轮缩放处理
515+
const handleWheelZoom = useCallback(
516+
(e: WheelEvent) => {
517+
const isZoomingOut = e.deltaY > 0;
518+
519+
const container = containerRef.current;
520+
const modalBox = imageItemRef.current?.modalBoxRef?.current;
521+
522+
// 无视口信息时,直接缩放
523+
if (!container || !modalBox) {
524+
isZoomingOut ? onZoomOut() : onZoom();
525+
return;
526+
}
527+
528+
// 缩小且图片超出视口:以视口中心为基准,向视口中心收敛
529+
if (isZoomingOut && isImageExceedsViewport(container, modalBox)) {
530+
const currentPosition = imageItemRef.current?.position ?? [0, 0];
531+
const currentTranslate = {
532+
translateX: currentPosition[0],
533+
translateY: currentPosition[1],
534+
};
535+
536+
const result = onZoomOut({
537+
mouseOffsetX: 0,
538+
mouseOffsetY: 0,
539+
currentTranslate,
540+
});
541+
542+
if (result?.newTranslate) {
543+
imageItemRef.current?.setPosition?.([
544+
result.newTranslate.translateX,
545+
result.newTranslate.translateY,
546+
]);
547+
}
548+
} else {
549+
// 正常缩放
550+
isZoomingOut ? onZoomOut() : onZoom();
551+
}
552+
},
553+
[onZoom, onZoomOut, isImageExceedsViewport],
554+
);
555+
556+
// 滚轮事件
557+
const onWheel = useCallback(
558+
(e: WheelEvent) => {
559+
e.preventDefault();
560+
handleWheelZoom(e);
561+
},
562+
[handleWheelZoom],
563+
);
564+
565+
useEffect(() => {
566+
if (!visible) return;
567+
document.addEventListener('wheel', onWheel, { passive: false });
568+
return () => {
569+
document.removeEventListener('wheel', onWheel);
570+
};
571+
}, [visible, onWheel]);
572+
472573
useEffect(() => {
473574
document.addEventListener('keydown', onKeyDown);
474575
return () => document.removeEventListener('keydown', onKeyDown);
@@ -537,6 +638,7 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
537638

538639
return (
539640
<div
641+
ref={containerRef}
540642
className={classNames(
541643
`${classPrefix}-image-viewer-preview-image`,
542644
{
@@ -594,6 +696,7 @@ export const ImageModal: React.FC<ImageModalProps> = (props) => {
594696
/>
595697
{closeNode}
596698
<ImageModalItem
699+
ref={imageItemRef}
597700
innerClassName={innerClassName}
598701
scale={scale}
599702
rotateZ={rotateZ}

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

Lines changed: 7 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];
@@ -33,8 +33,14 @@ const usePosition = (imgRef: React.RefObject<HTMLDivElement>, options?: Position
3333
},
3434
});
3535

36+
const resetPosition = useCallback(() => {
37+
setPosition(initPosition);
38+
}, [initPosition]);
39+
3640
return {
3741
position,
42+
setPosition,
43+
resetPosition,
3844
};
3945
};
4046

0 commit comments

Comments
 (0)