Vue3实现canvas画布组件,自定义画板

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

 使用

 效果图

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值