e脉通网站网页搜索优化
存在一个需求同豆包的图像生成的区域重绘功能,类似与下面这种
拆解一下需求,
1、鼠标移动上图像画面时出现跟随鼠标移动的空心圆形,移出图像画面、鼠标点击后、鼠标按下移动时消失,鼠标松开再次出现。
2、鼠标按下出现圆形透明颜色大小同空心圆形、鼠标按下移动形成轨迹,类似涂鸦笔效果,末端是圆形,鼠标松开后涂鸦效果结束。
3、鼠标松开后出现发送框,跟随鼠标松开的位置,鼠标点击后发送框消失。
4、鼠标松开即为一次记录,上方可以进行撤销还原操作,点击清空则清除所有涂鸦痕迹。
5、上方滑块进行更改涂鸦以及空心圆的直径大小。
6、需要导出base64的mask图(涂鸦痕迹)
需求实现思路:
使用canvas去实现该功能,至少需要三个canvas,第一个将图片铺到canvas上,第二个绘制涂鸦内容,第三个跟随鼠标的光圈。还需要一个临时的canvas去生成mask图(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"><divclass="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"><divclass="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-sliderv-model="circleDiameter":min="30":max="100"input-size="mini"@mousedown="clickCircleDiameter"@change="changeCircleDiameter"@input="inputCircleDiameter"></el-slider></div><div class="divide-line"></div><divclass="close-box":class="[step == 0 ? 'img-edit-btn-box-none' : 'img-edit-btn-box']"@click="undo"><div class="chexiao-icon"></div></div><divclass="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><divv-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;// 清空 CanvaspanContext.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() {// 创建一个临时 Canvasconst 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; // Rdata[i + 1] = 0; // Gdata[i + 2] = 0; // Bdata[i + 3] = 255; // A(不透明)} else {// 透明区域(背景)data[i] = 255; // Rdata[i + 1] = 255; // Gdata[i + 2] = 255; // Bdata[i + 3] = 255; // A(不透明)}}// 将处理后的像素数据放回临时 CanvastempContext.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;// imgPreviewContainerReflet 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>