/* 记事本: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(/^\/api\/wb-page\/([^/]+)\/?$/); 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}/api/wb-page/${boardId}`; const ok = await copyText(url); toast(ok ? "链接已复制" : "复制失败"); }); // ---------- 应用远端更新(保留光标) ---------- // 策略:用最长公共前后缀算出变更区间,仅替换该区间,光标按相对位置调整。 // 若本地有未发送的编辑(editor.value !== lastSentText),合并后重新 scheduleSend, // 避免本地编辑被远端覆盖后因 lastSentText 短路而丢弃。 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--; } const hadPending = editor.value !== lastSentText; 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 {} // 仅当编辑器当前有焦点时恢复焦点,避免抢其它控件焦点 if (document.activeElement === editor) editor.focus(); // 本地有未发送编辑被合并了,重新安排发送,避免被丢弃 if (hadPending) scheduleSend(); } // ---------- WebSocket ---------- function wsUrl() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}/api/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": // 连接建立时服务端下发当前文本。若本地有未发送编辑(断线期间输入的), // 不直接覆盖,而是把本地编辑作为最新版本发上去(last-writer-wins), // 避免断线期间的编辑被静默丢弃。 if (editor.value && editor.value !== lastSentText) { lastSentText = editor.value; send({ type: "edit", content: editor.value }); } else { 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 {} }); })();