Skip to content

Commit 32eafae

Browse files
committed
feat(ImageViewer): support drag and mouse-centered zoom
1 parent 02aadee commit 32eafae

File tree

7 files changed

+269
-30
lines changed

7 files changed

+269
-30
lines changed

packages/components/image-viewer/_example/base.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
<template>
22
<div class="tdesign-demo-image-viewer__base">
3-
<t-image-viewer :images="[img]" :z-index="10000"></t-image-viewer>
3+
<t-image-viewer
4+
:images="[img]"
5+
:z-index="10000"
6+
draggable-overlay
7+
:image-scale="{
8+
// max: 5,
9+
// min: 0.2,
10+
// step: 0.3,
11+
scaleFromMousePosition: true, // 启用鼠标位置缩放
12+
}"
13+
></t-image-viewer>
414
</div>
515
</template>
616
<script setup>

packages/components/image-viewer/base/ImageItem.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,42 @@ export default defineComponent({
1515
placementSrc: [String, Object] as PropType<string | File>,
1616
isSvg: Boolean,
1717
imageReferrerpolicy: String as PropType<TdImageViewerProps['imageReferrerpolicy']>,
18+
// 接收外部传入的拖拽状态和处理器
19+
transform: {
20+
type: Object as PropType<{ translateX: number; translateY: number }>,
21+
default: () => ({ translateX: 0, translateY: 0 }),
22+
},
23+
mouseDownHandler: {
24+
type: Function as PropType<(e: MouseEvent) => void>,
25+
default: undefined,
26+
},
1827
},
1928

20-
setup(props) {
21-
const { src, placementSrc, isSvg } = toRefs(props);
29+
setup(props, { expose }) {
30+
const {
31+
src,
32+
placementSrc,
33+
isSvg,
34+
transform: transformProp,
35+
mouseDownHandler: mouseDownHandlerProp,
36+
} = toRefs(props);
2237
const classPrefix = usePrefixClass();
2338
const error = ref(false);
2439
const loaded = ref(false);
25-
const { transform, mouseDownHandler } = useDrag({ translateX: 0, translateY: 0 });
40+
// 使用外部传入的 transform 和 mouseDownHandler,如果没有则使用内部的
41+
const { transform: internalTransform, mouseDownHandler: internalMouseDownHandler } = useDrag({
42+
translateX: 0,
43+
translateY: 0,
44+
});
45+
const transform = transformProp.value ? transformProp : internalTransform;
46+
const mouseDownHandler = mouseDownHandlerProp.value || internalMouseDownHandler;
2647
const { globalConfig } = useConfig('imageViewer');
2748
const errorText = globalConfig.value.errorText;
2849
const svgElRef = ref<HTMLDivElement>();
50+
const modalBoxRef = ref<HTMLDivElement>();
51+
52+
// 暴露 modal-box ref,用于检测鼠标是否在图片上
53+
expose({ modalBoxRef });
2954

3055
const imgStyle = computed(() => ({
3156
transform: `rotate(${props.rotate}deg)`,
@@ -121,7 +146,7 @@ export default defineComponent({
121146

122147
return () => (
123148
<div class={`${classPrefix.value}-image-viewer__modal-pic`}>
124-
<div class={`${classPrefix.value}-image-viewer__modal-box`} style={boxStyle.value}>
149+
<div ref={modalBoxRef} class={`${classPrefix.value}-image-viewer__modal-box`} style={boxStyle.value}>
125150
{error.value && (
126151
<div class={`${classPrefix.value}-image-viewer__img-error`}>
127152
{/* 脱离文档流 */}

packages/components/image-viewer/base/ImageViewerModal.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ export default defineComponent({
4444
showOverlay: Boolean,
4545
closeBtn: props.closeBtn,
4646
imageReferrerpolicy: props.imageReferrerpolicy,
47+
// 接收外部传入的拖拽状态和处理器
48+
transform: {
49+
type: Object as PropType<{ translateX: number; translateY: number }>,
50+
default: undefined,
51+
},
52+
mouseDownHandler: {
53+
type: Function as PropType<(e: MouseEvent) => void>,
54+
default: undefined,
55+
},
4756
},
4857
setup(props) {
4958
const classPrefix = usePrefixClass();
@@ -93,6 +102,8 @@ export default defineComponent({
93102
placementSrc={props.currentImage.thumbnail}
94103
isSvg={props.currentImage.isSvg}
95104
imageReferrerpolicy={props.imageReferrerpolicy}
105+
transform={props.transform}
106+
mouseDownHandler={props.mouseDownHandler}
96107
/>
97108
</div>
98109
</TDialog>

packages/components/image-viewer/hooks/index.ts

Lines changed: 97 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
import { positiveSubtract, positiveAdd } from '@tdesign/common-js/input-number/number';
22
import { ref, watch } from 'vue';
33
import { ImageScale } from '../type';
4-
import { throttle } from 'lodash-es';
54

65
interface InitTransform {
76
translateX: number;
87
translateY: number;
98
}
109

11-
export function useDrag(initTransform: InitTransform) {
10+
interface DragOptions {
11+
maxTranslateX?: number;
12+
maxTranslateY?: number;
13+
}
14+
15+
export function useDrag(
16+
initTransform: InitTransform,
17+
onDragStart?: () => void,
18+
onDragEnd?: (distance: number) => void,
19+
options?: DragOptions,
20+
) {
1221
const transform = ref(initTransform);
1322

1423
const mouseDownHandler = (e: MouseEvent) => {
@@ -17,18 +26,41 @@ export function useDrag(initTransform: InitTransform) {
1726

1827
const { pageX: startX, pageY: startY } = e;
1928
const { translateX, translateY } = transform.value;
29+
let totalDistance = 0;
30+
31+
onDragStart?.();
32+
2033
const mouseMoveHandler = (e: MouseEvent) => {
2134
const { pageX, pageY } = e;
35+
const deltaX = pageX - startX;
36+
const deltaY = pageY - startY;
37+
38+
// 计算移动距离
39+
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
40+
totalDistance = distance;
41+
42+
let newTranslateX = translateX + deltaX;
43+
let newTranslateY = translateY + deltaY;
44+
45+
// 应用边界限制
46+
if (options?.maxTranslateX !== undefined) {
47+
newTranslateX = Math.max(-options.maxTranslateX, Math.min(options.maxTranslateX, newTranslateX));
48+
}
49+
if (options?.maxTranslateY !== undefined) {
50+
newTranslateY = Math.max(-options.maxTranslateY, Math.min(options.maxTranslateY, newTranslateY));
51+
}
52+
2253
transform.value = {
23-
translateX: translateX + pageX - startX,
24-
translateY: translateY + pageY - startY,
54+
translateX: newTranslateX,
55+
translateY: newTranslateY,
2556
};
2657
};
2758

2859
const removeHandler = () => {
2960
document.removeEventListener('mousemove', mouseMoveHandler);
3061
document.removeEventListener('mouseup', mouseUpHandler);
3162
document.removeEventListener('mouseleave', mouseLeaveHandler);
63+
onDragEnd?.(totalDistance);
3264
};
3365

3466
const mouseUpHandler = () => removeHandler();
@@ -58,34 +90,79 @@ export function useMirror() {
5890
return { mirror, onMirror, resetMirror };
5991
}
6092

93+
export interface ZoomOptions {
94+
/** 鼠标相对于容器中心的 X 偏移 */
95+
mouseOffsetX?: number;
96+
/** 鼠标相对于容器中心的 Y 偏移 */
97+
mouseOffsetY?: number;
98+
/** 当前的位移值 */
99+
currentTranslate?: { translateX: number; translateY: number };
100+
}
101+
102+
export interface ZoomResult {
103+
/** 新的位移值(用于替换,而不是累加) */
104+
newTranslate?: { translateX: number; translateY: number };
105+
}
106+
61107
export function useScale(imageScale: ImageScale) {
62108
const params = { max: 2, min: 0.5, step: 0.2, defaultScale: 1, ...imageScale };
63109
const { max, min, step, defaultScale } = params;
64110
const scale = ref(defaultScale);
65111

66-
const onZoomIn = throttle(() => {
67-
const result = positiveAdd(scale.value, step);
68-
setScale(result);
69-
}, 50);
112+
/**
113+
* 计算缩放后的位移补偿,保持鼠标指向的图片内容在屏幕上的位置不变
114+
*
115+
* 公式推导:设 Z = 缩放中心(鼠标位置),T = 当前位移,λ'/λ = scaleRatio
116+
* newTranslate = scaleRatio * T + (1 - scaleRatio) * Z
117+
*/
118+
const calculateTranslateOffset = (
119+
oldScale: number,
120+
newScale: number,
121+
options?: ZoomOptions,
122+
): { translateX: number; translateY: number } | undefined => {
123+
// 缺少鼠标位置信息时,不计算位移补偿
124+
if (options?.mouseOffsetX == null || options?.mouseOffsetY == null) {
125+
return undefined;
126+
}
127+
128+
const scaleRatio = newScale / oldScale;
129+
const { translateX = 0, translateY = 0 } = options?.currentTranslate ?? {};
130+
const { mouseOffsetX, mouseOffsetY } = options;
131+
132+
return {
133+
translateX: scaleRatio * translateX + (1 - scaleRatio) * mouseOffsetX,
134+
translateY: scaleRatio * translateY + (1 - scaleRatio) * mouseOffsetY,
135+
};
136+
};
70137

71-
const onZoomOut = throttle(() => {
72-
const result = positiveSubtract(scale.value, step);
73-
setScale(result);
74-
}, 50);
138+
const onZoomIn = (options?: ZoomOptions): ZoomResult => {
139+
const oldScale = scale.value;
140+
const result = positiveAdd(oldScale, step);
141+
const newScale = Math.min(result, max);
142+
setScale(newScale);
143+
144+
return {
145+
newTranslate: calculateTranslateOffset(oldScale, newScale, options),
146+
};
147+
};
148+
149+
const onZoomOut = (options?: ZoomOptions): ZoomResult => {
150+
const oldScale = scale.value;
151+
const result = positiveSubtract(oldScale, step);
152+
const newScale = Math.max(result, min);
153+
setScale(newScale);
154+
155+
return {
156+
newTranslate: calculateTranslateOffset(oldScale, newScale, options),
157+
};
158+
};
75159

76160
const resetScale = () => {
77161
scale.value = defaultScale;
78162
};
79163

80164
const setScale = (newScale: number) => {
81-
let value = newScale;
82-
if (newScale < min) {
83-
value = min;
84-
}
85-
if (newScale > max) {
86-
value = max;
87-
}
88-
scale.value = value;
165+
scale.value = Math.max(min, Math.min(max, newScale));
89166
};
90167

91168
watch(

0 commit comments

Comments
 (0)