vue canvas标注

1.组件代码

复制代码
<template>
    <div class="annot-image">
        <div class="annot-toolbar">
            <button class="annot-btn" :class="{ active: drawMode === 'rect' }" @click="handleStartRect">
                <img src="@/assets/images/icon/icon-annotation-rect.png" alt="rect" />
                <span>矩形标记</span>
            </button>
            <button class="annot-btn" :class="{ active: drawMode === 'polygon' }" @click="handleStartPolygon">
                <img src="@/assets/images/icon/icon-annotation-rect.png" alt="rect" />
                <span>多边形标记</span>
            </button>
            <button class="annot-btn" :class="{ active: drawMode === 'multi' }" @click="handleStartMulti">
                <img src="@/assets/images/icon/icon-annotation-points.png" alt="multi" />
                <span>点标记</span>
            </button>
            <button class="annot-btn" @click="handleReplaceImage">
                <img src="@/assets/images/icon/icon-annotation-img.png" alt="replace" />
                <span>替换图片</span>
            </button>
            <button class="annot-btn" @click="handleUndo" :disabled="!canUndo">
                <img src="@/assets/images/icon/icon-annotation-back.png" alt="undo" />
                <span>撤销</span>
            </button>
            <button class="annot-btn" @click="handleClear" :disabled="shapes.length === 0">
                <img src="@/assets/images/icon/icon-annotation-clear.png" alt="clear" />
                <span>清空</span>
            </button>
        </div>
        <div class="canvas-wrapper" ref="canvasWrapper">
            <canvas ref="canvasRef"
                :class="{ 'drawing-mode': drawMode === 'rect' || drawMode === 'multi' || drawMode === 'polygon' }"
                @mousedown="handleMouseDown" @mousemove="handleMouseMove" @mouseup="handleMouseUp"
                @click="handleCanvasClick" @dblclick="handleDoubleClick"
                @contextmenu.prevent="handleRightClick"></canvas>
            <input type="file" ref="fileInputRef" accept="image/*" style="display: none"
                @change="handleFileChange" />
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, watch, nextTick, defineExpose } from 'vue'
import markerUrl from '@/assets/images/icon/mark.png'

const props = defineProps({
    image: {
        type: String,
        default: ''
    }
})

const emit = defineEmits(['imageChange'])

// Canvas 相关
const canvasRef = ref(null)
const canvasWrapper = ref(null)
const fileInputRef = ref(null)
const currentImage = ref('')
const drawMode = ref('') // 'rect' | 'multi' | 'polygon' | ''
const shapes = ref([]) // 存储所有绘制的图形
const history = ref([]) // 操作历史,用于撤销

// 缓存图片,避免每次重绘触发异步加载造成闪烁
const bgImg = ref(null)
const bgImgLoaded = ref(false)
const markerImg = new Image()
let markerImgLoaded = false
markerImg.onload = () => { markerImgLoaded = true }
markerImg.src = markerUrl

// 矩形绘制状态
const isDrawingRect = ref(false)
const rectStart = ref({ x: 0, y: 0 })
const currentRect = ref(null)

// 多点绘制状态
const multiPoints = ref([])
const isDrawingMulti = ref(false)
// 多边形绘制状态(预览点位为画布绝对坐标,保存为归一化坐标)
const polygonTempPoints = ref([])
const isDrawingPolygon = ref(false)

// 计算是否可以撤销
const canUndo = ref(false)

// 初始化 canvas
const initCanvas = () => {
    if (!canvasRef.value || !props.image) return

    nextTick(() => {
        const canvas = canvasRef.value
        const wrapper = canvasWrapper.value
        if (!wrapper) return

        // 确保 canvas 的实际像素尺寸和 CSS 显示尺寸一致,避免缩放导致的坐标偏移
        const cssWidth = wrapper.clientWidth
        const cssHeight = wrapper.clientHeight
        canvas.width = cssWidth
        canvas.height = cssHeight

        // 确保 CSS 尺寸也明确设置,避免浏览器自动缩放
        canvas.style.width = cssWidth + 'px'
        canvas.style.height = cssHeight + 'px'

        currentImage.value = props.image

        // 初始化历史记录(空状态)
        if (history.value.length === 0) {
            history.value.push([])
        }

        drawImage()
    })
}

// 绘制背景图片(使用缓存图片,避免频繁 onload 导致闪烁)
const drawImage = () => {
    if (!canvasRef.value || !currentImage.value) return

    const canvas = canvasRef.value
    const ctx = canvas.getContext('2d')

    if (!bgImg.value || bgImg.value.src !== currentImage.value) {
        bgImgLoaded.value = false
        bgImg.value = new Image()
        bgImg.value.onload = () => {
            bgImgLoaded.value = true
            // 计算图片显示尺寸(保持宽高比)并记录
            const canvasWidth = canvas.width
            const canvasHeight = canvas.height
            const imgWidth = bgImg.value.width
            const imgHeight = bgImg.value.height
            const scale = Math.min(canvasWidth / imgWidth, canvasHeight / imgHeight)
            const displayWidth = imgWidth * scale
            const displayHeight = imgHeight * scale
            const x = (canvasWidth - displayWidth) / 2
            const y = (canvasHeight - displayHeight) / 2
            canvas.dataset.imageX = x
            canvas.dataset.imageY = y
            canvas.dataset.imageWidth = displayWidth
            canvas.dataset.imageHeight = displayHeight
            // 首次绘制
            ctx.clearRect(0, 0, canvas.width, canvas.height)
            ctx.drawImage(bgImg.value, x, y, displayWidth, displayHeight)
            redrawShapes()
        }
        bgImg.value.src = currentImage.value
    } else if (bgImgLoaded.value) {
        const x = Number(canvas.dataset.imageX || 0)
        const y = Number(canvas.dataset.imageY || 0)
        const w = Number(canvas.dataset.imageWidth || canvas.width)
        const h = Number(canvas.dataset.imageHeight || canvas.height)
        ctx.clearRect(0, 0, canvas.width, canvas.height)
        ctx.drawImage(bgImg.value, x, y, w, h)
        redrawShapes()
    }
}

// 双击:多边形闭合;若点数不足三则清空当前临时点
const handleDoubleClick = (e) => {
    if (drawMode.value === 'polygon') {
        if (polygonTempPoints.value.length >= 3) {
            finishPolygonDrawing()
        } else {
            polygonTempPoints.value = []
            isDrawingPolygon.value = true
            redrawShapes()
        }
    }
}

// 重新绘制所有图形(同步使用已缓存的背景图和标记图)
const redrawShapes = () => {
    if (!canvasRef.value) return

    const canvas = canvasRef.value
    const ctx = canvas.getContext('2d')

    // 先清除 canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height)

    // 绘制背景图片
    if (currentImage.value && bgImg.value && bgImgLoaded.value) {
        const x = Number(canvas.dataset.imageX || 0)
        const y = Number(canvas.dataset.imageY || 0)
        const w = Number(canvas.dataset.imageWidth || canvas.width)
        const h = Number(canvas.dataset.imageHeight || canvas.height)
        ctx.drawImage(bgImg.value, x, y, w, h)
        // 绘制所有图形
        shapes.value.forEach(shape => {
            drawShape(ctx, shape)
        })

        // 多边形预览折线
        if (drawMode.value === 'polygon' && polygonTempPoints.value.length > 0) {
            ctx.save()
            ctx.strokeStyle = '#C73520'
            ctx.lineWidth = 2
            ctx.setLineDash([6, 6])
            ctx.beginPath()
            ctx.moveTo(polygonTempPoints.value[0].x, polygonTempPoints.value[0].y)
            for (let i = 1; i < polygonTempPoints.value.length; i++) {
                ctx.lineTo(polygonTempPoints.value[i].x, polygonTempPoints.value[i].y)
            }
            ctx.stroke()
            ctx.restore()
        }
    }
}

// 绘制单个图形
const drawShape = (ctx, shape) => {
    ctx.strokeStyle = '#C73520'
    ctx.fillStyle = 'rgba(199, 53, 32, 0.2)'
    ctx.lineWidth = 2

    if (shape.type === 'rect') {
        const { x, y, width, height } = shape
        ctx.fillRect(x, y, width, height)
        ctx.strokeRect(x, y, width, height)
    } else if (shape.type === 'point') {
        const size = shape.size || 20
        // 计算当前图片在画布中的显示区域
        const canvas = canvasRef.value
        const imgX = Number(canvas?.dataset.imageX || 0)
        const imgY = Number(canvas?.dataset.imageY || 0)
        const imgW = Number(canvas?.dataset.imageWidth || canvas.width)
        const imgH = Number(canvas?.dataset.imageHeight || canvas.height)
        // 兼容:优先使用归一化坐标 nx, ny;无则使用旧的绝对坐标 x, y
        const cx = typeof shape.nx === 'number' ? imgX + shape.nx * imgW : shape.x
        const cy = typeof shape.ny === 'number' ? imgY + shape.ny * imgH : shape.y
        // 标记锚点:底部居中
        const drawX = cx - size / 2
        const drawY = cy - size
        if (markerImgLoaded) {
            ctx.drawImage(markerImg, drawX, drawY, size, size)
        } else {
            // 图标未加载完成时,等加载后重绘
            markerImg.onload = () => {
                markerImgLoaded = true
                ctx.drawImage(markerImg, drawX, drawY, size, size)
            }
        }
    } else if (shape.type === 'polygon') {
        const { points } = shape // [{nx,ny}] or [{x,y}]
        const canvas = canvasRef.value
        const imgX = Number(canvas?.dataset.imageX || 0)
        const imgY = Number(canvas?.dataset.imageY || 0)
        const imgW = Number(canvas?.dataset.imageWidth || canvas.width)
        const imgH = Number(canvas?.dataset.imageHeight || canvas.height)
        const absPoints = points.map(p => ({
            x: typeof p.nx === 'number' ? imgX + p.nx * imgW : p.x,
            y: typeof p.ny === 'number' ? imgY + p.ny * imgH : p.y
        }))
        if (absPoints.length >= 2) {
            ctx.beginPath()
            ctx.moveTo(absPoints[0].x, absPoints[0].y)
            for (let i = 1; i < absPoints.length; i++) {
                ctx.lineTo(absPoints[i].x, absPoints[i].y)
            }
            if (absPoints.length >= 3) {
                ctx.closePath()
                ctx.fill()
            }
            ctx.stroke()
        }
    }
}

// 获取鼠标在 canvas 中的坐标(考虑 canvas 的实际像素尺寸和显示尺寸的缩放比例)
const getCanvasPoint = (e) => {
    const canvas = canvasRef.value
    if (!canvas) return { x: 0, y: 0 }

    const rect = canvas.getBoundingClientRect()
    // 计算 canvas 的实际像素尺寸和显示尺寸的比例
    const scaleX = canvas.width / rect.width
    const scaleY = canvas.height / rect.height

    // 将鼠标坐标转换为 canvas 实际像素坐标
    const x = (e.clientX - rect.left) * scaleX
    const y = (e.clientY - rect.top) * scaleY

    return { x, y }
}

// 矩形绘制:鼠标按下
const handleMouseDown = (e) => {
    if (drawMode.value !== 'rect') return

    isDrawingRect.value = true
    const point = getCanvasPoint(e)
    rectStart.value = point
    currentRect.value = { ...point, width: 0, height: 0 }
}

// 矩形绘制:鼠标移动
const handleMouseMove = (e) => {
    if (drawMode.value === 'rect' && isDrawingRect.value && currentRect.value) {
        const point = getCanvasPoint(e)
        currentRect.value.width = point.x - rectStart.value.x
        currentRect.value.height = point.y - rectStart.value.y

        // 实时绘制
        redrawShapes()
        const ctx = canvasRef.value.getContext('2d')
        drawShape(ctx, { type: 'rect', ...currentRect.value })
    }
}

// 矩形绘制:鼠标释放
const handleMouseUp = (e) => {
    if (drawMode.value === 'rect' && isDrawingRect.value) {
        isDrawingRect.value = false

        if (currentRect.value && Math.abs(currentRect.value.width) > 5 && Math.abs(currentRect.value.height) > 5) {
            // 保存矩形
            const rect = {
                type: 'rect',
                x: rectStart.value.x,
                y: rectStart.value.y,
                width: currentRect.value.width,
                height: currentRect.value.height
            }

            // 确保宽高为正
            if (rect.width < 0) {
                rect.x += rect.width
                rect.width = Math.abs(rect.width)
            }
            if (rect.height < 0) {
                rect.y += rect.height
                rect.height = Math.abs(rect.height)
            }

            saveShape(rect)
            currentRect.value = null
        }
    }
}

// 画布点击:点标记添加点;多边形采点预览
const handleCanvasClick = (e) => {
    const point = getCanvasPoint(e)

    if (drawMode.value === 'multi') {
        // 计算当前图片显示区域,转为归一化坐标,保证缩放/重绘精准
        const canvas = canvasRef.value
        const imgX = Number(canvas.dataset.imageX || 0)
        const imgY = Number(canvas.dataset.imageY || 0)
        const imgW = Number(canvas.dataset.imageWidth || canvas.width)
        const imgH = Number(canvas.dataset.imageHeight || canvas.height)
        const nx = (point.x - imgX) / imgW
        const ny = (point.y - imgY) / imgH
        const pointShape = { type: 'point', nx, ny, x: point.x, y: point.y, size: 20 }
        saveShape(pointShape)
    } else if (drawMode.value === 'polygon') {
        polygonTempPoints.value.push({ x: point.x, y: point.y })
        redrawShapes()
    }
}

// 右键点击:多点模式无操作;多边形用于完成闭合
const handleRightClick = (e) => {
    if (drawMode.value === 'polygon') {
        if (polygonTempPoints.value.length >= 3) {
            finishPolygonDrawing()
        } else {
            // 小于3个点则清空正在绘制的多边形,但保持在多边形模式
            polygonTempPoints.value = []
            isDrawingPolygon.value = true
            redrawShapes()
        }
    }
}

// 保存图形
const saveShape = (shape) => {
    shapes.value.push(shape)
    // 保存当前状态到历史记录
    history.value.push(shapes.value.map(s => JSON.parse(JSON.stringify(s))))
    canUndo.value = history.value.length > 1 || shapes.value.length > 0
    redrawShapes()
}

// 完成多点绘制(双击或右键)
const finishMultiDrawing = () => {
    multiPoints.value = []
    isDrawingMulti.value = false
}

// 完成多边形绘制:将临时采集的画布坐标转为归一化坐标后保存
const finishPolygonDrawing = () => {
    if (polygonTempPoints.value.length >= 3) {
        const canvas = canvasRef.value
        const imgX = Number(canvas.dataset.imageX || 0)
        const imgY = Number(canvas.dataset.imageY || 0)
        const imgW = Number(canvas.dataset.imageWidth || canvas.width)
        const imgH = Number(canvas.dataset.imageHeight || canvas.height)
        const points = polygonTempPoints.value.map(p => ({
            nx: (p.x - imgX) / imgW,
            ny: (p.y - imgY) / imgH
        }))
        saveShape({ type: 'polygon', points })
    }
    polygonTempPoints.value = []
    isDrawingPolygon.value = false
}

// 工具栏功能
const handleStartRect = () => {
    if (drawMode.value === 'rect') {
        drawMode.value = ''
    } else {
        drawMode.value = 'rect'
        finishMultiDrawing() // 如果正在多点绘制,先完成
    }
}

const handleStartMulti = () => {
    if (drawMode.value === 'multi') {
        // 如果已经在多点模式,完成当前绘制
        finishMultiDrawing()
        drawMode.value = ''
    } else {
        drawMode.value = 'multi'
        isDrawingRect.value = false
        if (!isDrawingMulti.value) {
            multiPoints.value = []
            isDrawingMulti.value = true
        }
    }
}

const handleStartPolygon = () => {
    if (drawMode.value === 'polygon') {
        finishPolygonDrawing()
        drawMode.value = ''
    } else {
        drawMode.value = 'polygon'
        isDrawingRect.value = false
        isDrawingMulti.value = false
        polygonTempPoints.value = []
        isDrawingPolygon.value = true
    }
}

const handleReplaceImage = () => {
    fileInputRef.value?.click()
}

const handleFileChange = (e) => {
    const file = e.target.files?.[0]
    if (file) {
        const reader = new FileReader()
        reader.onload = (event) => {
            currentImage.value = event.target.result
            shapes.value = []
            history.value = [[]] // 初始化空状态
            canUndo.value = false
            emit('imageChange', currentImage.value)
            drawImage()
        }
        reader.readAsDataURL(file)
    }
    // 清空 input,允许重复选择同一文件
    e.target.value = ''
}

const handleUndo = () => {
    if (history.value.length <= 1) {
        shapes.value = []
        history.value = [[]] // 保留初始空状态
        canUndo.value = false
    } else {
        history.value.pop()
        const lastState = history.value[history.value.length - 1]
        shapes.value = lastState ? lastState.map(s => JSON.parse(JSON.stringify(s))) : []
        canUndo.value = history.value.length > 1
    }
    redrawShapes()
}

const handleClear = () => {
    shapes.value = []
    history.value = [[]] // 初始化空状态
    canUndo.value = false
    multiPoints.value = []
    isDrawingMulti.value = false
    isDrawingRect.value = false
    isDrawingPolygon.value = false
    polygonTempPoints.value = []
    drawMode.value = ''
    redrawShapes()
}

// 导出画布为图片的方法(供父组件调用)
const exportCanvas = async () => {
    redrawShapes()
    const canvas = canvasRef.value
    if (canvas) {
        try {
            // 预览用的 base64(用于显示)
            const dataUrl = canvas.toDataURL('image/png')
            return dataUrl
        } catch (err) {
            console.error('导出画布失败', err)
            return null
        }
    }
    return null
}

// 导出画布为 Blob(用于上传)
const exportCanvasAsBlob = async () => {
    redrawShapes()
    const canvas = canvasRef.value
    if (canvas) {
        try {
            const blob = await new Promise((resolve) => {
                canvas.toBlob((blob) => resolve(blob), 'image/png')
            })
            return blob
        } catch (err) {
            console.error('导出画布为Blob失败', err)
            return null
        }
    }
    return null
}

// 监听图片变化
watch(() => props.image, (newImage) => {
    if (newImage) {
        initCanvas()
    }
}, { immediate: true })

// 暴露方法供父组件调用
defineExpose({
    exportCanvas,// 导出画布为图片
    exportCanvasAsBlob, // 导出画布为Blob
    redrawShapes // 重绘图形
})

onMounted(() => {
    if (props.image) {
        initCanvas()
    }
})
</script>

<style scoped>
/* 工具栏样式 */
.annot-toolbar {
    position: absolute;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    gap: 10px;
    z-index: 10;
}

.annot-btn {
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 5px;
    font-size: 16px;
    color: #FFFFFF;
    width: 110px;
    height: 32px;
    background: linear-gradient(180deg, #257D8F 0%, #3193A0 100%);
    border: 1px solid;
    border-image: linear-gradient(180deg, rgba(52.000000700354576, 116.00000068545341, 165.00000536441803, 1), rgba(142.00000673532486, 245.00000059604645, 254.00000005960464, 1)) 1 1;
    transition: all 0.3s;
}

.annot-btn img {
    width: 16px;
    height: 16px;
}

.annot-btn.active {
    background: linear-gradient(180deg, #02D4C6 0%, #02B8AB 100%);
    border-image: linear-gradient(180deg, rgba(2, 212, 198, 1), rgba(2, 184, 171, 1)) 1 1;
}

.annot-btn:disabled {
    cursor: not-allowed;
}

.annot-image {
    width: 100%;
    height: 460px;
    background: rgba(9, 39, 54, 0.6);
    position: relative;
    margin: 20px auto 0;
}

.canvas-wrapper {
    width: 100%;
    height: 100%;
    position: relative;
    overflow: hidden;
}

.canvas-wrapper canvas {
    width: 100%;
    height: 100%;
    cursor: default;
    display: block;
}

.canvas-wrapper canvas.drawing-mode {
    cursor: crosshair;
}
</style>

2.使用组件

复制代码
<ImageAnnotation ref="imageAnnotationRef" :image="image" @image-change="handleImageChange" />

3.通过暴露方法最终拿到图片

复制代码
imageAnnotationRef.value.exportCanvasAsBlob()
相关推荐
拉不动的猪2 小时前
什么是二义性,实际项目中又有哪些应用
前端·javascript·面试
海云前端12 小时前
Webpack打包提速95%实战:从20秒到1.5秒的优化技巧
前端
烟袅2 小时前
LeetCode 142:环形链表 II —— 快慢指针定位环的起点(JavaScript)
前端·javascript·算法
梦6502 小时前
什么是react?
前端·react.js·前端框架
zhougl9962 小时前
cookie、session、token、JWT(JSON Web Token)
前端·json
Ryan今天学习了吗2 小时前
💥不说废话,带你上手使用 qiankun 微前端并深入理解原理!
前端·javascript·架构
高端章鱼哥2 小时前
前端新人最怕的“居中问题”,八种CSS实现居中的方法一次搞懂!
前端
冷亿!2 小时前
Html爱心代码动态(可修改内容+带源码)
前端·html
Predestination王瀞潞2 小时前
Java EE开发技术(第六章:EL表达式)
前端·javascript·java-ee