前端Vue3图像编辑功能实现代码(并生成mask图)
作者:懮 俍
效果展示
存在一个需求同豆包的图像生成的区域重绘功能,类似与下面这种
需求详细拆解
1. 鼠标交互视觉反馈
悬浮状态:当鼠标移入图像区域时,显示一个跟随鼠标移动的空心圆形光标
消失条件:在以下情况下空心圆消失:
鼠标移出图像区域
鼠标点击操作
鼠标按下并移动时
重现条件:鼠标松开后,空心圆重新出现
2. 涂鸦绘制功能
按下状态:
显示与空心圆大小相同的实心半透明圆形
开始记录绘制轨迹
拖动状态:
根据鼠标移动路径形成连续轨迹
轨迹末端保持圆形形状
实现类似自由涂鸦笔的效果
结束条件:鼠标松开后结束当前涂鸦记录
3. 操作确认功能
发送框显示:鼠标松开后,在鼠标当前位置显示操作确认框
发送框隐藏:点击任意位置后隐藏发送框
4. 历史记录管理
记录单元:每次鼠标松开视为一次完整的涂鸦操作记录
撤销/重做:
支持逐步撤销之前的涂鸦操作
支持重做已撤销的操作
清空功能:一键清除所有涂鸦痕迹
5. 尺寸调节功能
提供滑块控件用于调整:
涂鸦笔触的直径大小
空心光标的直径大小
6. 数据导出功能
生成与图像实际尺寸一致的Base64格式mask图
mask图应准确反映所有涂鸦痕迹
技术实现方案
画布分层设计
采用多层canvas架构实现功能分离和性能优化:
基础图像层:
负责显示原始图像
作为其他层的背景参考
涂鸦绘制层:
实时记录和显示用户涂鸦轨迹
保存所有涂鸦操作数据
光标反馈层:
显示跟随鼠标的空心圆光标
处理所有光标状态变化
临时处理层:
用于生成最终的mask图像
确保输出尺寸与实际图像一致
不参与界面显示,仅用于数据处理
实现流程逻辑
初始化阶段:
加载图像到基础层
设置各canvas层级关系
初始化涂鸦参数(默认大小、颜色等)
交互阶段:
监听鼠标事件(移入、移出、按下、移动、松开)
根据状态切换显示对应canvas层内容
实时更新涂鸦轨迹
数据处理阶段:
维护操作历史栈(支持撤销/重做)
在临时canvas生成mask图像
转换为Base64格式输出
UI控制:
绑定滑块事件调整大小参数
实现清空按钮功能
管理发送框的显示/隐藏
性能优化考虑
采用分层渲染避免不必要的重绘
使用requestAnimationFrame优化光标跟随
对历史记录采用增量存储方式
仅在需要时生成mask图像
这种实现方式既能满足所有功能需求,又能保证良好的用户体验和性能表现。
相关代码如下:
<template> <div class="img-edit-box"> <div class="img-edit-box-top" v-if="currentImgEdit == 'all'"> <div class="img-edit-btn-box" @click="quoteImgEditChange"> <!-- @click=" quoteChange(true, currentImgUrl, 'imageEdit', currentImgQuestion) " --> <div class="img-edit-btn-zhineng"></div> <div class="img-edit-btn-text">智能编辑</div> </div> <div class="img-edit-btn-box" @click="changeEditStatus('scope')"> <div class="img-edit-btn-chonghui"></div> <div class="img-edit-btn-text">区域重绘</div> </div> <!-- <div class="img-edit-btn-box"> <div class="img-edit-btn-kuotu"></div> <div class="img-edit-btn-text">扩图</div> </div> --> <!-- <div class="img-edit-btn-box"> <div class="img-edit-btn-cachu"></div> <div class="img-edit-btn-text">擦除</div> </div> --> <div class="img-edit-btn-right to-right"> <div class="img-edit-btn-box" @click="downloadBase64" > <div class="img-edit-btn-download"></div> <div class="img-edit-btn-text">下载原图</div> </div> <div class="divide-line"></div> <div class="img-edit-btn-box close-box" @click="closeImgEditVisible"> <div class="close-icon"></div> </div> </div> </div> <div v-if="currentImgEdit == 'scope'" class="img-edit-box-top flex-center"> <div class="img-edit-btn-left"> <div class="img-edit-btn-box close-box" @click="changeEditStatus('all')" > <div class="back-icon"></div> </div> </div> <div class="img-edit-btn-center"> <!-- <div class="img-edit-btn-box"> <div class="img-edit-btn-download"></div> </div> --> <div class="img-edit-btn-slider"> <el-slider v-model="circleDiameter" :min="30" :max="100" input-size="mini" @mousedown="clickCircleDiameter" @change="changeCircleDiameter" @input="inputCircleDiameter" ></el-slider> </div> <div class="divide-line"></div> <div class="close-box" :class="[step == 0 ? 'img-edit-btn-box-none' : 'img-edit-btn-box']" @click="undo" > <div class="chexiao-icon"></div> </div> <div class="close-box" :class="[ step == history.length - 1 ? 'img-edit-btn-box-none' : 'img-edit-btn-box', ]" @click="redo" > <div class="huanyuan-icon"></div> </div> <div class="divide-line"></div> <div :class="[step == 0 ? 'img-edit-btn-box-none' : 'img-edit-btn-box']" style="width: max-content" @click="clearCanvas" > 清除 </div> <!-- <div :class="[ step == 0 ? 'img-edit-btn-box-none' : 'img-edit-btn-box', ]" style="width: max-content" @click="exportMaskImage" > 导出 </div> --> </div> <div class="img-edit-btn-right"></div> </div> <div class="img-edit-box-content"> <div class="img-preview-container" v-if="currentImgEdit == 'all'"> <img class="img-background" :src="currentImgUrl" /> </div> <div v-if="currentImgEdit != 'all'" ref="canvas_panelRef" class="img-preview-container" > <!-- <img ref="currentImgUrlRef" v-show="false" class="img-background" src="@/assets/image/test.png" /> --> <div class="img-preview-container-box" ref="imgPreviewContainerRef"> <canvas ref="currentImgUrlCanvasRef"></canvas> <canvas ref="currentMaskCanvasRef"></canvas> <canvas ref="currentPanCanvasRef"></canvas> </div> </div> </div> </div> </template> <script setup> import { nextTick } from "vue"; import { encryptText, decryptText } from "@/utils/crypto.js"; import { inject } from "vue"; const currentImgEdit = inject("currentImgEdit"); const showSend = inject("showSend"); const showSendRef = inject("showSendRef"); const props = defineProps({ currentImgUrl: String, }); // 更改图片编辑状态 // canvas相关代码 const canvas_panelRef = ref(); const imgPreviewContainerRef = ref(); const currentImgUrlCanvasRef = ref(); const currentMaskCanvasRef = ref(); const currentPanCanvasRef = ref(); let context = null; //背景图 let paintingContext = null; //paintingContext let panContext = null; //panContext let painting = false; const brushSize = ref(5); // 笔刷大小 let mouseX = 0; // 鼠标 X 坐标 let mouseY = 0; // 鼠标 Y 坐标 let lastX = 0; let lastY = 0; let ratio = 0; const canvasRect = ref({ top: 0, left: 0, width: 0, height: 0 }); const circleDiameter = ref(50); // 圆圈直径 const maxDiameter = 100; // 最大直径 const minDiameter = 30; // 最小直径 let isPanLeave = true; let tempCanvas = document.createElement("canvas"); let tempContext = tempCanvas.getContext("2d"); let history = ref([]); // 存储画布的历史状态 let step = ref(0); // 当前状态的索引,初始为 -1 表示没有历史记录 const clickCircleDiameter = () => { console.log("clickCircleDiameter"); mouseX = currentPanCanvasRef.value.width / 2; mouseY = currentPanCanvasRef.value.height / 2; drawCircle(); }; const changeCircleDiameter = () => { console.log("changeCircleDiameter"); panContext.clearRect( 0, 0, currentPanCanvasRef.value.width, currentPanCanvasRef.value.height ); }; const inputCircleDiameter = () => { console.log("inputCircleDiameter"); drawCircle(); }; const canvasOffset = { left: 0, top: 0, }; // 获取canvas的偏移值 function getCanvasOffset() { const rect = currentMaskCanvasRef.value.getBoundingClientRect(); canvasOffset.left = rect.left * (currentMaskCanvasRef.value.width / rect.width); // 兼容缩放场景 canvasOffset.top = rect.top * (currentMaskCanvasRef.value.height / rect.height); console.log("canvasOffset", canvasOffset); } // 计算当前鼠标相对于canvas的坐标 function calcRelativeCoordinate(x, y) { return { x: x - canvasOffset.left, y: y - canvasOffset.top, }; } // 存储数据 function saveState(data) { // 如果当前 step 不是最后一个状态,则删除之后的所有状态 if (step.value < history.value.length - 1) { history.value = history.value.slice(0, step.value + 1); } // 将新状态添加到历史数组中 history.value.push(data); step.value++; // 更新 step } function moveCallback(event) { if (!painting) { return; } const { clientX, clientY } = event; const { x, y } = calcRelativeCoordinate(clientX, clientY); paintingContext.lineTo(x, y); paintingContext.stroke(); } function updateCanvasOffset() { getCanvasOffset(); // 重新计算画布的偏移值 } // 绘制圆圈 const drawCircle = () => { if (!panContext) return; // 清空 Canvas panContext.clearRect( 0, 0, currentPanCanvasRef.value.width, currentPanCanvasRef.value.height ); if (mouseX < 0 || mouseY < 0) { return; } // 绘制空心圆圈 panContext.beginPath(); panContext.arc(mouseX, mouseY, circleDiameter.value / 2, 0, Math.PI * 2); panContext.strokeStyle = "#ffffff"; // 边框颜色 panContext.lineWidth = 2; // 边框宽度 panContext.stroke(); }; // 动画循环 const animate = () => { if (!isPanLeave) { drawCircle(); } requestAnimationFrame(animate); }; function downCallback(event) { console.log("222222222222222221111111"); event.preventDefault(); // 阻止默认行为 event.stopPropagation(); // 阻止事件冒泡 showSend.value = false; // 先保存之前的数据,用于撤销时恢复(绘制前保存,不是绘制后再保存) // 初始化临时画布 tempCanvas.width = currentMaskCanvasRef.value.width; tempCanvas.height = currentMaskCanvasRef.value.height; tempContext.clearRect(0, 0, tempCanvas.width, tempCanvas.height); const data = paintingContext.getImageData( 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height ); // 记录起始点 lastX = mouseX; lastY = mouseY; // 绘制实心圆圈 paintingContext.beginPath(); paintingContext.arc(mouseX, mouseY, circleDiameter.value / 2, 0, Math.PI * 2); paintingContext.fillStyle = "rgba(0, 119, 255, 0.5)"; // 填充圆形 paintingContext.fill(); painting = true; } // 监听鼠标移动 const handleMouseMove = (event) => { isPanLeave = false; const rect = canvasRect.value; mouseX = event.clientX - rect.left; mouseY = event.clientY - rect.top; if (!painting || (mouseX == lastX && mouseY == lastY)) { return; } // 设置混合模式 paintingContext.globalCompositeOperation = "xor"; // 直接在主画布上绘制线条 paintingContext.beginPath(); paintingContext.moveTo(lastX, lastY); paintingContext.lineTo(mouseX, mouseY); paintingContext.strokeStyle = "rgba(0, 119, 255, 0.5)"; // 固定透明度 paintingContext.lineWidth = circleDiameter.value; // 使用 circleDiameter 作为线条宽度 paintingContext.lineCap = "round"; // 设置线条末端为圆形 paintingContext.stroke(); // 更新上一个点的位置 lastX = mouseX; lastY = mouseY; }; const handleMouseUp = () => { if (painting) { // 保存当前画布状态 const data = paintingContext.getImageData( 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height ); saveState(data); // 调用 saveState 函数保存状态 // 绘制结束,清空临时画布 tempContext.clearRect(0, 0, tempCanvas.width, tempCanvas.height); painting = false; } showSendRef.value.style.top = `${event.y}px`; showSend.value = true; }; const handleMouseLeave = () => { isPanLeave = true; panContext.clearRect( 0, 0, currentPanCanvasRef.value.width, currentPanCanvasRef.value.height ); }; function undo() { if (step.value > 0) { step.value--; // 回退到上一步 const state = history.value[step.value]; paintingContext.putImageData(state, 0, 0); // 恢复状态 } } function redo() { if (step.value < history.value.length - 1) { step.value++; // 前进一步 const state = history.value[step.value]; paintingContext.putImageData(state, 0, 0); // 恢复状态 } } function clearCanvas() { paintingContext.clearRect( 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height ); // 存储最新的历史记录 const data = paintingContext.getImageData( 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height ); history.value = [data]; // 清空历史数组 step.value = 0; // 重置 step } function createMaskImage() { // 创建一个临时 Canvas const tempCanvas = document.createElement("canvas"); console.log('ratioratio',ratio) tempCanvas.width = currentMaskCanvasRef.value.width / ratio; tempCanvas.height = currentMaskCanvasRef.value.height / ratio; const tempContext = tempCanvas.getContext("2d"); // 将主 Canvas 的内容绘制到临时 Canvas 上 tempContext.drawImage( currentMaskCanvasRef.value, 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height, 0, 0, tempCanvas.width, tempCanvas.height ); // 获取临时 Canvas 的像素数据 const imageData = tempContext.getImageData( 0, 0, tempCanvas.width, tempCanvas.height ); const data = imageData.data; // 遍历像素数据,将非透明像素设置为黑色,透明像素设置为白色 for (let i = 0; i < data.length; i += 4) { const alpha = data[i + 3]; // 透明度通道 if (alpha > 0) { // 非透明区域(涂抹的区域) data[i] = 0; // R data[i + 1] = 0; // G data[i + 2] = 0; // B data[i + 3] = 255; // A(不透明) } else { // 透明区域(背景) data[i] = 255; // R data[i + 1] = 255; // G data[i + 2] = 255; // B data[i + 3] = 255; // A(不透明) } } // 将处理后的像素数据放回临时 Canvas tempContext.putImageData(imageData, 0, 0); // 导出图片 const image = tempCanvas.toDataURL("image/png"); return image; } const changeEditStatus = (type) => { currentImgEdit.value = type; nextTick(() => { function resetCanvas() { // 创建一个 Image 对象 let img = new Image(); img.src = props.currentImgUrl; // 等待图片加载完成 img.onload = () => { const imgAspectRatio = img.width / img.height; // imgPreviewContainerRef let maxWidth = 0; let maxHeight = 0; let style = window.getComputedStyle(canvas_panelRef.value); // 获取上内边距 let paddingTop = parseInt(style.paddingTop, 10); let paddingRight = parseInt(style.paddingRight, 10); let paddingBottom = parseInt(style.paddingBottom, 10); let paddingLeft = parseInt(style.paddingLeft, 10); maxWidth = canvas_panelRef.value.clientWidth - paddingRight - paddingLeft; maxHeight = canvas_panelRef.value.clientHeight - paddingTop - paddingBottom; let containerWidth = img.width; // 容器初始宽度 let containerHeight = img.height; // 容器初始高度 // 根据图片比例调整容器宽高 ratio = Math.min(maxWidth / img.width, maxHeight / img.height); containerWidth = img.width * ratio; containerHeight = img.height * ratio; // 设置容器宽高 imgPreviewContainerRef.value.style.width = containerWidth + "px"; imgPreviewContainerRef.value.style.height = containerHeight + "px"; // 设置 canvas 的宽高与容器一致 currentImgUrlCanvasRef.value.width = containerWidth; currentImgUrlCanvasRef.value.height = containerHeight; currentMaskCanvasRef.value.width = containerWidth; currentMaskCanvasRef.value.height = containerHeight; currentPanCanvasRef.value.width = containerWidth; currentPanCanvasRef.value.height = containerHeight; context = currentImgUrlCanvasRef.value.getContext("2d", { willReadFrequently: true, }); paintingContext = currentMaskCanvasRef.value.getContext("2d", { willReadFrequently: true, }); panContext = currentPanCanvasRef.value.getContext("2d", { willReadFrequently: true, }); // 初始位置在 Canvas 中心 const rect = currentPanCanvasRef.value.getBoundingClientRect(); canvasRect.value = rect; mouseX = -200; mouseY = -200; // mouseX.value = currentPanCanvasRef.value.width / 2; // mouseY.value = currentPanCanvasRef.value.height / 2; context.drawImage(img, 0, 0, containerWidth, containerHeight); // 存储最新的历史记录 const data = paintingContext.getImageData( 0, 0, currentMaskCanvasRef.value.width, currentMaskCanvasRef.value.height ); history.value = [data]; step.value = 0; // 更新 step }; getCanvasOffset(); // 更新画布位置 } resetCanvas(); window.addEventListener("resize", resetCanvas); window.addEventListener("scroll", updateCanvasOffset); // 添加滚动条滚动事件监听器 getCanvasOffset(); // paintingContext.lineGap = "round"; // paintingContext.lineJoin = "round"; // currentMaskCanvasRef.value.addEventListener("mousedown", downCallback); // currentMaskCanvasRef.value.addEventListener("mousemove", moveCallback); // currentMaskCanvasRef.value.addEventListener("mouseleave", closePaint); animate(); // 添加事件监听 currentPanCanvasRef.value.addEventListener("mousedown", downCallback); currentPanCanvasRef.value.addEventListener("mousemove", handleMouseMove); currentPanCanvasRef.value.addEventListener("mouseup", handleMouseUp); currentPanCanvasRef.value.addEventListener("mouseleave", handleMouseLeave); }); }; import { defineEmits } from "vue"; const emits = defineEmits(["quoteImgEditChange",'downloadBase64','closeImgEditVisible']); const quoteImgEditChange = () => { emits("quoteImgEditChange"); }; const downloadBase64 = () => { emits("downloadBase64"); }; const closeImgEditVisible = () => { emits("closeImgEditVisible"); }; defineExpose({ currentPanCanvasRef, createMaskImage, changeEditStatus }); </script> <style lang="scss" scoped> .img-edit-box { max-width: 700px; flex: 1; // background: #e5e9fa; background: #ffffff; align-items: center; display: flex; flex-direction: column; height: 100%; overflow: hidden; position: relative; box-shadow: 0 6px 10px 0 rgba(42, 60, 79, 0.1); transition: border-radius 0.4s ease-in-out; .img-edit-box-top { align-items: center; background: #ffffff; border-bottom: 0.5px solid rgba(0, 0, 0, 0.08); display: flex; flex-shrink: 0; // gap: 24px; height: 56px; padding: 0 16px; width: 100%; justify-content: flex-start; .img-edit-btn-slider { width: 80px; :deep(.el-slider) { height: unset; } :deep(.el-slider__button) { width: 15px; height: 15px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 0 1px rgba(0, 0, 0, 0.3); border: none; } :deep(.el-slider__bar) { background-color: #242525; } } .img-edit-btn-box-none { display: flex; align-items: center; cursor: not-allowed; padding: 6px 8px; color: #d9d9d9; .chexiao-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/have_chexiao.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; } .huanyuan-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/have_chexiao.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; transform: scaleX(-1); } } .img-edit-btn-box { display: flex; align-items: center; padding: 6px 8px; cursor: pointer; .img-edit-btn-zhineng { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/zhineng.png") no-repeat; background-size: 100% 100%; vertical-align: middle; } .img-edit-btn-chonghui { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/chonghui.png") no-repeat; background-size: 100% 100%; vertical-align: middle; } .img-edit-btn-kuotu { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/kuotu.png") no-repeat; background-size: 100% 100%; vertical-align: middle; } .img-edit-btn-cachu { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/cachu.png") no-repeat; background-size: 100% 100%; vertical-align: middle; } .img-edit-btn-download { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/download.png") no-repeat; background-size: 100% 100%; vertical-align: middle; } .img-edit-btn-text { // font-family: "Ali_Regular"; margin-left: 4px; font-size: 14px; } .chexiao-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/chexiao.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; cursor: pointer; } .huanyuan-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/chexiao.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; cursor: pointer; transform: scaleX(-1); } } .close-box { padding: 6px; } .img-edit-btn-box:hover { background: rgba(0, 0, 0, 0.04); border-radius: 8px; } .img-edit-btn-right { display: flex; align-items: center; gap: 10px; } .divide-line { background: rgba(0, 0, 0, 0.08); display: inline-block; flex-shrink: 0; height: 16px; margin: 0 4px 0 6px; width: 1px; } .close-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/close.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; cursor: pointer; } .back-icon { width: 16px; height: 16px; background: url("../../../assets/image/chat/imageEdit/editInside/back.png") no-repeat; background-size: 100% 100%; vertical-align: middle; padding: 6px 6px; cursor: pointer; } .to-right { margin-left: auto; } } .flex-center { justify-content: center; .img-edit-btn-left { flex: 1; display: flex; align-items: center; gap: 10px; } .img-edit-btn-center { flex: 1; display: flex; align-items: center; gap: 10px; } .img-edit-btn-right { flex: 1; } .to-left { margin-right: auto; } } .img-edit-box-content { position: relative; width: 100%; height: 100%; .img-preview-container { align-items: center; display: flex; justify-content: center; box-sizing: border-box; height: 100%; padding: 40px; width: 100%; .img-preview-container-box { position: relative; width: 100%; height: 100%; cursor: none; canvas { padding: 0px; margin: 0px; border: 0px; background: transparent; position: absolute; top: 0px; left: 0px; display: block; } } .img-background { border-radius: 10px; display: block; max-height: 100%; max-width: 100%; -o-object-fit: contain; object-fit: contain; } } } } </style>
总结
到此这篇关于前端Vue3图像编辑功能实现的文章就介绍到这了,更多相关前端Vue3图像编辑功能内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!