diff --git a/packages/vant/src/image-preview/ImagePreview.tsx b/packages/vant/src/image-preview/ImagePreview.tsx index 91df7a15752..337d42c3be6 100644 --- a/packages/vant/src/image-preview/ImagePreview.tsx +++ b/packages/vant/src/image-preview/ImagePreview.tsx @@ -78,6 +78,7 @@ export const imagePreviewProps = { closeOnClickOverlay: truthProp, closeIconPosition: makeStringProp('top-right'), teleport: [String, Object] as PropType, + rotate: Boolean, }; export type ImagePreviewProps = ExtractPropTypes; @@ -98,8 +99,17 @@ export default defineComponent({ rootWidth: 0, rootHeight: 0, disableZoom: false, + rotateAngles: [] as number[], }); + const handleRotate = (direction: 'left' | 'right') => { + if (!props.rotate) return; + const angle = 90 * (direction === 'left' ? -1 : 1); + // 更新当前图片的旋转角度 + state.rotateAngles[state.active] = + (state.rotateAngles[state.active] || 0) + angle; + }; + const resize = () => { if (swipeRef.value) { const rect = useRect(swipeRef.value.$el); @@ -154,6 +164,31 @@ export default defineComponent({ state.disableZoom = false; }; + const renderRotateButtons = () => { + if (!props.rotate) return null; + + return ( +
+ + +
+ ); + }; + const renderImages = () => ( { if (index === state.active) { activedPreviewItemRef.value = item as ImagePreviewItemInstance; @@ -241,6 +277,7 @@ export default defineComponent({ (value) => { const { images, startPosition } = props; if (value) { + state.rotateAngles = images.map(() => 0); setActive(+startPosition); nextTick(() => { resize(); @@ -266,6 +303,7 @@ export default defineComponent({ {renderClose()} {renderImages()} {renderIndex()} + {renderRotateButtons()} {renderCover()} ); diff --git a/packages/vant/src/image-preview/ImagePreviewItem.tsx b/packages/vant/src/image-preview/ImagePreviewItem.tsx index 15ed76f3a20..849a263fd11 100644 --- a/packages/vant/src/image-preview/ImagePreviewItem.tsx +++ b/packages/vant/src/image-preview/ImagePreviewItem.tsx @@ -17,6 +17,7 @@ import { makeRequiredProp, LONG_PRESS_START_TIME, type ComponentInstance, + makeNumberProp, } from '../utils'; // Composables @@ -57,6 +58,7 @@ const imagePreviewItemProps = { closeOnClickImage: Boolean, closeOnClickOverlay: Boolean, vertical: Boolean, + rotateAngle: makeNumberProp(0), }; export type ImagePreviewItemProps = ExtractPropTypes< @@ -87,43 +89,119 @@ export default defineComponent({ let initialMoveY = 0; + const getRotatedDimensions = ( + width: number, + height: number, + angle: number, + ) => { + const radians = (Math.abs(angle) * Math.PI) / 180; + const sin = Math.sin(radians); + const cos = Math.cos(radians); + const rotatedWidth = Math.abs(width * cos) + Math.abs(height * sin); + const rotatedHeight = Math.abs(width * sin) + Math.abs(height * cos); + return { width: rotatedWidth, height: rotatedHeight }; + }; + + const getContainScale = computed(() => { + if (!state.imageRatio) return 1; + const { rootWidth, rootHeight, rotateAngle } = props; + const naturalWidth = rootWidth; + const naturalHeight = naturalWidth * state.imageRatio; + const rotated = getRotatedDimensions( + naturalWidth, + naturalHeight, + rotateAngle, + ); + const scaleX = rootWidth / rotated.width; + const scaleY = rootHeight / rotated.height; + return Math.min(scaleX, scaleY); + }); + watch( + () => props.rotateAngle, + () => { + adjustPositionAfterRotate(); + }, + ); + + const adjustPositionAfterRotate = () => { + if (state.scale === 1) { + state.moveX = 0; + state.moveY = isLongImage.value ? initialMoveY : 0; + return; + } + state.moveX = clamp(state.moveX, -maxMoveX.value, maxMoveX.value); + state.moveY = clamp(state.moveY, -maxMoveY.value, maxMoveY.value); + if ( + Math.abs(state.moveX) > maxMoveX.value || + Math.abs(state.moveY) > maxMoveY.value + ) { + state.moveX = 0; + state.moveY = 0; + } + }; + const imageStyle = computed(() => { const { scale, moveX, moveY, moving, zooming, initializing } = state; const style: CSSProperties = { transitionDuration: zooming || moving || initializing ? '0s' : '.3s', }; - if (scale !== 1 || isLongImage.value) { - // use matrix to solve the problem of elements not rendering due to safari optimization - style.transform = `matrix(${scale}, 0, 0, ${scale}, ${moveX}, ${moveY})`; + const transforms: string[] = []; + const actualScale = scale * getContainScale.value; + + if (actualScale !== 1 || isLongImage.value) { + transforms.push( + `matrix(${actualScale}, 0, 0, ${actualScale}, ${moveX}, ${moveY})`, + ); + } + + if (props.rotateAngle !== 0) { + const angle = (props.rotateAngle * Math.PI) / 180; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + transforms.push(`matrix(${cos}, ${sin}, ${-sin}, ${cos}, 0, 0)`); + } + + if (transforms.length) { + style.transform = transforms.join(' '); } return style; }); + // 添加旋转后的尺寸计算 + const getRotatedImageSize = computed(() => { + const { rootWidth } = props; + const naturalWidth = rootWidth; + const naturalHeight = naturalWidth * state.imageRatio; + const { width: rotatedWidth, height: rotatedHeight } = + getRotatedDimensions(naturalWidth, naturalHeight, props.rotateAngle); + return { + width: rotatedWidth, + height: rotatedHeight, + }; + }); + const maxMoveX = computed(() => { if (state.imageRatio) { - const { rootWidth, rootHeight } = props; - const displayWidth = vertical.value - ? rootHeight / state.imageRatio - : rootWidth; + const { rootWidth } = props; + const { width: rotatedWidth } = getRotatedImageSize.value; + const scaledWidth = rotatedWidth * state.scale * getContainScale.value; - return Math.max(0, (state.scale * displayWidth - rootWidth) / 2); + return Math.max(0, (scaledWidth - rootWidth) / 2); } - return 0; }); const maxMoveY = computed(() => { if (state.imageRatio) { - const { rootWidth, rootHeight } = props; - const displayHeight = vertical.value - ? rootHeight - : rootWidth * state.imageRatio; + const { rootHeight } = props; + const { height: rotatedHeight } = getRotatedImageSize.value; + const scaledHeight = + rotatedHeight * state.scale * getContainScale.value; - return Math.max(0, (state.scale * displayHeight - rootHeight) / 2); + return Math.max(0, (scaledHeight - rootHeight) / 2); } - return 0; }); diff --git a/packages/vant/src/image-preview/README.md b/packages/vant/src/image-preview/README.md index a4e6fc1b7c6..905e0e03b23 100644 --- a/packages/vant/src/image-preview/README.md +++ b/packages/vant/src/image-preview/README.md @@ -238,6 +238,7 @@ Vant exports following ImagePreview utility functions: | overlayClass | Custom overlay class | _string \| Array \| object_ | - | | overlayStyle | Custom overlay style | _object_ | - | | teleport | Specifies a target element where ImagePreview will be mounted | _string \| Element_ | - | +| rotate | Whether to enable the image rotation function | _boolean_ | `false` | ### Props @@ -266,6 +267,7 @@ Vant exports following ImagePreview utility functions: | overlay-class | Custom overlay class | _string \| Array \| object_ | - | | overlay-style | Custom overlay style | _object_ | - | | teleport | Specifies a target element where ImagePreview will be mounted | _string \| Element_ | - | +| rotate | Whether to enable the image rotation function | _boolean_ | `false` | ### Events diff --git a/packages/vant/src/image-preview/README.zh-CN.md b/packages/vant/src/image-preview/README.zh-CN.md index f06483f83f4..5e72b234d05 100644 --- a/packages/vant/src/image-preview/README.zh-CN.md +++ b/packages/vant/src/image-preview/README.zh-CN.md @@ -241,6 +241,7 @@ Vant 中导出了以下 ImagePreview 相关的辅助函数: | overlayClass | 自定义遮罩层类名 | _string \| Array \| object_ | - | | overlayStyle | 自定义遮罩层样式 | _object_ | - | | teleport | 指定挂载的节点,等同于 Teleport 组件的 [to 属性](https://cn.vuejs.org/api/built-in-components.html#teleport) | _string \| Element_ | - | +| rotate | 是否开启图片旋转功能 | _boolean_ | `false` | ### Props @@ -271,6 +272,7 @@ Vant 中导出了以下 ImagePreview 相关的辅助函数: | overlay-class | 自定义遮罩层类名 | _string \| Array \| object_ | - | | overlay-style | 自定义遮罩层样式 | _object_ | - | | teleport | 指定挂载的节点,等同于 Teleport 组件的 [to 属性](https://cn.vuejs.org/api/built-in-components.html#teleport) | _string \| Element_ | - | +| rotate | 是否开启图片旋转功能 | _boolean_ | `false` | ### Events diff --git a/packages/vant/src/image-preview/index.less b/packages/vant/src/image-preview/index.less index 5ca9ca308c1..307453391d2 100644 --- a/packages/vant/src/image-preview/index.less +++ b/packages/vant/src/image-preview/index.less @@ -9,6 +9,9 @@ --van-image-preview-close-icon-color: var(--van-gray-5); --van-image-preview-close-icon-margin: var(--van-padding-md); --van-image-preview-close-icon-z-index: 1; + --van-image-preview-rotate-icon-size: 22px; + --van-image-preview-rotate-button-bottom: 40px; + --van-image-preview-rotate-button-gap: 40px; } .van-image-preview { @@ -111,4 +114,30 @@ bottom: var(--van-image-preview-close-icon-margin); } } + + &__rotate-buttons { + position: absolute; + bottom: var(--van-image-preview-rotate-button-bottom); + left: 50%; + transform: translateX(-50%); + z-index: 99; + display: flex; + gap: var(--van-image-preview-rotate-button-gap); + } + + &__rotate-button { + width: var(--van-image-preview-rotate-icon-size); + height: var(--van-image-preview-rotate-icon-size); + border-radius: 50%; + background: var(--van-image-preview-close-icon-color); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + &__rotate-icon--reverse { + transform: scaleX(-1); + } } diff --git a/packages/vant/src/image-preview/test/index.spec.ts b/packages/vant/src/image-preview/test/index.spec.ts index 94525b683a1..1b0959fe1c0 100644 --- a/packages/vant/src/image-preview/test/index.spec.ts +++ b/packages/vant/src/image-preview/test/index.spec.ts @@ -9,6 +9,7 @@ import { LONG_PRESS_START_TIME } from '../../utils'; import ImagePreview from '../ImagePreview'; import { images, triggerDoubleTap, triggerZoom } from './shared'; import type { ImagePreviewInstance } from '../types'; +import { nextTick } from 'vue'; test('should swipe to current index after calling the swipeTo method', async () => { const wrapper = mount(ImagePreview, { @@ -460,3 +461,71 @@ test('should reset scale after calling the resetScale method', async () => { await later(); expect(image.style.transform).toBeFalsy(); }); + +test('should render rotate buttons when rotate prop is number', async () => { + const wrapper = mount(ImagePreview, { + props: { + show: true, + images, + rotate: 90, + }, + }); + + await later(); + + const rotateButtons = wrapper.findAll('.van-image-preview__rotate-button'); + expect(rotateButtons).toHaveLength(2); + expect( + wrapper.find('.van-image-preview__rotate-buttons').exists(), + ).toBeTruthy(); +}); + +test('should not render rotate buttons when rotate prop is null', () => { + const wrapper = mount(ImagePreview, { + props: { + show: true, + images, + }, + }); + + expect( + wrapper.find('.van-image-preview__rotate-buttons').exists(), + ).toBeFalsy(); +}); + +test('should respect custom rotation angle', async () => { + const wrapper = mount(ImagePreview, { + props: { + show: true, + images, + rotate: true, + }, + }); + await later(); + const rotateButtons = wrapper.findAll('.van-image-preview__rotate-button'); + + await rotateButtons[1].trigger('click'); + await nextTick(); + + const image = wrapper.find('.van-image-preview__image'); + const transformStyle = image.element.style.transform; + + const matches = transformStyle.match( + /matrix\(([-\d.e+-]+), ([-\d.e+-]+), ([-\d.e+-]+), ([-\d.e+-]+), ([-\d.e+-]+), ([-\d.e+-]+)\)/, + ); + if (!matches) { + console.log('No matches found for transform style'); + return; + } + const actualMatrix = matches + .slice(1, 7) + .map((n: string) => { + const num = Number(n); + return Math.abs(num) < 1e-10 ? '0.000000' : num.toFixed(6); + }) + .join(', '); + + expect(actualMatrix).toBe( + '0.000000, 1.000000, -1.000000, 0.000000, 0.000000, 0.000000', + ); +});