Files
zTools2/static/whiteboard.js
zikai 655e039aad 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 同步更新。
2026-07-21 15:00:25 +00:00

259 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 记事本textarea + WebSocket 实时同步 + 心跳。
- 文本以整文本 debounce 400ms 后发服务端,服务端存为新版本并广播给其他端。
- 收到他人 update 时用 diff 应用变更,保留本地光标位置(按相对偏移调整)。
- 收到 cleared 清空本地 textarea。
- 心跳 3s 一次 ping服务端 15s 无心跳判失活会主动断连,前端据此重连。 */
(function () {
"use strict";
const { toast, copyText } = window.ZK;
// ---------- 从 URL 解析 board_id ----------
const m = location.pathname.match(/^\/wb\/([^/]+)\/?$/);
let boardId = m ? decodeURIComponent(m[1]) : "default";
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(boardId)) boardId = "default";
document.getElementById("boardId").textContent = boardId;
// ---------- DOM ----------
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 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;
let lastSentText = ""; // 上次发到服务端的文本(避免无变更时重复发)
let suppressInput = false; // 应用远端更新时抑制 input 事件,防回环
let debounceTimer = null;
// ---------- 本地编辑 -> debounce -> 发送 ----------
editor.addEventListener("input", () => {
if (suppressInput) return;
scheduleSend();
});
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;
send({ type: "clear" });
});
copyBtn.addEventListener("click", async () => {
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:";
return `${proto}//${location.host}/ws/wb/${encodeURIComponent(boardId)}`;
}
function connect() {
setStatus("连接中…");
try {
ws = new WebSocket(wsUrl());
} catch (e) {
scheduleReconnect();
return;
}
ws.onopen = () => {
connected = true;
setStatus("已连接", false);
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":
suppressInput = true;
editor.value = msg.content || "";
lastSentText = editor.value;
suppressInput = false;
editor.focus();
setStatus("已同步", false);
break;
case "pong":
break;
case "update":
if (msg.client_id === clientId) break; // 自己的,已本地更新
applyRemoteUpdate(msg.content || "");
flashStatus("对方有更新");
break;
case "cleared":
suppressInput = true;
editor.value = "";
lastSentText = "";
suppressInput = false;
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 /api/wb/{id} 确保白板存在(不存在则服务端新建),再连 WS
fetch(`/api/wb/${encodeURIComponent(boardId)}`, { headers: { accept: "application/json" } })
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (body && typeof body.content === "string") {
editor.value = body.content;
lastSentText = body.content;
}
connect();
})
.catch(() => { connect(); });
// 页面可见性:重新可见时若已断连则主动重连;隐藏时 flush 未发送的编辑
document.addEventListener("visibilitychange", () => {
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 {}
});
})();