先看一下效果图吧
代码组成:画笔大小、颜色、工具按钮都是组件,通俗易懂,可以按照自己的需求调整。
主要代码App.vue
<template><div class="page"><div class="main"><div id="canvas_panel"><canvas id="canvas" :style="{ backgroundImage: `url(${backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }">当前浏览器不支持canvas。</canvas></div></div><div class="footer"><BrushSize :size="brushSize" @change-size="onChangeSize" /><ColorPicker :color="brushColor" @change-color="onChangeColor" /><ToolBtns :tool="brushTool" @change-tool="onChangeTool" /></div></div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import BrushSize from './components/BrushSize.vue';
import ColorPicker from './components/ColorPicker.vue';
import ToolBtns from './components/ToolBtns.vue';let canvas = null;
let context = null;
let painting = false;
const historyData = []; // 存储历史数据,用于撤销
const brushSize = ref(5); // 笔刷大小
const brushColor = ref('#000000'); // 笔刷颜色
const brushTool = ref('brush');
// canvas相对于(0, 0)的偏移,用于计算鼠标相对于canvas的坐标
const canvasOffset = {left: 0,top: 0,
};
const backgroundImage = ref('https://t7.baidu.com/it/u=1819248061,230866778&fm=193&f=GIF'); // 默认背景图为空function changeBackground(imgUrl) {backgroundImage.value = imgUrl;
}
function initCanvas() {function resetCanvas() {const elPanel = document.getElementById('canvas_panel');canvas.width = elPanel.clientWidth;canvas.height = elPanel.clientHeight;context = canvas.getContext('2d', { willReadFrequently: true }); // 添加这一行context.fillStyle = 'white';context.fillRect(0, 0, canvas.width, canvas.height);context.fillStyle = 'black';getCanvasOffset(); // 更新画布位置}resetCanvas();window.addEventListener('resize', resetCanvas);
}
// 获取canvas的偏移值
function getCanvasOffset() {const rect = canvas.getBoundingClientRect();canvasOffset.left = rect.left * (canvas.width / rect.width); // 兼容缩放场景canvasOffset.top = rect.top * (canvas.height / rect.height);
}
// 计算当前鼠标相对于canvas的坐标
function calcRelativeCoordinate(x, y) {return {x: x - canvasOffset.left,y: y - canvasOffset.top,};
}function downCallback(event) {// 先保存之前的数据,用于撤销时恢复(绘制前保存,不是绘制后再保存)const data = context.getImageData(0, 0, canvas.width, canvas.height);saveData(data);const { clientX, clientY } = event;const { x, y } = calcRelativeCoordinate(clientX, clientY);context.beginPath();context.moveTo(x, y);context.lineWidth = brushSize.value;context.strokeStyle = brushTool.value === 'eraser' ? '#FFFFFF' : brushColor.value;painting = true;
}
function moveCallback(event) {if (!painting) {return;}const { clientX, clientY } = event;const { x, y } = calcRelativeCoordinate(clientX, clientY);context.lineTo(x, y);context.stroke();
}
function closePaint() {painting = false;
}
function updateCanvasOffset() {getCanvasOffset(); // 重新计算画布的偏移值
}
onMounted(() => {canvas = document.getElementById('canvas');if (canvas.getContext) {context = canvas.getContext('2d', { willReadFrequently: true });initCanvas();// window.addEventListener('resize', updateCanvasPosition);window.addEventListener('scroll', updateCanvasOffset); // 添加滚动条滚动事件监听器getCanvasOffset();context.lineGap = 'round';context.lineJoin = 'round';canvas.addEventListener('mousedown', downCallback);canvas.addEventListener('mousemove', moveCallback);canvas.addEventListener('mouseup', closePaint);canvas.addEventListener('mouseleave', closePaint);}toolClear()
});function onChangeSize(size) {brushSize.value = size;
}
function onChangeColor(color) {brushColor.value = color;
}
function onChangeTool(tool) {brushTool.value = tool;switch (tool) {case 'clear':toolClear();break;case 'undo':toolUndo();break;case 'save':toolSave();break;}
}
function toolClear() {context.clearRect(0, 0, canvas.width, canvas.height);resetToolActive();
}
function toolSave() {const imageDataUrl = canvas.toDataURL('image/png');console.log(imageDataUrl)// const imgUrl = canvas.toDataURL('image/png');// const el = document.createElement('a');// el.setAttribute('href', imgUrl);// el.setAttribute('target', '_blank');// el.setAttribute('download', `graffiti-${Date.now()}`);// document.body.appendChild(el);// el.click();// document.body.removeChild(el);// resetToolActive();
}
function toolUndo() {if (historyData.length <= 0) {resetToolActive();return;}const lastIndex = historyData.length - 1;context.putImageData(historyData[lastIndex], 0, 0);historyData.pop();resetToolActive();
}
// 存储数据
function saveData(data) {historyData.length >= 50 && historyData.shift(); // 设置储存上限为50步historyData.push(data);
}
// 清除、撤销、保存状态不需要保持,操作完后恢复笔刷状态
function resetToolActive() {setTimeout(() => {brushTool.value = 'brush';}, 1000);
}
</script><style scoped>
.page {display: flex;flex-direction: column;width: 1038px;height: 866px;
}.main {flex: 1;
}.footer {display: flex;justify-content: space-around;align-items: center;height: 88px;background-color: #fff;
}#canvas_panel {margin: 12px;height: calc(100% - 24px);/* 消除空格影响 */font-size: 0;background-color: #fff;box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12);
}#canvas {cursor: crosshair;/* background: url('https://t7.baidu.com/it/u=1819248061,230866778&fm=193&f=GIF') no-repeat !important; */}
</style>
接下来就是三个组件
BrushSize.vue(画笔大小)
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({size: {type: Number,default: 5,},
});
const brushSize = computed(() => props.size);
</script><template><div class="wrap-range"><!-- 为了不在子组件中变更值,不用v-model --><inputtype="range":value="brushSize"min="1"max="30"title="调整笔刷粗细"@change="event => $emit('change-size', +event.target.value)"/></div>
</template><style scoped>
.wrap-range input {width: 150px;height: 20px;margin: 0;transform-origin: 75px 75px;border-radius: 15px;-webkit-appearance: none;appearance: none;outline: none;position: relative;
}.wrap-range input::after {display: block;content: '';width: 0;height: 0;border: 5px solid transparent;border-right: 150px solid #00ccff;border-left-width: 0;position: absolute;left: 0;top: 5px;border-radius: 15px;z-index: 0;
}.wrap-range input[type='range']::-webkit-slider-thumb,
.wrap-range input[type='range']::-moz-range-thumb {-webkit-appearance: none;
}.wrap-range input[type='range']::-webkit-slider-runnable-track,
.wrap-range input[type='range']::-moz-range-track {height: 10px;border-radius: 10px;box-shadow: none;
}.wrap-range input[type='range']::-webkit-slider-thumb {-webkit-appearance: none;height: 20px;width: 20px;margin-top: -1px;background: #ffffff;border-radius: 50%;box-shadow: 0 0 8px #00ccff;position: relative;z-index: 999;
}
</style>
ColorPicker.vue(颜色)
<script setup>
import { ref, computed } from 'vue';const props = defineProps(['color']);
const emit = defineEmits(['change-color']);const colorList = ref(['#000000', '#808080', '#FF3333', '#0066FF', '#FFFF33', '#33CC66']);const colorSelected = computed(() => props.color);function onChangeColor(color) {emit('change-color', color);
}
</script><template><div><spanv-for="(color, index) of colorList"class="color-item":class="{ active: colorSelected === color }":style="{ backgroundColor: color }":key="index"@click="onChangeColor(color)"></span></div>
</template><style scoped>
.color-item {display: inline-block;width: 32px;height: 32px;margin: 0 4px;box-sizing: border-box;border: 4px solid white;box-shadow: 0 0 8px rgba(0, 0, 0, 0.2);cursor: pointer;transition: 0.3s;
}
.color-item.active {box-shadow: 0 0 15px #00ccff;
}
</style>
ToolBtns.vue(按钮)
<script setup>
import { ref, computed } from 'vue';const props = defineProps({tool: {type: String,default: 'brush',},
});
const emit = defineEmits(['change-tool']);const toolSelected = computed(() => props.tool);const toolList = ref([{ name: 'brush', title: '画笔', icon: 'icon-qianbi' },{ name: 'eraser', title: '橡皮擦', icon: 'icon-xiangpi' },{ name: 'clear', title: '清空', icon: 'icon-qingchu' },{ name: 'undo', title: '撤销', icon: 'icon-chexiao' },{ name: 'save', title: '保存', icon: 'icon-fuzhi' },
]);function onChangeTool(tool) {emit('change-tool', tool);
}
</script><template><div class="tools"><buttonv-for="item of toolList":class="{ active: toolSelected === item.name }":title="item.title"@click="onChangeTool(item.name)"><i :class="['iconfont', item.icon]"></i></button></div>
</template><style scoped>
.tools button {/* border-radius: 50%; */width: 32px;height: 32px;background-color: rgba(255, 255, 255, 0.7);border: 1px solid #eee;outline: none;cursor: pointer;box-sizing: border-box;margin: 0 8px;padding: 0;text-align: center;color: #ccc;box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);transition: 0.3s;
}.tools button.active,
.tools button:active {/* box-shadow: 0 0 15px #00CCFF; */color: #00ccff;
}.tools button i {font-size: 20px;
}
</style>
🐱 个人主页:TechCodeAI启航,公众号:SHOW科技
🙋♂️ 作者简介:2020参加工作,专注于前端各领域技术,共同学习共同进步,一起加油呀!
💫 优质专栏:前端主流技术分享
📢 资料领取:前端进阶资料可以找我免费领取
🔥 摸鱼学习交流:我们的宗旨是在「工作中摸鱼,摸鱼中进步」,期待大佬一起来摸鱼!