1- import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
1+ import React , { useCallback , useEffect , useImperativeHandle , useMemo , useRef , useState } from 'react' ;
22import classNames from 'classnames' ;
33import { isArray , isFunction } from 'lodash-es' ;
44import {
@@ -20,14 +20,45 @@ import { ImageModalMini } from './ImageViewerMini';
2020import useIconMap from './hooks/useIconMap' ;
2121import useIndex from './hooks/useIndex' ;
2222import useMirror from './hooks/useMirror' ;
23- import usePosition from './hooks/usePosition' ;
23+ import usePosition , { PositionType } from './hooks/usePosition' ;
2424import useRotate from './hooks/useRotate' ;
25- import useScale from './hooks/useScale' ;
25+ import useScale , { type UseScaleOptions } from './hooks/useScale' ;
2626
2727import type { TNode } from '../common' ;
2828import type { ImageViewerProps } from './ImageViewer' ;
2929import 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+
3162const 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// 旋转角度单位
194244const 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 }
0 commit comments