前言

因为课程需要以及自己想找点事做的原因,在咨询GPT-5后,我决定开发一个协同画布进行练手。做的很粗糙,本蒟蒻再次球球大佬轻喷Orz。

Note:我同时使用Pad和PC书写这篇blog,所以会导致标点全角和半角混用,请见谅。

后端

前端比较好做,于是我们先来考虑后端。首先考虑的是架构问题。我最开始想使用传统的php,但是php更适合做轮询,而在我们这个项目中轮询的话首先对于服务器的压力较大,其次对于我们延迟的要求也比较高。于是我们转为websocket。只有当websocket不可用或者断开时, 我们再采取轮询重新建立连接或者作为降级方案.

WebSocket简介

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。浏览器或脚本先通过一次 HTTP 请求「升级」到 WebSocket,之后双方可以随时互相发数据,无需再发新的 HTTP 请求。

在常见的即时场景, 如聊天等地方用WebSocket能大大减少带宽使用, 节约服务器资源.

有以下常见WebSocket库:

  • Node.js
    • ws:轻量、性能好,常见选择
    • Socket.IO:在 WebSocket 之上封装(含自动重连、房间、回退到轮询等),但不等同于纯 WebSocket 协议
  • Java
    • Jakarta WebSocket(javax.websocket / jakarta.websocket)标准 API(Tomcat/Jetty/Undertow 等)
    • Spring WebSocket / STOMP:常用于与 Spring 生态整合、支持消息代理
    • Netty:更底层、更高性能与可定制
  • Go
    • gorilla/websocket:经典库
    • nhooyr/websocket:更现代的实现
  • Python
    • websockets、aiohttp(异步)
    • Django Channels(带频道层、适合与 Django 集成)
  • .NET
    • ASP.NET Core WebSockets / SignalR(SignalR 会做更高层封装与回退)
  • PHP
    • Ratchet、Swoole(Swoole 偏高性能常驻内存服务)

我们选择Node.js进行开发, 因为其与我们所熟悉的JavaScript极为相似, 有助于我们的初次开发.

Node.js初步起步

什么是Node.js

Node.js 是一个基于 Chrome V8 JavaScript 引擎构建的 JavaScript 运行时环境。简单来说,Node.js 让 JavaScript 可以在服务器端运行,而不仅仅局限于浏览器中。

1. 单线程事件循环

  • Node.js 使用单线程的事件循环模型
  • 通过事件驱动和回调函数处理并发请求
  • 避免了传统多线程编程中的线程切换开销

2. 非阻塞 I/O

  • 所有 I/O 操作(文件读写、网络请求等)都是异步的
  • 当等待 I/O 操作完成时,程序不会被阻塞,可以继续处理其他任务
  • 大大提高了应用程序的吞吐量

3. 跨平台

  • 支持 Windows、macOS、Linux 等多种操作系统
  • 一次编写,到处运行

4. 丰富的生态系统

  • npm(Node Package Manager)拥有数百万个开源包
  • 活跃的开发者社区

Node.js使用单线程异步处理吗所以避免了多线程可能引发的一切问题, 适合I/O密集型任务.

如何安装Node.js-docker

如果想让Node.js在docker环境下运行, 请确保你的服务器有docker环境并配置镜像源.

由于我是腾讯云服务器, 可以使用腾讯云镜像(仅限内网访问):

编辑配置文件:vim /etc/docker/daemon.json:

{
  "registry-mirrors": [
  "https://mirror.ccs.tencentyun.com"
],
  "insecure-registries": [
  "mirror.ccs.tencentyun.com"
]
}

重启docker:

systemctl daemon-reload
systemctl restart docker

接下来下载Node.js:

进入Node.js — Download Node.js®我们可以查询到官方下载命令:

Get Node.js®v24.14.0 (LTS) for Linux using Docker with npm

# Docker has specific installation instructions for each operating system.
# Please refer to the official documentation at https://docker.com/get-started/
# Pull the Node.js Docker image:
docker pull node:24-alpine
# Create a Node.js container and start a Shell session:
docker run -it --rm --entrypoint sh node:24-alpine
# Verify the Node.js version:
node -v # Should print "v24.14.0".
# Verify npm version:
npm -v # Should print "11.9.0".

如何安装node.js - apt-get install

由于我使用vscode-server进行开发, 它运行是直接执行node指令, 而不会进入docker环境, 所以我采取直接安装. 直接使用apt-get安装只会安装18版本的, 有点过于老了. 于是我们可以进Nodesource Node.js DEB来安装node.js 22.x LTS.

当我执行apt-get update时, 爆了公钥无法识别的问题: The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 3B4FE6ACC0B21F32

这种情况下我们只需要导入公钥即可. 如果你和我一样是Ubuntu可以按照以下操作:

sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32

这里的公钥3B4FE6ACC0B21F32替换为你上面报错的公钥.

接下来, 正常安装node.js即可. 如果安装成功, 理应可以通过node -v和npm -v查询版本号.

接下来, 创建Hello World.js:

console.log("Hello, node.js!")

运行:

大功告成!

基本框架

我们使用Socket.IO来作为创建WebSocket的框架服务. 它给了一个很方便的简易聊天室的demo教程, 我们稍作修改即可. 首先, 导入socket.io的modules:

import { Server } from "socket.io";

项目初始化

首先,在项目目录下新建package.json作为我们的描述文件。

键入以下文件:

{
  "name": "auto-sync-drawboard",
  "version": "0.0.1",
  "description": "an app that allow users to draw anything in one drawboard and can automatically sync.",
  "type": "module",
  "dependencies": {}
}

接下来,安装我们所需的依赖:

npm install express@4

然后在index.js键入以下内容:

import express from 'express';
import { createServer } from 'node:http';

const app = express();
const server = createServer(app);

app.get('/', (req, res) => {
  res.send('<h1>Hello world</h1>');
});

server.listen(4268, () => {
  console.log('server running at http://localhost:4268');
});

运行。由于我已经将端口4268转发到draw.endermite.cn,于是可以直接打开draw.endermite.cn:

如果能够正常显示,那么已经有一个很好的开头了!

转发HTML

我们不难发现,如果我们将HTML全部写到我们的js里面将会导致臃肿以及难以维护,于是我们还是按照传统方式开发一个index.html将静态部分放入html文件中。(app.get部分是修改)

在index.js中加入以下内容:

import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));

app.get('/', (req, res) => {
  res.sendFile(join(__dirname, 'index.html'));
});

然后我们再来简单写个前端demo。我使用gpt-5.3-codex(ai简直太好用了你知道吗)进行初步开发, 先做了份本地可以绘制的基本版本:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
  <title>协同画板 Demo(前端)</title>
  <style>
    :root{
      --bg:#f5f7fb;
      --panel:#ffffffd9;
      --border:#e8ebf3;
      --text:#1f2937;
      --sub:#6b7280;
      --primary:#3b82f6;
      --shadow:0 10px 30px rgba(25,35,70,.08);
      --r:16px;

      --app-gap:10px;
      --top-h:64px;
      --footer-h:42px;
      --side-w:280px;
    }

    *{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
    html,body{
      width:100%;height:100%;margin:0;overflow:hidden;
      font-family:"PingFang SC","Microsoft YaHei",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
      color:var(--text);
      background:
        radial-gradient(1200px 600px at 10% -20%, #e8f0ff, transparent 60%),
        radial-gradient(1000px 500px at 110% -10%, #e7fff7, transparent 55%),
        var(--bg);
    }

    .app{
      height:100dvh;
      padding:12px;
      display:grid;
      grid-template-rows:var(--top-h) 1fr var(--footer-h);
      gap:var(--app-gap);
    }

    .topbar,.footer,.tools,.board-wrap{
      background:var(--panel);
      border:1px solid var(--border);
      box-shadow:var(--shadow);
      backdrop-filter:blur(10px);
    }

    .topbar{
      border-radius:var(--r);
      display:flex;align-items:center;justify-content:space-between;
      padding:0 14px;
      z-index:3;
    }
    .brand{display:flex;align-items:center;gap:10px;min-width:0}
    .logo{
      width:38px;height:38px;border-radius:12px;display:grid;place-items:center;color:#fff;
      background:linear-gradient(135deg,#3b82f6,#8b5cf6);
      box-shadow:0 8px 20px rgba(59,130,246,.35);
      font-size:18px;flex:0 0 auto;
    }
    .title{font-size:16px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .subtitle{font-size:12px;color:var(--sub);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
    .status{
      display:flex;align-items:center;gap:8px;
      color:#10b981;font-size:13px;background:#ecfdf5;border:1px solid #d1fae5;
      padding:6px 10px;border-radius:999px;flex:0 0 auto;
    }
    .dot{width:8px;height:8px;border-radius:50%;background:#10b981;animation:pulse 1.8s infinite}

    .main{
      min-height:0;
      display:grid;
      grid-template-columns:var(--side-w) 1fr;
      gap:var(--app-gap);
      /* 关键:让中间区严格扣除上下高度,避免挡 footer */
      height:calc(100dvh - 24px - var(--top-h) - var(--footer-h) - var(--app-gap) * 2);
    }

    .tools{
      border-radius:var(--r);
      padding:10px;
      display:flex;flex-direction:column;gap:10px;
      min-height:0;
      overflow:auto; /* 防溢出 */
    }
    .group{
      border:1px solid #eef1f7;border-radius:12px;padding:8px;background:#fff;
      display:grid;gap:8px;
    }
    .tool-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}
    .btn{
      height:38px;border:1px solid var(--border);background:#fff;border-radius:10px;cursor:pointer;
      display:grid;place-items:center;font-size:16px;transition:.16s;
    }
    .btn:hover{transform:translateY(-1px);box-shadow:0 8px 16px rgba(59,130,246,.12);border-color:#dbe5ff}
    .btn.active{color:var(--primary);border-color:#bfdbfe;background:#eff6ff}
    .label{font-size:12px;color:var(--sub);padding-left:2px}
    input[type=range]{width:100%;accent-color:var(--primary);cursor:pointer}

    /* Adobe 风格色盘 */
    .picker-wrap{display:grid;gap:8px}
    .sv-panel{
      width:100%;
      aspect-ratio:1.2/1;
      border-radius:10px;
      position:relative;
      cursor:crosshair;
      border:1px solid #dfe5f2;
      overflow:hidden;
      background:red;
      touch-action:none;
    }
    .sv-white{
      position:absolute;inset:0;
      background:linear-gradient(to right,#fff,rgba(255,255,255,0));
    }
    .sv-black{
      position:absolute;inset:0;
      background:linear-gradient(to top,#000,rgba(0,0,0,0));
    }
    .sv-pointer{
      position:absolute;width:14px;height:14px;border:2px solid #fff;border-radius:50%;
      box-shadow:0 0 0 1px rgba(0,0,0,.35);
      transform:translate(-7px,-7px);
      pointer-events:none;
    }

    .hue{
      -webkit-appearance:none;appearance:none;
      height:12px;border-radius:999px;outline:none;border:1px solid #dfe5f2;
      background:linear-gradient(90deg,
        #ff0000 0%,
        #ffff00 17%,
        #00ff00 33%,
        #00ffff 50%,
        #0000ff 67%,
        #ff00ff 83%,
        #ff0000 100%);
    }
    .hue::-webkit-slider-thumb{
      -webkit-appearance:none;appearance:none;
      width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid #111827;
      box-shadow:0 1px 4px rgba(0,0,0,.25);
    }

    .color-preview{
      height:28px;border-radius:8px;border:1px solid #dfe5f2;display:flex;align-items:center;justify-content:center;
      font-size:12px;color:#111827;background:#fff;font-weight:600;
    }

    .board-wrap{
      border-radius:var(--r);padding:10px;display:flex;flex-direction:column;gap:8px;min-height:0;
      overflow:hidden;
    }
    .board-head{
      display:flex;justify-content:space-between;align-items:center;gap:8px;
      color:var(--sub);font-size:12px;padding:0 4px;
    }
    .canvas-box{
      flex:1;min-height:0;border-radius:12px;border:1px dashed #dbe2f0;
      background:
        linear-gradient(90deg,#f9fbff 0 24px,transparent 24px) 0 0/25px 25px,
        linear-gradient(#f9fbff 0 24px,transparent 24px) 0 0/25px 25px,
        #fff;
      overflow:hidden;position:relative;touch-action:none;
    }
    canvas{width:100%;height:100%;display:block;cursor:crosshair}

    .footer{
      border-radius:12px;display:flex;align-items:center;justify-content:space-between;
      padding:0 12px;font-size:12px;color:var(--sub);
      position:relative;z-index:5; /* 防止被画布视觉覆盖 */
    }
    .kbd{
      border:1px solid #d7deee;border-bottom-width:2px;border-radius:6px;
      padding:1px 6px;background:#fff;color:#4b5563;font-size:11px;margin-left:4px;
    }

    @media (max-width: 900px){
      :root{
        --top-h:56px;
        --footer-h:40px;
      }
      .main{
        grid-template-columns:1fr;
        grid-template-rows:auto 1fr;
      }
      .tools{
        flex-direction:row;
        overflow-x:auto;
        overflow-y:hidden;
        padding:8px;
        gap:8px;
        align-items:stretch;
      }
      .group{
        min-width:170px;
        gap:6px;
        padding:7px;
      }
      .subtitle{display:none}
      .btn{height:34px}
      .label{font-size:11px}
      .status{font-size:12px;padding:5px 8px}
      .picker-wrap .color-preview{height:24px;font-size:11px}
    }

    @keyframes pulse{
      0%{box-shadow:0 0 0 0 rgba(16,185,129,.6)}
      70%{box-shadow:0 0 0 9px rgba(16,185,129,0)}
      100%{box-shadow:0 0 0 0 rgba(16,185,129,0)}
    }
  </style>
</head>
<body>
<div class="app">
  <header class="topbar">
    <div class="brand">
      <div class="logo">🖊️</div>
      <div>
        <div class="title">协同画板 Demo</div>
        <div class="subtitle">前端单机版 | Canvas | 预留协同接入</div>
      </div>
    </div>
    <div class="status"><span class="dot"></span><span>在线(本地模式)</span></div>
  </header>

  <main class="main">
    <aside class="tools">
      <section class="group">
        <div class="tool-row">
          <button id="penBtn" class="btn active" title="画笔">✏️</button>
          <button id="eraserBtn" class="btn" title="橡皮擦">🧽</button>
        </div>
      </section>

      <section class="group">
        <div class="label">颜色(Adobe 风格)🎨</div>
        <div class="picker-wrap">
          <div id="svPanel" class="sv-panel">
            <div class="sv-white"></div>
            <div class="sv-black"></div>
            <div id="svPointer" class="sv-pointer"></div>
          </div>
          <input id="hueRange" class="hue" type="range" min="0" max="360" value="220" />
          <div id="colorPreview" class="color-preview">#3B82F6</div>
        </div>
      </section>

      <section class="group">
        <div class="label">粗细 🧵</div>
        <input id="sizeRange" type="range" min="1" max="32" value="4" />
        <div class="tool-row">
          <button id="undoBtn" class="btn" title="撤销">↩️</button>
          <button id="redoBtn" class="btn" title="重做">↪️</button>
          <button id="clearBtn" class="btn" title="清空">🗑️</button>
          <button id="saveBtn" class="btn" title="保存">💾</button>
        </div>
      </section>
    </aside>

    <section class="board-wrap">
      <div class="board-head">
        <span>画布区域</span>
        <span id="meta">颜色 #3B82F6 · 粗细 4px</span>
      </div>
      <div id="canvasBox" class="canvas-box">
        <canvas id="board"></canvas>
      </div>
    </section>
  </main>

  <footer class="footer">
    <div>提示:移动端支持手指绘制</div>
    <div>快捷键 <span class="kbd">Ctrl/Cmd + Z</span><span class="kbd">Ctrl/Cmd + Y</span></div>
  </footer>
</div>

<script>
  const canvas = document.getElementById('board');
  const box = document.getElementById('canvasBox');
  const ctx = canvas.getContext('2d');

  const penBtn = document.getElementById('penBtn');
  const eraserBtn = document.getElementById('eraserBtn');
  const sizeRange = document.getElementById('sizeRange');
  const meta = document.getElementById('meta');

  const undoBtn = document.getElementById('undoBtn');
  const redoBtn = document.getElementById('redoBtn');
  const clearBtn = document.getElementById('clearBtn');
  const saveBtn = document.getElementById('saveBtn');

  // 颜色选择器元素(Adobe 风格)
  const svPanel = document.getElementById('svPanel');
  const svPointer = document.getElementById('svPointer');
  const hueRange = document.getElementById('hueRange');
  const colorPreview = document.getElementById('colorPreview');

  let isDrawing = false;
  let mode = 'pen';
  let size = Number(sizeRange.value);
  let color = '#3B82F6';

  // HSV
  let h = 220, s = 0.75, v = 0.96;

  // ====== 笔画事件模型 ======
  let strokes = [];        // 已确认笔画
  let undoneStrokes = [];  // 撤销缓存(重做用)
  let currentStroke = null;

  function genId() {
    return 'stroke_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
  }

  // ---------- 颜色工具 ----------
  function hsvToRgb(h, s, v) {
    const c = v * s;
    const x = c * (1 - Math.abs((h / 60) % 2 - 1));
    const m = v - c;
    let r=0,g=0,b=0;
    if (h < 60) [r,g,b] = [c,x,0];
    else if (h < 120) [r,g,b] = [x,c,0];
    else if (h < 180) [r,g,b] = [0,c,x];
    else if (h < 240) [r,g,b] = [0,x,c];
    else if (h < 300) [r,g,b] = [x,0,c];
    else [r,g,b] = [c,0,x];
    return {
      r: Math.round((r + m) * 255),
      g: Math.round((g + m) * 255),
      b: Math.round((b + m) * 255),
    };
  }

  function rgbToHex(r,g,b) {
    return '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join('').toUpperCase();
  }

  function updateColorUI() {
    svPanel.style.background = `hsl(${h} 100% 50%)`;
    const rgb = hsvToRgb(h, s, v);
    color = rgbToHex(rgb.r, rgb.g, rgb.b);

    colorPreview.style.background = color;
    colorPreview.textContent = color;
    colorPreview.style.color = v < .55 ? '#fff' : '#111827';

    meta.textContent = `颜色 ${color} · 粗细 ${size}px`;
  }

  function setSVByClient(clientX, clientY) {
    const rect = svPanel.getBoundingClientRect();
    const x = Math.min(Math.max(0, clientX - rect.left), rect.width);
    const y = Math.min(Math.max(0, clientY - rect.top), rect.height);

    s = x / rect.width;
    v = 1 - y / rect.height;

    svPointer.style.left = `${x}px`;
    svPointer.style.top = `${y}px`;

    mode = 'pen';
    setActiveTool(penBtn);
    updateColorUI();
  }

  let pickSV = false;
  svPanel.addEventListener('mousedown', e => { pickSV = true; setSVByClient(e.clientX, e.clientY); });
  window.addEventListener('mousemove', e => { if (pickSV) setSVByClient(e.clientX, e.clientY); });
  window.addEventListener('mouseup', () => pickSV = false);

  svPanel.addEventListener('touchstart', e => {
    pickSV = true;
    const t = e.touches[0];
    setSVByClient(t.clientX, t.clientY);
  }, { passive: false });

  window.addEventListener('touchmove', e => {
    if (!pickSV) return;
    const t = e.touches[0];
    setSVByClient(t.clientX, t.clientY);
  }, { passive: false });

  window.addEventListener('touchend', () => pickSV = false);

  hueRange.addEventListener('input', e => {
    h = Number(e.target.value);
    updateColorUI();
  });

  // ---------- 工具 ----------
  function setActiveTool(btn) {
    [penBtn, eraserBtn].forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
  }

  penBtn.onclick = () => { mode = 'pen'; setActiveTool(penBtn); };
  eraserBtn.onclick = () => { mode = 'eraser'; setActiveTool(eraserBtn); };

  sizeRange.addEventListener('input', e => {
    size = Number(e.target.value);
    meta.textContent = `颜色 ${color} · 粗细 ${size}px`;
  });

  // ---------- Canvas 尺寸 ----------
  function resizeCanvas() {
    const rect = box.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;

    canvas.width = Math.floor(rect.width * dpr);
    canvas.height = Math.floor(rect.height * dpr);
    canvas.style.width = rect.width + 'px';
    canvas.style.height = rect.height + 'px';

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    redrawAll();
    // 同步色盘指针位置
    svPointer.style.left = `${s * svPanel.clientWidth}px`;
    svPointer.style.top = `${(1 - v) * svPanel.clientHeight}px`;
  }

  // ---------- 绘制核心 ----------
  function getPoint(e) {
    const rect = canvas.getBoundingClientRect();
    const p = e.touches ? e.touches[0] : e;
    return {
      x: p.clientX - rect.left,
      y: p.clientY - rect.top,
      t: Date.now(),
    };
  }

  function drawStroke(stroke) {
    const pts = stroke.points;
    if (!pts || pts.length === 0) return;

    ctx.save();
    ctx.lineWidth = stroke.size;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    if (stroke.mode === 'eraser') {
      ctx.globalCompositeOperation = 'destination-out';
    } else {
      ctx.globalCompositeOperation = 'source-over';
      ctx.strokeStyle = stroke.color;
    }

    // 只有一个点时画圆点
    if (pts.length === 1) {
      ctx.beginPath();
      ctx.arc(pts[0].x, pts[0].y, stroke.size / 2, 0, Math.PI * 2);
      if (stroke.mode === 'eraser') {
        ctx.fillStyle = 'rgba(0,0,0,1)';
      } else {
        ctx.fillStyle = stroke.color;
      }
      ctx.fill();
      ctx.restore();
      return;
    }

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

  function redrawAll() {
    const rect = box.getBoundingClientRect();
    ctx.clearRect(0, 0, rect.width, rect.height);

    for (const s of strokes) drawStroke(s);
    if (currentStroke) drawStroke(currentStroke); // 正在绘制时实时预览
  }

  // ---------- 输入事件 ----------
  function startDraw(e) {
    e.preventDefault();
    isDrawing = true;

    const p = getPoint(e);
    currentStroke = {
      id: genId(),
      mode,
      color,
      size,
      points: [p],
    };

    // 新笔画出现,重做栈清空(标准行为)
    undoneStrokes = [];
    redrawAll();
  }

  function moveDraw(e) {
    if (!isDrawing || !currentStroke) return;
    e.preventDefault();

    const p = getPoint(e);
    const pts = currentStroke.points;
    const last = pts[pts.length - 1];

    // 简单节流:距离太小就不加点,减少数据量
    const dx = p.x - last.x;
    const dy = p.y - last.y;
    if (dx * dx + dy * dy < 1.5) return;

    pts.push(p);
    redrawAll();
  }

  function endDraw() {
    if (!isDrawing) return;
    isDrawing = false;

    if (currentStroke && currentStroke.points.length) {
      strokes.push(currentStroke);

    }

    currentStroke = null;
    redrawAll();
  }

  canvas.addEventListener('mousedown', startDraw);
  window.addEventListener('mousemove', moveDraw);
  window.addEventListener('mouseup', endDraw);

  canvas.addEventListener('touchstart', startDraw, { passive: false });
  window.addEventListener('touchmove', moveDraw, { passive: false });
  window.addEventListener('touchend', endDraw);

  // ---------- 撤销 / 重做 / 清空 ----------
  undoBtn.onclick = () => {
    if (!strokes.length) return;
    const s = strokes.pop();
    undoneStrokes.push(s);
    redrawAll();

  };

  redoBtn.onclick = () => {
    if (!undoneStrokes.length) return;
    const s = undoneStrokes.pop();
    strokes.push(s);
    redrawAll();

  };

  clearBtn.onclick = () => {
    if (!strokes.length) return;
    // 这里把全部移到 undone,保留可重做能力
    undoneStrokes.push(...strokes);
    strokes = [];
    currentStroke = null;
    redrawAll();

    
  };

  // ---------- 导出 ----------
  saveBtn.onclick = () => {
    // 注意:导出时以当前像素内容导出
    // redrawAll 已经保证 canvas 是最新
    const a = document.createElement('a');
    a.download = `whiteboard-${Date.now()}.png`;
    a.href = canvas.toDataURL('image/png');
    a.click();
  };

  // ---------- 快捷键 ----------
  window.addEventListener('keydown', e => {
    const isMac = navigator.platform.toUpperCase().includes('MAC');
    const cmd = isMac ? e.metaKey : e.ctrlKey;
    if (!cmd) return;

    if (e.key.toLowerCase() === 'z') {
      e.preventDefault();
      undoBtn.click();
    } else if (e.key.toLowerCase() === 'y') {
      e.preventDefault();
      redoBtn.click();
    }
  });

  window.addEventListener('resize', resizeCanvas);

  // ---------- 初始化 ----------
  updateColorUI();
  requestAnimationFrame(() => {
    svPointer.style.left = `${s * svPanel.clientWidth}px`;
    svPointer.style.top = `${(1 - v) * svPanel.clientHeight}px`;
  });
  resizeCanvas();
</script>

</body>
</html>

ok, 接下来就是难点了,如何创建socket,接下来就没法用ai了

导入socket.io

socket.io由两个部分组成,一个是后端,一个是前端。

后端来说,我们只需要来执行一句npm install:

npm install socket.io

这一步会自动安装socket.io并把依赖添加至package.json。

在安装时,自动下载了前端所需文件。在node_modules/socket.io/client-dist/文件夹中, 但是我们需要将其加入public目录以方便暴露:

mkdir public
mkdir js
cp -r node_modules/socket.io/client-dist/* public/js/

接下来,我们在index.js中加入以下代码:

import { createServer} from 'node:http';//这一行代码为更改
import { Server } from 'socket.io';
app.use('/public', express.static(join(__dirname, 'public')));//这一行是为了暴露public目录
const io = new Server(server);
io.on('connection', (socket) => {
  console.log('a user connected');
  socket.on("disconnect", (reason) => {
    console.log("disconnect:", socket.id, reason);
  });
});

然后进入index.html,追加以下代码:

<script src="/public/js/socket.io.js"></script>
<script>
  const socket = io("https://draw.endermite.cn", {
  path: "/socket.io/",
  transports: ["websocket", "polling"],
});
  socket.on("connect", () => console.log("connect", socket.id));
  socket.on("disconnect", (reason) => console.log("disconnect", reason));
  socket.on("connect_error", (err) => console.log("connect_error", err.message));
</script>

这里的网址请自行替换

请注意官方的版本是有问题的, io里面还是要加网站, path, transports, 不然会弹transport error.

发送WebSocket信息

接下来,我们需要在用户抬起鼠标的时候将信息传递给服务器。由于我们之前是使用codex书写的html文件,我们先审查一下之前的代码。当然,这个部分在我们后续的步骤中同步进行。如果不是什么重要的我就不在blog中写了。

首先,我现在不打算在笔画结构体中加入身份信息,所以我们先去掉clear,undo等功能(毕竟只是一个demo)。

然后,我们应该在笔抬起来的时候向服务器发送笔画数据,于是在endDraw函数末尾加上:

socket.emit('stroke:add', currentStroke)

并在index.js中加入,以便我们查看信息:

io.on('connection', (socket) => {
  socket.on('stroke:add', (msg) => {
    console.log('stroke:add: ' + msg);
  });
});

如果顺利,你应该能看到以下类似输出:

当然,把加号换成逗号就可以输出具体objects,但是这样太长了。

我们接下来就可以存储了:

let strokes = [];

io.on('connection', (socket) => {
  socket.on('stroke:add', (stroke) => {
    console.log('stroke:add: ' + stroke);
    strokes.push(stroke);
    socket.broadcast.emit(stroke);
  });
});//这一部分自行和之前合并

我们通过push将笔画存入后端内存(如果需要可以自行改为长时存储),并通过broadcast将笔画发给除了本socket的其余socket。

然后我们需要加上初始化功能。只需要加上一种操作:init即可。


socket.emit('stroke:init', strokes);//在connection里面书写

接下来就只是一些简单的css等小地方的优化。也就是说,我们的协同画板第一版已经完美成功!

此作者没有提供个人介绍。
最后更新于 2026-04-02