diff --git a/packages/common b/packages/common index b945955d28..6cb4b3674b 160000 --- a/packages/common +++ b/packages/common @@ -1 +1 @@ -Subproject commit b945955d2845c887f685148799d6b8790470cd58 +Subproject commit 6cb4b3674b305663b75b8898d1a1a868bea24f08 diff --git a/packages/components/image-viewer/ImageViewerMini.tsx b/packages/components/image-viewer/ImageViewerMini.tsx index db398f1139..bf79d4663e 100644 --- a/packages/components/image-viewer/ImageViewerMini.tsx +++ b/packages/components/image-viewer/ImageViewerMini.tsx @@ -34,10 +34,10 @@ export interface ImageModalMiniProps { prev: () => void; next: () => void; onMirror: () => void; - onZoom: () => void; + onZoomIn: () => void; onZoomOut: () => void; onReset: () => void; - onRotate: (red: number) => void; + onRotate: () => void; onClose: (context: { trigger: 'close-btn' | 'overlay' | 'esc'; e: MouseEvent | KeyboardEvent }) => void; innerClassName: TdImageViewerProps['innerClassName']; } @@ -46,6 +46,8 @@ export const ImageModalMiniContent: React.FC = (props) => { const { classPrefix } = useConfig(); return ( + // TODO: viewerScale(minWidth/minHeight)应作为 style 应用到此容器,参考 tdesign-next-vue 实现 + // 需将 viewerScale 从 TdImageViewerProps → ImageViewer → ImageModal → ImageModalMini → ImageModalMiniContent 完整透传
= (props) => { className, style, onZoomOut, - onZoom, + onZoomIn, onClose, onRotate, onMirror, @@ -90,7 +92,7 @@ export const ImageModalMini: React.FC = (props) => { tipText={tipText} currentImage={currentImage} zIndex={props.zIndex + 1} - onZoom={onZoom} + onZoomIn={onZoomIn} onZoomOut={onZoomOut} onRotate={onRotate} onMirror={onMirror} diff --git a/packages/components/image-viewer/ImageViewerModal.tsx b/packages/components/image-viewer/ImageViewerModal.tsx index c15683aec8..7efb161cc7 100644 --- a/packages/components/image-viewer/ImageViewerModal.tsx +++ b/packages/components/image-viewer/ImageViewerModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import { isArray, isFunction } from 'lodash-es'; import { @@ -7,6 +7,7 @@ import { MirrorIcon as TdMirrorIcon, RotationIcon as TdRotationIcon, } from 'tdesign-icons-react'; +import { isImageExceedsViewport } from '@tdesign/common-js/image-viewer/transform'; import { downloadImage } from '@tdesign/common-js/image-viewer/utils'; import { largeNumberToFixed } from '@tdesign/common-js/input-number/large-number'; @@ -25,9 +26,26 @@ import useScale from './hooks/useScale'; import { ImageModalMini } from './ImageViewerMini'; import type { TNode } from '../common'; +import type { PositionType } from './hooks/usePosition'; import type { ImageViewerProps } from './ImageViewer'; import type { ImageInfo, ImageScale, ImageViewerScale, TdImageViewerProps } from './type'; +/** ImageModalItem 暴露给父组件的接口 */ +export interface ImageModalItemRef { + /** modal-box 容器 DOM 引用 */ + modalBoxRef: React.RefObject; + /** 当前位移(ref,始终最新) */ + positionRef: React.RefObject; + /** 设置位移 */ + setPosition: React.Dispatch>; + /** 重置位移 */ + resetPosition: () => void; + /** 是否正在拖拽(ref,始终最新) */ + isDraggingRef: React.RefObject; + /** 启用向中心缩放动画(CSS 类名驱动) */ + enableTransition: () => void; +} + const ImageError = ({ errorText }: { errorText: string }) => { const { classPrefix } = useConfig(); const { ImageErrorIcon } = useGlobalIcon({ ImageErrorIcon: TdImageErrorIcon }); @@ -55,143 +73,193 @@ interface ImageModalItemProps { } // 单个弹窗实例 -export const ImageModalItem: React.FC = ({ - rotateZ, - scale, - src, - preSrc, - mirror, - errorText, - imageReferrerpolicy, - isSvg, - innerClassName, -}) => { - const { classPrefix } = useConfig(); - - const imgRef = useRef(null); - const svgRef = useRef(null); - - const [loaded, setLoaded] = useState(false); - const [error, setError] = useState(false); +export const ImageModalItem = React.forwardRef( + ({ rotateZ, scale, src, preSrc, mirror, errorText, imageReferrerpolicy, isSvg, innerClassName }, ref) => { + const { classPrefix } = useConfig(); + + const imgRef = useRef(null); + const svgRef = useRef(null); + const modalBoxRef = useRef(null); + + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + + const imgStyle: React.CSSProperties = { + transform: `rotateZ(${rotateZ}deg) scale(${scale})`, + display: !preSrc || loaded ? 'block' : 'none', + }; + + const { previewUrl: preSrcImagePreviewUrl } = useImagePreviewUrl(preSrc); + const { previewUrl: mainImagePreviewUrl } = useImagePreviewUrl(src); + + const displayRef = useMemo(() => { + if (isSvg) return svgRef; + return imgRef; + }, [isSvg]); + // positionRef / isDraggingRef 由 usePosition 内部维护并直接返回 + const { position, positionRef, setPosition, resetPosition, isDraggingRef } = usePosition(displayRef); + + const preImgStyle: React.CSSProperties = { + transform: `rotateZ(${rotateZ}deg) scale(${scale})`, + display: !loaded ? 'block' : 'none', + }; + const boxStyle: React.CSSProperties = { + transform: `translate(${position[0]}px, ${position[1]}px) scale(${mirror}, 1)`, + }; + + const transitioningClass = `${classPrefix}-image-viewer__modal-box--transitioning`; + const transitionEndHandlerRef = useRef<((e: TransitionEvent) => void) | null>(null); + const fallbackTimerRef = useRef>(); + + const cleanupTransition = useCallback(() => { + const modalBox = modalBoxRef.current; + if (transitionEndHandlerRef.current && modalBox) { + modalBox.removeEventListener('transitionend', transitionEndHandlerRef.current); + } + transitionEndHandlerRef.current = null; + clearTimeout(fallbackTimerRef.current); + modalBox?.classList.remove(transitioningClass); + }, [transitioningClass]); + + const enableTransition = useCallback(() => { + const modalBox = modalBoxRef.current; + if (!modalBox) return; + + modalBox.classList.add(transitioningClass); + + clearTimeout(fallbackTimerRef.current); + fallbackTimerRef.current = setTimeout(cleanupTransition, 350); + + const handleTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName !== 'transform') return; + cleanupTransition(); + }; + if (transitionEndHandlerRef.current) { + modalBox.removeEventListener('transitionend', transitionEndHandlerRef.current); + } + transitionEndHandlerRef.current = handleTransitionEnd; + modalBox.addEventListener('transitionend', handleTransitionEnd); + }, [transitioningClass, cleanupTransition]); + + useEffect(() => cleanupTransition, [cleanupTransition]); + + useImperativeHandle( + ref, + () => ({ + modalBoxRef, + positionRef, + setPosition, + resetPosition, + isDraggingRef, + enableTransition, + }), + [positionRef, setPosition, isDraggingRef, resetPosition, enableTransition], + ); - const imgStyle = { - transform: `rotateZ(${rotateZ}deg) scale(${scale})`, - display: !preSrc || loaded ? 'block' : 'none', - }; + const createSvgShadow = useCallback( + async (url: string) => { + const response = await fetch(url); + if (!response.ok) { + setError(true); + throw new Error(`Failed to fetch SVG: ${response.statusText}`); + } + + const svgText = await response.text(); + + const element = svgRef.current; + if (!element) return; + + element.innerHTML = ''; + element.classList?.add(`${classPrefix}-image-viewer__modal-image-svg`); + const shadowRoot = element.attachShadow({ mode: 'closed' }); + + const container = document.createElement('div'); + container.style.background = 'transparent'; + container.innerHTML = svgText; + shadowRoot.appendChild(container); + + const svgElement = container.querySelector('svg'); + if (svgElement) { + const svgViewBox = svgElement.getAttribute('viewBox'); + if (svgViewBox) { + const viewBoxValues = svgViewBox + .split(/[\s,]/) + .filter((v) => v) + .map(parseFloat); + + // svg viewbox x(0) and y(1) offset, width(2) and height(3) + container.style.width = `${viewBoxValues[2]}px`; + container.style.height = `${viewBoxValues[3]}px`; + } else { + const bbox = svgElement.getBBox(); + const calculatedViewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`; + svgElement.setAttribute('viewBox', calculatedViewBox); + + container.style.width = `${bbox.width}px`; + container.style.height = `${bbox.height}px`; + } + } + setLoaded(true); + }, + [classPrefix], + ); - const { previewUrl: preSrcImagePreviewUrl } = useImagePreviewUrl(preSrc); - const { previewUrl: mainImagePreviewUrl } = useImagePreviewUrl(src); - - const displayRef = useMemo(() => { - if (isSvg) return svgRef; - return imgRef; - }, [isSvg]); - const { position } = usePosition(displayRef); - const preImgStyle = { transform: `rotateZ(${rotateZ}deg) scale(${scale})`, display: !loaded ? 'block' : 'none' }; - const boxStyle = { transform: `translate(${position[0]}px, ${position[1]}px) scale(${mirror}, 1)` }; - - const createSvgShadow = async (url: string) => { - const response = await fetch(url); - if (!response.ok) { - setError(true); - throw new Error(`Failed to fetch SVG: ${response.statusText}`); - } + useEffect(() => { + setError(false); + }, [preSrcImagePreviewUrl, mainImagePreviewUrl]); - const svgText = await response.text(); - - const element = svgRef.current; - element.innerHTML = ''; - element.classList?.add(`${classPrefix}-image-viewer__modal-image-svg`); - const shadowRoot = element.attachShadow({ mode: 'closed' }); - - const container = document.createElement('div'); - container.style.background = 'transparent'; - container.innerHTML = svgText; - shadowRoot.appendChild(container); - - const svgElement = container.querySelector('svg'); - if (svgElement) { - const svgViewBox = svgElement.getAttribute('viewBox'); - if (svgViewBox) { - const viewBoxValues = svgViewBox - .split(/[\s,]/) - .filter((v) => v) - .map(parseFloat); - - // svg viewbox x(0) and y(1) offset, width(2) and height(3),eg - const svgViewBoxWidth = viewBoxValues[2]; - const svgViewBoxHeight = viewBoxValues[3]; - container.style.width = `${svgViewBoxWidth}px`; - container.style.height = `${svgViewBoxHeight}px`; - } else { - const bbox = svgElement.getBBox(); - const calculatedViewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`; - svgElement.setAttribute('viewBox', calculatedViewBox); - - container.style.width = `${bbox.width}px`; - container.style.height = `${bbox.height}px`; + useEffect(() => { + if (isSvg && mainImagePreviewUrl) { + createSvgShadow(mainImagePreviewUrl); } - } - setLoaded(true); - }; - - useEffect(() => { - setError(false); - }, [preSrcImagePreviewUrl, mainImagePreviewUrl]); + }, [isSvg, mainImagePreviewUrl, createSvgShadow]); - useEffect(() => { - if (isSvg && mainImagePreviewUrl) { - createSvgShadow(mainImagePreviewUrl); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [mainImagePreviewUrl]); - - return ( -
-
- {error && } - {/* 预览图 */} - {!error && !!preSrc && preSrcImagePreviewUrl && ( - image - )} - {/* 普通主图 */} - {!error && mainImagePreviewUrl && !isSvg && ( - setLoaded(true)} - onError={() => setError(true)} - referrerPolicy={imageReferrerpolicy} - alt="image" - draggable="false" - /> - )} - {/* SVG 主图 */} - {!error && !!mainImagePreviewUrl && isSvg && ( -
- )} + return ( +
+
+ {error && } + {/* 预览图 */} + {!error && !!preSrc && preSrcImagePreviewUrl && ( + image + )} + {/* 普通主图 */} + {!error && mainImagePreviewUrl && !isSvg && ( + setLoaded(true)} + onError={() => setError(true)} + referrerPolicy={imageReferrerpolicy} + alt="image" + draggable="false" + /> + )} + {/* SVG 主图 */} + {!error && !!mainImagePreviewUrl && isSvg && ( +
+ )} +
-
- ); -}; + ); + }, +); -// 旋转角度单位 -const ROTATE_COUNT = 90; +ImageModalItem.displayName = 'ImageModalItem'; interface ImageModalIconProps { name?: string; @@ -232,8 +300,8 @@ interface ImageViewerUtilsProps { }; zIndex: number; onMirror: () => void; - onRotate: (ROTATE_COUNT: number) => void; - onZoom: () => void; + onRotate: () => void; + onZoomIn: () => void; onZoomOut: () => void; onReset: () => void; onDownload?: TdImageViewerProps['onDownload']; @@ -246,7 +314,7 @@ export const ImageViewerUtils: React.FC = ({ zIndex, onMirror, onRotate, - onZoom, + onZoomIn, onZoomOut, onReset, onDownload, @@ -277,7 +345,7 @@ export const ImageViewerUtils: React.FC = ({ showShadow={false} zIndex={zIndex} > -
onRotate(-ROTATE_COUNT)}> +
@@ -287,7 +355,7 @@ export const ImageViewerUtils: React.FC = ({ size="medium" label={`${largeNumberToFixed(String(scale * 100))}%`} /> - + = ({ name="download" onClick={() => { if (isFunction(onDownload)) { - // 自定义图片预览下载 onDownload(currentImage.mainImage); return; } @@ -442,37 +509,87 @@ export const ImageModal: React.FC = (props) => { const { next, prev } = useIndex(props, images); const { rotateZ, onResetRotate, onRotate } = useRotate(); - const { scale, onZoom, onZoomOut, onResetScale } = useScale(imageScale, visible); const { mirror, onResetMirror, onMirror } = useMirror(); + const containerRef = useRef(null); + const imageItemRef = useRef(null); + + // handleWheel 用 ref 存储最新版本,传给 useScale 注册 passive:false 的 wheel 监听 + const handleWheelRef = useRef<(e: WheelEvent) => void>(null); + const stableHandleWheel = useCallback((e: WheelEvent) => handleWheelRef.current?.(e), []); + + const { scale, onZoomIn, onZoomOut, onResetScale } = useScale(imageScale, visible, stableHandleWheel); + + // ⚠️ 不能用 React 的 onWheel —— React 17+ 将 wheel 注册为 passive: true, + // 导致 e.preventDefault() 无效,无法阻止页面滚动。 + // 直接赋值给 ref(无需额外 useCallback,ref 赋值本身不触发重渲染) + handleWheelRef.current = (e: WheelEvent) => { + e.preventDefault(); + + const isZoomOut = e.deltaY > 0; + const container = containerRef.current; + const modalBox = imageItemRef.current?.modalBoxRef?.current; + + if (isZoomOut && container && modalBox && isImageExceedsViewport(container, modalBox)) { + const currentPosition = imageItemRef.current?.positionRef?.current ?? [0, 0]; + const result = onZoomOut({ + mouseOffsetX: 0, + mouseOffsetY: 0, + currentTranslate: { + translateX: currentPosition[0], + translateY: currentPosition[1], + }, + }); + if (result?.newTranslate) { + imageItemRef.current?.enableTransition?.(); + imageItemRef.current?.setPosition?.([result.newTranslate.translateX, result.newTranslate.translateY]); + } + } else { + isZoomOut ? onZoomOut() : onZoomIn(); + } + }; + + // imageScale 动态变化时重置缩放 + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onResetScale(); + }, [imageScale, onResetScale]); + const onReset = useCallback(() => { onResetScale(); onResetRotate(); onResetMirror(); + imageItemRef.current?.resetPosition?.(); }, [onResetMirror, onResetScale, onResetRotate]); - const onKeyDown = useCallback( - (event) => { - switch (event.key) { - case 'ArrowRight': - return next(); - case 'ArrowLeft': - return prev(); - case 'ArrowUp': - return onZoom(); - case 'ArrowDown': - return onZoomOut(); - case 'Escape': - return closeOnEscKeydown && onClose?.({ trigger: 'esc', e: event }); - } - }, - [next, onClose, prev, onZoom, onZoomOut, closeOnEscKeydown], - ); - + // 用 ref 保存最新的 handlers,避免 keydown listener 因闭包 stale 而丢失事件 + const keyHandlersRef = useRef({ next, prev, onZoomIn, onZoomOut, onClose, closeOnEscKeydown }); + keyHandlersRef.current = { next, prev, onZoomIn, onZoomOut, onClose, closeOnEscKeydown }; + + const onKeyDown = useCallback((event: KeyboardEvent) => { + const handlers = keyHandlersRef.current; + const keyActionMap: Partial void>> = { + ArrowRight: () => handlers.next(), + ArrowLeft: () => handlers.prev(), + ArrowUp: () => handlers.onZoomIn(), + ArrowDown: () => handlers.onZoomOut(), + Escape: () => + handlers.closeOnEscKeydown && + handlers.onClose?.({ trigger: 'esc', e: event as unknown as React.KeyboardEvent }), + }; + keyActionMap[event.key]?.(); + }, []); + + // 弹窗可见时绑定键盘事件,关闭后解绑 useEffect(() => { + if (!visible) return; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); - }, [onKeyDown]); + }, [visible, onKeyDown]); useEffect(() => { onReset(); @@ -514,7 +631,7 @@ export const ImageModal: React.FC = (props) => { next={next} onMirror={onMirror} onRotate={onRotate} - onZoom={onZoom} + onZoomIn={onZoomIn} onZoomOut={onZoomOut} onReset={onReset} onClose={onClose} @@ -522,8 +639,8 @@ export const ImageModal: React.FC = (props) => { ); } - // boolean控制显示,tnode直接展示 - let closeNode: TNode = closeBtn; + // boolean 控制显示,tnode 直接展示 + let closeNode: TNode = null; if (closeBtn === true) { closeNode = ( = (props) => { onClick={(e: React.MouseEvent) => onClose && onClose({ trigger: 'close-btn', e })} /> ); - } else if (isFunction(closeBtn)) closeNode = closeBtn({ onClose, onOpen }); + } else if (closeBtn !== false) { + closeNode = isFunction(closeBtn) ? closeBtn({ onClose, onOpen }) : closeBtn; + } return (
= (props) => { tipText={tipText} currentImage={currentImage} zIndex={zIndex + 1} - onZoom={onZoom} + onZoomIn={onZoomIn} onZoomOut={onZoomOut} onDownload={onDownload} onRotate={onRotate} @@ -594,6 +714,7 @@ export const ImageModal: React.FC = (props) => { /> {closeNode} 多图导航:上一张/下一张按钮快照 > toolbar-navigate-next 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+
+
+ + 2/3 +
+
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 多图导航:上一张/下一张按钮快照 > toolbar-navigate-prev 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+
+
+ + 2/3 +
+
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 点击原始大小按钮后快照 > toolbar-after-reset 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 点击放大按钮后快照 > toolbar-after-zoom-in 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 120% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 点击旋转按钮后快照 > toolbar-after-rotate 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 点击缩小按钮后快照 > toolbar-after-zoom-out 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 80% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 工具栏操作快照 > 点击镜像按钮后快照 > toolbar-after-mirror 1`] = ` + +
+ + 工具栏测试 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > DefaultTrigger 多张图片默认触发器 > image-viewer-default-trigger-multi 1`] = ` +
+
+
+ preview +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+ + + + + + + + + + 预览 + +
+
+
+`; + +exports[`ImageViewer 快照测试 > DefaultTrigger 默认触发器 > image-viewer-default-trigger 1`] = ` +
+
+
+ preview +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+ + + + + + + + + + 预览 + +
+
+
+`; + +exports[`ImageViewer 快照测试 > attach 到指定容器 > image-viewer-attach-custom-container 1`] = ` +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+
+`; + +exports[`ImageViewer 快照测试 > modeless 模式 > image-viewer-modeless 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+
+ +
+ + + + + + + +
+
+
+
+
+ image + +
+
+
+
+ +
+
+
+
+
+ +`; + +exports[`ImageViewer 快照测试 > modeless 模式切换图片 > image-viewer-modeless-second-image 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+
+ +
+ + + + + + + +
+
+
+
+
+ image + +
+
+
+
+ +
+
+
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 不可拖拽 > image-viewer-not-draggable 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 关闭按钮为 false > image-viewer-close-btn-disabled 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 单张图片默认状态 > image-viewer-single-image-default 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 多张图片默认状态 > image-viewer-multi-image-default 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+
+
+ + 1/3 +
+
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 无遮罩层 > image-viewer-no-overlay 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 自定义 imageScale 配置 > image-viewer-custom-scale-config 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+
+
+ + 1/2 +
+
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 200% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 自定义 title > image-viewer-custom-title 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+ + + + + +
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + +
+
+ 图片加载中 +
+
+
+
+
+
+
+
+
+ + 自定义标题 + + 1/2 +
+
+ + + + + +
+
+ + + + + +
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+
+ + + + + +
+
+
+ image + +
+
+
+ +`; + +exports[`ImageViewer 快照测试 > 自定义关闭按钮 > image-viewer-custom-close-btn 1`] = ` + +
+ + 预览单张图片 + +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + + + + + + +
+
+ + 100% + +
+
+ + + + + + + +
+
+
+ + + + + + + + +
+
+
+ + + + + + +
+
+
+ + ✕ + +
+
+ image + +
+
+
+ +`; diff --git a/packages/components/image-viewer/__tests__/__snapshots__/utils.test.ts.snap b/packages/components/image-viewer/__tests__/__snapshots__/utils.test.ts.snap new file mode 100644 index 0000000000..45eb55245a --- /dev/null +++ b/packages/components/image-viewer/__tests__/__snapshots__/utils.test.ts.snap @@ -0,0 +1,54 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`formatImages 快照 > ImageInfo 对象数组快照 > formatImages-image-info-array 1`] = ` +[ + { + "download": true, + "mainImage": "main1.jpg", + "thumbnail": "thumb1.jpg", + }, + { + "download": false, + "isSvg": true, + "mainImage": "main2.jpg", + "thumbnail": "main2.jpg", + }, +] +`; + +exports[`formatImages 快照 > 字符串数组快照 > formatImages-string-array 1`] = ` +[ + { + "download": true, + "mainImage": "img1.jpg", + "thumbnail": "img1.jpg", + }, + { + "download": true, + "mainImage": "img2.png", + "thumbnail": "img2.png", + }, + { + "download": true, + "mainImage": "img3.gif", + "thumbnail": "img3.gif", + }, +] +`; + +exports[`formatImages 快照 > 混合数组快照 > formatImages-mixed-array 1`] = ` +[ + { + "download": true, + "mainImage": "simple.jpg", + "thumbnail": "simple.jpg", + }, + { + "download": true, + "mainImage": "complex.jpg", + "thumbnail": "complex-thumb.jpg", + }, +] +`; + +exports[`formatImages 快照 > 空数组快照 > formatImages-empty 1`] = `[]`; diff --git a/packages/components/image-viewer/__tests__/hooks.test.tsx b/packages/components/image-viewer/__tests__/hooks.test.tsx new file mode 100644 index 0000000000..71575eda32 --- /dev/null +++ b/packages/components/image-viewer/__tests__/hooks.test.tsx @@ -0,0 +1,1028 @@ +/** + * hooks.test.tsx — ImageViewer hooks 单元测试 + * + * 测试以下 hooks: + * - useScale(含 ZoomOptions 中心缩放、节流、双指缩放) + * - useMirror + * - useRotate + * - useImageScale + * - usePosition(拖拽位移) + * - useViewerScale(弹窗尺寸配置) + * - useIndex(图片切换下标) + */ +import { DEFAULT_IMAGE_SCALE } from '@tdesign/common-js/image-viewer/transform'; +import { act, renderHook, vi } from '@test/utils'; + +import useImageScale from '../hooks/useImageScale'; +import useIndex from '../hooks/useIndex'; +import useMirror from '../hooks/useMirror'; +import usePosition from '../hooks/usePosition'; +import useRotate from '../hooks/useRotate'; +import useScale from '../hooks/useScale'; +import useViewerScale from '../hooks/useViewerScale'; + +import type React from 'react'; + +// ─── useMirror ─────────────────────────────────────────────────────────── +describe('useMirror 镜像', () => { + test('初始值为 1', () => { + const { result } = renderHook(() => useMirror()); + expect(result.current.mirror).toBe(1); + }); + + test('在 1 和 -1 之间切换', () => { + const { result } = renderHook(() => useMirror()); + + act(() => result.current.onMirror()); + expect(result.current.mirror).toBe(-1); + + act(() => result.current.onMirror()); + expect(result.current.mirror).toBe(1); + + act(() => result.current.onMirror()); + expect(result.current.mirror).toBe(-1); + }); + + test('resetMirror 恢复为 1', () => { + const { result } = renderHook(() => useMirror()); + + act(() => result.current.onMirror()); + act(() => result.current.onMirror()); + act(() => result.current.onMirror()); + expect(result.current.mirror).toBe(-1); + + act(() => result.current.onResetMirror()); + expect(result.current.mirror).toBe(1); + }); + + test('连续切换 10 次回到 1', () => { + const { result } = renderHook(() => useMirror()); + + act(() => { + for (let i = 0; i < 10; i++) { + result.current.onMirror(); + } + }); + expect(result.current.mirror).toBe(1); + }); + + test('多次 resetMirror 幂等', () => { + const { result } = renderHook(() => useMirror()); + + act(() => result.current.onMirror()); + act(() => result.current.onResetMirror()); + act(() => result.current.onResetMirror()); + act(() => result.current.onResetMirror()); + expect(result.current.mirror).toBe(1); + }); +}); + +// ─── useRotate ─────────────────────────────────────────────────────────── +describe('useRotate 旋转', () => { + test('初始值为 0', () => { + const { result } = renderHook(() => useRotate()); + expect(result.current.rotateZ).toBe(0); + }); + + test('每次旋转 -90°', () => { + const { result } = renderHook(() => useRotate()); + + act(() => result.current.onRotate()); + expect(result.current.rotateZ).toBe(-90); + + act(() => result.current.onRotate()); + expect(result.current.rotateZ).toBe(-180); + + act(() => result.current.onRotate()); + expect(result.current.rotateZ).toBe(-270); + + act(() => result.current.onRotate()); + expect(result.current.rotateZ).toBe(-360); + }); + + test('超过 360° 继续累加(5 次 = -450°)', () => { + const { result } = renderHook(() => useRotate()); + + act(() => { + for (let i = 0; i < 5; i++) { + result.current.onRotate(); + } + }); + expect(result.current.rotateZ).toBe(-450); + }); + + test('从 -270° resetRotate(最短路径到 0°)', () => { + const { result } = renderHook(() => useRotate()); + + act(() => { + for (let i = 0; i < 3; i++) { + result.current.onRotate(); + } + }); + expect(result.current.rotateZ).toBe(-270); + + act(() => result.current.onResetRotate()); + expect(result.current.rotateZ).toBe(-360); + }); + + test('从 -180° resetRotate(边界值)', () => { + const { result } = renderHook(() => useRotate()); + + act(() => { + result.current.onRotate(); + result.current.onRotate(); + }); + expect(result.current.rotateZ).toBe(-180); + + act(() => result.current.onResetRotate()); + expect(result.current.rotateZ).toBe(0); + }); + + test('从 0° resetRotate 无变化', () => { + const { result } = renderHook(() => useRotate()); + + act(() => result.current.onResetRotate()); + expect(result.current.rotateZ).toBe(0); + }); + + test('旋转过程中重置', () => { + const { result } = renderHook(() => useRotate()); + + act(() => { + result.current.onRotate(); // -90 + result.current.onRotate(); // -180 + }); + expect(result.current.rotateZ).toBe(-180); + + act(() => result.current.onResetRotate()); // → 0 + + act(() => result.current.onRotate()); // -90 + expect(result.current.rotateZ).toBe(-90); + }); +}); + +// ─── useImageScale ─────────────────────────────────────────────────────── +describe('useImageScale 缩放配置', () => { + test('无配置:返回 DEFAULT_IMAGE_SCALE 的所有字段', () => { + const { result } = renderHook(() => useImageScale()); + expect(result.current.max).toBe(DEFAULT_IMAGE_SCALE.max); + expect(result.current.min).toBe(DEFAULT_IMAGE_SCALE.min); + expect(result.current.step).toBe(DEFAULT_IMAGE_SCALE.step); + expect(result.current.defaultScale).toBe(DEFAULT_IMAGE_SCALE.defaultScale); + }); + + test('部分配置:仅覆盖指定字段', () => { + const { result } = renderHook(() => useImageScale({ max: 5 })); + expect(result.current.max).toBe(5); + expect(result.current.min).toBe(DEFAULT_IMAGE_SCALE.min); + expect(result.current.step).toBe(DEFAULT_IMAGE_SCALE.step); + expect(result.current.defaultScale).toBe(DEFAULT_IMAGE_SCALE.defaultScale); + }); + + test('defaultScale 大于 max 时截断为 max', () => { + const { result } = renderHook(() => useImageScale({ max: 3, defaultScale: 5 })); + expect(result.current.defaultScale).toBe(3); + }); + + test('defaultScale 小于 min 时截断为 min', () => { + const { result } = renderHook(() => useImageScale({ min: 2, defaultScale: 1 })); + expect(result.current.defaultScale).toBe(2); + }); + + test('defaultScale 在范围内不被截断', () => { + const { result } = renderHook(() => useImageScale({ max: 5, min: 0.1, step: 0.5, defaultScale: 2 })); + expect(result.current.defaultScale).toBe(2); + }); + + test('未传 defaultScale:使用默认值', () => { + const { result } = renderHook(() => useImageScale({ max: 3, min: 0.5, step: 0.1 })); + expect(result.current.defaultScale).toBe(DEFAULT_IMAGE_SCALE.defaultScale); + }); + + test('defaultScale 恰好等于 max 不截断', () => { + const { result } = renderHook(() => useImageScale({ max: 2, defaultScale: 2 })); + expect(result.current.defaultScale).toBe(2); + }); + + test('defaultScale 恰好等于 min 不截断', () => { + const { result } = renderHook(() => useImageScale({ min: 0.5, defaultScale: 0.5 })); + expect(result.current.defaultScale).toBe(0.5); + }); +}); + +// ─── useScale ──────────────────────────────────────────────────────────── +describe('useScale 缩放', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('默认缩放值为 1', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + expect(result.current.scale).toBe(1); + }); + + test('自定义 defaultScale', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.1, defaultScale: 1.5 }, true)); + expect(result.current.scale).toBe(1.5); + }); + + test('defaultScale 超过 max 被截断', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 5 }, true)); + expect(result.current.scale).toBe(2); + }); + + test('defaultScale 低于 min 被截断', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 1.5, step: 0.2, defaultScale: 0.5 }, true)); + expect(result.current.scale).toBe(1.5); + }); + + test('onZoomIn 放大', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + }); + expect(result.current.scale).toBeCloseTo(1.2); + }); + + test('onZoomOut 缩小', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomOut(); + }); + expect(result.current.scale).toBeCloseTo(0.8); + }); + + test('放大不超过最大值', () => { + const { result } = renderHook(() => useScale({ max: 1.5, min: 0.5, step: 0.5, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(1.5); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(1.5); + }); + + test('缩小不低于最小值', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.3, defaultScale: 0.6 }, true)); + + act(() => { + result.current.onZoomOut(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(0.5); + }); + + test('onResetScale 恢复默认缩放值', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBeCloseTo(1.4); + + act(() => result.current.onResetScale()); + expect(result.current.scale).toBe(1); + }); + + test('小步长缩放', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.05, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBeCloseTo(1.05); + }); + + test('大范围缩放值', () => { + const { result } = renderHook(() => useScale({ max: 10, min: 0.1, step: 1, defaultScale: 5 }, true)); + expect(result.current.scale).toBe(5); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(6); + + act(() => { + result.current.onZoomOut(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(5); + }); + + // ─── ZoomOptions(中心缩放)────────────────────────────────────────── + describe('ZoomOptions 中心缩放', () => { + test('onZoomIn 中心缩放(偏移为 0)', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomIn({ + mouseOffsetX: 0, + mouseOffsetY: 0, + currentTranslate: { translateX: 100, translateY: 50 }, + }); + }); + + expect(result.current.scale).toBeCloseTo(1.2); + expect(zoomResult.newTranslate).toEqual({ translateX: 120, translateY: 60 }); + }); + + test('onZoomIn 非中心偏移缩放', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomIn({ + mouseOffsetX: 100, + mouseOffsetY: 50, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + }); + + expect(result.current.scale).toBeCloseTo(1.2); + expect(zoomResult.newTranslate.translateX).toBeCloseTo(-20); + expect(zoomResult.newTranslate.translateY).toBeCloseTo(-10); + }); + + test('onZoomOut 带位移时保持缩放位移', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomOut({ + mouseOffsetX: 100, + mouseOffsetY: 100, + currentTranslate: { translateX: 50, translateY: 50 }, + }); + }); + + expect(result.current.scale).toBeCloseTo(0.8); + expect(zoomResult.newTranslate).toEqual({ translateX: 60, translateY: 60 }); + }); + + test('缺少 mouseOffset 返回空结果', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomIn({ + mouseOffsetY: 50, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + }); + expect(zoomResult.newTranslate).toBeUndefined(); + }); + + test('已达最大值时 ZoomIn 返回空结果', () => { + const { result } = renderHook(() => useScale({ max: 1.2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + vi.advanceTimersByTime(100); + }); + expect(result.current.scale).toBe(1.2); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomIn({ + mouseOffsetX: 100, + mouseOffsetY: 100, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + }); + expect(zoomResult).toEqual({}); + }); + + test('带已有位移和非零偏移的缩放', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + let zoomResult; + act(() => { + zoomResult = result.current.onZoomIn({ + mouseOffsetX: 200, + mouseOffsetY: 100, + currentTranslate: { translateX: 100, translateY: 50 }, + }); + }); + + expect(zoomResult.newTranslate.translateX).toBeCloseTo(80); + expect(zoomResult.newTranslate.translateY).toBeCloseTo(40); + }); + + test('放大到最大 → 拖出视口 → 缩小向中心收敛', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.5, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + result.current.onZoomIn(); + }); + expect(result.current.scale).toBe(2); + + let zoomResult: any; + act(() => { + zoomResult = result.current.onZoomOut({ + mouseOffsetX: 0, + mouseOffsetY: 0, + currentTranslate: { translateX: 500, translateY: 400 }, + }); + }); + + expect(result.current.scale).toBe(1.5); + expect(zoomResult.newTranslate.translateX).toBeCloseTo(375); + expect(zoomResult.newTranslate.translateY).toBeCloseTo(300); + }); + + test('放大到最大 → 拖出视口 → 多次缩小持续向中心收敛', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.5, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + result.current.onZoomIn(); + }); + expect(result.current.scale).toBe(2); + + let translate = { translateX: 600, translateY: 400 }; + act(() => { + const r = result.current.onZoomOut({ mouseOffsetX: 0, mouseOffsetY: 0, currentTranslate: translate }); + translate = r.newTranslate; + }); + expect(result.current.scale).toBe(1.5); + expect(translate.translateX).toBeCloseTo(450); + expect(translate.translateY).toBeCloseTo(300); + + act(() => { + const r = result.current.onZoomOut({ mouseOffsetX: 0, mouseOffsetY: 0, currentTranslate: translate }); + translate = r.newTranslate; + }); + expect(result.current.scale).toBe(1); + expect(translate.translateX).toBeCloseTo(300); + expect(translate.translateY).toBeCloseTo(200); + }); + }); + + // 无节流,每次调用都直接生效 + describe('快速连续缩放(无节流)', () => { + test('快速连续 onZoomIn 都生效', () => { + const { result } = renderHook(() => useScale({ max: 5, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomIn(); + result.current.onZoomIn(); + result.current.onZoomIn(); + }); + expect(result.current.scale).toBeCloseTo(1.6); + }); + + test('快速连续 onZoomOut 都生效', () => { + const { result } = renderHook(() => useScale({ max: 5, min: 0.1, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + result.current.onZoomOut(); + result.current.onZoomOut(); + result.current.onZoomOut(); + }); + expect(result.current.scale).toBeCloseTo(0.4); + }); + }); + + describe('visible=false 时不注册事件监听', () => { + test('visible=false 时 wheel 事件不触发缩放', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, false)); + + const wheelEvent = new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }); + act(() => { + document.dispatchEvent(wheelEvent); + }); + + expect(result.current.scale).toBe(1); + }); + + test('visible=true 时 wheel 事件触发回调', () => { + const onWheel = vi.fn(); + const { rerender } = renderHook( + ({ visible }) => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, visible, onWheel), + { initialProps: { visible: false } }, + ); + + const wheelEvent = new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }); + act(() => { + document.dispatchEvent(wheelEvent); + }); + expect(onWheel).not.toHaveBeenCalled(); + + rerender({ visible: true }); + act(() => { + document.dispatchEvent(wheelEvent); + }); + expect(onWheel).toHaveBeenCalledTimes(1); + }); + }); +}); + +// ─── usePosition ───────────────────────────────────────────────────────── +describe('usePosition 拖拽位移', () => { + const createHook = (initPosition?: [number, number]) => { + const ref = { current: document.createElement('div') }; + return renderHook(() => usePosition(ref as React.RefObject, { initPosition })); + }; + + test('默认位置为 [0, 0]', () => { + const { result } = createHook(); + expect(result.current.position).toEqual([0, 0]); + expect(result.current.isDragging).toBe(false); + }); + + test('自定义 initPosition', () => { + const { result } = createHook([100, 200]); + expect(result.current.position).toEqual([100, 200]); + }); + + test('resetPosition 恢复到 initPosition', () => { + const { result } = createHook([50, 80]); + + act(() => { + result.current.setPosition([300, 400]); + }); + expect(result.current.position).toEqual([300, 400]); + + act(() => { + result.current.resetPosition(); + }); + expect(result.current.position).toEqual([50, 80]); + }); + + test('默认 [0,0] 的 resetPosition', () => { + const { result } = createHook(); + + act(() => { + result.current.setPosition([123, 456]); + }); + act(() => { + result.current.resetPosition(); + }); + expect(result.current.position).toEqual([0, 0]); + }); + + test('setPosition 直接设置位置', () => { + const { result } = createHook(); + + act(() => { + result.current.setPosition([42, 99]); + }); + expect(result.current.position).toEqual([42, 99]); + }); + + test('mousedown 设置 isDragging 为 true', () => { + const divEl = document.createElement('div'); + document.body.appendChild(divEl); + const ref = { current: divEl }; + const { result } = renderHook(() => usePosition(ref as React.RefObject)); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 10, screenY: 20, button: 0 })); + }); + expect(result.current.isDragging).toBe(true); + document.body.removeChild(divEl); + }); + + test('mouseup 重置 isDragging 为 false', () => { + const divEl = document.createElement('div'); + document.body.appendChild(divEl); + const ref = { current: divEl }; + const { result } = renderHook(() => usePosition(ref as React.RefObject)); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 0, screenY: 0, button: 0 })); + }); + expect(result.current.isDragging).toBe(true); + + act(() => { + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + expect(result.current.isDragging).toBe(false); + document.body.removeChild(divEl); + }); + + test('拖拽移动位置', () => { + const divEl = document.createElement('div'); + document.body.appendChild(divEl); + const ref = { current: divEl }; + const { result } = renderHook(() => usePosition(ref as React.RefObject)); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 100, screenY: 100, button: 0 })); + }); + act(() => { + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, screenX: 150, screenY: 130 })); + }); + + expect(result.current.position[0]).toBe(50); + expect(result.current.position[1]).toBe(30); + document.body.removeChild(divEl); + }); + + test('连续拖拽累积位移', () => { + const divEl = document.createElement('div'); + document.body.appendChild(divEl); + const ref = { current: divEl }; + const { result } = renderHook(() => usePosition(ref as React.RefObject)); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 0, screenY: 0, button: 0 })); + }); + act(() => { + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, screenX: 100, screenY: 50 })); + }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + expect(result.current.position).toEqual([100, 50]); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 200, screenY: 200, button: 0 })); + }); + act(() => { + document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, screenX: 250, screenY: 230 })); + }); + act(() => { + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + expect(result.current.position).toEqual([150, 80]); + document.body.removeChild(divEl); + }); +}); + +// ─── useViewerScale ─────────────────────────────────────────────────────── +describe('useViewerScale 弹窗尺寸', () => { + test('无配置返回默认值', () => { + const { result } = renderHook(() => useViewerScale(undefined)); + expect(result.current).toEqual({ minWidth: 1000, minHeight: 1000 }); + }); + + test('空对象返回默认值', () => { + const { result } = renderHook(() => useViewerScale({})); + expect(result.current).toEqual({ minWidth: 1000, minHeight: 1000 }); + }); + + test('仅覆盖 minWidth', () => { + const { result } = renderHook(() => useViewerScale({ minWidth: 800 })); + expect(result.current).toEqual({ minWidth: 800, minHeight: 1000 }); + }); + + test('仅覆盖 minHeight', () => { + const { result } = renderHook(() => useViewerScale({ minHeight: 600 })); + expect(result.current).toEqual({ minWidth: 1000, minHeight: 600 }); + }); + + test('同时覆盖 minWidth 和 minHeight', () => { + const { result } = renderHook(() => useViewerScale({ minWidth: 500, minHeight: 400 })); + expect(result.current).toEqual({ minWidth: 500, minHeight: 400 }); + }); + + test('零值可接受', () => { + const { result } = renderHook(() => useViewerScale({ minWidth: 0, minHeight: 0 })); + expect(result.current).toEqual({ minWidth: 0, minHeight: 0 }); + }); +}); + +// ─── useScale: 双指缩放 ──────────────────────────────────────────────────── +describe('useScale 触摸事件', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const createTouchEvent = (x1: number, y1: number, x2: number, y2: number, type: string) => { + const t1 = { pageX: x1, pageY: y1 } as Touch; + const t2 = { pageX: x2, pageY: y2 } as Touch; + return new TouchEvent(type, { touches: [t1, t2], cancelable: true }); + }; + + test('双指触摸记录初始距离', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 100, 0, 'touchstart')); + }); + expect(result.current.scale).toBe(1); + }); + + test('单指触摸不触发缩放', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + const touchStart = new TouchEvent('touchstart', { + touches: [{ pageX: 0, pageY: 0 } as Touch], + cancelable: true, + }); + act(() => { + document.dispatchEvent(touchStart); + }); + expect(result.current.scale).toBe(1); + }); + + test('双指外扩(pinch out)放大', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 100, 0, 'touchstart')); + }); + + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 200, 0, 'touchmove')); + }); + + expect(result.current.scale).toBeCloseTo(1.2); + }); + + test('双指内收(pinch in)缩小', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 200, 0, 'touchstart')); + }); + + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 100, 0, 'touchmove')); + }); + + expect(result.current.scale).toBeCloseTo(0.8); + }); + + test('单指移动不触发缩放', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + const touchMove = new TouchEvent('touchmove', { + touches: [{ pageX: 100, pageY: 0 } as Touch], + cancelable: true, + }); + act(() => { + document.dispatchEvent(touchMove); + }); + expect(result.current.scale).toBe(1); + }); + + test('touchEnd 重置距离后,新 touchMove 重新计算缩放', () => { + const { result } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 100, 0, 'touchstart')); + }); + act(() => { + document.dispatchEvent(new TouchEvent('touchend', { cancelable: true })); + }); + act(() => { + document.dispatchEvent(createTouchEvent(0, 0, 200, 0, 'touchmove')); + }); + expect(result.current.scale).toBeCloseTo(1.2); + }); +}); + +// ─── useIndex ──────────────────────────────────────────────────────────── +describe('useIndex 图片切换下标', () => { + const createImages = (count: number) => Array.from({ length: count }, (_, i) => `img-${i}.jpg`); + + test('默认 index 为 0', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 0 }, createImages(3))); + expect(result.current.index).toBe(0); + }); + + test('defaultIndex 设置初始下标', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 2 }, createImages(5))); + expect(result.current.index).toBe(2); + }); + + test('next 递增 index', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 0 }, createImages(5))); + act(() => result.current.next()); + expect(result.current.index).toBe(1); + act(() => result.current.next()); + expect(result.current.index).toBe(2); + }); + + test('prev 递减 index', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 2 }, createImages(5))); + act(() => result.current.prev()); + expect(result.current.index).toBe(1); + act(() => result.current.prev()); + expect(result.current.index).toBe(0); + }); + + test('next 到最后一张不再递增', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 2 }, createImages(3))); + act(() => result.current.next()); + expect(result.current.index).toBe(2); + }); + + test('prev 到第一张不再递减', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 0 }, createImages(3))); + act(() => result.current.prev()); + expect(result.current.index).toBe(0); + }); + + test('prev 在 index=0 时不触发 onIndexChange', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 0, onIndexChange }, createImages(3))); + act(() => result.current.prev()); + expect(result.current.index).toBe(0); + expect(onIndexChange).not.toHaveBeenCalled(); + }); + + test('next 在最后一帧时不触发 onIndexChange', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 2, onIndexChange }, createImages(3))); + act(() => result.current.next()); + expect(result.current.index).toBe(2); + expect(onIndexChange).not.toHaveBeenCalled(); + }); + + test('setIndex 直接设置下标', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 0, onIndexChange }, createImages(5))); + act(() => result.current.setIndex(3, { trigger: 'current' })); + expect(result.current.index).toBe(3); + }); + + test('onIndexChange 回调在 next 时携带 trigger: next', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 0, onIndexChange }, createImages(3))); + act(() => result.current.next()); + expect(onIndexChange).toHaveBeenCalledWith(1, { trigger: 'next' }); + }); + + test('onIndexChange 回调在 prev 时携带 trigger: prev', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 2, onIndexChange }, createImages(3))); + act(() => result.current.prev()); + expect(onIndexChange).toHaveBeenCalledWith(1, { trigger: 'prev' }); + }); + + test('onIndexChange 回调在 setIndex 时携带 trigger: current', () => { + const onIndexChange = vi.fn(); + const { result } = renderHook(() => useIndex({ defaultIndex: 0, onIndexChange }, createImages(5))); + act(() => result.current.setIndex(3, { trigger: 'current' })); + expect(onIndexChange).toHaveBeenCalledWith(3, { trigger: 'current' }); + }); + + test('受控 index 模式', () => { + const { result, rerender } = renderHook( + ({ index }) => useIndex({ index, onIndexChange: vi.fn() }, createImages(5)), + { initialProps: { index: 0 } }, + ); + expect(result.current.index).toBe(0); + rerender({ index: 3 }); + expect(result.current.index).toBe(3); + }); + + test('单图列表:next/prev 不越界', () => { + const { result } = renderHook(() => useIndex({ defaultIndex: 0 }, createImages(1))); + act(() => result.current.next()); + expect(result.current.index).toBe(0); + act(() => result.current.prev()); + expect(result.current.index).toBe(0); + }); +}); + +// ─── Hooks 组合 ────────────────────────────────────────────────────────── +describe('Hooks 组合使用', () => { + test('所有 hooks 协同完成图片变换', () => { + vi.useFakeTimers(); + + const { result: mirrorResult } = renderHook(() => useMirror()); + const { result: rotateResult } = renderHook(() => useRotate()); + const { result: scaleResult } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true)); + + act(() => { + mirrorResult.current.onMirror(); + rotateResult.current.onRotate(); + rotateResult.current.onRotate(); + scaleResult.current.onZoomIn(); + }); + + expect(mirrorResult.current.mirror).toBe(-1); + expect(rotateResult.current.rotateZ).toBe(-180); + expect(scaleResult.current.scale).toBeCloseTo(1.2); + + act(() => { + mirrorResult.current.onResetMirror(); + rotateResult.current.onResetRotate(); + scaleResult.current.onResetScale(); + }); + + expect(mirrorResult.current.mirror).toBe(1); + expect(rotateResult.current.rotateZ).toBe(0); + expect(scaleResult.current.scale).toBe(1); + + vi.useRealTimers(); + }); + + test('跨 hooks 快速连续操作', () => { + vi.useFakeTimers(); + + const { result: scaleResult } = renderHook(() => useScale({ max: 3, min: 0.5, step: 0.1, defaultScale: 1 }, true)); + const { result: mirrorResult } = renderHook(() => useMirror()); + const { result: rotateResult } = renderHook(() => useRotate()); + + act(() => { + for (let i = 0; i < 5; i++) { + scaleResult.current.onZoomIn(); + vi.advanceTimersByTime(60); + mirrorResult.current.onMirror(); + rotateResult.current.onRotate(); + } + }); + + expect(scaleResult.current.scale).toBeCloseTo(1.5); + expect(mirrorResult.current.mirror).toBe(-1); + expect(rotateResult.current.rotateZ).toBe(-450); + + vi.useRealTimers(); + }); +}); + +// ─── usePosition 右键和非左键不触发拖拽 ────────────────────────────────── +describe('usePosition 右键和非左键', () => { + test('右键不触发拖拽', () => { + const divEl = document.createElement('div'); + document.body.appendChild(divEl); + const ref = { current: divEl }; + const { result } = renderHook(() => usePosition(ref as React.RefObject)); + + act(() => { + divEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, screenX: 10, screenY: 20, button: 2 })); + }); + expect(result.current.isDragging).toBe(false); + document.body.removeChild(divEl); + }); +}); + +// ─── useScale 已达极限值时返回空 ────────────────────────────────────────── +describe('useScale 极限值', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('已达最小值时 onZoomOut 返回空', () => { + const { result } = renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 0.5 }, true)); + const ret = result.current.onZoomOut(); + expect(ret).toEqual({}); + expect(result.current.scale).toBe(0.5); + }); + + test('已达最大值时 onZoomIn 不带 options 返回空', () => { + const { result } = renderHook(() => useScale({ max: 1.2, min: 0.5, step: 0.2, defaultScale: 1.2 }, true)); + const ret = result.current.onZoomIn(); + expect(ret).toEqual({}); + expect(result.current.scale).toBe(1.2); + }); +}); + +// ─── useScale onWheel 回调 ───────────────────────────────────────────── +describe('useScale onWheel 回调', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + test('visible=true 时 wheel 事件调用 onWheel 回调', () => { + const onWheel = vi.fn(); + renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, true, onWheel)); + + const wheelEvent = new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }); + act(() => { + document.dispatchEvent(wheelEvent); + }); + expect(onWheel).toHaveBeenCalledTimes(1); + }); + + test('visible=false 时 wheel 事件不调用 onWheel', () => { + const onWheel = vi.fn(); + renderHook(() => useScale({ max: 2, min: 0.5, step: 0.2, defaultScale: 1 }, false, onWheel)); + + const wheelEvent = new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }); + act(() => { + document.dispatchEvent(wheelEvent); + }); + expect(onWheel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/components/image-viewer/__tests__/image-viewer.test.tsx b/packages/components/image-viewer/__tests__/image-viewer.test.tsx index 858b351dc0..3752a9476a 100644 --- a/packages/components/image-viewer/__tests__/image-viewer.test.tsx +++ b/packages/components/image-viewer/__tests__/image-viewer.test.tsx @@ -6,12 +6,12 @@ import { ImageViewer } from '../index'; const imgUrl = 'https://tdesign.gtimg.com/demo/demo-image-1.png'; const imgUrl2 = 'https://tdesign.gtimg.com/demo/demo-image-2.png'; -// const errorImgUrl = 'https://tdesixxxxxxxxgn.gtimg.com/demo/demo-image-1.png'; +const imgUrl3 = 'https://tdesign.gtimg.com/demo/demo-image-3.png'; -describe('ImageViewer', () => { +describe('ImageViewer 组件测试', () => { const triggerText = '预览单张图片'; - test('base', async () => { + test('基本渲染和关闭', async () => { const onClose = vi.fn(); const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; @@ -19,31 +19,26 @@ describe('ImageViewer', () => { }; const { getByText } = render(); - // 点击前,没有元素存在 const imgContainer = document.querySelector('.t-image-viewer-preview-image'); expect(imgContainer).toBeNull(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); - // 鼠标点击后,有元素 expect(onClose).toHaveBeenCalledTimes(0); const imgModal = document.querySelector('.t-image-viewer__modal-pic'); expect(imgModal).toBeTruthy(); - // 模拟鼠标点击关闭 const closeBtn = document.querySelector('.t-image-viewer__modal-close-bt'); act(() => { fireEvent.click(closeBtn); }); - // 点击后,没有元素存在 expect(onClose).toHaveBeenCalledTimes(1); await mockTimeout(() => expect(document.querySelector('.t-image-viewer-preview-image')).toBeNull()); }); - test('base:trigger is not Fn', async () => { + test('trigger 为非函数', async () => { const BasicImageViewer = () => { const trigger = {triggerText}; return ; @@ -52,55 +47,49 @@ describe('ImageViewer', () => { expect(getByText(triggerText)).toBeTruthy(); }); - test('base:attach is default=body', async () => { + test('attach 默认为 body', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return ; }; const { getByText } = render(); - // 点击前,没有元素存在 const imgContainer = document.body.querySelector('.t-image-viewer-preview-image'); expect(imgContainer).toBeNull(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); - // 鼠标点击后,有元素 const imgModal = document.body.querySelector('.t-image-viewer__modal-pic'); expect(imgModal).toBeTruthy(); }); - test('base:attach is function', async () => { + test('attach 为函数', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return document.body} />; }; const { getByText } = render(); - // 点击前,没有元素存在 const imgContainer = document.body.querySelector('.t-image-viewer-preview-image'); expect(imgContainer).toBeNull(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); act(() => { - // 鼠标点击后,有元素 const imgModal = document.body.querySelector('.t-image-viewer__modal-pic'); expect(imgModal).toBeTruthy(); }); }); }); -describe('ImageViewerMini', () => { +describe('ImageViewerMini 组件测试', () => { const triggerText = '预览单张图片'; - test('modeless', async () => { + test('modeless 模式', async () => { const onClose = vi.fn(); const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; @@ -108,16 +97,13 @@ describe('ImageViewerMini', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); - // 鼠标点击后,有 mini 元素 const miniFooter = await waitFor(() => document.querySelector('.t-image-viewer-mini__footer')); expect(miniFooter).toBeTruthy(); - // 模拟鼠标点击关闭 const closeBtn = await waitFor(() => document.querySelector('.t-icon-close')); act(() => { fireEvent.click(closeBtn); @@ -126,9 +112,10 @@ describe('ImageViewerMini', () => { }); }); -describe('ImageViewerModal', () => { +describe('ImageViewerModal 组件测试', () => { const triggerText = '预览单张图片'; - test('base', async () => { + + test('键盘操作和遮罩关闭', async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onIndexChange = vi.fn(); @@ -146,22 +133,22 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); - // 模拟键盘事件 - await user.type(document.body, '{Escape}'); - expect(onClose).toHaveBeenCalledTimes(1); - await user.type(document.body, '{ArrowRight}'); expect(onIndexChange).toHaveBeenCalledTimes(1); await user.type(document.body, '{ArrowLeft}'); expect(onIndexChange).toHaveBeenCalledTimes(2); - // 鼠标点击遮罩 + await user.type(document.body, '{Escape}'); + expect(onClose).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); const mask = await waitFor(() => document.querySelector('.t-image-viewer__modal-mask')); act(() => { fireEvent.click(mask); @@ -169,7 +156,29 @@ describe('ImageViewerModal', () => { expect(onClose).toHaveBeenCalledTimes(2); }); - test('single', async () => { + test('弹窗关闭后键盘事件失效', async () => { + const user = userEvent.setup(); + const onIndexChange = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await user.type(document.body, '{ArrowRight}'); + expect(onIndexChange).toHaveBeenCalledTimes(1); + + await user.type(document.body, '{Escape}'); + + await user.type(document.body, '{ArrowRight}'); + await user.type(document.body, '{ArrowLeft}'); + expect(onIndexChange).toHaveBeenCalledTimes(1); + }); + + test('单图操作(缩放/旋转/重置)', async () => { const user = userEvent.setup(); const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; @@ -177,7 +186,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -186,41 +194,38 @@ describe('ImageViewerModal', () => { expect(getComputedStyle(img).transform).toBe('rotateZ(0deg) scale(1)'); await user.type(document.body, '{ArrowUp}'); - expect(getComputedStyle(img).transform).toBe('rotateZ(0deg) scale(1.5)'); + expect(getComputedStyle(img).transform).toBe('rotateZ(0deg) scale(1.2)'); await user.type(document.body, '{ArrowDown}'); expect(getComputedStyle(img).transform).toBe('rotateZ(0deg) scale(1)'); const rotateIcon = await waitFor(() => document.querySelector('.t-icon-rotation')); - // 模拟鼠标点击 act(() => { fireEvent.click(rotateIcon); }); expect(getComputedStyle(img).transform).toBe('rotateZ(-90deg) scale(1)'); const resetIcon = await waitFor(() => document.querySelector('.t-icon-image')); - // 模拟鼠标点击 act(() => { fireEvent.click(resetIcon); }); expect(getComputedStyle(img).transform).toBe('rotateZ(0deg) scale(1)'); }); - test('closeBtn', async () => { + test('自定义关闭按钮', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; - return closeBtn} />; + return closeBtn} />; }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); expect(getByText('closeBtn')).toBeTruthy(); }); - test('closeOnEscKeydown is false', async () => { + test('closeOnEscKeydown 为 false', async () => { const user = userEvent.setup(); const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; @@ -228,19 +233,17 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); expect(document.querySelector('.t-image-viewer-preview-image')).toBeInTheDocument(); - // 模拟键盘事件 await user.type(document.body, '{Escape}'); await mockDelay(300); expect(document.querySelector('.t-image-viewer-preview-image')).toBeInTheDocument(); }); - test('imageScale defaultScale', async () => { + test('imageScale defaultScale 正常范围', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return ( @@ -258,7 +261,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -271,7 +273,7 @@ describe('ImageViewerModal', () => { }); }); - test('imageScale defaultScale is larger than max', async () => { + test('imageScale defaultScale 超过 max 被截断', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return ( @@ -289,7 +291,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -302,7 +303,7 @@ describe('ImageViewerModal', () => { }); }); - test('imageScale defaultScale is smaller than min', async () => { + test('imageScale defaultScale 低于 min 被截断', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return ( @@ -320,7 +321,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -333,7 +333,7 @@ describe('ImageViewerModal', () => { }); }); - test('imageScale max is unexpectedly smaller than min', async () => { + test('imageScale max 意外小于 min 时使用 min', async () => { const BasicImageViewer = () => { const trigger = ({ open }) => open()}>{triggerText}; return ( @@ -351,7 +351,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -364,7 +363,7 @@ describe('ImageViewerModal', () => { }); }); - test('imageReferrerpolicy', async () => { + test('imageReferrerpolicy 属性', async () => { const referrerPolicy = 'strict-origin-when-cross-origin'; const BasicImageViewer = () => { @@ -373,7 +372,6 @@ describe('ImageViewerModal', () => { }; const { getByText } = render(); - // 模拟鼠标点击 act(() => { fireEvent.click(getByText(triggerText)); }); @@ -383,3 +381,1147 @@ describe('ImageViewerModal', () => { expect(document.querySelector('.t-image-viewer__modal-image')?.getAttribute('referrerpolicy')).toBe(referrerPolicy); }); }); + +// ─── 快照测试 ───────────────────────────────────────────────────────────────── +describe('ImageViewer 快照测试', () => { + const triggerText = '预览单张图片'; + + test('单张图片默认状态', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-single-image-default'); + }); + + test('多张图片默认状态', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-multi-image-default'); + }); + + test('自定义关闭按钮', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ✕} />; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-custom-close-btn'); + }); + + test('关闭按钮为 false', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-close-btn-disabled'); + }); + + test('无遮罩层', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-no-overlay'); + }); + + test('不可拖拽', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-not-draggable'); + }); + + test('自定义 imageScale 配置', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ( + + ); + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-custom-scale-config'); + }); + + test('modeless 模式', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-modeless'); + }); + + test('modeless 模式切换图片', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + const user = userEvent.setup(); + await user.type(document.body, '{ArrowRight}'); + await mockDelay(); + + expect(document.body).toMatchSnapshot('image-viewer-modeless-second-image'); + }); + + test('自定义 title', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(document.body).toMatchSnapshot('image-viewer-custom-title'); + }); + + test('attach 到指定容器', async () => { + const customContainer = document.createElement('div'); + customContainer.id = 'custom-attach-container'; + document.body.appendChild(customContainer); + + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return customContainer} />; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + + await mockDelay(); + expect(customContainer).toMatchSnapshot('image-viewer-attach-custom-container'); + + document.body.removeChild(customContainer); + }); + + test('DefaultTrigger 默认触发器', async () => { + const { container } = render(); + await mockDelay(); + expect(container).toMatchSnapshot('image-viewer-default-trigger'); + }); + + test('DefaultTrigger 多张图片默认触发器', async () => { + const { container } = render(); + await mockDelay(); + expect(container).toMatchSnapshot('image-viewer-default-trigger-multi'); + }); +}); + +describe('ImageViewerModal 滚轮向中心缩放', () => { + const fireWheelEvent = (deltaY: number) => { + act(() => { + document.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })); + }); + }; + + const parseTranslate = (style: string) => { + const match = style.match(/translate\(([-\d.]+)px,\s*([-\d.]+)px\)/); + return match ? { x: parseFloat(match[1]), y: parseFloat(match[2]) } : { x: 0, y: 0 }; + }; + + test('图片超出视口时缩小触发向中心路径', async () => { + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + const triggerText = '预览图片'; + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await mockDelay(); + + const modalBox = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + expect(modalBox).toBeTruthy(); + + fireWheelEvent(-120); + fireWheelEvent(-120); + await mockDelay(); + + const scaleText = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleText).toBe('200%'); + + exceedsSpy.mockReturnValue(true); + exceedsSpy.mockClear(); + + fireWheelEvent(120); + await mockDelay(); + + expect(exceedsSpy).toHaveBeenCalled(); + + const scaleTextAfter = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleTextAfter).toBe('150%'); + + const translateAfter = parseTranslate(modalBox.style.transform); + expect(translateAfter).toEqual({ x: 0, y: 0 }); + + exceedsSpy.mockRestore(); + }); + + test('图片在视口内时缩小走普通路径', async () => { + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + const triggerText = '预览图片2'; + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await mockDelay(); + + const modalBox = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + const translateBefore = parseTranslate(modalBox.style.transform); + + exceedsSpy.mockClear(); + + fireWheelEvent(120); + await mockDelay(); + + expect(exceedsSpy).toHaveBeenCalled(); + + const translateAfter = parseTranslate(modalBox.style.transform); + expect(translateAfter).toEqual(translateBefore); + + exceedsSpy.mockRestore(); + }); + + test('滚轮放大时走 onZoomIn 普通路径', async () => { + const triggerText = '放大路径测试'; + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await mockDelay(); + + const scaleText = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleText).toBe('100%'); + + fireWheelEvent(-120); + await mockDelay(); + + const scaleTextAfter = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleTextAfter).toBe('150%'); + }); + + test('滚轮事件调用 preventDefault', async () => { + const triggerText = 'preventDefault测试'; + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await mockDelay(); + + const wheelEvent = new WheelEvent('wheel', { deltaY: -120, bubbles: true, cancelable: true }); + const preventDefaultSpy = vi.spyOn(wheelEvent, 'preventDefault'); + + act(() => { + document.dispatchEvent(wheelEvent); + }); + + expect(preventDefaultSpy).toHaveBeenCalled(); + + preventDefaultSpy.mockRestore(); + }); +}); + +describe('ImageViewerModal 向中心缩放动画', () => { + const fireWheelEvent = (deltaY: number) => { + act(() => { + document.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })); + }); + }; + + const TRANSITIONING_CLASS = 't-image-viewer__modal-box--transitioning'; + + test('向中心缩放时添加 transitioning class', async () => { + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + const triggerText = '动画测试1'; + render( + open()}>{triggerText}} + images={[imgUrl]} + imageScale={{ max: 2, min: 0.5, step: 0.5 }} + />, + ); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + fireWheelEvent(-120); + fireWheelEvent(-120); + await mockDelay(); + + exceedsSpy.mockReturnValue(true); + + fireWheelEvent(120); + + const box = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + exceedsSpy.mockRestore(); + }); + + test('transitionend 后移除 transitioning class', async () => { + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + render( + open()}>动画测试2} + images={[imgUrl]} + imageScale={{ max: 2, min: 0.5, step: 0.5 }} + />, + ); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + fireWheelEvent(-120); + fireWheelEvent(-120); + await mockDelay(); + + exceedsSpy.mockReturnValue(true); + fireWheelEvent(120); + + const box = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + act(() => { + const e = new Event('transitionend', { bubbles: true }); + Object.defineProperty(e, 'propertyName', { value: 'transform' }); + box.dispatchEvent(e); + }); + + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(false); + + exceedsSpy.mockRestore(); + }); + + test('fallback timer(350ms)超时后自动移除 transitioning class', async () => { + vi.useFakeTimers(); + + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + render( + open()}>动画测试3} + images={[imgUrl]} + imageScale={{ max: 2, min: 0.5, step: 0.5 }} + />, + ); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + + act(() => { + fireWheelEvent(-120); + fireWheelEvent(-120); + }); + + exceedsSpy.mockReturnValue(true); + act(() => { + fireWheelEvent(120); + }); + + const box = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + act(() => { + vi.advanceTimersByTime(400); + }); + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(false); + + vi.useRealTimers(); + exceedsSpy.mockRestore(); + }); + + test('连续快速缩放:fallback timer 重置', async () => { + vi.useFakeTimers(); + + const transformModule = await import('@tdesign/common-js/image-viewer/transform'); + const exceedsSpy = vi.spyOn(transformModule, 'isImageExceedsViewport').mockReturnValue(false); + + render( + open()}>动画测试4} + images={[imgUrl]} + imageScale={{ max: 2, min: 0.5, step: 0.5 }} + />, + ); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + + act(() => { + fireWheelEvent(-120); + fireWheelEvent(-120); + }); + + exceedsSpy.mockReturnValue(true); + + act(() => { + fireWheelEvent(120); + }); + const box = document.querySelector('.t-image-viewer__modal-box') as HTMLElement; + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + act(() => { + fireWheelEvent(120); + }); + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(true); + + act(() => { + vi.advanceTimersByTime(200); + }); + expect(box.classList.contains(TRANSITIONING_CLASS)).toBe(false); + + vi.useRealTimers(); + exceedsSpy.mockRestore(); + }); +}); + +describe('ImageViewer 工具栏操作快照', () => { + const triggerText = '工具栏测试'; + + const openViewer = async (images = [imgUrl]) => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + const { getByText } = render(); + act(() => { + fireEvent.click(getByText(triggerText)); + }); + await mockDelay(); + return { getByText }; + }; + + test('点击旋转按钮后快照', async () => { + await openViewer(); + const rotateBtn = document.querySelector('.t-icon-rotation')?.closest('.t-image-viewer__modal-icon'); + expect(rotateBtn).toBeTruthy(); + act(() => { + fireEvent.click(rotateBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-after-rotate'); + }); + + test('点击放大按钮后快照', async () => { + await openViewer(); + const zoomInBtn = document.querySelector('.t-icon-zoom-in')?.closest('.t-image-viewer__modal-icon'); + expect(zoomInBtn).toBeTruthy(); + act(() => { + fireEvent.click(zoomInBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-after-zoom-in'); + }); + + test('点击缩小按钮后快照', async () => { + await openViewer(); + const zoomOutBtn = document.querySelector('.t-icon-zoom-out')?.closest('.t-image-viewer__modal-icon'); + expect(zoomOutBtn).toBeTruthy(); + act(() => { + fireEvent.click(zoomOutBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-after-zoom-out'); + }); + + test('点击镜像按钮后快照', async () => { + await openViewer(); + const mirrorBtn = document.querySelector('.t-icon-mirror')?.closest('.t-image-viewer__modal-icon'); + expect(mirrorBtn).toBeTruthy(); + act(() => { + fireEvent.click(mirrorBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-after-mirror'); + }); + + test('点击原始大小按钮后快照', async () => { + await openViewer(); + const resetBtn = document.querySelector('.t-icon-image')?.closest('.t-image-viewer__modal-icon'); + expect(resetBtn).toBeTruthy(); + act(() => { + fireEvent.click(resetBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-after-reset'); + }); + + test('多图导航:上一张/下一张按钮快照', async () => { + await openViewer([imgUrl, imgUrl2, imgUrl3]); + + const nextBtn = document.querySelector('.t-icon-chevron-right')?.closest('.t-image-viewer__modal-icon'); + expect(nextBtn).toBeTruthy(); + act(() => { + fireEvent.click(nextBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-navigate-next'); + + const nextBtn2 = document.querySelector('.t-icon-chevron-right')?.closest('.t-image-viewer__modal-icon'); + expect(nextBtn2).toBeTruthy(); + act(() => { + fireEvent.click(nextBtn2); + }); + await mockDelay(); + + const prevBtn = document.querySelector('.t-icon-chevron-left')?.closest('.t-image-viewer__modal-icon'); + expect(prevBtn).toBeTruthy(); + act(() => { + fireEvent.click(prevBtn); + }); + await mockDelay(); + expect(document.body).toMatchSnapshot('toolbar-navigate-prev'); + }); +}); + +// ─── onClose trigger 来源验证 ─────────────────────────────────────────── +describe('onClose trigger 来源', () => { + const triggerText = '关闭来源测试'; + + test('点击关闭按钮时 trigger 为 close-btn', async () => { + const onClose = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const closeBtn = document.querySelector('.t-image-viewer__modal-close-bt'); + expect(closeBtn).toBeTruthy(); + act(() => { + fireEvent.click(closeBtn); + }); + expect(onClose).toHaveBeenCalledWith({ trigger: 'close-btn', e: expect.any(Object) }); + }); + + test('点击遮罩时 trigger 为 overlay', async () => { + const onClose = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const mask = document.querySelector('.t-image-viewer__modal-mask'); + expect(mask).toBeTruthy(); + act(() => { + fireEvent.click(mask); + }); + expect(onClose).toHaveBeenCalledWith({ trigger: 'overlay', e: expect.any(Object) }); + }); + + test('ESC 关闭时 trigger 为 esc', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + await user.type(document.body, '{Escape}'); + expect(onClose).toHaveBeenCalledWith({ trigger: 'esc', e: expect.any(Object) }); + }); +}); + +// ─── onDownload 回调 ──────────────────────────────────────────────────── +describe('onDownload 下载回调', () => { + const triggerText = '下载测试'; + + test('自定义 onDownload 时被调用', async () => { + const onDownload = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const downloadBtn = + document + .querySelector('.t-image-viewer__modal-icon [data-td-icon="download"]') + ?.closest('.t-image-viewer__modal-icon') ?? + document.querySelector('.t-icon-download')?.closest('.t-image-viewer__modal-icon'); + expect(downloadBtn).toBeTruthy(); + act(() => { + fireEvent.click(downloadBtn); + }); + expect(onDownload).toHaveBeenCalledWith(imgUrl); + }); + + test('currentImage.download 为 false 时不显示下载按钮', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const downloadIcon = document.querySelector('.t-icon-download'); + expect(downloadIcon).toBeNull(); + }); +}); + +// ─── 受控模式 ────────────────────────────────────────────────────────── +describe('受控 visible/index', () => { + test('受控 visible 打开/关闭', async () => { + const ControlledViewer = () => { + const [visible, setVisible] = React.useState(false); + return ( + <> + + + setVisible(false)} /> + + ); + }; + render(); + + expect(document.querySelector('.t-image-viewer-preview-image')).toBeNull(); + + act(() => { + fireEvent.click(document.querySelector('[data-testid="open"]')); + }); + await mockDelay(); + expect(document.querySelector('.t-image-viewer-preview-image')).toBeTruthy(); + + act(() => { + fireEvent.click(document.querySelector('[data-testid="close"]')); + }); + await mockTimeout(() => expect(document.querySelector('.t-image-viewer-preview-image')).toBeNull()); + }); + + test('受控 index 切换图片', async () => { + const ControlledViewer = () => { + const [index, setIndex] = React.useState(0); + return ( + <> + + setIndex(i as number)} + /> + + ); + }; + render(); + await mockDelay(); + + // 初始 index=0 + const indexText = document.querySelector('.t-image-viewer__modal-index'); + expect(indexText).toBeTruthy(); + + // 外部修改 index 为 2 + act(() => { + fireEvent.click(document.querySelector('[data-testid="set-index"]')); + }); + await mockDelay(); + + // 验证组件已更新到第 3 张 + const updatedIndexText = document.querySelector('.t-image-viewer__modal-index'); + expect(updatedIndexText?.textContent).toContain('3/3'); + }); +}); + +// ─── 图片加载错误状态 ────────────────────────────────────────────────── +describe('图片加载错误状态', () => { + const triggerText = '错误状态测试'; + + test('图片加载失败显示错误 UI', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>{triggerText}; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const img = document.querySelector('.t-image-viewer__modal-image') as HTMLImageElement; + expect(img).toBeTruthy(); + + act(() => { + fireEvent.error(img); + }); + await mockDelay(); + + const errorEl = document.querySelector('.t-image-viewer__img-error'); + // In JSDOM, error UI may render; verify either error element appears or image is hidden + const hasError = + errorEl !== null || document.querySelector('.t-image-viewer__modal-image[style*="display: none"]') !== null; + expect(hasError).toBe(true); + }); +}); + +// ─── open(index) 指定索引打开 ────────────────────────────────────────── +describe('open(index) 指定索引打开', () => { + test('open 传入 index 后显示对应图片', async () => { + const onIndexChange = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open(2)}>指定索引打开; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const indexText = document.querySelector('.t-image-viewer__modal-index'); + expect(indexText?.textContent).toContain('3/3'); + }); +}); + +// ─── 缩略图导航 ────────────────────────────────────────────────────── +describe('ImageViewerHeader 缩略图导航', () => { + test('点击缩略图切换到对应图片', async () => { + const onIndexChange = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>缩略图测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const headerBoxes = document.querySelectorAll('.t-image-viewer__header-box'); + expect(headerBoxes.length).toBe(3); + + act(() => { + fireEvent.click(headerBoxes[2]); + }); + expect(onIndexChange).toHaveBeenCalledWith(2, { trigger: 'current' }); + }); + + test('当前图片缩略图有 active 状态', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>缩略图测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const headerBoxes = document.querySelectorAll('.t-image-viewer__header-box'); + expect(headerBoxes[0].classList.contains('t-is-active')).toBe(true); + expect(headerBoxes[1].classList.contains('t-is-active')).toBe(false); + }); +}); + +// ─── closeBtn 为函数 ────────────────────────────────────────────────── +describe('closeBtn 为函数', () => { + test('closeBtn 为函数时渲染自定义关闭按钮', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>函数关闭按钮; + return ( + ( + + )} + /> + ); + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const customBtn = document.querySelector('[data-testid="custom-close"]'); + expect(customBtn).toBeTruthy(); + expect(customBtn?.textContent).toBe('X'); + }); +}); + +// ─── DefaultTrigger 点击交互 ────────────────────────────────────────── +describe('DefaultTrigger 点击交互', () => { + test('默认触发器点击后打开预览', async () => { + render(); + + const triggerEl = document.querySelector('.t-image-viewer__trigger'); + expect(triggerEl).toBeTruthy(); + + act(() => { + fireEvent.click(triggerEl); + }); + await mockDelay(); + + expect(document.querySelector('.t-image-viewer-preview-image')).toBeTruthy(); + }); +}); + +// ─── 切换图片重置状态 ──────────────────────────────────────────────── +describe('切换图片后重置旋转/缩放/位移', () => { + test('切换图片后缩放重置为 1', async () => { + const user = userEvent.setup(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>重置测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + // 放大 + await user.type(document.body, '{ArrowUp}'); + await mockDelay(); + const scaleText = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleText).not.toBe('100%'); + + // 切换到下一张 + await user.type(document.body, '{ArrowRight}'); + await mockDelay(); + + const scaleTextAfter = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleTextAfter).toBe('100%'); + }); +}); + +// ─── innerClassName 属性 ────────────────────────────────────────────── +describe('innerClassName 属性', () => { + test('innerClassName 应用到图片容器', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>innerClassName测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const picEl = document.querySelector('.t-image-viewer__modal-pic'); + expect(picEl?.classList.contains('custom-inner')).toBe(true); + }); +}); + +// ─── showOverlay 为 false ───────────────────────────────────────────── +describe('showOverlay 属性', () => { + test('showOverlay 为 false 时不渲染遮罩', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>遮罩测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const mask = document.querySelector('.t-image-viewer__modal-mask'); + expect(mask).toBeNull(); + }); +}); + +// ─── closeOnOverlay 为 false ────────────────────────────────────────── +describe('closeOnOverlay 属性', () => { + test('closeOnOverlay 为 false 时点击遮罩不关闭', async () => { + const onClose = vi.fn(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>遮罩不关闭测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const mask = document.querySelector('.t-image-viewer__modal-mask'); + expect(mask).toBeTruthy(); + act(() => { + fireEvent.click(mask); + }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); + +// ─── title 为函数 ───────────────────────────────────────────────────── +describe('title 为 TNode', () => { + test('title 为 ReactNode 时渲染自定义标题', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>标题测试; + return ( + 自定义标题} + /> + ); + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const titleEl = document.querySelector('.custom-title-node'); + expect(titleEl).toBeTruthy(); + expect(titleEl?.textContent).toBe('自定义标题'); + }); +}); + +// ─── 第一张/最后一张箭头 disabled ────────────────────────────────────── +describe('第一张/最后一张箭头 disabled 状态', () => { + test('第一张图片时 prev 按钮有 disabled 样式', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>箭头测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const prevBtn = document.querySelector('.t-image-viewer__modal-prev-bt'); + expect(prevBtn?.classList.contains('t-is-disabled')).toBe(true); + + const nextBtn = document.querySelector('.t-image-viewer__modal-next-bt'); + expect(nextBtn?.classList.contains('t-is-disabled')).toBe(false); + }); + + test('最后一张图片时 next 按钮有 disabled 样式', async () => { + const user = userEvent.setup(); + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>箭头测试2; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + await user.type(document.body, '{ArrowRight}'); + await mockDelay(); + + const nextBtn = document.querySelector('.t-image-viewer__modal-next-bt'); + expect(nextBtn?.classList.contains('t-is-disabled')).toBe(true); + }); +}); + +// ─── 空图片数组 ─────────────────────────────────────────────────────── +describe('空图片数组', () => { + test('images 为空数组时不渲染弹窗', async () => { + const BasicImageViewer = () => { + const trigger = ({ open }) => open()}>空数组测试; + return ; + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + expect(document.querySelector('.t-image-viewer-preview-image')).toBeNull(); + }); +}); + +// ─── imageScale 动态变化重置 ────────────────────────────────────────── +describe('imageScale 动态变化重置缩放', () => { + test('imageScale 变化后缩放重置', async () => { + const DynamicScaleViewer = () => { + const [scale, setScale] = React.useState({ max: 2, min: 0.5, step: 0.2 }); + const trigger = ({ open }) => open()}>动态缩放; + return ( + <> + + + + ); + }; + render(); + + act(() => { + fireEvent.click(document.querySelector('span')); + }); + await mockDelay(); + + const scaleText = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleText).toBe('100%'); + + act(() => { + fireEvent.click(document.querySelector('[data-testid="change-scale"]')); + }); + await mockDelay(); + + const scaleTextAfter = document.querySelector('.t-image-viewer__utils-scale')?.textContent; + expect(scaleTextAfter).toBe('100%'); + }); +}); diff --git a/packages/components/image-viewer/__tests__/transform.test.ts b/packages/components/image-viewer/__tests__/transform.test.ts new file mode 100644 index 0000000000..1b669c47db --- /dev/null +++ b/packages/components/image-viewer/__tests__/transform.test.ts @@ -0,0 +1,413 @@ +/** + * transform.test.ts — 纯函数测试 + * + * 测试 @tdesign/common-js/image-viewer/transform 中的所有导出函数: + * - toggleMirror / MIRROR_DEFAULT + * - calcResetRotation / ROTATE_DEG + * - clampScale / calcZoomInScale / calcZoomOutScale + * - calculateTranslateOffset + * - zoomIn / zoomOut + * - isImageExceedsViewport + * - DEFAULT_IMAGE_SCALE + */ +import { describe, expect, test } from 'vitest'; +import { + calcResetRotation, + calculateTranslateOffset, + calcZoomInScale, + calcZoomOutScale, + clampScale, + DEFAULT_IMAGE_SCALE, + isImageExceedsViewport, + MIRROR_DEFAULT, + ROTATE_DEG, + toggleMirror, + zoomIn, + zoomOut, +} from '@tdesign/common-js/image-viewer/transform'; + +// ─── 常量 ──────────────────────────────────────────────────────────────── +describe('常量', () => { + test('DEFAULT_IMAGE_SCALE 默认缩放配置', () => { + expect(DEFAULT_IMAGE_SCALE).toEqual({ + max: 2, + min: 0.5, + step: 0.2, + defaultScale: 1, + }); + }); + + test('MIRROR_DEFAULT 默认值为 1', () => { + expect(MIRROR_DEFAULT).toBe(1); + }); + + test('ROTATE_DEG 旋转角度为 -90', () => { + expect(ROTATE_DEG).toBe(-90); + }); +}); + +// ─── toggleMirror ──────────────────────────────────────────────────────── +describe('toggleMirror 镜像切换', () => { + test('从默认值 1 切换为 -1', () => { + expect(toggleMirror(1)).toBe(-1); + }); + + test('从 -1 切换回 1', () => { + expect(toggleMirror(-1)).toBe(1); + }); + + test('连续切换 10 次回到 1', () => { + let mirror = MIRROR_DEFAULT; + for (let i = 0; i < 10; i++) { + mirror = toggleMirror(mirror); + } + expect(mirror).toBe(1); + }); + + test('奇数次切换结果为 -1', () => { + let mirror = MIRROR_DEFAULT; + for (let i = 0; i < 5; i++) { + mirror = toggleMirror(mirror); + } + expect(mirror).toBe(-1); + }); +}); + +// ─── calcResetRotation ────────────────────────────────────────────────── +describe('calcResetRotation 旋转重置', () => { + test('0° 时无需调整', () => { + expect(calcResetRotation(0)).toBe(0); + }); + + test('-90° 时保持不变(|deg| ≤ 180)', () => { + expect(calcResetRotation(-90)).toBe(-90); + }); + + test('-180° 边界值保持不变', () => { + expect(calcResetRotation(-180)).toBe(-180); + }); + + test('-270° 走最短路径(+90)', () => { + expect(calcResetRotation(-270)).toBe(90); + }); + + test('-360° 整圈旋转无需调整', () => { + expect(calcResetRotation(-360)).toBe(0); + }); + + test('-450° 等价于 -90°', () => { + expect(calcResetRotation(-450)).toBe(-90); + }); + + test('-720° 两圈旋转无需调整', () => { + expect(calcResetRotation(-720)).toBe(0); + }); + + test('正 90° 保持不变', () => { + expect(calcResetRotation(90)).toBe(90); + }); + + test('正 270° 走最短路径', () => { + expect(calcResetRotation(270)).toBe(270); + }); + + test('正 360° 整圈旋转无需调整', () => { + expect(calcResetRotation(360)).toBe(0); + }); + + test('正 180° 保持不变', () => { + expect(calcResetRotation(180)).toBe(180); + }); + + test('正 540° 等价于 180°', () => { + expect(calcResetRotation(540)).toBe(180); + }); + + test('负 630° 最短路径', () => { + expect(calcResetRotation(-630)).toBe(90); + }); +}); + +// ─── clampScale ────────────────────────────────────────────────────────── +describe('clampScale 缩放值限制', () => { + test('范围内值不变', () => { + expect(clampScale(1, 0.5, 2)).toBe(1); + }); + + test('低于最小值返回最小值', () => { + expect(clampScale(0.1, 0.5, 2)).toBe(0.5); + }); + + test('超过最大值返回最大值', () => { + expect(clampScale(5, 0.5, 2)).toBe(2); + }); + + test('等于最小值', () => { + expect(clampScale(0.5, 0.5, 2)).toBe(0.5); + }); + + test('等于最大值', () => { + expect(clampScale(2, 0.5, 2)).toBe(2); + }); + + test('零值被限制为最小值', () => { + expect(clampScale(0, 0.5, 2)).toBe(0.5); + }); + + test('负值被限制为最小值', () => { + expect(clampScale(-1, 0.5, 2)).toBe(0.5); + }); +}); + +// ─── calcZoomInScale / calcZoomOutScale ────────────────────────────────── +describe('calcZoomInScale 放大计算', () => { + test('基本放大', () => { + expect(calcZoomInScale(1, 0.2, 0.5, 2)).toBeCloseTo(1.2); + }); + + test('放大到最大值时截断', () => { + expect(calcZoomInScale(1.9, 0.2, 0.5, 2)).toBe(2); + }); + + test('已在最大值时保持不变', () => { + expect(calcZoomInScale(2, 0.2, 0.5, 2)).toBe(2); + }); + + test('大步长放大', () => { + expect(calcZoomInScale(1, 1, 0.5, 2)).toBe(2); + }); + + test('小步长放大', () => { + expect(calcZoomInScale(1, 0.05, 0.5, 2)).toBeCloseTo(1.05); + }); +}); + +describe('calcZoomOutScale 缩小计算', () => { + test('基本缩小', () => { + expect(calcZoomOutScale(1, 0.2, 0.5, 2)).toBeCloseTo(0.8); + }); + + test('缩小到最小值时截断', () => { + expect(calcZoomOutScale(0.6, 0.2, 0.5, 2)).toBe(0.5); + }); + + test('已在最小值时保持不变', () => { + expect(calcZoomOutScale(0.5, 0.2, 0.5, 2)).toBe(0.5); + }); + + test('大步长缩小', () => { + expect(calcZoomOutScale(1, 1, 0.5, 2)).toBe(0.5); + }); +}); + +// ─── calculateTranslateOffset ──────────────────────────────────────────── +describe('calculateTranslateOffset 位移计算', () => { + test('缺少 mouseOffsetX 时返回 undefined', () => { + const result = calculateTranslateOffset(1, 1.2, { + mouseOffsetY: 50, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + expect(result).toBeUndefined(); + }); + + test('缺少 mouseOffsetY 时返回 undefined', () => { + const result = calculateTranslateOffset(1, 1.2, { + mouseOffsetX: 50, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + expect(result).toBeUndefined(); + }); + + test('两个偏移都缺失时返回 undefined', () => { + const result = calculateTranslateOffset(1, 1.2, { + currentTranslate: { translateX: 0, translateY: 0 }, + }); + expect(result).toBeUndefined(); + }); + + test('options 为 undefined 时返回 undefined', () => { + expect(calculateTranslateOffset(1, 1.2)).toBeUndefined(); + }); + + test('中心缩放(偏移为 0):newT = scaleRatio * T', () => { + const result = calculateTranslateOffset(1, 1.2, { + mouseOffsetX: 0, + mouseOffsetY: 0, + currentTranslate: { translateX: 100, translateY: 50 }, + }); + expect(result).toEqual({ translateX: 120, translateY: 60 }); + }); + + test('非中心缩放:公式验证', () => { + const result = calculateTranslateOffset(1, 1.2, { + mouseOffsetX: 100, + mouseOffsetY: 50, + currentTranslate: { translateX: 0, translateY: 0 }, + }); + expect(result.translateX).toBeCloseTo(-20); + expect(result.translateY).toBeCloseTo(-10); + }); + + test('缩小并带已有位移', () => { + const result = calculateTranslateOffset(1, 0.8, { + mouseOffsetX: 100, + mouseOffsetY: 100, + currentTranslate: { translateX: 50, translateY: 50 }, + }); + expect(result).toEqual({ translateX: 60, translateY: 60 }); + }); + + test('缩放比例为 1 时位移不变', () => { + const result = calculateTranslateOffset(1, 1, { + mouseOffsetX: 200, + mouseOffsetY: 200, + currentTranslate: { translateX: 50, translateY: 50 }, + }); + expect(result).toEqual({ translateX: 50, translateY: 50 }); + }); + + test('缺少 currentTranslate 时默认为 {0, 0}', () => { + const result = calculateTranslateOffset(1, 1.2, { + mouseOffsetX: 100, + mouseOffsetY: 100, + }); + expect(result.translateX).toBeCloseTo(-20); + expect(result.translateY).toBeCloseTo(-20); + }); +}); + +// ─── zoomIn / zoomOut (组合函数) ───────────────────────────────────────── +describe('zoomIn 放大', () => { + test('无 ZoomOptions 时只返回 newScale', () => { + const { newScale, zoomResult } = zoomIn(1, 0.2, 0.5, 2); + expect(newScale).toBeCloseTo(1.2); + expect(zoomResult.newTranslate).toBeUndefined(); + }); + + test('带 ZoomOptions 时计算新位移', () => { + const { newScale, zoomResult } = zoomIn(1, 0.2, 0.5, 2, { + mouseOffsetX: 0, + mouseOffsetY: 0, + currentTranslate: { translateX: 100, translateY: 50 }, + }); + expect(newScale).toBeCloseTo(1.2); + expect(zoomResult.newTranslate).toEqual({ translateX: 120, translateY: 60 }); + }); + + test('已达最大值时只返回 newScale', () => { + const { newScale, zoomResult } = zoomIn(2, 0.2, 0.5, 2); + expect(newScale).toBe(2); + expect(zoomResult.newTranslate).toBeUndefined(); + }); +}); + +describe('zoomOut 缩小', () => { + test('无 ZoomOptions 时只返回 newScale', () => { + const { newScale, zoomResult } = zoomOut(1, 0.2, 0.5, 2); + expect(newScale).toBeCloseTo(0.8); + expect(zoomResult.newTranslate).toBeUndefined(); + }); + + test('带 ZoomOptions 时计算新位移', () => { + const { newScale, zoomResult } = zoomOut(1, 0.2, 0.5, 2, { + mouseOffsetX: 100, + mouseOffsetY: 100, + currentTranslate: { translateX: 50, translateY: 50 }, + }); + expect(newScale).toBeCloseTo(0.8); + expect(zoomResult.newTranslate).toEqual({ translateX: 60, translateY: 60 }); + }); + + test('已达最小值时只返回 newScale', () => { + const { newScale, zoomResult } = zoomOut(0.5, 0.2, 0.5, 2); + expect(newScale).toBe(0.5); + expect(zoomResult.newTranslate).toBeUndefined(); + }); +}); + +// ─── isImageExceedsViewport ────────────────────────────────────────────── +describe('isImageExceedsViewport 图片是否超出视口', () => { + const createMockElement = (rect: Partial) => { + const el = document.createElement('div'); + el.getBoundingClientRect = () => ({ + top: 0, + left: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + // eslint-disable-next-line @typescript-eslint/no-empty-function + toJSON: () => {}, + ...rect, + }); + return el; + }; + + test('图片在视口内', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 700, top: 50, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); + + test('图片超出左侧', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: -50, right: 700, top: 50, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(true); + }); + + test('图片超出右侧', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 900, top: 50, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(true); + }); + + test('图片超出顶部', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 700, top: -10, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(true); + }); + + test('图片超出底部', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 700, top: 50, bottom: 650 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(true); + }); + + test('图片四边均超出', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: -100, right: 900, top: -100, bottom: 700 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(true); + }); + + test('图片与视口完全重合', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); + + test('图片左边与容器左边对齐(边界不超出)', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 0, right: 700, top: 50, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); + + test('图片右边与容器右边对齐(边界不超出)', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 800, top: 50, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); + + test('图片上边与容器上边对齐(边界不超出)', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 700, top: 0, bottom: 550 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); + + test('图片下边与容器下边对齐(边界不超出)', () => { + const container = createMockElement({ left: 0, right: 800, top: 0, bottom: 600 }); + const modalBox = createMockElement({ left: 100, right: 700, top: 50, bottom: 600 }); + expect(isImageExceedsViewport(container, modalBox)).toBe(false); + }); +}); diff --git a/packages/components/image-viewer/__tests__/utils.test.ts b/packages/components/image-viewer/__tests__/utils.test.ts new file mode 100644 index 0000000000..a6f66d5164 --- /dev/null +++ b/packages/components/image-viewer/__tests__/utils.test.ts @@ -0,0 +1,366 @@ +/** + * utils.test.ts — 工具函数测试 + * + * 测试 @tdesign/common-js/image-viewer/utils 中的导出函数: + * - formatImages + * - downloadImage(含跨域 canvasDownload 路径) + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { downloadImage, formatImages } from '@tdesign/common-js/image-viewer/utils'; +import { act } from '@test/utils'; + +// ─── formatImages ──────────────────────────────────────────────────────── +describe('formatImages 图片格式化', () => { + test('非数组输入返回空数组', () => { + expect(formatImages(null as unknown as ImageInfo[])).toEqual([]); + expect(formatImages(undefined as unknown as ImageInfo[])).toEqual([]); + expect(formatImages('string' as unknown as ImageInfo[])).toEqual([]); + expect(formatImages(123 as unknown as ImageInfo[])).toEqual([]); + }); + + test('空数组', () => { + expect(formatImages([])).toEqual([]); + }); + + test('字符串数组:使用默认属性', () => { + const images = ['image1.jpg', 'image2.png']; + const result = formatImages(images); + + expect(result).toEqual([ + { mainImage: 'image1.jpg', thumbnail: 'image1.jpg', download: true }, + { mainImage: 'image2.png', thumbnail: 'image2.png', download: true }, + ]); + }); + + test('ImageInfo 对象:补全默认属性', () => { + const images = [{ mainImage: 'main1.jpg' }, { mainImage: 'main2.jpg', thumbnail: 'thumb2.jpg' }]; + const result = formatImages(images); + + expect(result[0]).toEqual({ mainImage: 'main1.jpg', thumbnail: 'main1.jpg', download: true }); + expect(result[1]).toEqual({ mainImage: 'main2.jpg', thumbnail: 'thumb2.jpg', download: true }); + }); + + test('自定义 download 设置被保留', () => { + const images = [{ mainImage: 'main.jpg', download: false }]; + const result = formatImages(images); + expect(result[0].download).toBeFalsy(); + }); + + test('字符串与 ImageInfo 混合数组', () => { + const images = ['string-image.jpg', { mainImage: 'object-main.jpg', thumbnail: 'object-thumb.jpg' }]; + const result = formatImages(images); + + expect(result).toEqual([ + { mainImage: 'string-image.jpg', thumbnail: 'string-image.jpg', download: true }, + { mainImage: 'object-main.jpg', thumbnail: 'object-thumb.jpg', download: true }, + ]); + }); + + test('File 对象作为图片输入', () => { + const file = new File(['test'], 'test.png', { type: 'image/png' }); + const result = formatImages([file]); + + expect(result[0].mainImage).toBe(file); + expect(result[0].thumbnail).toBe(file); + expect(result[0].download).toBeTruthy(); + }); + + test('ImageInfo 包含全部属性', () => { + const images = [{ mainImage: 'main.jpg', thumbnail: 'thumb.jpg', download: true, isSvg: true }]; + const result = formatImages(images); + expect(result[0]).toEqual({ mainImage: 'main.jpg', thumbnail: 'thumb.jpg', download: true, isSvg: true }); + }); + + test('thumbnail 默认等于 mainImage', () => { + const images = [{ mainImage: 'only-main.jpg' }]; + const result = formatImages(images); + expect(result[0].thumbnail).toBe('only-main.jpg'); + }); + + test('大数组处理(100 张图片)', () => { + const images = Array.from({ length: 100 }, (_, i) => `image-${i}.jpg`); + const result = formatImages(images); + expect(result).toHaveLength(100); + result.forEach((item, i) => { + expect(item.mainImage).toBe(`image-${i}.jpg`); + expect(item.thumbnail).toBe(`image-${i}.jpg`); + expect(item.download).toBe(true); + }); + }); +}); + +// ─── downloadImage ─────────────────────────────────────────────────────── +describe('downloadImage 图片下载', () => { + let mockCreateObjectURL: ReturnType; + let mockRevokeObjectURL: ReturnType; + let mockCreateElement: typeof document.createElement; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateObjectURL = vi.fn().mockReturnValue('blob:test'); + mockRevokeObjectURL = vi.fn(); + window.URL.createObjectURL = mockCreateObjectURL; + window.URL.revokeObjectURL = mockRevokeObjectURL; + mockCreateElement = document.createElement.bind(document); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('File 输入使用 URL.createObjectURL', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const file = new File(['test'], 'test-image.png', { type: 'image/png' }); + downloadImage(file); + + expect(mockCreateObjectURL).toHaveBeenCalledWith(file); + expect(mockAnchor.download).toBe('test-image.png'); + expect(mockClick).toHaveBeenCalled(); + }); + + test('同源 URL 直接下载', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const sameOriginUrl = `${window.location.origin}/test-image.png`; + downloadImage(sameOriginUrl); + + expect(mockAnchor.href).toBe(sameOriginUrl); + expect(mockAnchor.download).toBe('test-image.png'); + expect(mockClick).toHaveBeenCalled(); + }); + + test('从带查询参数的 URL 提取文件名', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const urlWithParams = `${window.location.origin}/path/image.png?sign=xxx&token=yyy`; + downloadImage(urlWithParams); + expect(mockAnchor.download).toBe('image.png'); + }); + + test('从带 hash 的 URL 提取文件名', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const urlWithHash = `${window.location.origin}/path/image.jpg#section`; + downloadImage(urlWithHash); + expect(mockAnchor.download).toBe('image.jpg'); + }); + + test('跨域 URL 触发 canvasDownload 路径', () => { + const crossOriginUrl = 'https://cross-origin.example.com/photo.jpg'; + + const setAttribute = vi.fn(); + const mockImage: Partial = { setAttribute }; + const ImageSpy = vi.fn().mockImplementation(() => mockImage); + (global as any).Image = ImageSpy; + + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + downloadImage(crossOriginUrl); + + expect(setAttribute).toHaveBeenCalledWith('crossOrigin', 'anonymous'); + expect(mockImage.src).toBe(crossOriginUrl); + expect(mockClick).not.toHaveBeenCalled(); + }); + + test('跨域图片 onload 后触发 canvas 下载', () => { + const crossOriginUrl = 'https://cross-origin.example.com/photo.png'; + + const mockCanvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toBlob: vi.fn().mockImplementation((cb) => cb(new Blob())), + }; + + let onloadFn: (() => void) | null = null; + const mockImage: Partial & { width: number; height: number } = { + setAttribute: vi.fn(), + width: 100, + height: 80, + }; + Object.defineProperty(mockImage, 'onload', { + set(fn) { + onloadFn = fn; + }, + }); + + const ImageSpy = vi.fn().mockImplementation(() => mockImage); + (global as any).Image = ImageSpy; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement; + return mockCreateElement(tag); + }); + + const mockBlobClick = vi.fn(); + const mockBlobAnchor = { href: '', download: '', click: mockBlobClick, remove: vi.fn() }; + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement; + if (tag === 'a') return mockBlobAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + downloadImage(crossOriginUrl); + + expect(onloadFn).toBeTruthy(); + act(() => { + onloadFn?.(); + }); + + expect(mockCanvas.getContext).toHaveBeenCalledWith('2d'); + expect(mockCanvas.toBlob).toHaveBeenCalled(); + }); + + test('URL 无文件名时使用随机名称', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const urlNoFilename = `${window.location.origin}/`; + downloadImage(urlNoFilename); + expect(mockAnchor.download).toBeTruthy(); + expect(mockClick).toHaveBeenCalled(); + }); + + test('URL 含多个 / 和复杂路径时正确提取文件名', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const deepUrl = `${window.location.origin}/a/b/c/deep-image.png`; + downloadImage(deepUrl); + expect(mockAnchor.download).toBe('deep-image.png'); + }); + + test('File 对象下载后调用 revokeObjectURL', () => { + const mockClick = vi.fn(); + const mockRemove = vi.fn(); + const mockAnchor = { href: '', download: '', click: mockClick, remove: mockRemove }; + + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'a') return mockAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' }); + downloadImage(file); + + expect(mockCreateObjectURL).toHaveBeenCalledWith(file); + expect(mockClick).toHaveBeenCalled(); + // revokeObjectURL is called after click, need to verify + expect(mockRevokeObjectURL).toHaveBeenCalled(); + }); + + test('跨域 URL jpg 扩展名使用 image/jpeg mime', () => { + const crossOriginUrl = 'https://other.com/photo.jpg'; + + let onloadFn: (() => void) | null = null; + const mockImage: Partial & { width: number; height: number } = { + setAttribute: vi.fn(), + width: 100, + height: 100, + }; + Object.defineProperty(mockImage, 'onload', { + set(fn) { + onloadFn = fn; + }, + }); + const ImageSpy = vi.fn().mockImplementation(() => mockImage); + (global as any).Image = ImageSpy; + + const mockCanvas = { + width: 0, + height: 0, + getContext: vi.fn().mockReturnValue({ drawImage: vi.fn() }), + toBlob: vi.fn(), + }; + + const mockBlobClick = vi.fn(); + const mockBlobAnchor = { href: '', download: '', click: mockBlobClick, remove: vi.fn() }; + vi.spyOn(document, 'createElement').mockImplementation((tag) => { + if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement; + if (tag === 'a') return mockBlobAnchor as unknown as HTMLAnchorElement; + return mockCreateElement(tag); + }); + + downloadImage(crossOriginUrl); + + expect(onloadFn).toBeTruthy(); + act(() => { + onloadFn?.(); + }); + + // toBlob should be called with jpeg mime for .jpg extension + expect(mockCanvas.toBlob).toHaveBeenCalledWith(expect.any(Function), 'image/jpeg'); + }); +}); + +// ─── 快照测试 ──────────────────────────────────────────────────────────── +describe('formatImages 快照', () => { + test('字符串数组快照', () => { + const result = formatImages(['img1.jpg', 'img2.png', 'img3.gif']); + expect(result).toMatchSnapshot('formatImages-string-array'); + }); + + test('ImageInfo 对象数组快照', () => { + const result = formatImages([ + { mainImage: 'main1.jpg', thumbnail: 'thumb1.jpg', download: true }, + { mainImage: 'main2.jpg', download: false, isSvg: true }, + ]); + expect(result).toMatchSnapshot('formatImages-image-info-array'); + }); + + test('混合数组快照', () => { + const result = formatImages(['simple.jpg', { mainImage: 'complex.jpg', thumbnail: 'complex-thumb.jpg' }]); + expect(result).toMatchSnapshot('formatImages-mixed-array'); + }); + + test('空数组快照', () => { + expect(formatImages([])).toMatchSnapshot('formatImages-empty'); + }); +}); diff --git a/packages/components/image-viewer/hooks/useImageScale.ts b/packages/components/image-viewer/hooks/useImageScale.ts index 1b33ac41e7..151adfb2fa 100644 --- a/packages/components/image-viewer/hooks/useImageScale.ts +++ b/packages/components/image-viewer/hooks/useImageScale.ts @@ -1,11 +1,12 @@ +import { DEFAULT_IMAGE_SCALE } from '@tdesign/common-js/image-viewer/transform'; + import type { ImageScale } from '../type'; const useImageScale = (imageScale?: Partial) => { + // 合并默认值和用户设置 const result: ImageScale = { - max: imageScale?.max ?? 2, - min: imageScale?.min ?? 0.5, - step: imageScale?.step ?? 0.5, - defaultScale: imageScale?.defaultScale, + ...DEFAULT_IMAGE_SCALE, + ...imageScale, }; // defaultScale 不能超出本身设置的最大和最小值 if (imageScale?.defaultScale !== undefined) { diff --git a/packages/components/image-viewer/hooks/useIndex.ts b/packages/components/image-viewer/hooks/useIndex.ts index 40ac0112bc..e584b21293 100644 --- a/packages/components/image-viewer/hooks/useIndex.ts +++ b/packages/components/image-viewer/hooks/useIndex.ts @@ -14,9 +14,10 @@ const useIndex = (resProps, images) => { }, [setIndex, index, images.length]); const prev = useCallback(() => { - const newIndex = index - 1 > 0 ? index - 1 : 0; + const newIndex = index - 1; + if (newIndex < 0) return index; setIndex(newIndex, { trigger: 'prev' }); - }, [index, setIndex]); + }, [setIndex, index]); return { index, diff --git a/packages/components/image-viewer/hooks/useMirror.ts b/packages/components/image-viewer/hooks/useMirror.ts index 1939577f4e..56149b0659 100644 --- a/packages/components/image-viewer/hooks/useMirror.ts +++ b/packages/components/image-viewer/hooks/useMirror.ts @@ -1,14 +1,15 @@ // 镜像控制 import { useCallback, useState } from 'react'; +import { MIRROR_DEFAULT, toggleMirror } from '@tdesign/common-js/image-viewer/transform'; const useMirror = () => { - const [mirror, setMirror] = useState(1); + const [mirror, setMirror] = useState(MIRROR_DEFAULT); const onMirror = useCallback(() => { - setMirror((mirror) => (mirror > 0 ? -1 : 1)); + setMirror((current) => toggleMirror(current)); }, []); - const onResetMirror = useCallback(() => setMirror(1), []); + const onResetMirror = useCallback(() => setMirror(MIRROR_DEFAULT), []); return { mirror, diff --git a/packages/components/image-viewer/hooks/usePosition.ts b/packages/components/image-viewer/hooks/usePosition.ts index 30662ddaaf..f09e0a21c0 100644 --- a/packages/components/image-viewer/hooks/usePosition.ts +++ b/packages/components/image-viewer/hooks/usePosition.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import useMouseEvent from '../../hooks/useMouseEvent'; @@ -12,12 +12,21 @@ const usePosition = (imgRef: React.RefObject, options?: Position const { initPosition = [0, 0] } = options || {}; const [position, setPosition] = useState(initPosition); + const [isDragging, setIsDragging] = useState(false); const lastScreenPositionRef = useRef<{ x: number; y: number } | null>(null); + // 始终保持最新值的 ref,供外部免订阅地读取 + const positionRef = useRef(position); + positionRef.current = position; + + const isDraggingRef = useRef(isDragging); + isDraggingRef.current = isDragging; + useMouseEvent(imgRef, { onDown: (e) => { const { screenX, screenY } = e; lastScreenPositionRef.current = { x: screenX, y: screenY }; + setIsDragging(true); }, onMove: (e) => { if (!lastScreenPositionRef.current) return; @@ -31,11 +40,21 @@ const usePosition = (imgRef: React.RefObject, options?: Position }, onUp: () => { lastScreenPositionRef.current = null; + setIsDragging(false); }, }); + const resetPosition = useCallback(() => { + setPosition(initPosition); + }, [initPosition]); + return { position, + positionRef, + setPosition, + resetPosition, + isDragging, + isDraggingRef, }; }; diff --git a/packages/components/image-viewer/hooks/useRotate.ts b/packages/components/image-viewer/hooks/useRotate.ts index f8e9fb5800..b83360b349 100644 --- a/packages/components/image-viewer/hooks/useRotate.ts +++ b/packages/components/image-viewer/hooks/useRotate.ts @@ -1,28 +1,25 @@ // 旋转控制 import { useCallback, useRef, useState } from 'react'; +import { calcResetRotation, ROTATE_DEG } from '@tdesign/common-js/image-viewer/transform'; const useRotate = () => { // There is an useEffect in the line 472 of ImageViewerModal.tsx, so we need to use a ref to store the rotateZ value. const rotRef = useRef(0); const [rotateZ, setRotateZ] = useState(0); - const onRotate = useCallback((ROTATE_COUNT: number) => { - setRotateZ((rotateZ) => { - rotRef.current = rotateZ + ROTATE_COUNT; - return rotateZ + ROTATE_COUNT; + const onRotate = useCallback(() => { + setRotateZ((prev) => { + rotRef.current = prev + ROTATE_DEG; + return prev + ROTATE_DEG; }); }, []); const onResetRotate = useCallback(() => { - let degreeToRotate = rotRef.current % 360; - // make sure we always rotate to the shortest direction - if (Math.abs(degreeToRotate) > 180) { - degreeToRotate = (degreeToRotate + 360) % 360; - } - if (degreeToRotate !== 0) { - setRotateZ((rotateZ) => { - rotRef.current = rotateZ - degreeToRotate; - return rotateZ - degreeToRotate; + const adjusted = calcResetRotation(rotRef.current); + if (adjusted !== 0) { + setRotateZ((prev) => { + rotRef.current = prev - adjusted; + return prev - adjusted; }); } }, []); diff --git a/packages/components/image-viewer/hooks/useScale.ts b/packages/components/image-viewer/hooks/useScale.ts index 4a5f7c4ec3..0c1ab12685 100644 --- a/packages/components/image-viewer/hooks/useScale.ts +++ b/packages/components/image-viewer/hooks/useScale.ts @@ -1,46 +1,45 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { clampScale, DEFAULT_IMAGE_SCALE, zoomIn, zoomOut } from '@tdesign/common-js/image-viewer/transform'; +import type { ZoomOptions, ZoomResult } from '@tdesign/common-js/image-viewer/transform'; import type { ImageScale } from '../type'; -const useScale = (imageScale: ImageScale, visible: boolean) => { - const { max = Infinity, min = 0, step = 0.1, defaultScale = 1 } = imageScale; +const useScale = (imageScale: ImageScale, visible: boolean, onWheel?: (e: WheelEvent) => void) => { + const { max, min, step, defaultScale } = { ...DEFAULT_IMAGE_SCALE, ...imageScale }; - const calcDefaultScale = useCallback(() => Math.max(Math.min(defaultScale, max), min), [defaultScale, max, min]); + const calcDefaultScale = useCallback(() => clampScale(defaultScale, min, max), [defaultScale, max, min]); const distance = useRef(0); const [scale, setScale] = useState(calcDefaultScale()); + const scaleRef = useRef(scale); - const onZoom = useCallback(() => { - setScale((scale) => { - const newScale = scale + step; - if (newScale < min) return min; - if (newScale > max) return max; - return newScale; - }); - }, [max, min, step]); - - const onZoomOut = useCallback(() => { - setScale((scale) => { - const newScale = scale - step; - if (newScale < min) return min; - if (newScale > max) return max; - return newScale; - }); - }, [max, min, step]); + const paramsRef = useRef({ step, min, max }); + paramsRef.current = { step, min, max }; + + const onZoomIn = useCallback((zoomOptions?: ZoomOptions): ZoomResult => { + const { step: s, min: mi, max: ma } = paramsRef.current; + const { newScale, zoomResult } = zoomIn(scaleRef.current, s, mi, ma, zoomOptions); + if (newScale === scaleRef.current) return {}; + scaleRef.current = newScale; + setScale(newScale); + return zoomResult; + }, []); + + const onZoomOut = useCallback((zoomOptions?: ZoomOptions): ZoomResult => { + const { step: s, min: mi, max: ma } = paramsRef.current; + const { newScale, zoomResult } = zoomOut(scaleRef.current, s, mi, ma, zoomOptions); + if (newScale === scaleRef.current) return {}; + scaleRef.current = newScale; + setScale(newScale); + return zoomResult; + }, []); const onResetScale = useCallback(() => { - setScale(calcDefaultScale()); + const defaultVal = calcDefaultScale(); + scaleRef.current = defaultVal; + setScale(defaultVal); }, [calcDefaultScale]); - // 鼠标滚轮缩放 - const onWheel = useCallback( - (e: WheelEvent) => { - e.preventDefault(); - e.deltaY < 0 ? onZoom() : onZoomOut(); - }, - [onZoom, onZoomOut], - ); - // 双指缩放 const onTouchStart = useCallback((e: TouchEvent) => { if (e.touches.length !== 2) return; @@ -56,38 +55,48 @@ const useScale = (imageScale: ImageScale, visible: boolean) => { const [touch1, touch2] = Array.from(e.touches); const currentDistance = Math.hypot(touch2.pageX - touch1.pageX, touch2.pageY - touch1.pageY); if (currentDistance > distance.current) { - onZoom(); + onZoomIn(); } else { onZoomOut(); } distance.current = currentDistance; }, - [onZoom, onZoomOut], + [onZoomIn, onZoomOut], ); const onTouchEnd = useCallback(() => { distance.current = 0; }, []); + const onWheelRef = useRef(onWheel); + onWheelRef.current = onWheel; + + const stableOnWheel = useCallback((e: WheelEvent) => { + onWheelRef.current?.(e); + }, []); + useEffect(() => { if (!visible) return; - document.addEventListener('wheel', onWheel, { passive: false }); + document.addEventListener('wheel', stableOnWheel, { passive: false }); document.addEventListener('touchstart', onTouchStart, { passive: false }); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); return () => { - document.removeEventListener('wheel', onWheel); + document.removeEventListener('wheel', stableOnWheel); document.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); }; - }, [visible, onWheel, onTouchStart, onTouchMove, onTouchEnd]); + }, [visible, stableOnWheel, onTouchStart, onTouchMove, onTouchEnd]); return { scale, - onZoom, + onZoomIn, onZoomOut, onResetScale, + onTouchStart, + onTouchMove, + onTouchEnd, }; }; diff --git a/packages/tdesign-react/.changelog/pr-4184.md b/packages/tdesign-react/.changelog/pr-4184.md new file mode 100644 index 0000000000..adb888e0a4 --- /dev/null +++ b/packages/tdesign-react/.changelog/pr-4184.md @@ -0,0 +1,6 @@ +--- +pr_number: 4184 +contributor: RSS1102 +--- + +- feat(ImageViewer): 优化 ImageViewer 的行为,支持视口之外的图片向中心缩放 @RSS1102 ([#4184](https://github.com/Tencent/tdesign-react/pull/4184))