存在一个需求同豆包的图像生成的区域重绘功能,类似与下面这种
拆解一下需求,
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">
<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>