refactor: 白板从画笔画板改为文本记事本
原实现是 Canvas 画笔画板,与「文字白板/记事本」需求不符。重做为纯文本实时协作: - model:strokes JSON -> content TEXT + version INT(乐观锁)+ edit_count; schema/dao/service 同步重构,append_stroke/replace_strokes -> update_content。 - WS 协议:stroke -> edit(发完整文本,debounce 400ms);init 下发 content/version。 update 帧广播给他人,cleared 广播给所有人。 - 前端:canvas -> textarea;收到远端 update 用最长公共前后缀算变更区间, 仅替换该区间并保留光标(区间前不动/后平移/内移末尾);清空/复制文本按钮。 - schema.sql 更新 whiteboard 表 DDL;DB 旧表 DROP 重建(开发环境)。 - 测试脚本与 README 同步更新。
This commit is contained in:
@@ -1,35 +1,26 @@
|
||||
/* 白板:Canvas 绘画 + WebSocket 实时同步 + 心跳。
|
||||
- 笔画以 {points:[[x,y],...], color, width} 表示,增量广播。
|
||||
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。
|
||||
- 收到他人 stroke 增量重绘该笔;收到 cleared 清空本地画布。
|
||||
- 兼容鼠标 + 触摸:统一用 pointer events,touch-action:none 防滚动缩放。 */
|
||||
/* 记事本:textarea + WebSocket 实时同步 + 心跳。
|
||||
- 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端。
|
||||
- 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)。
|
||||
- 收到 cleared 清空本地 textarea。
|
||||
- 心跳 3s 一次 ping;服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
|
||||
(function () {
|
||||
"use strict";
|
||||
const { el, toast, copyText } = window.ZK;
|
||||
const { toast, copyText } = window.ZK;
|
||||
|
||||
// ---------- 从 URL 解析 board_id ----------
|
||||
// 路径形如 /wb/{id};id 为 [a-zA-Z0-9_-]{1,64}
|
||||
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
|
||||
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 editor = document.getElementById("editor");
|
||||
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) {
|
||||
@@ -39,111 +30,99 @@
|
||||
let heartbeatTimer = null;
|
||||
let reconnectTimer = null;
|
||||
let connected = false;
|
||||
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
|
||||
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
|
||||
let debounceTimer = null;
|
||||
|
||||
// ---------- 画布尺寸 ----------
|
||||
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);
|
||||
// ---------- 本地编辑 -> debounce -> 发送 ----------
|
||||
editor.addEventListener("input", () => {
|
||||
if (suppressInput) return;
|
||||
scheduleSend();
|
||||
});
|
||||
|
||||
widthInput.addEventListener("input", () => (widthVal.textContent = widthInput.value));
|
||||
function scheduleSend() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
const text = editor.value;
|
||||
if (text === lastSentText) return;
|
||||
lastSentText = text;
|
||||
send({ type: "edit", content: text });
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function flushSend() {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
const text = editor.value;
|
||||
if (text !== lastSentText) {
|
||||
lastSentText = text;
|
||||
send({ type: "edit", content: text });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
if (!connected) { flashStatus("未连接"); return; }
|
||||
if (!confirm("确定清空白板?所有人的内容都会被清除。")) return;
|
||||
if (!confirm("确定清空全部内容?所有人的内容都会被清除。")) return;
|
||||
send({ type: "clear" });
|
||||
});
|
||||
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
const url = `${location.origin}/wb/${boardId}`;
|
||||
const ok = await copyText(url);
|
||||
toast(ok ? "链接已复制" : "复制失败");
|
||||
const text = editor.value;
|
||||
if (!text) { toast("内容为空"); return; }
|
||||
const ok = await copyText(text);
|
||||
toast(ok ? "已复制全部文本" : "复制失败");
|
||||
});
|
||||
|
||||
// ---------- 应用远端更新(保留光标) ----------
|
||||
// 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。
|
||||
function applyRemoteUpdate(newText) {
|
||||
const oldText = editor.value;
|
||||
if (newText === oldText) return;
|
||||
|
||||
const selStart = editor.selectionStart;
|
||||
const selEnd = editor.selectionEnd;
|
||||
|
||||
// 算公共前缀
|
||||
let prefix = 0;
|
||||
const minLen = Math.min(oldText.length, newText.length);
|
||||
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix++;
|
||||
|
||||
// 算公共后缀(不能与前缀重叠)
|
||||
let suffixOld = oldText.length;
|
||||
let suffixNew = newText.length;
|
||||
while (suffixOld > prefix && suffixNew > prefix && oldText[suffixOld - 1] === newText[suffixNew - 1]) {
|
||||
suffixOld--;
|
||||
suffixNew--;
|
||||
}
|
||||
|
||||
suppressInput = true;
|
||||
// 用 setRangeText 替换 [prefix, suffixOld) 为 newText[prefix, suffixNew)
|
||||
editor.setRangeText(newText.slice(prefix, suffixNew), prefix, suffixOld, "end");
|
||||
suppressInput = false;
|
||||
lastSentText = editor.value;
|
||||
|
||||
// 调整光标:若光标在变更区间之前,不动;在之后,平移差值;在区间内,移到区间末尾
|
||||
const delta = (suffixNew - prefix) - (suffixOld - prefix);
|
||||
let newStart = selStart, newEnd = selEnd;
|
||||
if (selStart <= prefix) {
|
||||
// 光标在变更前,不变
|
||||
} else if (selStart >= suffixOld) {
|
||||
// 光标在变更后,平移
|
||||
newStart = selStart + delta;
|
||||
newEnd = selEnd + delta;
|
||||
} else {
|
||||
// 光标在变更区间内,移到区间末尾
|
||||
newStart = newEnd = suffixNew;
|
||||
}
|
||||
try {
|
||||
editor.setSelectionRange(newStart, newEnd);
|
||||
} catch {}
|
||||
editor.focus();
|
||||
}
|
||||
|
||||
// ---------- WebSocket ----------
|
||||
function wsUrl() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -160,7 +139,7 @@
|
||||
}
|
||||
ws.onopen = () => {
|
||||
connected = true;
|
||||
setStatus("已连接", true);
|
||||
setStatus("已连接", false);
|
||||
onlineEl.classList.remove("off");
|
||||
send({ type: "hello", client_id: clientId });
|
||||
startHeartbeat();
|
||||
@@ -175,23 +154,26 @@
|
||||
try { msg = JSON.parse(raw); } catch { return; }
|
||||
switch (msg.type) {
|
||||
case "init":
|
||||
strokes = Array.isArray(msg.strokes) ? msg.strokes : [];
|
||||
redraw();
|
||||
setStatus(`已同步 ${strokes.length} 笔`, true);
|
||||
suppressInput = true;
|
||||
editor.value = msg.content || "";
|
||||
lastSentText = editor.value;
|
||||
suppressInput = false;
|
||||
editor.focus();
|
||||
setStatus("已同步", false);
|
||||
break;
|
||||
case "pong":
|
||||
// 心跳回声,保持连接
|
||||
break;
|
||||
case "stroke":
|
||||
if (msg.client_id === clientId) break; // 自己的,已本地画
|
||||
strokes.push(msg.stroke);
|
||||
drawStroke(msg.stroke);
|
||||
case "update":
|
||||
if (msg.client_id === clientId) break; // 自己的,已本地更新
|
||||
applyRemoteUpdate(msg.content || "");
|
||||
flashStatus("对方有更新");
|
||||
break;
|
||||
case "cleared":
|
||||
strokes = [];
|
||||
current = null;
|
||||
redraw();
|
||||
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了白板");
|
||||
suppressInput = true;
|
||||
editor.value = "";
|
||||
lastSentText = "";
|
||||
suppressInput = false;
|
||||
flashStatus(msg.client_id === clientId ? "已清空" : "对方清空了内容");
|
||||
break;
|
||||
case "error":
|
||||
flashStatus(msg.msg || "错误", true);
|
||||
@@ -244,26 +226,31 @@
|
||||
// ---------- 启动 ----------
|
||||
// 先 GET /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
|
||||
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
if (body && Array.isArray(body.strokes)) {
|
||||
strokes = body.strokes;
|
||||
redraw();
|
||||
if (body && typeof body.content === "string") {
|
||||
editor.value = body.content;
|
||||
lastSentText = body.content;
|
||||
}
|
||||
resize();
|
||||
connect();
|
||||
})
|
||||
.catch(() => { resize(); connect(); });
|
||||
.catch(() => { connect(); });
|
||||
|
||||
// 页面隐藏时不发心跳可被服务端判失活;重新可见时主动重连
|
||||
// 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible" && (!ws || ws.readyState !== WebSocket.OPEN)) {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
connect();
|
||||
if (document.visibilityState === "visible") {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
connect();
|
||||
}
|
||||
} else {
|
||||
flushSend();
|
||||
}
|
||||
});
|
||||
|
||||
// 离开前 flush 未发送编辑
|
||||
window.addEventListener("beforeunload", () => {
|
||||
flushSend();
|
||||
stopHeartbeat();
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
try { ws && ws.close(); } catch {}
|
||||
|
||||
Reference in New Issue
Block a user