Vue3实现canvas画布组件自定义画板实例代码

 更新时间:2024年09月28日 14:06:23   作者:Circle_Key  
Vue Canvas是一个基于Vue.js的轻量级画板组件,旨在提供一个简易的画布功能,用户可以在网页上进行自由绘图,文中通过代码介绍的非常详细,需要的朋友可以参考下

代码示例:

<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>

使用

 效果图

到此这篇关于Vue3实现canvas画布组件自定义画板的文章就介绍到这了,更多相关Vue3 canvas画布组件自定义画板内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • vue 动态生成拓扑图的示例

    vue 动态生成拓扑图的示例

    这篇文章主要介绍了vue 动态生成拓扑图的示例,帮助大家更好的理解和使用vue框架,感兴趣的朋友可以了解下
    2021-01-01
  • VUE3 加载自定义SVG文件的详细步骤

    VUE3 加载自定义SVG文件的详细步骤

    要在 Vue 项目中使用 svg-sprite-loader 来管理 SVG 图标,需要执行相应的步骤,接下来通过本文给大家介绍VUE3 加载自定义SVG文件的详细步骤,感兴趣的朋友一起看看吧
    2024-01-01
  • 如何在宝塔面板部署vue项目

    如何在宝塔面板部署vue项目

    这篇文章主要给大家介绍了关于如何在宝塔面板部署vue项目的相关资料,宝塔面板可以通过Nginx来部署Vue项目,并解决跨域问题,文中通过图文介绍的非常详细,需要的朋友可以参考下
    2023-11-11
  • Vue的自定义组件不能使用click方法的解决

    Vue的自定义组件不能使用click方法的解决

    这篇文章主要介绍了Vue的自定义组件不能使用click方法的解决,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-07-07
  • 基于VUE实现的九宫格抽奖功能

    基于VUE实现的九宫格抽奖功能

    这篇文章主要介绍了基于VUE实现的九宫格抽奖功能,代码简单易懂,非常不错,具有一定的参考借鉴价值,需要的朋友可以参考下
    2018-09-09
  • 3分钟读懂移动端rem使用方法(推荐)

    3分钟读懂移动端rem使用方法(推荐)

    这篇文章主要介绍了rem使用方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-05-05
  • vue-cli 引入、配置axios的方法

    vue-cli 引入、配置axios的方法

    这篇文章主要介绍了vue-cli 引入axios的方法,文中还给大家提到了vue-cli 配置axios的方法,非常不错,具有参考借鉴价值,需要的朋友可以参考下
    2018-05-05
  • vue props使用typescript自定义类型的方法实例

    vue props使用typescript自定义类型的方法实例

    这篇文章主要给大家介绍了关于vue props使用typescript自定义类型的相关资料,文中通过实例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2023-01-01
  • Vue中使用clipboard实现复制功能

    Vue中使用clipboard实现复制功能

    这篇文章主要介绍了Vue中结合clipboard实现复制功能 ,非常不错,具有一定的参考借鉴价值,需要的朋友可以参考下
    2018-09-09
  • 使用vue-cli(vue脚手架)快速搭建项目的方法

    使用vue-cli(vue脚手架)快速搭建项目的方法

    本篇文章主要介绍了使用vue-cli(vue脚手架)快速搭建项目的方法,vue-cli 是一个官方发布 vue.js 项目脚手架,使用 vue-cli 可以快速创建 vue 项目,感兴趣的小伙伴们可以参考一下
    2018-05-05

最新评论