/* 白板:Canvas 绘画 + WebSocket 实时同步 + 心跳。 - 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。 - 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 - 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。 - 兼容鼠标 + 触摸:统一用 pointer events,touch-action:none 防滚动缩放。 */ (function () { "use strict"; const { el, toast, copyText } = window.ZK; // ---------- 从 URL 解析 board_id ---------- // 路径形如 /whiteboard/{id};id 为 [a-zA-Z0-9_-]{1,64} const m = location.pathname.match(/^\/whiteboard\/([^/]+)\/?$/); let boardId = m ? decodeURIComponent(m[1]) : "default"; // 合法性兜底:前端非法字符直接回退到 default,真正校验在服务端 if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default"; document.getElementById("boardId").textContent = boardId; // ---------- DOM ---------- const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const colorInput = document.getElementById("color"); const widthInput = document.getElementById("width"); const widthVal = document.getElementById("widthVal"); const clearBtn = document.getElementById("clearBtn"); const copyBtn = document.getElementById("copyBtn"); const statusEl = document.getElementById("status"); const onlineEl = document.getElementById("online"); // ---------- 状态 ---------- let strokes = []; // 已确认的笔画 let current = null; // 正在画的笔画(本地未提交) let drawing = false; let ws = null; let clientId = localStorage.getItem("wb_cid") || ""; if (!clientId) { clientId = "c_" + Math.random().toString(36).slice(2, 10); localStorage.setItem("wb_cid", clientId); } let heartbeatTimer = null; let reconnectTimer = null; let connected = false; // ---------- 画布尺寸 ---------- function resize() { const dpr = window.devicePixelRatio || 1; const w = canvas.clientWidth; const h = canvas.clientHeight; canvas.width = Math.max(1, Math.floor(w * dpr)); canvas.height = Math.max(1, Math.floor(h * dpr)); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); redraw(); } window.addEventListener("resize", resize); // ---------- 绘制 ---------- function drawStroke(s) { if (!s || !s.points || s.points.length < 1) return; ctx.strokeStyle = s.color || "#1565c0"; ctx.lineWidth = Number(s.width) || 3; ctx.lineCap = "round"; ctx.lineJoin = "round"; const pts = s.points; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); if (pts.length === 1) { // 单点:画一个小圆点 ctx.arc(pts[0][0], pts[0][1], (ctx.lineWidth || 3) / 2, 0, Math.PI * 2); ctx.fillStyle = ctx.strokeStyle; ctx.fill(); return; } for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.stroke(); } function redraw() { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const s of strokes) drawStroke(s); if (current) drawStroke(current); } // ---------- 指针事件 ---------- function pos(e) { const r = canvas.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; } canvas.addEventListener("pointerdown", (e) => { if (!connected) { flashStatus("未连接,正在重连…"); return; } e.preventDefault(); canvas.setPointerCapture(e.pointerId); drawing = true; current = { points: [pos(e)], color: colorInput.value, width: Number(widthInput.value) }; drawStroke(current); }); canvas.addEventListener("pointermove", (e) => { if (!drawing) return; e.preventDefault(); const p = pos(e); const last = current.points[current.points.length - 1]; // 跳过过近的点,减少数据量 if (Math.hypot(p[0] - last[0], p[1] - last[1]) < 1.5) return; current.points.push(p); // 增量画最后一段 ctx.strokeStyle = current.color; ctx.lineWidth = current.width; ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); ctx.moveTo(last[0], last[1]); ctx.lineTo(p[0], p[1]); ctx.stroke(); }); function endStroke(e) { if (!drawing) return; drawing = false; if (e && e.pointerId !== undefined) { try { canvas.releasePointerCapture(e.pointerId); } catch {} } if (current && current.points.length) { strokes.push(current); send({ type: "stroke", stroke: current }); } current = null; } canvas.addEventListener("pointerup", endStroke); canvas.addEventListener("pointercancel", endStroke); canvas.addEventListener("pointerleave", (e) => { // 仅在抬起时结束;离开但按住不放不结束(pointer capture 已处理) if (!drawing) return; if (e.buttons === 0) endStroke(e); }); widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value)); clearBtn.addEventListener("click", () => { if (!connected) { flashStatus("未连接"); return; } if (!confirm("确定清空白板?所有人的内容都会被清除。")) return; send({ type: "clear" }); }); copyBtn.addEventListener("click", async () => { const url = `${location.origin}/whiteboard/${boardId}`; const ok = await copyText(url); toast(ok ? "链接已复制" : "复制失败"); }); // ---------- WebSocket ---------- function wsUrl() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/ws/whiteboard/${encodeURIComponent(boardId)}`; } function connect() { setStatus("连接中…"); try { ws = new WebSocket(wsUrl()); } catch (e) { scheduleReconnect(); return; } ws.onopen = () => { connected = true; setStatus("已连接", true); onlineEl.classList.remove("off"); send({ type: "hello", client_id: clientId }); startHeartbeat(); }; ws.onmessage = (ev) => onMessage(ev.data); ws.onclose = () => onLost("连接已关闭"); ws.onerror = () => { /* close 会跟进 */ }; } function onMessage(raw) { let msg; try { msg = JSON.parse(raw); } catch { return; } switch (msg.type) { case "init": strokes = Array.isArray(msg.strokes) ? msg.strokes : []; redraw(); setStatus(`已同步 ${strokes.length} 笔`, true); break; case "pong": // 心跳回声,保持连接 break; case "stroke": if (msg.client_id === clientId) break; // 自己的,已本地画 strokes.push(msg.stroke); drawStroke(msg.stroke); break; case "cleared": strokes = []; current = null; redraw(); flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板"); break; case "error": flashStatus(msg.msg || "错误", true); break; } } function send(obj) { if (ws && ws.readyState === WebSocket.OPEN) { try { ws.send(JSON.stringify(obj)); } catch {} } } function startHeartbeat() { stopHeartbeat(); heartbeatTimer = setInterval(() => send({ type: "ping" }), 3000); } function stopHeartbeat() { if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } } function onLost(reason) { connected = false; stopHeartbeat(); onlineEl.classList.add("off"); setStatus(reason + ",重连中…", true); scheduleReconnect(); } function scheduleReconnect() { if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 2000); } function setStatus(text, isErr) { statusEl.textContent = text; statusEl.classList.add("show"); statusEl.classList.toggle("err", !!isErr); } let statusTimer = null; function flashStatus(text, isErr) { setStatus(text, isErr); clearTimeout(statusTimer); statusTimer = setTimeout(() => statusEl.classList.remove("show"), 1600); } // ---------- 启动 ---------- // 先 GET /whiteboard/{id} 确保白板存在(不存在则服务端新建),再连 WS fetch(`/whiteboard/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } }) .then((r) => r.ok ? r.json() : null) .then((body) => { if (body && Array.isArray(body.strokes)) { strokes = body.strokes; redraw(); } resize(); connect(); }) .catch(() => { resize(); connect(); }); // 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连 document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) { if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } connect(); } }); window.addEventListener("beforeunload", () => { stopHeartbeat(); if (reconnectTimer) clearTimeout(reconnectTimer); try { ws && ws.close(); } catch {} }); })();