vue3使用Trae封装一个内部可自由放大、缩小、移动、旋转的通用盒子
1、概述
需求:加工或者检验需要查看图纸,有的字太小需要放大查看,还有的可能是竖直的,需要旋转以更好的查看。各大组件的图片预览直接全屏展示了,用户不可能看一个属性点一次查看在关闭预览吧,显然不符合需求,应该像下面这样左侧对比图可自由操作,右侧直接对比结果,当然,这只是应该demo,数据都是假的。而且图纸不一定是以图片的形式,可能是pdf或者其它格式,这时候就需要自己封装一个通用的盒子。
思路:外层盒子嵌套内层盒子,对内存盒子进行放大、旋转等操作,甭管内层盒子的内容是啥,只对内层的盒子进行操作。
效果预览
这是pc端的效果预览图,鼠标滑动进行缩放,长按左键进行拖动。移动端效果一致,双指进行缩放,长按就行拖动,移动端就懒得截了,大家感兴趣可以自己测试。
思路好了,接下来就是把需求喂给ai,让ai帮我们实现,最后我们只需要修复一下小bug和微调样式就ok了。
2、AI实现
我只提供了两个嵌套盒子,然后把思路喂给ai,让ai帮我们完成。
具体实现如下:
到这里,基本功能已经实现的,但是pc端有一个小bug,就是拖动内层盒子时,如果拖动的是图片元素,那么松开左键键后内层盒子还在跟随鼠标移动,正常情况时松开左键后只停止跟随鼠标移动。在浏览器中,img 标签默认是可以被拖动的,这是浏览器提供的原生拖动行为。当你把鼠标放在图片上并按住鼠标左键拖动时,通常会触发拖动操作,拖动时会出现一个半透明的拖动效果,可能是这个导致事件冲突了,我没让ai帮我修复,而是简单粗暴的给内层盒子加上了一个透明的遮罩层,完美解决问题。
3、源码及使用
- 把代码提取到单独的组件 moveBox.vue 中
- 在需要使用的地方,调用组件即可
源码
<template>
<div id="outerContainer" ref="outerContainerRef">
<div class="container" ref="containerRef" :style="containerStyle">
<slot></slot>
<div class="mask"></div>
</div>
<div class="controls">
<button @click="rotateLeft">向左旋转</button>
<button @click="rotateRight">向右旋转</button>
<button @click="resetTransform">样式还原</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
const outerContainerRef = ref<HTMLElement | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const transform = reactive({
scale: 1,
translateX: 0,
translateY: 0,
rotate: 0,
})
const containerStyle = computed(() => ({
transform: `translate(${transform.translateX}px, ${transform.translateY}px) scale(${transform.scale}) rotate(${transform.rotate}deg)`,
transformOrigin: 'center center',
cursor: 'grab', // Indicate it's draggable
}))
// --- Mouse Panning ---
let isPanning = false
let panStartX = 0
let panStartY = 0
let initialPanX = 0
let initialPanY = 0
const onMouseDown = (event: MouseEvent) => {
if (event.button !== 0) return // Only left click
isPanning = true
panStartX = event.clientX
panStartY = event.clientY
initialPanX = transform.translateX
initialPanY = transform.translateY
if (containerRef.value) {
containerRef.value.style.cursor = 'grabbing'
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
const onMouseMove = (event: MouseEvent) => {
if (!isPanning) return
const dx = event.clientX - panStartX
const dy = event.clientY - panStartY
transform.translateX = initialPanX + dx
transform.translateY = initialPanY + dy
}
const onMouseUp = () => {
isPanning = false
if (containerRef.value) {
containerRef.value.style.cursor = 'grab'
}
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
// --- Mouse Wheel Zoom ---
const onWheel = (event: WheelEvent) => {
event.preventDefault()
if (!outerContainerRef.value || !containerRef.value) return
const zoomFactor = 0.1
const oldScale = transform.scale
let newScale = oldScale
if (event.deltaY < 0) { // Zoom in
newScale = oldScale * (1 + zoomFactor)
} else { // Zoom out
newScale = oldScale * (1 - zoomFactor)
}
newScale = Math.max(0.5, Math.min(newScale, 3)) // Clamp scale
transform.scale = newScale
}
// --- Touch Interactions ---
let initialTouches: TouchList | null = null
let initialPinchDistance = 0
const getDistance = (touches: TouchList) => {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
const onTouchStart = (event: TouchEvent) => {
if (!outerContainerRef.value || !containerRef.value) return
// event.preventDefault(); // Prevent default only if interacting with the container
initialTouches = event.touches
if (initialTouches.length === 1) { // Pan
isPanning = true
panStartX = initialTouches[0].clientX
panStartY = initialTouches[0].clientY
initialPanX = transform.translateX
initialPanY = transform.translateY
} else if (initialTouches.length === 2) { // Pinch zoom (rotation removed)
isPanning = false; // Stop panning if it was active
initialPinchDistance = getDistance(initialTouches)
// initialAngle = getAngle(initialTouches); // Removed
}
}
const onTouchMove = (event: TouchEvent) => {
if (!initialTouches || !outerContainerRef.value || !containerRef.value) return
// event.preventDefault(); // Prevent default only if interacting with the container
const currentTouches = event.touches
if (currentTouches.length === 1 && isPanning) { // Pan
const dx = currentTouches[0].clientX - panStartX
const dy = currentTouches[0].clientY - panStartY
transform.translateX = initialPanX + dx
transform.translateY = initialPanY + dy
} else if (currentTouches.length === 2 && initialTouches && initialTouches.length === 2) { // Pinch zoom (rotation removed)
const currentPinchDistance = getDistance(currentTouches)
// const currentAngle = getAngle(currentTouches); // Removed
// Zoom
const oldScale = transform.scale
const newScale = oldScale * (currentPinchDistance / initialPinchDistance)
transform.scale = Math.max(0.5, Math.min(newScale, 3)) // Clamp scale
// Rotate (Removed)
// const angleDiff = currentAngle - initialAngle;
// transform.rotate += angleDiff;
// Update for next move
initialPinchDistance = currentPinchDistance // Update for continuous scaling
}
}
const onTouchEnd = (event: TouchEvent) => {
if (event.touches.length < 2) {
// If less than 2 touches, reset pinch state (rotation state removed)
initialTouches = null
initialPinchDistance = 0
// initialAngle = 0; // Removed
}
if (event.touches.length < 1) {
isPanning = false
}
// If one touch remains, and we were pinching, re-initialize for panning
if (event.touches.length === 1 && initialTouches && initialTouches.length === 2) {
isPanning = true;
panStartX = event.touches[0].clientX;
panStartY = event.touches[0].clientY;
initialPanX = transform.translateX;
initialPanY = transform.translateY;
}
initialTouches = event.touches.length > 0 ? event.touches : null;
}
onMounted(() => {
const el = containerRef.value
const outerEl = outerContainerRef.value
if (el) {
el.addEventListener('mousedown', onMouseDown)
// Touch events are added to the outer container to capture gestures starting outside the inner one
// but still intended for it, or to handle multi-touch better.
}
if (outerEl) {
outerEl.addEventListener('wheel', onWheel, { passive: false }) // passive: false to allow preventDefault
outerEl.addEventListener('touchstart', onTouchStart, { passive: false })
outerEl.addEventListener('touchmove', onTouchMove, { passive: false })
outerEl.addEventListener('touchend', onTouchEnd)
outerEl.addEventListener('touchcancel', onTouchEnd)
}
})
onUnmounted(() => {
const el = containerRef.value
const outerEl = outerContainerRef.value
if (el) {
el.removeEventListener('mousedown', onMouseDown)
}
if (outerEl) {
outerEl.removeEventListener('wheel', onWheel)
outerEl.removeEventListener('touchstart', onTouchStart)
outerEl.removeEventListener('touchmove', onTouchMove)
outerEl.removeEventListener('touchend', onTouchEnd)
outerEl.removeEventListener('touchcancel', onTouchEnd)
}
// Clean up document listeners if any were left (e.g., mousemove/mouseup)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
})
// --- Button Controls ---
const rotateLeft = () => {
transform.rotate -= 90 // Rotate 90 degrees left
}
const rotateRight = () => {
transform.rotate += 90 // Rotate 90 degrees right
}
const resetTransform = () => {
transform.scale = 1
transform.translateX = 0
transform.translateY = 0
transform.rotate = 0
}
</script>
<style scoped lang="scss">
#outerContainer {
width: 100%;
height: 100%;
overflow: hidden;
/* Crucial: clips the inner container */
background-color: #f0f0f0;
/* So we can see the bounds */
position: relative;
/* For positioning context if needed */
touch-action: none;
/* Prevents default browser touch actions like scrolling or pinch-zoom on the page itself */
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 20;
/* Ensure buttons are above the mask */
}
.controls button {
padding: 8px 15px;
cursor: pointer;
}
.container {
width: 50%;
/* Example fixed width */
height: 50%;
/* Example fixed height */
background-color: lightblue;
border: 1px solid blue;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
user-select: none;
p {
margin: 10px;
font-size: 1.2em;
}
.mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: transparent;
/* Semi-transparent black */
cursor: grab;
/* Indicate it's draggable */
z-index: 10;
}
}
</style>
使用
内层盒子内容直接通过匿名插槽传递进去就可以了,甭管是什么内容,都通用。
<template>
<MoveBox>
<h1>异环二测测试招募</h1>
<img src="https://yh.wanmei.com/images/cover250513/pageCity/slide4.jpg" style="width: 100%;" alt="">
</MoveBox>
</template>
<script setup lang="ts">
import MoveBox from '@/components/moveBox/moveBox.vue'
</script>
<style scoped></style>
4、遗留的问题
- 没有对边界进行处理,会直接把图片拖没了,只能通过还原按钮还原样式
- 没有进行节流操作,某些老的设备可能存在性能问题
有需要的可以让ai加上这两个功能。
5、优化,添加旋转过渡动画
- 添加过渡动画
- 使用 requestAnimationFrame 优化动画帧,减少不必要的动画重绘
- 移除 mask 遮罩层,减少不必要的性能开销,给需要移除事件的元素单独添加 pointer-events: none; 属性移除事件(img元素等)。
给样式添加动态的过渡动画样式即可:
<template>
<div id="outerContainer" ref="outerContainerRef">
<div class="container" ref="containerRef" :style="containerStyle">
<slot></slot>
</div>
<div class="controls">
<button @click="rotateLeft">↶ 左旋</button>
<button @click="resetTransform">还原</button>
<button @click="rotateRight">右旋 ↷</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
const outerContainerRef = ref<HTMLElement | null>(null)
const containerRef = ref<HTMLElement | null>(null)
const transform = reactive({
scale: 1,
translateX: 0,
translateY: 0,
rotate: 0,
transition: 'none'
})
const containerStyle = computed(() => ({
transform: `translate(${transform.translateX}px, ${transform.translateY}px) scale(${transform.scale}) rotate(${transform.rotate}deg)`,
transformOrigin: 'center center',
cursor: 'grab', // Indicate it's draggable
transition: transform.transition, // Only animate rotation
}))
// --- Mouse Panning ---
let isPanning = false
let panStartX = 0
let panStartY = 0
let initialPanX = 0
let initialPanY = 0
const onMouseDown = (event: MouseEvent) => {
transform.transition = 'none'
if (event.button !== 0) return // Only left click
isPanning = true
panStartX = event.clientX
panStartY = event.clientY
initialPanX = transform.translateX
initialPanY = transform.translateY
if (containerRef.value) {
containerRef.value.style.cursor = 'grabbing'
}
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
}
let animationFrameId: number | null = null
const onMouseMove = (event: MouseEvent) => {
transform.transition = 'none'
if (!isPanning) return
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
const dx = event.clientX - panStartX
const dy = event.clientY - panStartY
animationFrameId = requestAnimationFrame(() => {
transform.translateX = initialPanX + dx
transform.translateY = initialPanY + dy
})
}
const onMouseUp = () => {
transform.transition = 'none'
isPanning = false
if (containerRef.value) {
containerRef.value.style.cursor = 'grab'
}
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
// --- Mouse Wheel Zoom ---
const onWheel = (event: WheelEvent) => {
transform.transition = 'none'
event.preventDefault()
if (!outerContainerRef.value || !containerRef.value) return
const zoomFactor = 0.1
const oldScale = transform.scale
let newScale = oldScale
if (event.deltaY < 0) { // Zoom in
newScale = oldScale * (1 + zoomFactor)
} else { // Zoom out
newScale = oldScale * (1 - zoomFactor)
}
newScale = Math.max(0.5, Math.min(newScale, 3)) // Clamp scale
transform.scale = newScale
}
// --- Touch Interactions ---
let initialTouches: TouchList | null = null
let initialPinchDistance = 0
const getDistance = (touches: TouchList) => {
const dx = touches[0].clientX - touches[1].clientX
const dy = touches[0].clientY - touches[1].clientY
return Math.sqrt(dx * dx + dy * dy)
}
const onTouchStart = (event: TouchEvent) => {
transform.transition = 'none'
if (!outerContainerRef.value || !containerRef.value) return
// event.preventDefault(); // Prevent default only if interacting with the container
initialTouches = event.touches
if (initialTouches.length === 1) { // Pan
isPanning = true
panStartX = initialTouches[0].clientX
panStartY = initialTouches[0].clientY
initialPanX = transform.translateX
initialPanY = transform.translateY
} else if (initialTouches.length === 2) { // Pinch zoom (rotation removed)
isPanning = false; // Stop panning if it was active
initialPinchDistance = getDistance(initialTouches)
// initialAngle = getAngle(initialTouches); // Removed
}
}
const onTouchMove = (event: TouchEvent) => {
transform.transition = 'none'
if (!initialTouches || !outerContainerRef.value || !containerRef.value) return
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
const currentTouches = event.touches
animationFrameId = requestAnimationFrame(() => {
if (currentTouches.length === 1 && isPanning) { // Pan
const dx = currentTouches[0].clientX - panStartX
const dy = currentTouches[0].clientY - panStartY
transform.translateX = initialPanX + dx
transform.translateY = initialPanY + dy
} else if (currentTouches.length === 2 && initialTouches && initialTouches.length === 2) { // Pinch zoom (rotation removed)
const currentPinchDistance = getDistance(currentTouches)
// Zoom
const oldScale = transform.scale
const newScale = oldScale * (currentPinchDistance / initialPinchDistance)
transform.scale = Math.max(0.5, Math.min(newScale, 3)) // Clamp scale
// Update for next move
initialPinchDistance = currentPinchDistance
}
})
}
const onTouchEnd = (event: TouchEvent) => {
transform.transition = 'none'
if (event.touches.length < 2) {
initialTouches = null
initialPinchDistance = 0
}
if (event.touches.length < 1) {
isPanning = false
}
if (event.touches.length === 1 && initialTouches && initialTouches.length === 2) {
isPanning = true;
panStartX = event.touches[0].clientX;
panStartY = event.touches[0].clientY;
initialPanX = transform.translateX;
initialPanY = transform.translateY;
}
initialTouches = event.touches.length > 0 ? event.touches : null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
onMounted(() => {
const el = containerRef.value
const outerEl = outerContainerRef.value
if (el) {
el.addEventListener('mousedown', onMouseDown)
// Touch events are added to the outer container to capture gestures starting outside the inner one
// but still intended for it, or to handle multi-touch better.
}
if (outerEl) {
outerEl.addEventListener('wheel', onWheel, { passive: false }) // passive: false to allow preventDefault
outerEl.addEventListener('touchstart', onTouchStart, { passive: false })
outerEl.addEventListener('touchmove', onTouchMove, { passive: false })
outerEl.addEventListener('touchend', onTouchEnd)
outerEl.addEventListener('touchcancel', onTouchEnd)
}
})
onUnmounted(() => {
const el = containerRef.value
const outerEl = outerContainerRef.value
if (el) {
el.removeEventListener('mousedown', onMouseDown)
}
if (outerEl) {
outerEl.removeEventListener('wheel', onWheel)
outerEl.removeEventListener('touchstart', onTouchStart)
outerEl.removeEventListener('touchmove', onTouchMove)
outerEl.removeEventListener('touchend', onTouchEnd)
outerEl.removeEventListener('touchcancel', onTouchEnd)
}
// Clean up document listeners if any were left (e.g., mousemove/mouseup)
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
})
// --- Button Controls ---
const rotateLeft = () => {
transform.transition = 'transform 0.3s ease'
transform.rotate -= 90 // Rotate 90 degrees left
}
const rotateRight = () => {
transform.transition = 'transform 0.3s ease'
transform.rotate += 90 // Rotate 90 degrees right
}
const resetTransform = () => {
transform.scale = 1
transform.translateX = 0
transform.translateY = 0
transform.rotate = 0
transform.transition = 'none'
}
</script>
<style scoped lang="scss">
#outerContainer {
width: 100%;
height: 100%;
overflow: hidden;
/* Crucial: clips the inner container */
background-color: #f0f0f0;
/* So we can see the bounds */
position: relative;
/* For positioning context if needed */
touch-action: none;
/* Prevents default browser touch actions like scrolling or pinch-zoom on the page itself */
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
}
.controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 10px;
z-index: 20;
/* Ensure buttons are above the mask */
}
.controls button {
padding: 5px 20px;
cursor: pointer;
border: none;
background-color: dodgerblue;
color: white;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
&:nth-child(2) {
background-color: grey;
&:hover {
background-color: rgb(85, 85, 85);
}
}
&:hover {
background-color: rgb(0, 122, 245);
}
}
.container {
width: 90%;
/* Example fixed width */
height: fit-content;
/* Example fixed height */
// background-color: lightblue;
// border: 10px solid blue;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
user-select: none;
will-change: transform;
transform: translateZ(0);
p {
margin: 10px;
font-size: 1.2em;
}
}
</style>