前端canvas实现图片涂鸦(Vue2、Vue3都支持)

先看一下效果图吧

代码组成:画笔大小、颜色、工具按钮都是组件,通俗易懂,可以按照自己的需求调整。

主要代码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 -->
    <input
      type="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>
    <span
      v-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">
    <button
      v-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参加工作,专注于前端各领域技术,共同学习共同进步,一起加油呀!

💫 优质专栏:前端主流技术分享

📢 资料领取:前端进阶资料可以找我免费领取

🔥 摸鱼学习交流:我们的宗旨是在「工作中摸鱼,摸鱼中进步」,期待大佬一起来摸鱼!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/656839.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

CR渲染噪点严重怎么办?常见噪点来源+排查方法

使用Corona渲染器进行渲染时&#xff0c;画面出现噪点是常见现象&#xff0c;尤其是在渲染初期。轻微的高频噪点通常是正常的&#xff0c;但如果经过多次渲染或长时间渲染后噪点依然明显&#xff0c;就可能意味着渲染设置或场景本身存在问题。虽然我们可以利用降噪功能模糊噪点…

电脑记事软件哪款安全?好用且安全的桌面记事工具

在快节奏的现代生活中&#xff0c;我们每天都要用电脑处理大量的工作。电脑不仅提升了工作效率&#xff0c;还成为了我们记录重要事项和灵感的得力助手。比如&#xff0c;在策划项目时&#xff0c;我会直接在电脑上列出要点和步骤&#xff1b;在开会时&#xff0c;我也会用电脑…

浅谈桌面数控铣床在中职数控专业教学中的运用

目前大多数中职院校的数控铣床实训教学均采用工业数控机床设备&#xff0c;基本存在实训耗材费用高、教学成本高、实操危险性大、学生独立操作时间少,教学效率不高,教学质量也因设备数量原因较难提高等问题&#xff0c;桌面数控铣床具有成本低&#xff0c;体积小,操作灵活结构简…

Unity3D插件开发教程(二):制作批处理工具

Unity3D插件开发教程&#xff08;二&#xff09;&#xff1a;制作批处理工具 文章来源&#xff1a;Unity3D插件开发教程&#xff08;二&#xff09;&#xff1a;制作批处理工具 - 知乎 (zhihu.com) 声明&#xff1a; 题图来自于Gratisography | Free High Resolution Pictures…

STL源码刨析:序列式容器之list

目录 1.前言 2.list的节点定义和结构 3.list的迭代器定义和结构 4.list的定义和结构 5.list的内存管理 6.list的元素操作 前言 在刨析了vector容器的源码后&#xff0c;list容器相比与vector容器&#xff0c;其元素的插入和删除较快&#xff0c;不需要对原本容器中的元…

集合的交集、并集和差集运算

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 集合最常用的操作就是进行交集、并集、差集和对称差集运算。进行交集运算时使用“&”符号&#xff0c;进行并集运算时使用“&#xff5c;”符号&…

SQL数据库多层嵌套 json转sql建表语句,SQL数据库里数组里对象数据怎么创建

1. uniapp sqlite 一个数组包含对象嵌套对象通过主外键方式插入数据库&#xff1a; // 假设有一个对象数组&#xff0c;对象中包含嵌套对象 const objectsArray [{parentObject: {id: 1,name: Parent 1,// 其他父对象属性},childObject: {id: 11,parentId: 1,name: Child 1 o…

vue中的$nextTick和过渡与动画

一.vue中的$nextTick 简述与用法&#xff1a;这是一个生命周期钩子 1.语法&#xff1a;this.$nextTick(回调函数) 2.作用&#xff1a;在下一次DOM更新结束后执行其指定的回调 3.什么时候用&#xff1a;当修改数据后&#xff0c;要基于更新后的新dom进行某些操作时&#xff0c;…

精酿啤酒:品质与口感在不同消费人群中的差异与共性

在啤酒市场中&#xff0c;不同消费人群对品质与口感的喜好存在一定的差异。然而&#xff0c;Fendi club啤酒凭借其卓着的品质和与众不同的口感&#xff0c;在不同消费人群中都展现出一定的共性。 从性别差异来看&#xff0c;男性消费者通常更注重啤酒的品质和口感&#xff0c;而…

Llama 3 模型家族构建安全可信赖企业级AI应用之使用 Llama Guard 保护大模型对话 (八)

LlaMA 3 系列博客 基于 LlaMA 3 LangGraph 在windows本地部署大模型 &#xff08;一&#xff09; 基于 LlaMA 3 LangGraph 在windows本地部署大模型 &#xff08;二&#xff09; 基于 LlaMA 3 LangGraph 在windows本地部署大模型 &#xff08;三&#xff09; 基于 LlaMA…

机器学习(七) ----------聚类(K-means)

目录 1 核心思想 2 K-means算法 2.1 算法概述 2.2 算法步骤 2.3 数学原理 2.4 ‘肘’方法确定K值 2.4.1 原理 2.4.2 步骤 2.4.3 代码实现 2.5 聚类评估方法 2.5.1 SC轮廓系数&#xff08;Silhouette Coefficient&#xff09; 计算方法 解读 注意事项 2.5.2 Cal…

Windows UWP ContentDialog去掉阴影(全透明)的实现

一、前言 在WIndows开发中&#xff0c;使用UWP&#xff08;Universal WIndows&#xff09;项目开发过程中&#xff0c;使用ContentDialog 的过程中&#xff0c;我们可能并不满足现有的样式&#xff0c;这时就需要自定义样式。笔者在自定义样式过程中&#xff0c;遇到了一个难题…

数据库多表查询

多表查询&#xff1a; SELECT *FROM stu_table,class WHERE stu_table.c_idclass.c_id; 多表查询——内连接 查询两张表交集部分。 隐式内连接&#xff1a; #查询学生姓名&#xff0c;和班级名称&#xff0c;隐式调用 SELECT stu_table.s_name,class.c_name FROM stu_table…

php反序列化学习(1)

1、php面向对象基本概念 类的定义&#xff1a; 类是定义了一件事物的抽象特征&#xff0c;它将数据的形式以及这些数据上的操作封装住在一起。&#xff08;对象是具有类类型的变量&#xff0c;是对类的实例&#xff09; 构成&#xff1a; 成员变量&#xff08;属性&#xf…

来自工业界的知识库 RAG 服务(二),RagFlow 源码全流程深度解析

背景介绍 前面介绍过 有道 QAnything 源码解析&#xff0c;通过深入了解工业界的知识库 RAG 服务&#xff0c;得到了不少调优 RAG 服务的新想法。 因此本次趁热打铁&#xff0c;额外花费一点时间&#xff0c;深入研究了另一个火热的开源 RAG 服务 RagFlow 的完整实现流程&…

上交提出TrustGAIN,提出6G网络中可信AIGC新模式!

月16日至18日&#xff0c;2024全球6G技术大会在南京召开。会上&#xff0c;全球移动通信标准制定组织3GPP&#xff08;第三代合作伙伴计划&#xff09;的3位联席主席分享了3GPP6G标准时间表&#xff1a; 2024年9月&#xff0c;启动6G业务需求研究&#xff1b; 2025年6月&…

FastReport 主子表关系

代码中只需要绑定主表的数据就可以&#xff0c;子表的数据会通过报表中的关连关系自动到数据库中带出。 using CloudSaaS.DB.Handler; using CloudSaaS.Model; using CloudSaaS.DAL; using FastReport; using FastReport.Web; using System; using System.Collections.Generic;…

Hotcoin Research | 市场洞察:2024年5月13日-5月19日

加密货币市场表现 目前&#xff0c;加密货币总市值为1.32万亿&#xff0c;BTC占比54.41%。 本周行情呈现震荡上行的态势&#xff0c;BTC在5月15日-16日&#xff0c;有一波大的拉升&#xff0c;周末为震荡行情。BTC现价为67125美元。 上涨的主要原因&#xff1a;美国4月CPI为3…

Oracle创建用户时提示ORA-65096:公用用户名或角色名无效

Oracle创建用户时提示“ORA-65096&#xff1a;公用用户名或角色名无效” 如下图所示&#xff1a; 解决方法&#xff1a;在新增用户名前面加上C##或者c##就可以解决无效问题&#xff0c;具体什么原因还不清楚&#xff0c;需要再研究一下。

JS 中怎么删除数组元素?有哪几种方法?

正文开始之前推荐一位宝藏博主免费分享的学习教程,学起来! 编号学习链接1Cesium: 保姆级教程+源码示例2openlayers: 保姆级教程+源码示例3Leaflet: 保姆级教程+源码示例4MapboxGL: 保姆级教程+源码示例splice() JavaScript中的splice()方法是一个内置的数组对象函数, 用于…