vue3使用Trae封装一个内部可自由放大、缩小、移动、旋转的通用盒子

350 阅读8分钟

vue3使用Trae封装一个内部可自由放大、缩小、移动、旋转的通用盒子

1、概述

需求:加工或者检验需要查看图纸,有的字太小需要放大查看,还有的可能是竖直的,需要旋转以更好的查看。各大组件的图片预览直接全屏展示了,用户不可能看一个属性点一次查看在关闭预览吧,显然不符合需求,应该像下面这样左侧对比图可自由操作,右侧直接对比结果,当然,这只是应该demo,数据都是假的。而且图纸不一定是以图片的形式,可能是pdf或者其它格式,这时候就需要自己封装一个通用的盒子。

image.png

思路:外层盒子嵌套内层盒子,对内存盒子进行放大、旋转等操作,甭管内层盒子的内容是啥,只对内层的盒子进行操作。

image.png

效果预览
这是pc端的效果预览图,鼠标滑动进行缩放,长按左键进行拖动。移动端效果一致,双指进行缩放,长按就行拖动,移动端就懒得截了,大家感兴趣可以自己测试。

cazqn-lskb0.gif

思路好了,接下来就是把需求喂给ai,让ai帮我们实现,最后我们只需要修复一下小bug和微调样式就ok了。

2、AI实现

我只提供了两个嵌套盒子,然后把思路喂给ai,让ai帮我们完成。

image.png

具体实现如下:

image.png image.png image.png image.png image.png image.png

到这里,基本功能已经实现的,但是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、遗留的问题

  1. 没有对边界进行处理,会直接把图片拖没了,只能通过还原按钮还原样式
  2. 没有进行节流操作,某些老的设备可能存在性能问题

有需要的可以让ai加上这两个功能。

5、优化,添加旋转过渡动画

  1. 添加过渡动画
  2. 使用 requestAnimationFrame 优化动画帧,减少不必要的动画重绘
  3. 移除 mask 遮罩层,减少不必要的性能开销,给需要移除事件的元素单独添加 pointer-events: none; 属性移除事件(img元素等)。
15-24-17 -big-original.gif

给样式添加动态的过渡动画样式即可:

<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>