【Canvas 2D】day35-boss-paint-board

作者:mario 发布时间: 2026-09-01 阅读量:4 评论数:0

Day 35 BOSS 战:简易画板 — 第 5 周技能总决战

本周 6 天学的每个技能,今天要在一个真实作品里全部用上:DPR 适配(Day 29)让画板在 Retina 屏上清晰锐利;平滑笔迹(Day 30)让线条不再锯齿;颜色与粗细(Day 31)提供创作自由度;橡皮擦(Day 32 的 destination-out)是真擦除不是画白;文字工具(Day 33)支持在画布上落款;拖拽绘制 + 状态驱动(Day 34)是整个画板的交互骨架。做完这个画板,你对 Canvas 2D 基础的理解就从"会用 API"升级到"能造工具"。


目录


一、需求分析与技术方案

1.1 产品需求清单

功能 说明 用到的本周技能
画笔 按住拖动绘制平滑线条 Day 30 平滑算法 + Day 34 拖拽
橡皮擦 真擦除(露出透明背景),非画白 Day 32 destination-out
颜色选择 预设色板 + 自定义 Day 31 fillStyle
粗细调节 滑块 1-40px Day 30 lineWidth
文字工具 点击位置输入文字 Day 33 fillText
撤销/重做 Ctrl+Z / Ctrl+Y ImageData 快照栈
清空 一键清空画布 clearRect
导出 PNG 下载作品图片 toDataURL
触摸支持 手机也能画 Day 34 touch 三件套

1.2 技术选型决策

笔迹平滑方案?
  ❌ 直接连点(lineTo 折线)→ 锯齿感明显
  ✅ 二次贝塞尔中点法(Day 30 的 drawSmoothLine)→ 圆滑自然

橡皮擦方案?
  ❌ 画背景色 → 背景透明时露馅,且擦不掉文字
  ✅ destination-out 合成 → 真·擦除,透明区域导出 PNG 也正确

撤销方案?
  ❌ 存操作记录重放 → 复杂、文字工具难重放
  ✅ ImageData 快照栈 → 简单粗暴,内存可控(限制栈深度)

二、架构设计:状态驱动 + 工具模式

2.1 整体架构图

┌──────────────────────────────────────────────┐
│                  用户交互                      │
│        mousedown / move / up / touch          │
└──────────────────┬───────────────────────────┘
                   ↓ 修改状态
┌──────────────────────────────────────────────┐
│               画板状态 AppState                │
│  tool: "pen" | "eraser" | "text"              │
│  color / lineWidth / undoStack / redoStack    │
└──────────────────┬───────────────────────────┘
                   ↓ 读取状态渲染
┌──────────────────────────────────────────────┐
│              渲染层(Canvas)                  │
│   主画布(已提交内容)+ 实时笔画直接绘制        │
└──────────────────────────────────────────────┘

2.2 核心状态类型定义

/** 工具类型:可辨识联合的基础标签 */
type ToolKind = "pen" | "eraser" | "text";

/** 画板核心状态 —— 所有交互只改这里,渲染只读这里 */
interface AppState {
  tool: ToolKind;              // 当前工具
  color: string;               // 画笔颜色(橡皮擦不用)
  lineWidth: number;           // 线宽(橡皮擦自动放大 3 倍)
  drawing: boolean;            // 是否正在绘制中
  strokePoints: Point[];       // 当前笔画的轨迹点(实时收集)
  undoStack: ImageData[];      // 撤销栈(快照)
  redoStack: ImageData[];      // 重做栈
}

interface Point { x: number; y: number }

const state: AppState = {
  tool: "pen",
  color: "#00ff88",
  lineWidth: 4,
  drawing: false,
  strokePoints: [],
  undoStack: [],
  redoStack: [],
};

三、逐模块实现

3.1 画布初始化(Day 29 复用)

// paint-board.ts —— 简易画板主程序
import { bootCanvas } from "../day29/canvas-boot.js";

const canvas = document.querySelector("#board") as HTMLCanvasElement;
const { ctx, cssWidth, cssHeight } = bootCanvas(canvas);

3.2 事件绑定:鼠标 + 触摸统一处理

/**
 * 启动画板交互:把鼠标与触摸统一到同一套 start/move/end 回调
 * 好处:核心绘制逻辑只写一份,两套事件只是"翻译官"
 */
function bindEvents(): void {
  // --- 鼠标 ---
  canvas.addEventListener("mousedown", (e) => {
    const p = eventToCanvas(canvas, e);
    startStroke(p);
  });
  canvas.addEventListener("mousemove", (e) => {
    if (!state.drawing) return;            // 没在画就忽略(性能)
    const p = eventToCanvas(canvas, e);
    moveStroke(p);
  });
  window.addEventListener("mouseup", endStroke);   // ⭐ window 上监听:拖出画布也能结束

  // --- 触摸(统一翻译成同一套回调) ---
  canvas.addEventListener("touchstart", (e) => {
    e.preventDefault();                    // 阻止页面滚动
    const t = e.touches[0];
    startStroke({ x: t.clientX - canvas.getBoundingClientRect().left,
                  y: t.clientY - canvas.getBoundingClientRect().top });
  }, { passive: false });                  // ⚠ 要 preventDefault 必须 passive: false
  canvas.addEventListener("touchmove", (e) => {
    e.preventDefault();
    if (!state.drawing) return;
    const t = e.touches[0];
    const rect = canvas.getBoundingClientRect();
    moveStroke({ x: t.clientX - rect.left, y: t.clientY - rect.top });
  }, { passive: false });
  canvas.addEventListener("touchend", (e) => {
    e.preventDefault();
    endStroke();
  }, { passive: false });
}

/** 视口坐标 → 画布坐标(Day 34 的换算函数) */
function eventToCanvas(c: HTMLCanvasElement, e: MouseEvent): Point {
  const rect = c.getBoundingClientRect();
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}

3.3 笔画三阶段:start / move / end

/**
 * 开始一笔:拍照存快照(供撤销)+ 记录起点
 */
function startStroke(p: Point): void {
  if (state.tool === "text") {
    insertText(p);                        // 文字工具走独立流程(见 3.5)
    return;
  }

  // ⭐ 撤销的关键:落笔【之前】把当前画面存进撤销栈
  pushUndoSnapshot();

  state.drawing = true;
  state.strokePoints = [p];               // 轨迹从起点开始收集

  // 橡皮擦切换合成模式(真擦除);画笔保持默认
  if (state.tool === "eraser") {
    ctx.globalCompositeOperation = "destination-out";
    ctx.lineWidth = state.lineWidth * 3;     // 橡皮擦放大 3 倍更好用
    ctx.strokeStyle = "rgba(0,0,0,1)";       // 颜色无所谓,只借形状
  } else {
    ctx.strokeStyle = state.color;
    ctx.lineWidth = state.lineWidth;
    ctx.lineCap = "round";                    // 圆头: Day 30
    ctx.lineJoin = "round";
  }
}

/**
 * 笔画进行中:收集轨迹点 + 实时把"最后两个点"画出来
 *
 * ⚠ 为什么只画最后一段而不是整条重画?
 *   实时整条重画(clear + drawSmoothLine 全轨迹)也行,但点多了每帧都全量重绘;
 *   增量画最后一段性能最好。整条平滑交给 endStroke 的贝塞尔收尾。
 */
function moveStroke(p: Point): void {
  state.strokePoints.push(p);

  const pts = state.strokePoints;
  const n = pts.length;
  if (n < 2) return;

  // 增量绘制:从上一段的中点,以倒数第二点为控制点,画到最新点与上一点的终点
  ctx.beginPath();
  ctx.moveTo(pts[n - 2].x, pts[n - 2].y);
  ctx.lineTo(pts[n - 1].x, pts[n - 1].y);
  ctx.stroke();
}

/**
 * 结束一笔:恢复状态 + 清空轨迹
 */
function endStroke(): void {
  if (!state.drawing) return;
  state.drawing = false;
  state.strokePoints = [];
  ctx.globalCompositeOperation = "source-over";   // ⭐ 必须恢复,否则画笔也变橡皮!
  ctx.lineWidth = state.lineWidth;                 // 恢复线宽
}

3.4 撤销 / 重做:快照栈

const MAX_UNDO = 30;   // 内存保险丝:最多存 30 步

/** 把当前画面压入撤销栈(落笔前调用) */
function pushUndoSnapshot(): void {
  // ⚠ getImageData 用的是物理像素坐标(不受 ctx.scale 影响)!
  // Day 29 我们 canvas.width = cssWidth * dpr,所以要取全尺寸:
  state.undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
  state.redoStack = [];                             // 新笔画清空重做栈(标准编辑器行为)

  if (state.undoStack.length > MAX_UNDO) {
    state.undoStack.shift();                        // 超限丢最老的
  }
}

/** 撤销:弹出撤销栈顶 → 当前画面压入重做栈 → 恢复弹出的快照 */
function undo(): void {
  const snap = state.undoStack.pop();
  if (!snap) return;
  state.redoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
  ctx.putImageData(snap, 0, 0);
}

/** 重做:镜像逻辑 */
function redo(): void {
  const snap = state.redoStack.pop();
  if (!snap) return;
  state.undoStack.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
  ctx.putImageData(snap, 0, 0);
}

// 快捷键
window.addEventListener("keydown", (e) => {
  if (!(e.ctrlKey || e.metaKey)) return;
  if (e.key === "z") { e.preventDefault(); undo(); }
  if (e.key === "y") { e.preventDefault(); redo(); }
});

3.5 文字工具(Day 33 复用)

/**
 * 在点击位置插入文字:用临时 input 浮在页面上输入,回车确认后画上画布
 * (Canvas 没有原生文本输入框,这是业界通行做法)
 */
function insertText(p: Point): void {
  pushUndoSnapshot();   // 文字也算一步操作,可撤销

  const input = document.createElement("input");
  input.style.position = "fixed";
  input.style.left = `${canvas.getBoundingClientRect().left + p.x}px`;
  input.style.top = `${canvas.getBoundingClientRect().top + p.y - 14}px`;
  input.style.fontSize = `${16 + state.lineWidth}px`;
  input.placeholder = "输入文字,回车确认";
  document.body.appendChild(input);
  input.focus();

  const commit = (): void => {
    if (input.value.trim()) {
      ctx.fillStyle = state.color;
      ctx.font = `${16 + state.lineWidth}px sans-serif`;
      ctx.textBaseline = "top";              // 点击点即文字顶部,直觉自然
      ctx.fillText(input.value, p.x, p.y);
    }
    input.remove();
  };
  input.addEventListener("keydown", (e) => {
    if (e.key === "Enter") commit();
    if (e.key === "Escape") input.remove();  // Esc 取消
  });
  input.addEventListener("blur", commit);    // 失焦也提交
}

3.6 工具栏与导出

/** 清空画布(可撤销!先存快照) */
function clearBoard(): void {
  pushUndoSnapshot();
  ctx.clearRect(0, 0, cssWidth, cssHeight);   // 注意用 CSS 尺寸(ctx 已 scale)
}

/** 导出 PNG:透明背景合成白底(否则透明区在部分看图软件显示黑底) */
function exportPNG(): void {
  const out = document.createElement("canvas");
  out.width = canvas.width;
  out.height = canvas.height;
  const octx = out.getContext("2d")!;
  octx.fillStyle = "#ffffff";                       // 先铺白底
  octx.fillRect(0, 0, out.width, out.height);
  octx.drawImage(canvas, 0, 0);                     // 再叠画布内容

  const link = document.createElement("a");
  link.download = `paint-${Date.now()}.png`;
  link.href = out.toDataURL("image/png");
  link.click();
}

// --- 工具栏事件(HTML 略:色板若干色块 + 粗细滑块 + 6 个按钮) ---
document.querySelectorAll("[data-tool]").forEach((btn) => {
  btn.addEventListener("click", () => {
    state.tool = (btn as HTMLElement).dataset.tool as ToolKind;
  });
});
document.querySelector("#size")!.addEventListener("input", (e) => {
  state.lineWidth = Number((e.target as HTMLInputElement).value);
});
document.querySelector("#undo")!.addEventListener("click", undo);
document.querySelector("#redo")!.addEventListener("click", redo);
document.querySelector("#clear")!.addEventListener("click", clearBoard);
document.querySelector("#save")!.addEventListener("click", exportPNG);

四、核心难点攻关

难点 1:橡皮擦为什么会"擦不掉"?

// 症状:切到橡皮擦后画了几笔,切回画笔 —— 画笔也变成擦的了!
// 原因:globalCompositeOperation 是【状态】,endStroke 忘了恢复就泄漏了

// ✅ 解法已写在 endStroke:
ctx.globalCompositeOperation = "source-over";   // 每笔结束必恢复默认

// ⭐ 教训(呼应 Day 32 的阴影泄漏):ctx 的所有状态都是"粘"的,
//    每次绘制前显式设置完整状态,或结束后恢复 —— 二选一,必须做

难点 2:getImageData / putImageData 与 DPR 的恩怨

// getImageData/putImageData 操作的是【物理像素缓冲区】,无视 ctx.scale!
// 所以快照必须用 canvas.width / canvas.height(物理尺寸):
ctx.getImageData(0, 0, canvas.width, canvas.height);        // ✅ 全量
ctx.getImageData(0, 0, cssWidth, cssHeight);                // ❌ 只截了左上 1/dpr 区域

// 而 clearRect 走绘制管线(受 ctx.scale 影响),用 CSS 尺寸:
ctx.clearRect(0, 0, cssWidth, cssHeight);                   // ✅ 全量

记忆口诀:put/get ImageData 用物理,绘制类 API 用 CSS。

难点 3:单指触摸 preventDefault 的 passive 坑

// 症状:手机上 preventDefault 报错 "Unable to preventDefault inside passive..."
// 原因:Chrome 56+ 默认 touch 监听是 passive: true(为了滚动性能,禁止 preventDefault)
// 解法:显式声明 { passive: false }
canvas.addEventListener("touchmove", handler, { passive: false });

五、验收标准与自测

5.1 功能验收清单

  • [ ] Retina 屏(或浏览器缩放 200%)画出的线依然清晰不模糊
  • [ ] 画笔线条圆滑无锯齿,粗细随滑块变化
  • [ ] 橡皮擦擦过的区域是真透明(导出 PNG 后与白色对比可验证)
  • [ ] 文字工具:点击 → 输入 → 回车上画布,Esc 取消
  • [ ] Ctrl+Z 撤销 5 步,Ctrl+Y 重做 5 步,行为符合直觉
  • [ ] 撤销栈超过 30 步不内存爆炸(DevTools Memory 面板确认)
  • [ ] 手机浏览器可正常画画,画时页面不滚动
  • [ ] 导出的 PNG 打开后是白底 + 完整画作

5.2 代码质量自检

  • [ ] 交互只改 state,绘制逻辑只读 state(状态驱动)
  • [ ] 每笔结束后 ctx 状态全部复位(合成模式 / 线宽)
  • [ ] 没有重复的坐标换算代码(函数复用)
  • [ ] 代码分模块:boot / events / stroke / history / toolbar 各司其职

六、进阶挑战(选做)

  1. 平滑升级:把 moveStroke 的增量直线改为 Day 30 的二次贝塞尔中点法实时平滑(提示:保存上一中点,每 move 画一段 quadraticCurveTo)。
  2. 矩形/圆形工具:拖拽预览形状(需要在第二层"预览画布"上绘制,松手才提交主画布——为第 7 周多层画布埋伏笔)。
  3. 压力感应PointerEvent.pressure 获取压感(数位板),线宽随压力变化。
  4. 性能压测:快速乱画 30 秒,用 Performance 面板录制,确认帧率稳定 60FPS、无 Long Task 超过 50ms。

七、常见坑点速查

# 症状 一句话解法
1 合成模式泄漏 切回画笔还在擦 endStroke 恢复 source-over
2 快照尺寸错误 撤销后画面只恢复一角 getImageData 用 canvas.width(物理)
3 clearRect 尺寸错误 清不干净 clearRect 用 cssWidth(CSS)
4 触摸 passive 报错 preventDefault 无效 { passive: false }
5 拖出画布松手卡死 drawing 永远 true mouseup 监听 window
6 导出黑底 透明区在别的软件变黑 导出前铺白底再 drawImage
7 重做栈逻辑乱 撤销后画新笔,重做把旧内容"复活" 新笔画清空 redoStack
8 移动端双击缩放 快速画两笔页面放大 viewport meta + touch-action: none

八、周总结与知识图谱

8.1 第 5 周技能全景

第 5 周 Canvas 基础(Day 29-35)
├── Day 29 入门与坐标系
│   ├── DPR 适配三步曲(分辨率×dpr + ctx.scale)
│   └── 矩形三件套 fillRect / strokeRect / clearRect
├── Day 30 路径与线条
│   ├── beginPath 两阶段(描草稿 → 显影)
│   ├── lineCap / lineJoin 圆滑关键
│   └── 二次贝塞尔中点平滑法
├── Day 31 渐变与填充
│   ├── 线性 / 径向渐变(色标 addColorStop)
│   └── createPattern 平铺
├── Day 32 阴影与合成
│   ├── 阴影四件套(用完必须关!)
│   └── destination-out 橡皮擦 / lighter 叠加发光
├── Day 33 文本与图片
│   ├── textAlign / textBaseline 锚点系统
│   ├── measureText 动态排版
│   └── drawImage 九参裁剪 + 跨域 crossOrigin
├── Day 34 交互与动画
│   ├── getBoundingClientRect 坐标换算
│   ├── rAF + dt 帧率无关动画
│   └── 状态驱动渲染思想
└── Day 35 BOSS:简易画板 🎯
    ├── 状态驱动 + 工具模式架构
    ├── 快照栈撤销 / 重做
    ├── 真·橡皮擦(合成模式)
    └── 鼠标 / 触摸统一事件层

8.2 本周认知升级

之前(学本周前) 现在(学完本周后)
Canvas 是"高级 div",画画靠猜 Canvas 是状态机 + 像素缓冲区,一切尽在掌握
高清屏上模糊是"浏览器问题" DPR 适配三行代码,原理门儿清
橡皮擦 = 画白色 橡皮擦 = destination-out 合成,真擦除
动画 = setInterval 动画 = rAF + 时间差,帧率无关
画完就完了,改不了 状态驱动:画面是状态的投影,随时可撤销可重放

8.3 下周预告(第 6 周:Canvas 变换与像素)

画板里"跟随鼠标旋转的笔刷"、“整体缩放的画布"靠的就是变换矩阵(translate/rotate/scale 与 save/restore);“图片滤镜"靠的是 **ImageData 像素级操作”。下周从"画什么"进阶到"怎么变换着画"与"直接操纵向素”——工业组态的图元旋转、热成像滤镜,全在这一周。

本周毕业标准:画板作品至少使用 6 项功能完成一幅作品并导出 PNG,代码能脱离文档独立复写核心模块(笔画三阶段 + 撤销)。

评论