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

news2024/11/21 2:31:28

先看一下效果图吧

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

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

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

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

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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

RedHat9 | DNS剖析-DNS服务器综合部署

一、配置需求及网络拓扑 1、配置拓扑 2、配置需求 使用【主DNS服务器】管理meaauf.cn域和gz.meaauf.cn域&#xff1b;并将bj.meaauf.cn域委派给【子域DNS服务器】进行管理。在【主DNS服务器】上添加相应的A记录、别名记录、MX记录和PTR记录&#xff1a;【辅助DNS服务器】作为…

乡村振兴的实践与探索:以生态优先、绿色发展为导向,推动农村人居环境整治,建设美丽宜居乡村

一、引言 随着我国经济社会的快速发展&#xff0c;乡村振兴成为了新时代的重要战略。在这一背景下&#xff0c;以生态优先、绿色发展为导向的乡村振兴模式成为了重要的实践方向。本文旨在探讨如何通过生态优先、绿色发展的理念&#xff0c;推动农村人居环境整治&#xff0c;建…

FL Studio v21.2.3.4004中文破解版百度网盘下载

FL Studio v21.2.3.4004中文破解版是一款完整的软件音乐制作环境或数字音频工作站 (DAW)。代表了超过 18 年的创新发展&#xff0c;它在一个软件包中提供了您创作、编曲、录制、编辑、混音和掌握专业品质音乐所需的一切。FL Studio v21.2.3.4004中文破解版现在是世界上最受欢迎…

基于香橙派搭建家庭网盘

一、概述 家庭网盘是一种用于家庭用户的在线存储和文件共享服务。它允许家庭成员在云端存储、同步和分享照片、视频、文档等文件&#xff0c;方便快捷地访问和管理个人和家庭数据。家庭网盘通常提供安全可靠的数据存储和备份功能&#xff0c;保障用户数据的安全性。此外&#x…

vscode常用操作

1 vscode跳转node_modules下文件&#xff0c;没有切换定位到左侧菜单目录的问题 2&#xff0c;搜索node-modules 3&#xff0c;设置选中字体颜色 {"workbench.colorTheme": "Default Light Modern","editor.mouseWheelZoom": true,"termin…

斯坦福报告解读3:图解有趣的评估基准(上)

《人工智能指数报告》由斯坦福大学、AI指数指导委员会及业内众多大佬Raymond Perrault、Erik Brynjolfsson 、James Manyika等人员和组织合著&#xff0c;旨在追踪、整理、提炼并可视化与人工智能&#xff08;AI&#xff09;相关各类数据&#xff0c;该报告已被大多数媒体及机构…

基于朴素贝叶斯算法的微博舆情监控系统,flask后端,可视化丰富

背景&#xff1a; 微博作为中国最大的社交媒体平台之一&#xff0c;汇聚了海量用户生成的文本数据&#xff0c;承载着丰富的社会信息和舆论动向。随着互联网的快速发展&#xff0c;人们对于利用这些数据进行舆情分析和预测的需求日益增加。在这种情况下&#xff0c;以Python为…

为什么使用数据库类型器件库

项目地址&#xff1a;https://github.com/boringhex-top/OpenECADLib Altium 数据库类型器件库&#xff08;DbLib&#xff09;具有显著的优势&#xff0c;特别是对于复杂设计和高效元件管理来说。这里详细介绍数据库类型器件库的优势以及相关背景知识&#xff0c;以帮助你更好…

内网穿透入门使用(frp和natapp)

内网穿透入门使用 简单介绍穿透工具推荐FrpFrp下载安装服务端配置启动服务端配置客户端启动客户端效果查看 NATAppNATApp下载安装NATApp配置启动NATApp 使用途径 我的博客&#xff1a;Lichg&#xff0c;欢迎大家访问留言。 简单介绍 什么是内网穿透&#xff1a; 首先我们对内网…

【Oracle】PL SQL 怎么重新编译无效的对象

1.打开PL SQL &#xff0c;点击图中有红色的 2.点击齿轮按钮即可 from&#xff1a;【Oracle】PL SQL 怎么重新编译无效的对象_plsql编译无效对象的按钮在哪里-CSDN博客

python双色球选号程序的实现与解析

新书上架~&#x1f447;全国包邮奥~ python实用小工具开发教程http://pythontoolsteach.com/3 欢迎关注我&#x1f446;&#xff0c;收藏下次不迷路┗|&#xff40;O′|┛ 嗷~~ 目录 一、引言&#xff1a;双色球选号游戏的魅力 二、程序设计与实现 1. 生成红色球号码 2. 生…

JavaScript原型链污染原理及相关CVE漏洞剖析

0x00 背景 2019年初&#xff0c;Snyk的安全研究人员披露了流行的JavaScript库Lodash中一个严重漏洞的详细信息&#xff0c;该漏洞使黑客能够攻击多个Web应用程序&#xff0c;这个安全漏洞就是一个“原型污染漏洞”&#xff08;JavaScript Prototype Pollution&#xff09;&…

Linux:top命令的每一列的具体含义

Linux&#xff1a;top命令的每一列的具体含义 文章目录 Linux&#xff1a;top命令的每一列的具体含义图片显示top命令的概念语法显示字段的含义顶部字段第二行第三行第四行第五行每列字段的含义 图片显示 top命令的概念 top命令上一个常用的Linux命令行工具&#xff0c;用于实…

django中,无法跳转到请求的html页面?

出现错误&#xff1a; You’re seeing this error because you have DEBUG True in your Django settings file. Change that to False, and Django will display a standard 404 page. 在urls中&#xff0c;注释了系统的默认配置&#xff0c;这时就需要在setting配置文件中&…

K8S集群监控方案之Prometheus+kube-state-metrics+Grafana

序言 | Prometheus 中文文档 方案简单架构图 一、部署kube-state-metrics 1、部署文件下载 地址 kube-state-metrics/examples/standard at main kubernetes/kube-state-metrics GitHub 2、修改下载的文件 2.1、修改镜像 原镜像可能下载不了&#xff0c;这里修改deploy…

万界星空科技定制化MES系统帮助实现数字化生产

由于不同企业的生产流程、需求和目标各异&#xff0c;MES管理系统的个性化和定制化需求也不同。有些企业需要将MES管理系统与ERP等其他管理系统进行集成&#xff0c;以实现全面的信息共享和协同工作。有些企业需要将MES管理系统与SCADA等控制系统进行集成&#xff0c;以实现实时…

C++笔试强训day35

目录 1.奇数位丢弃 2.求和 3.计算字符串的编辑距离 1.奇数位丢弃 链接https://www.nowcoder.com/practice/196141ecd6eb401da3111748d30e9141?tpId128&tqId33775&ru/exam/oj 数据量不大&#xff0c;可以直接进行模拟&#xff1a; #include <iostream> #incl…

瑞芯微RV1126——人脸识别框架分析

项目核心是在Linux平台上利用摄像头采集人脸&#xff0c;并进行人脸识别。这个项目使用的是FFMPEGOPENCV虹软框架完成。 FFMPEG的主要工作是负责采集摄像头的数据并把摄像头数据发送给opencv。 Opencv的主要工作则是把摄像头数据转换成矩阵数据。 虹软的主要功能则是利用Open…

AGI |一文快速上手LangChain的新利器:LangGraph!

目录 前言 Part1 LLM Agent &#xff08;一&#xff09;Agent概述 &#xff08;二&#xff09;Agent框架 Part2 LangGraph &#xff08;一&#xff09;LangGraph介绍 &#xff08;二&#xff09;LangGraph组成 &#xff08;三&#xff09;LangGraph使用 &#xff08;四…

MQTT 5.0 报文解析 06:AUTH

欢迎阅读 MQTT 5.0 报文系列 的最后一篇文章。在上一篇中&#xff0c;我们已经介绍了 MQTT 5.0 的 DISCONNECT 报文。现在&#xff0c;我们将介绍 MQTT 中的最后一个控制报文&#xff1a;AUTH。 MQTT 5.0 引入了增强认证特性&#xff0c;它使 MQTT 除了简单密码认证和 Token 认…