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>

展示效果

相关推荐
To_OC7 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic8 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy8 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志8 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble10 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯10 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜11 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司11 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g12 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森12 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http