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,26 @@ 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' ;
2525import useScale from './hooks/useScale' ;
2626
2727import type { TNode } from '../common' ;
2828import type { ImageViewerProps } from './ImageViewer' ;
2929import 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+
3143const 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// 旋转角度单位
194217const 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 }
0 commit comments