Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(imagePreview): Added image preview ability to rotate images #13205

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
38 changes: 38 additions & 0 deletions packages/vant/src/image-preview/ImagePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export const imagePreviewProps = {
closeOnClickOverlay: truthProp,
closeIconPosition: makeStringProp<PopupCloseIconPosition>('top-right'),
teleport: [String, Object] as PropType<TeleportProps['to']>,
rotate: Boolean,
};

export type ImagePreviewProps = ExtractPropTypes<typeof imagePreviewProps>;
Expand All @@ -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);
Expand Down Expand Up @@ -154,6 +164,31 @@ export default defineComponent({
state.disableZoom = false;
};

const renderRotateButtons = () => {
if (!props.rotate) return null;

return (
<div class={bem('rotate-buttons')}>
<button
class={bem('rotate-button')}
onClick={() => handleRotate('left')}
>
<Icon
role="button"
name="replay"
class={bem('rotate-icon--reverse')}
/>
</button>
<button
class={bem('rotate-button')}
onClick={() => handleRotate('right')}
>
<Icon role="button" name="replay" />
</button>
</div>
);
};

const renderImages = () => (
<Swipe
ref={swipeRef}
Expand All @@ -174,6 +209,7 @@ export default defineComponent({
v-slots={{
image: slots.image,
}}
rotateAngle={state.rotateAngles[index]}
ref={(item) => {
if (index === state.active) {
activedPreviewItemRef.value = item as ImagePreviewItemInstance;
Expand Down Expand Up @@ -241,6 +277,7 @@ export default defineComponent({
(value) => {
const { images, startPosition } = props;
if (value) {
state.rotateAngles = images.map(() => 0);
setActive(+startPosition);
nextTick(() => {
resize();
Expand All @@ -266,6 +303,7 @@ export default defineComponent({
{renderClose()}
{renderImages()}
{renderIndex()}
{renderRotateButtons()}
{renderCover()}
</Popup>
);
Expand Down
108 changes: 93 additions & 15 deletions packages/vant/src/image-preview/ImagePreviewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
makeRequiredProp,
LONG_PRESS_START_TIME,
type ComponentInstance,
makeNumberProp,
} from '../utils';

// Composables
Expand Down Expand Up @@ -57,6 +58,7 @@ const imagePreviewItemProps = {
closeOnClickImage: Boolean,
closeOnClickOverlay: Boolean,
vertical: Boolean,
rotateAngle: makeNumberProp(0),
};

export type ImagePreviewItemProps = ExtractPropTypes<
Expand Down Expand Up @@ -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;
});

Expand Down
2 changes: 2 additions & 0 deletions packages/vant/src/image-preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/vant/src/image-preview/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
29 changes: 29 additions & 0 deletions packages/vant/src/image-preview/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
69 changes: 69 additions & 0 deletions packages/vant/src/image-preview/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down Expand Up @@ -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',
);
});