根因: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)。
269 lines
8.8 KiB
JavaScript
269 lines
8.8 KiB
JavaScript
/* 记事本: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 {}
|
||
});
|
||
})();
|