<template>
<div>
<div class="toolbar">
<el-color-picker v-model="currentColor" />
<el-slider v-model="currentLineWidth" :min="1" :max="10" />
<el-button :class="{ 'active': currentTool === 'brush' }" @click="selectTool('brush')">画笔</el-button>
<el-button :class="{ 'active': currentTool === 'eraser' }" @click="selectTool('eraser')">橡皮擦</el-button>
<el-slider v-if="currentTool === 'eraser'" v-model="eraserSize" :min="10" :max="100" />
<el-button :class="{ 'active': currentTool === 'rectangle' }" @click="selectTool('rectangle')">长方形</el-button>
<el-button :class="{ 'active': currentTool === 'circle' }" @click="selectTool('circle')">圆形</el-button>
<el-slider v-if="currentTool === 'check' || currentTool === 'cross' || currentTool === 'arrow'"
v-model="shapeSize" :min="10" :max="100" />
<el-button :class="{ 'active': currentTool === 'check' }" @click="selectTool('check')">打√</el-button>
<el-button :class="{ 'active': currentTool === 'cross' }" @click="selectTool('cross')">打×</el-button>
<el-button :class="{ 'active': currentTool === 'arrow' }" @click="selectTool('arrow')">箭头</el-button>
<el-button :class="{ 'active': currentTool === 'text' }" @click="selectTool('text')">文本</el-button>
<el-button @click="clearCanvas">清除</el-button>
<el-button @click="saveCanvas">保存</el-button>
<el-button @click="undo">撤销</el-button>
<el-button @click="redo">重做</el-button>
<el-button @click="rotateCanvas">翻转</el-button>
<el-button @click="zoomIn">放大</el-button>
<el-button @click="zoomOut">缩小</el-button>
</div>
<div class="canvas-container">
<canvas ref="bgCanvas" width="800" height="600" style="position: absolute;"></canvas>
<canvas ref="drawCanvas" width="800" height="600" style="position: absolute;"></canvas>
<canvas ref="shapeCanvas" @mousedown="handleClick" @mousemove="draw" @mouseup="stopDrawing"
@mouseout="stopDrawing" width="800" height="600" style="position: absolute; border: 1px solid #000;"></canvas>
<textarea v-if="currentTool === 'text'" v-model="textContent" :style="[textStyle, { color: currentColor }]"
@blur="finishTextEditing" @input="updateTextContent" ref="textArea" class="text-editor"
placeholder="请输入文本"></textarea>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue';
const props = defineProps({
imageUrl: {
type: String,
required: true,
},
});
const bgCanvas = ref(null);
const drawCanvas = ref(null);
const shapeCanvas = ref(null);
const bgContext = ref(null);
const drawContext = ref(null);
const shapeContext = ref(null);
const drawing = ref(false);
const currentColor = ref('#E81E1E');
const currentLineWidth = ref(2);
const currentTool = ref('brush');
const eraserSize = ref(20);
const history = reactive({
undoStack: [],
redoStack: [],
});
const shapes = ref([]);
const textShapes = ref([]); // 用于保存文本形状
const activeShape = ref(null);
const shapeSize = ref(30);
const textContent = ref('');
const textArea = ref(null);
const textStyle = reactive({
position: 'absolute',
border: '1px dashed black',
backgroundColor: 'rgba(255, 255, 255, 0)',
resize: 'none',
width: '200px',
height: '100px',
zIndex: 10,
display: 'none',
});
const rotation = ref(0);
const zoomFactor = ref(1); // 当前缩放比例
const rotateCanvas = () => {
rotation.value = (rotation.value + 90) % 360;
redrawCanvas();
};
const redrawCanvas = () => {
clearAllCanvases();
drawImage();
redrawShapeCanvas();
};
const clearAllCanvases = () => {
drawContext.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height);
shapeContext.value.clearRect(0, 0, shapeCanvas.value.width, shapeCanvas.value.height);
};
const saveState = () => {
try {
const state = {
draw: drawCanvas.value.toDataURL(),
shapes: shapes.value.map(shape => ({ ...shape })),
textShapes: textShapes.value.map(text => ({ ...text })),
rotation: rotation.value,
};
history.undoStack.push(state);
history.redoStack = [];
} catch (e) {
console.error('Cannot save canvas state:', e);
}
};
const restoreState = (state) => {
drawContext.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height);
shapeContext.value.clearRect(0, 0, shapeCanvas.value.width, shapeCanvas.value.height);
const img = new Image();
img.src = state.draw;
img.onload = () => {
drawContext.value.drawImage(img, 0, 0);
shapes.value = state.shapes;
textShapes.value = state.textShapes;
rotation.value = state.rotation;
redrawCanvas(); // 调用自定义的重绘函数以应用旋转
};
};
const handleClick = (event) => {
const { offsetX, offsetY } = event;
if (currentTool.value === 'text') {
textStyle.left = `${offsetX}px`;
textStyle.top = `${offsetY}px`;
textStyle.display = 'block';
textArea.value.focus();
} else if (['check', 'cross'].includes(currentTool.value)) {
const shape = {
type: currentTool.value,
color: currentColor.value,
lineWidth: currentLineWidth.value,
startX: offsetX,
startY: offsetY,
width: shapeSize.value,
height: shapeSize.value,
};
shapes.value.push(shape);
drawShape(shape);
saveState();
} else {
startDrawing(event);
}
};
const startDrawing = (event) => {
drawing.value = true;
const { offsetX, offsetY } = event;
if (currentTool.value === 'brush') {
drawContext.value.lineWidth = currentLineWidth.value;
drawContext.value.strokeStyle = currentColor.value;
drawContext.value.beginPath();
drawContext.value.moveTo(offsetX, offsetY);
} else if (['arrow', 'rectangle', 'circle'].includes(currentTool.value)) {
const shape = {
type: currentTool.value,
color: currentColor.value,
lineWidth: currentLineWidth.value,
startX: offsetX,
startY: offsetY,
width: 0,
height: 0,
};
shapes.value.push(shape);
activeShape.value = shape;
}
};
const draw = (event) => {
if (!drawing.value) return;
const { offsetX, offsetY } = event;
if (currentTool.value === 'brush') {
drawContext.value.lineTo(offsetX, offsetY);
drawContext.value.stroke();
} else if (currentTool.value === 'eraser') {
const x = offsetX - eraserSize.value / 2;
const y = offsetY - eraserSize.value / 2;
drawContext.value.clearRect(x, y, eraserSize.value, eraserSize.value);
} else if (['rectangle', 'circle', 'arrow'].includes(currentTool.value)) {
if (activeShape.value) {
activeShape.value.width = offsetX - activeShape.value.startX;
activeShape.value.height = offsetY - activeShape.value.startY;
redrawShapeCanvas();
}
}
};
const stopDrawing = () => {
if (drawing.value) {
if (currentTool.value === 'brush') {
drawContext.value.closePath();
}
saveState();
drawing.value = false;
activeShape.value = null;
}
};
const clearCanvas = () => {
drawContext.value.clearRect(0, 0, drawCanvas.value.width, drawCanvas.value.height);
shapeContext.value.clearRect(0, 0, shapeCanvas.value.width, shapeCanvas.value.height);
shapes.value = [];
textShapes.value = [];
saveState();
};
const saveCanvas = () => {
const link = document.createElement('a');
link.download = 'drawing.png';
const combinedCanvas = document.createElement('canvas');
combinedCanvas.width = drawCanvas.value.width;
combinedCanvas.height = drawCanvas.value.height;
const combinedContext = combinedCanvas.getContext('2d');
combinedContext.drawImage(bgCanvas.value, 0, 0);
combinedContext.drawImage(drawCanvas.value, 0, 0);
combinedContext.drawImage(shapeCanvas.value, 0, 0);
link.href = combinedCanvas.toDataURL();
link.click();
};
const selectTool = (tool) => {
currentTool.value = tool;
if (tool === 'eraser') {
updateEraserCursor();
} else {
shapeCanvas.value.style.cursor = tool === 'brush' ? 'crosshair' : 'default';
}
};
const updateEraserCursor = () => {
const cursorUrl = `data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="${eraserSize.value}" height="${eraserSize.value}" viewBox="0 0 ${eraserSize.value} ${eraserSize.value}"><rect x="0" y="0" width="${eraserSize.value}" height="${eraserSize.value}" stroke="rgba(0, 0, 0, 0.5)" stroke-width="1" fill="rgba(255, 255, 255, 0.3)" /></svg>`;
shapeCanvas.value.style.cursor = `url('${cursorUrl}') ${eraserSize.value / 2} ${eraserSize.value / 2}, auto`;
};
const undo = () => {
if (history.undoStack.length > 0) {
const lastState = history.undoStack.pop();
history.redoStack.push({
draw: drawCanvas.value.toDataURL(),
shapes: shapes.value.map(shape => ({ ...shape })),
textShapes: textShapes.value.map(text => ({ ...text })),
rotation: rotation.value,
});
restoreState(lastState);
}
};
const redo = () => {
if (history.redoStack.length > 0) {
const lastState = history.redoStack.pop();
history.undoStack.push({
draw: drawCanvas.value.toDataURL(),
shapes: shapes.value.map(shape => ({ ...shape })),
textShapes: textShapes.value.map(text => ({ ...text })),
rotation: rotation.value,
});
restoreState(lastState);
}
};
const zoomIn = () => {
zoomFactor.value *= 1.1; // 放大10%
redrawCanvas();
};
const zoomOut = () => {
zoomFactor.value /= 1.1; // 缩小10%
redrawCanvas();
};
const drawImage = () => {
if (props.imageUrl) {
const img = new Image();
img.crossOrigin = 'anonymous'; // 处理跨域图片
img.src = props.imageUrl;
img.onload = () => {
const padding = 30; // 设置留白区域的大小
bgContext.value.clearRect(0, 0, bgCanvas.value.width, bgCanvas.value.height);
bgContext.value.save();
bgContext.value.translate(padding, padding); // 在绘制时增加留白
// bgContext.value.drawImage(img, 0, 0, bgCanvas.value.width - 2 * padding, bgCanvas.value.height - 2 * padding);
bgContext.value.translate(bgCanvas.value.width / 2, bgCanvas.value.height / 2);
bgContext.value.rotate((rotation.value * Math.PI) / 180);
bgContext.value.scale(zoomFactor.value, zoomFactor.value); // 应用缩放
// bgContext.value.drawImage(img, -bgCanvas.value.width / 2, -bgCanvas.value.height / 2, bgCanvas.value.width, bgCanvas.value.height);
bgContext.value.drawImage(img, -bgCanvas.value.width / 2, -bgCanvas.value.height / 2, bgCanvas.value.width - 2 * padding, bgCanvas.value.height - 2 * padding);
bgContext.value.restore();
};
}
};
const redrawShapeCanvas = () => {
shapeContext.value.clearRect(0, 0, shapeCanvas.value.width, shapeCanvas.value.height);
shapes.value.forEach(shape => {
drawShape(shape);
});
textShapes.value.forEach(text => {
drawShape(text);
});
};
const drawShape = (shape) => {
shapeContext.value.beginPath();
shapeContext.value.strokeStyle = shape.color;
shapeContext.value.lineWidth = shape.lineWidth;
if (shape.type === 'rectangle') {
shapeContext.value.rect(shape.startX, shape.startY, shape.width, shape.height);
} else if (shape.type === 'circle') {
shapeContext.value.arc(shape.startX, shape.startY, Math.sqrt(Math.pow(shape.width, 2) + Math.pow(shape.height, 2)), 0, 2 * Math.PI);
} else if (shape.type === 'check') {
shapeContext.value.beginPath();
shapeContext.value.moveTo(shape.startX, shape.startY);
shapeContext.value.lineTo(shape.startX + shape.width, shape.startY + shape.height / 2);
shapeContext.value.lineTo(shape.startX + shape.width * 2, shape.startY - shape.height);
shapeContext.value.stroke();
} else if (shape.type === 'cross') {
shapeContext.value.beginPath();
shapeContext.value.moveTo(shape.startX, shape.startY);
shapeContext.value.lineTo(shape.startX + shape.width, shape.startY + shape.height);
shapeContext.value.moveTo(shape.startX, shape.startY + shape.height);
shapeContext.value.lineTo(shape.startX + shape.width, shape.startY);
shapeContext.value.stroke();
} else if (shape.type === 'arrow') {
const headLength = 10;
const angle = Math.atan2(shape.height, shape.width);
shapeContext.value.moveTo(shape.startX, shape.startY);
shapeContext.value.lineTo(shape.startX + shape.width, shape.startY + shape.height);
shapeContext.value.lineTo(shape.startX + shape.width - headLength * Math.cos(angle - Math.PI / 6), shape.startY + shape.height - headLength * Math.sin(angle - Math.PI / 6));
shapeContext.value.moveTo(shape.startX + shape.width, shape.startY + shape.height);
shapeContext.value.lineTo(shape.startX + shape.width - headLength * Math.cos(angle + Math.PI / 6), shape.startY + shape.height - headLength * Math.sin(angle + Math.PI / 6));
} else if (shape.type === 'text') {
shapeContext.value.fillStyle = shape.color;
shapeContext.value.font = '16px Arial';
shapeContext.value.textAlign = 'left';
shapeContext.value.textBaseline = 'top';
shapeContext.value.fillText(shape.content, shape.startX, shape.startY);
}
shapeContext.value.stroke();
};
const finishTextEditing = () => {
if (textContent.value.trim() === '') return;
const { offsetLeft, offsetTop, offsetWidth, offsetHeight } = textArea.value;
const shape = {
type: 'text',
content: textContent.value,
color: currentColor.value,
startX: offsetLeft,
startY: offsetTop,
width: offsetWidth,
height: offsetHeight,
};
textShapes.value.push(shape);
redrawShapeCanvas();
textContent.value = '';
textStyle.display = 'none';
};
const updateTextContent = () => {
// Optional: Handle text content updates here if needed
};
onMounted(() => {
bgContext.value = bgCanvas.value.getContext('2d');
drawContext.value = drawCanvas.value.getContext('2d');
shapeContext.value = shapeCanvas.value.getContext('2d');
drawImage();
saveState();
});
watch(() => props.imageUrl, drawImage);
watch(() => eraserSize.value, updateEraserCursor);
</script>
<style scoped>
.toolbar {
margin-bottom: 10px;
}
.canvas-container {
position: relative;
/* width: 800px;
height: 600px; */
}
canvas {
cursor: default;
/* border: 1px solid #ccc; */
/* padding: 20px; */
}
.text-editor {
display: none;
/* Hidden initially */
}
.el-button.active {
background-color: #409EFF;
color: white;
}
.text-editor {
display: block;
position: absolute;
border: 2px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
font-size: 14px;
padding: 10px;
resize: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
transition: border-color 0.3s ease;
width: 200px;
height: 100px;
top: 0;
left: 0;
}
.text-editor:focus {
border-color: #409EFF;
outline: none;
}
.text-editor::placeholder {
color: #888;
font-style: italic;
}
</style>
使用
效果图