vue-drawer-board 简单的画图功能

环境:s^2.15.8+nuxt-property-decorator^2.9.1+portal-vue@2

plugins/portal-vue.js

js 复制代码
import Vue from 'vue' 
import PortalVue from 'portal-vue' 
Vue.use(PortalVue)

nuxt.config.js

js 复制代码
export default { plugins: ['~/plugins/portal-vue.js'] }

pages/xxx.vue或components/xxx.vue

js 复制代码
<template>
  <div class="draw-page">
    <header class="draw-header">
      <p>直接在下方区域进行涂鸦创作</p>
    </header>

    <!-- 工具栏 -->
    <div class="toolbar">
      <div class="tool-item">
        <label>颜色</label>
        <input type="color" v-model="brushColor" />
      </div>
      <div class="tool-item">
        <label>粗细</label>
        <input type="range" min="1" max="20" v-model="brushSize" />
      </div>
      <div class="actions">
        <button @click="undo" :disabled="history.length === 0">↩ 撤销</button>
        <button @click="redo" :disabled="redoStack.length === 0">↪ 重做</button>
        <button @click="clearCanvas" class="btn-text">清空</button>
        <button @click="saveImage" class="btn-primary">保存作品</button>
      </div>
    </div>

    <!-- 画板容器 -->
    <div class="board-container" ref="boardContainer">
      <canvas
        ref="canvasEl"
        @mousedown="startDrawing"
        @mousemove="draw"
        @mouseup="stopDrawing"
        @mouseleave="stopDrawing"
      ></canvas>
    </div>
  </div>
</template>

<script lang="ts">
import { Component, Vue, Watch } from "nuxt-property-decorator";

interface Point {
  x: number;
  y: number;
}
interface Stroke {
  points: Point[];
  color: string;
  size: number;
}

@Component
export default class EmbeddedBoard extends Vue {
  // 状态
  brushColor = "#000000";
  brushSize = 5;
  isDrawing = false;

  // 历史记录
  history: Stroke[] = [];
  redoStack: Stroke[] = [];
  currentStroke: Stroke = { points: [], color: "", size: 0 };

  // 引用
  $refs!: {
    canvasEl: HTMLCanvasElement;
    boardContainer: HTMLDivElement;
  };

  private ctx: CanvasRenderingContext2D | null = null;

  // 监听画笔属性变化
  @Watch("brushColor")
  @Watch("brushSize")
  updateBrushStyle() {
    if (this.ctx) {
      this.ctx.strokeStyle = this.brushColor;
      this.ctx.lineWidth = this.brushSize;
    }
  }

  // 组件挂载后初始化
  mounted() {
    this.$nextTick(() => {
      this.initCanvas();
    });
  }

  // 初始化画布尺寸
  private initCanvas() {
    const canvas = this.$refs.canvasEl;
    const container = this.$refs.boardContainer;

    if (canvas && container) {
      // 设置实际像素尺寸(解决模糊问题)
      canvas.width = container.clientWidth;
      canvas.height = container.clientHeight;

      const context = canvas.getContext("2d");
      if (context) {
        this.ctx = context;
        // 初始化画笔
        this.ctx.lineCap = "round";
        this.ctx.lineJoin = "round";
        this.ctx.lineWidth = this.brushSize;
        this.ctx.strokeStyle = this.brushColor;
        // 绘制白色背景
        this.fillWhiteBackground();
      }
    }
  }

  // 填充白色背景(防止保存时透明变黑)
  private fillWhiteBackground() {
    if (!this.ctx || !this.$refs.canvasEl) return;
    this.ctx.fillStyle = "#ffffff";
    this.ctx.fillRect(
      0,
      0,
      this.$refs.canvasEl.width,
      this.$refs.canvasEl.height
    );
  }

  // 获取坐标
  private getPos(e: MouseEvent): Point {
    const rect = this.$refs.canvasEl.getBoundingClientRect();
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
    };
  }

  startDrawing(e: MouseEvent) {
    if (!this.ctx) return;
    this.isDrawing = true;
    const pos = this.getPos(e);

    // 开始新的一笔
    this.currentStroke = {
      points: [pos],
      color: this.brushColor,
      size: this.brushSize,
    };

    // 画一个点
    this.ctx.beginPath();
    this.ctx.moveTo(pos.x, pos.y);
    this.ctx.lineTo(pos.x, pos.y);
    this.ctx.stroke();
  }

  draw(e: MouseEvent) {
    if (!this.isDrawing || !this.ctx) return;
    e.preventDefault(); // 防止拖动图片

    const pos = this.getPos(e);
    this.currentStroke.points.push(pos);

    this.ctx.lineTo(pos.x, pos.y);
    this.ctx.stroke();
    this.ctx.beginPath();
    this.ctx.moveTo(pos.x, pos.y);
  }

  stopDrawing() {
    if (!this.isDrawing) return;
    this.isDrawing = false;
    if (this.ctx) this.ctx.beginPath(); // 重置路径

    // 保存历史
    if (this.currentStroke.points.length > 1) {
      this.history.push({ ...this.currentStroke });
      this.redoStack = []; // 新操作清空重做栈
    }
  }

  // 重绘整个画布(用于撤销/重做)
  private redraw() {
    if (!this.ctx || !this.$refs.canvasEl) return;

    // 清空
    this.ctx.clearRect(
      0,
      0,
      this.$refs.canvasEl.width,
      this.$refs.canvasEl.height
    );
    this.fillWhiteBackground();

    // 重绘所有笔画
    this.history.forEach((stroke) => {
      if (stroke.points.length === 0) return;
      this.ctx!.beginPath();
      this.ctx!.lineCap = "round";
      this.ctx!.lineJoin = "round";
      this.ctx!.lineWidth = stroke.size;
      this.ctx!.strokeStyle = stroke.color;

      this.ctx!.moveTo(stroke.points[0].x, stroke.points[0].y);
      for (let i = 1; i < stroke.points.length; i++) {
        this.ctx!.lineTo(stroke.points[i].x, stroke.points[i].y);
      }
      this.ctx!.stroke();
    });
  }

  undo() {
    if (this.history.length === 0) return;
    const last = this.history.pop();
    if (last) this.redoStack.push(last);
    this.redraw();
  }

  redo() {
    if (this.redoStack.length === 0) return;
    const last = this.redoStack.pop();
    if (last) this.history.push(last);
    this.redraw();
  }

  clearCanvas() {
    if (!this.ctx) return;
    this.history = [];
    this.redoStack = [];
    this.ctx.clearRect(
      0,
      0,
      this.$refs.canvasEl.width,
      this.$refs.canvasEl.height
    );
    this.fillWhiteBackground();
  }

  saveImage() {
    const link = document.createElement("a");
    link.download = `sketch-${Date.now()}.png`;
    link.href = this.$refs.canvasEl.toDataURL();
    link.click();
  }
}
</script>

<style scoped>
.draw-page {
  /* max-width: 1000px; */
  margin: 40px auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

.draw-header {
  text-align: center;
  margin-bottom: 1px;
}

.draw-header h2 {
  margin: 0 0 10px 0;
  color: #333;
  font-size: 28px;
}

.draw-header p {
  color: #666;
  margin: 0;
}

/* 工具栏样式 */
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  align-items: center;
  padding: 15px 20px;
  background: #f8f9fa;
  border: 1px solid #e9ecef;
  border-bottom: none;
  border-radius: 8px 8px 0 0;
}

.tool-item {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  color: #495057;
}

.tool-item input[type="color"] {
  border: none;
  width: 30px;
  height: 30px;
  cursor: pointer;
  background: none;
}

.tool-item input[type="range"] {
  width: 100px;
}

.actions {
  margin-left: auto;
  display: flex;
  gap: 10px;
}

button {
  padding: 8px 16px;
  border-radius: 6px;
  border: 1px solid #dee2e6;
  background: #fff;
  color: #495057;
  cursor: pointer;
  font-size: 14px;
  transition: all 0.2s;
}

button:hover:not(:disabled) {
  background: #e9ecef;
  border-color: #adb5bd;
}

button:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.btn-primary {
  background: #228be6;
  color: white;
  border-color: #228be6;
}

.btn-primary:hover:not(:disabled) {
  background: #1c7ed6;
}

.btn-text {
  color: #fa5252;
  border-color: transparent;
  background: transparent;
}

.btn-text:hover:not(:disabled) {
  background: #fff5f5;
  color: #fa5252;
}

/* 画板容器 */
.board-container {
  width: 100%;
  height: 500px; /* 固定高度,也可以设为 auto */
  border: 1px solid #e9ecef;
  border-radius: 0 0 8px 8px;
  overflow: hidden;
  background: #fff;
  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}

canvas {
  display: block;
  cursor: crosshair;
}
</style>

展示效果

相关推荐
AI大模型-小华38 分钟前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅3 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni3 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师5 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc885 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn6 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端
Csvn6 小时前
🎨 CSS @layer:用「层叠优先级」终结样式打架的世纪难题
前端