Files
zTools2/static/whiteboard.js
zikai 4de2648178 fix: 记事本修改需手动刷新才同步(同浏览器多 tab clientId 共享导致 update 被跳过)
根因:clientId 存 localStorage,同浏览器多个 tab 共享同一 clientId。
前端 onMessage 的 update 分支用 `if (msg.client_id === clientId) break` 跳过
「自己的」更新,但服务端 exclude=conn 是按连接对象排除,非按 client_id。
于是 tab B 编辑后,tab A 收到的 update client_id 等于自己的 clientId,
被误判为「自己的」跳过 -> 不同步,只有手动刷新(重新 init)才看到内容。

修复:
- clientId 改用 sessionStorage(每 tab 独立),不再跨 tab 共享。
- 移除 update 分支的 client_id 跳过逻辑(服务端 exclude=conn 已排除发送者,
  前端跳过是多余且会误杀同 client_id 的其他 tab)。
2026-07-22 00:36:44 +00:00

269 lines
8.8 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 copyLinkBtn = document.getElementById("copyLinkBtn");
const statusEl = document.getElementById("status");
const onlineEl = document.getElementById("online");
// ---------- 状态 ----------
let ws = null;
// 每个 tab 独立的 client_id用 sessionStorage 而非 localStorage避免同浏览器
// 多 tab 共享 id 导致收到的 update 被误判为「自己的」而跳过不同步)。
// 服务端已用 exclude=conn 排除发送者,前端不再用 client_id 跳过 update。
let clientId = sessionStorage.getItem("wb_cid") || "";
if (!clientId) {
clientId = "c_" + Math.random().toString(36).slice(2, 10);
sessionStorage.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 ? "已复制全部文本" : "复制失败");
});
copyLinkBtn.addEventListener("click", async () => {
const url = `${location.origin}/wb/${boardId}`;
const ok = await copyText(url);
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":
// 服务端 broadcast 已用 exclude=conn 排除发送者,收到即他人编辑,直接应用。
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 {}
});
})();