简单的画板小工具,下载即用

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>画图工具 - 绘制/擦除/选中</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #1e1e2e; }

.toolbar {
  background: #181825;
  padding: 12px 18px;
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
  border-bottom: 3px solid #313244;
}

.toolbar button {
  padding: 10px 18px;
  font-size: 14px;
  font-weight: 600;
  border: 2px solid #45475a;
  border-radius: 8px;
  background: #313244;
  color: #cdd6f4;
  cursor: pointer;
  user-select: none;
  -webkit-user-select: none;
  transition: all 0.15s;
}

.toolbar button:hover {
  background: #585b70;
  color: #fff;
}

.toolbar button.active {
  background: #89b4fa;
  border-color: #89b4fa;
  color: #1e1e2e;
}

.toolbar .sep {
  width: 2px;
  height: 28px;
  background: #45475a;
  margin: 0 4px;
}

.toolbar label {
  color: #a6adc8;
  font-size: 13px;
  margin-left: 6px;
  display: flex;
  align-items: center;
  gap: 5px;
}

.toolbar input[type="color"] {
  width: 40px;
  height: 34px;
  border: 2px solid #45475a;
  border-radius: 6px;
  cursor: pointer;
  padding: 2px;
  background: #313244;
}

.toolbar input[type="range"] {
  width: 110px;
  margin-left: 5px;
  accent-color: #89b4fa;
}

#status {
  color: #a6adc8;
  font-size: 13px;
  margin-left: auto;
  padding: 4px 10px;
  background: #313244;
  border-radius: 6px;
}

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

<div class="toolbar">
  <button id="btn-pen">✏️ 画笔</button>
  <button id="btn-line">📏 直线</button>
  <button id="btn-erase">🧽 橡皮擦</button>
  <button id="btn-select">👆 选中</button>
  <div class="sep"></div>
  <button id="btn-demo">➕ 示例线</button>
  <button id="btn-clear">🗑 清空</button>
  <div class="sep"></div>
  <label>颜色<input type="color" id="colorPicker" value="#e74c3c"></label>
  <label>粗细<input type="range" id="sizeSlider" min="1" max="20" value="3"></label>
  <span id="status">就绪</span>
</div>

<canvas id="c" width="1000" height="600"></canvas>

<script>
// ============================================================
//  获取 DOM 元素(全部用 addEventListener 绑定,不依赖任何库)
// ============================================================
var canvas      = document.getElementById('c');
var ctx         = canvas.getContext('2d');
var statusEl    = document.getElementById('status');

var btnPen      = document.getElementById('btn-pen');
var btnLine     = document.getElementById('btn-line');
var btnErase    = document.getElementById('btn-erase');
var btnSelect   = document.getElementById('btn-select');
var btnDemo     = document.getElementById('btn-demo');
var btnClear    = document.getElementById('btn-clear');
var colorPicker = document.getElementById('colorPicker');
var sizeSlider  = document.getElementById('sizeSlider');

// ============================================================
//  全局状态
// ============================================================
var mode = 'pen';          // pen | line | erase | select
var isDrawing = false;
var startX, startY;
var snapshot = null;        // 直线预览用
var selectedIndex = -1;    // 当前选中对象下标

// 每个"对象" = 一条独立直线/笔画,有自己独立的离屏 canvas
var objects = [];

// ============================================================
//  工具函数
// ============================================================
function setStatus(msg) { statusEl.textContent = msg; }

function setActive(btn) {
  btnPen.classList.remove('active');
  btnLine.classList.remove('active');
  btnErase.classList.remove('active');
  btnSelect.classList.remove('active');
  btn.classList.add('active');
}

function getColor() { return colorPicker.value; }
function getSize()  { return parseInt(sizeSlider.value, 10); }

function getPos(e) {
  var rect = canvas.getBoundingClientRect();
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}

// 重绘主画布 = 把所有对象画上去 + 选中框
function redraw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (var i = 0; i < objects.length; i++) {
    ctx.drawImage(objects[i].canvas, 0, 0);
  }
  if (selectedIndex >= 0 && selectedIndex < objects.length) {
    drawSelection(objects[selectedIndex]);
  }
}

// 创建一个新对象(独立离屏 canvas)
function createObject() {
  var off = document.createElement('canvas');
  off.width  = canvas.width;
  off.height = canvas.height;
  var offCtx = off.getContext('2d');
  var obj = { canvas: off, ctx: offCtx, hasContent: false };
  objects.push(obj);
  return obj;
}

// ============================================================
//  命中检测:点 (x,y) 是否在该对象上(含容差)
// ============================================================
function hitTest(obj, x, y, tolerance) {
  var tol = tolerance || 6;
  var w = obj.canvas.width, h = obj.canvas.height;
  var x0 = Math.max(0, x - tol), y0 = Math.max(0, y - tol);
  var x1 = Math.min(w, x + tol), y1 = Math.min(h, y + tol);
  var imgData = obj.ctx.getImageData(x0, y0, x1 - x0, y1 - y0).data;
  for (var i = 3; i < imgData.length; i += 4) {
    if (imgData[i] > 30) return true;
  }
  return false;
}

// 找对象的精确包围盒(用于选中框)
function getBounds(obj) {
  var w = obj.canvas.width, h = obj.canvas.height;
  var data = obj.ctx.getImageData(0, 0, w, h).data;
  var minX = w, minY = h, maxX = 0, maxY = 0, found = false;
  for (var y = 0; y < h; y += 2) {
    for (var x = 0; x < w; x += 2) {
      if (data[(y * w + x) * 4 + 3] > 30) {
        found = true;
        if (x < minX) minX = x;
        if (x > maxX) maxX = x;
        if (y < minY) minY = y;
        if (y > maxY) maxY = y;
      }
    }
  }
  return found ? { minX: minX, minY: minY, maxX: maxX, maxY: maxY } : null;
}

// 画选中框(蓝色虚线矩形,紧贴可见像素)
function drawSelection(obj) {
  var b = getBounds(obj);
  if (!b) return;
  ctx.save();
  ctx.strokeStyle = '#89b4fa';
  ctx.lineWidth = 2;
  ctx.setLineDash([8, 5]);
  ctx.strokeRect(b.minX - 5, b.minY - 5, b.maxX - b.minX + 10, b.maxY - b.minY + 10);
  ctx.setLineDash([]);
  ctx.restore();
}

// ============================================================
//  橡皮擦:在所有对象的离屏 canvas 上擦 + 主画布也擦
// ============================================================
function eraseAt(x, y) {
  var r = getSize() + 4;
  // 主画布
  ctx.save();
  ctx.globalCompositeOperation = 'destination-out';
  ctx.beginPath();
  ctx.arc(x, y, r, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
  // 每个对象
  for (var i = 0; i < objects.length; i++) {
    var obj = objects[i];
    if (!obj.hasContent) continue;
    obj.ctx.save();
    obj.ctx.globalCompositeOperation = 'destination-out';
    obj.ctx.beginPath();
    obj.ctx.arc(x, y, r, 0, Math.PI * 2);
    obj.ctx.fill();
    obj.ctx.restore();
  }
}

// 删除全透明的空对象
function cleanupEmpty() {
  var kept = [];
  for (var i = 0; i < objects.length; i++) {
    var obj = objects[i];
    if (!obj.hasContent) { kept.push(obj); continue; }
    var w = obj.canvas.width, h = obj.canvas.height;
    var data = obj.ctx.getImageData(0, 0, w, h).data;
    var hasPixel = false;
    // 采样(步长100像素,够快够准)
    for (var j = 3; j < data.length; j += 400) {
      if (data[j] > 30) { hasPixel = true; break; }
    }
    if (hasPixel) {
      kept.push(obj);
    }
  }
  objects = kept;
  if (selectedIndex >= objects.length) selectedIndex = -1;
}

// ============================================================
//  鼠标事件(全部用 addEventListener,不依赖 Fabric)
// ============================================================
canvas.addEventListener('mousedown', onDown);
canvas.addEventListener('mousemove', onMove);
canvas.addEventListener('mouseup',    onUp);
canvas.addEventListener('mouseleave', function() { if (isDrawing) onUp(); }); // 防止卡住

function onDown(e) {
  var p = getPos(e);

  // ---- 画笔 ----
  if (mode === 'pen') {
    isDrawing = true;
    var obj = createObject();
    obj.hasContent = true;
    obj.ctx.strokeStyle = getColor();
    obj.ctx.lineWidth = getSize();
    obj.ctx.lineCap = 'round';
    obj.ctx.lineJoin = 'round';
    obj.ctx.beginPath();
    obj.ctx.moveTo(p.x, p.y);
    obj._lx = p.x;
    obj._ly = p.y;
    setStatus('✏️ 画笔:绘制中...');
  }

  // ---- 直线 ----
  else if (mode === 'line') {
    isDrawing = true;
    startX = p.x;
    startY = p.y;
    snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
    setStatus('📏 直线:拖拽中...');
  }

  // ---- 橡皮擦 ----
  else if (mode === 'erase') {
    isDrawing = true;
    eraseAt(p.x, p.y);
    setStatus('🧽 橡皮擦:擦除中...');
  }

  // ---- 选中 ----
  else if (mode === 'select') {
    // 从顶层(最后画的)往下找
    selectedIndex = -1;
    for (var i = objects.length - 1; i >= 0; i--) {
      if (hitTest(objects[i], p.x, p.y)) {
        selectedIndex = i;
        redraw();
        setStatus('👆 选中了对象 #' + (i + 1) + '(共 ' + objects.length + ' 个)--- 按 Delete 删除');
        return;
      }
    }
    redraw();
    setStatus('👆 未选中(共 ' + objects.length + ' 个对象)');
  }
}

function onMove(e) {
  var p = getPos(e);

  // ---- 画笔 ----
  if (mode === 'pen' && isDrawing) {
    var obj = objects[objects.length - 1];
    // 画到离屏
    obj.ctx.lineTo(p.x, p.y);
    obj.ctx.stroke();
    // 画到主画布(实时显示)
    ctx.strokeStyle = getColor();
    ctx.lineWidth = getSize();
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.beginPath();
    ctx.moveTo(obj._lx, obj._ly);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();
    obj._lx = p.x;
    obj._ly = p.y;
  }

  // ---- 直线预览 ----
  else if (mode === 'line' && isDrawing) {
    ctx.putImageData(snapshot, 0, 0);
    ctx.strokeStyle = getColor();
    ctx.lineWidth = getSize();
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();
  }

  // ---- 橡皮擦 ----
  else if (mode === 'erase' && isDrawing) {
    eraseAt(p.x, p.y);
  }
}

function onUp(e) {
  if (!isDrawing) return;
  isDrawing = false;

  // ---- 直线完成 ----
  if (mode === 'line') {
    var p = getPos(e);
    var len = Math.sqrt((p.x - startX) * (p.x - startX) + (p.y - startY) * (p.y - startY));
    if (len > 3) {
      var obj = createObject();
      obj.hasContent = true;
      obj.ctx.strokeStyle = getColor();
      obj.ctx.lineWidth = getSize();
      obj.ctx.lineCap = 'round';
      obj.ctx.beginPath();
      obj.ctx.moveTo(startX, startY);
      obj.ctx.lineTo(p.x, p.y);
      obj.ctx.stroke();
      redraw();
      setStatus('✅ 直线完成,长 ' + Math.round(len) + 'px(共 ' + objects.length + ' 个对象)');
    } else {
      redraw();
      setStatus('直线太短,已取消');
    }
    snapshot = null;
  }

  // ---- 画笔完成 ----
  else if (mode === 'pen') {
    cleanupEmpty();
    redraw();
    setStatus('✅ 画笔完成(共 ' + objects.length + ' 个对象)');
  }

  // ---- 橡皮擦完成 ----
  else if (mode === 'erase') {
    cleanupEmpty();
    redraw();
    setStatus('✅ 橡皮擦完成(剩余 ' + objects.length + ' 个对象)');
  }
}

// ============================================================
//  按钮事件(全部用 addEventListener,绝不用 onclick=)
// ============================================================
btnPen.addEventListener('click', function() {
  mode = 'pen';
  selectedIndex = -1;
  setActive(btnPen);
  setStatus('模式:✏️ 画笔 --- 在画布上拖动即可绘制');
  redraw();
});

btnLine.addEventListener('click', function() {
  mode = 'line';
  selectedIndex = -1;
  setActive(btnLine);
  setStatus('模式:📏 直线 --- 按住拖拽画直线');
  redraw();
});

btnErase.addEventListener('click', function() {
  mode = 'erase';
  selectedIndex = -1;
  setActive(btnErase);
  setStatus('模式:🧽 橡皮擦 --- 在线条上拖动即可擦除(粗细可调)');
  redraw();
});

btnSelect.addEventListener('click', function() {
  mode = 'select';
  setActive(btnSelect);
  setStatus('模式:👆 选中 --- 点击对象可选中,按 Delete 删除,拖动看效果');
  redraw();
});

btnDemo.addEventListener('click', function() {
  // 3 条示例线,每条独立对象
  var demos = [
    { x1: 150, y1: 200, x2: 450, y2: 200, color: '#e74c3c', w: 4 },  // 红线 300px
    { x1: 500, y1: 150, x2: 750, y2: 350, color: '#27ae60', w: 4 },  // 绿斜线
    { x1: 800, y1: 100, x2: 800, y2: 450, color: '#2980b9', w: 4 },  // 蓝竖线
  ];
  for (var i = 0; i < demos.length; i++) {
    var d = demos[i];
    var obj = createObject();
    obj.hasContent = true;
    obj.ctx.strokeStyle = d.color;
    obj.ctx.lineWidth = d.w;
    obj.ctx.lineCap = 'round';
    obj.ctx.beginPath();
    obj.ctx.moveTo(d.x1, d.y1);
    obj.ctx.lineTo(d.x2, d.y2);
    obj.ctx.stroke();
  }
  redraw();
  setStatus('✅ 已生成 3 条示例线(红300px / 绿斜线 / 蓝竖线)--- 共 ' + objects.length + ' 个对象');
});

btnClear.addEventListener('click', function() {
  objects = [];
  selectedIndex = -1;
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  setStatus('🗑 画布已清空');
});

// ============================================================
//  键盘快捷键
// ============================================================
document.addEventListener('keydown', function(e) {
  // Delete / Backspace:删除选中对象
  if ((e.key === 'Delete' || e.key === 'Backspace') && selectedIndex >= 0) {
    objects.splice(selectedIndex, 1);
    selectedIndex = -1;
    redraw();
    setStatus('🗑 已删除选中对象(剩余 ' + objects.length + ' 个)');
    e.preventDefault();
  }
  // 快捷键切模式
  if (e.key === '1') btnPen.click();
  if (e.key === '2') btnLine.click();
  if (e.key === '3') btnErase.click();
  if (e.key === '4') btnSelect.click();
});

// ============================================================
//  初始状态
// ============================================================
setActive(btnPen);
setStatus('点击「➕ 示例线」快速体验,或直接在画布上画');
</script>
</body>
</html>
相关推荐
一朵好运莲1 小时前
智能体使用 Chrome DevTools MCP 调试浏览器
开发语言·javascript·react.js
嘟嘟07171 小时前
Next.js 里 SSR、CSR 和水合到底差在哪?从一段待办代码说起
前端·后端·next.js
sibylyue1 小时前
工作流表单和流程设计前端
开发语言·javascript·开源
用户526835677901 小时前
Kafka 消费倾斜死锁与 Partition 掉队:我用 Rust 写了个“数据管道物理哨兵”,比 Grafana 报警快了 18 秒
javascript
南雨北斗1 小时前
回调地狱、jQuery jqXHR .then 链式、Promise + async/await写法对比
前端
liuxiaocheng1 小时前
快速上手:5 分钟跑通第一个 AI SDK 例子
前端·人工智能·后端
嘟嘟07171 小时前
ESLint 入门:从 npm run lint 到 --fix 与规则级别一次讲清
javascript·代码规范·eslint
一个游离的指针1 小时前
JS中的对象的相关概念
开发语言·javascript·原型模式
霹雳桃2 小时前
移动端 H5 折叠屏适配实战:为什么 max-width 没用,min(vw, px) 才是正解
前端·前端框架