/* 记事本: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 {} }); })();