some changes

This commit is contained in:
Mateusz Gruszczyński
2026-07-30 23:57:35 +02:00
parent c5ca8e8d0b
commit 4edc511272
20 changed files with 686 additions and 65 deletions
+105
View File
@@ -355,6 +355,96 @@ textarea:focus {
background: var(--danger);
}
.connection-notice {
position: absolute;
top: 64px;
left: 50%;
z-index: 18;
display: flex;
width: min(520px, calc(100% - 32px));
box-sizing: border-box;
align-items: center;
gap: 12px;
padding: 11px 14px;
border: 1px solid color-mix(in srgb, var(--danger) 48%, var(--border-strong));
border-radius: 12px;
background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--danger));
box-shadow: 0 16px 42px rgb(0 0 0 / 38%);
opacity: 0;
pointer-events: none;
transform: translate(-50%, -10px) scale(.98);
transition: opacity .2s ease, transform .2s ease, border-color .2s ease;
}
.connection-notice.is-visible {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
.connection-notice.is-restored {
border-color: color-mix(in srgb, var(--success) 58%, var(--border-strong));
background: color-mix(in srgb, var(--surface-strong, #11161e) 94%, var(--success));
}
.connection-notice__signal {
display: inline-flex;
width: 26px;
height: 26px;
align-items: center;
justify-content: center;
flex: 0 0 auto;
gap: 3px;
border-radius: 50%;
background: color-mix(in srgb, var(--danger) 18%, transparent);
}
.connection-notice.is-restored .connection-notice__signal {
background: color-mix(in srgb, var(--success) 18%, transparent);
}
.connection-notice__signal span {
width: 3px;
height: 10px;
border-radius: 3px;
background: var(--danger);
animation: connection-pulse .9s ease-in-out infinite;
}
.connection-notice__signal span:nth-child(2) {
animation-delay: .12s;
}
.connection-notice__signal span:nth-child(3) {
animation-delay: .24s;
}
.connection-notice.is-restored .connection-notice__signal span {
background: var(--success);
animation: none;
}
.connection-notice__content {
display: grid;
min-width: 0;
gap: 2px;
}
.connection-notice__content strong {
color: var(--text);
font-size: .84rem;
}
.connection-notice__content>span {
color: var(--muted);
font-size: .76rem;
line-height: 1.35;
}
@keyframes connection-pulse {
0%, 100% { transform: scaleY(.45); opacity: .48; }
50% { transform: scaleY(1); opacity: 1; }
}
.editor-layout {
position: relative;
display: grid;
@@ -369,6 +459,7 @@ textarea:focus {
}
.editor-panel {
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
@@ -4412,6 +4503,12 @@ dialog::backdrop {
}
@media (max-width: 760px) {
.connection-notice {
top: 76px;
width: calc(100% - 20px);
padding: 10px 12px;
}
.pad-page .editor-toolbar {
grid-template-columns: minmax(0, 1fr);
}
@@ -4457,6 +4554,14 @@ dialog::backdrop {
}
}
@media (prefers-reduced-motion: reduce) {
.connection-notice,
.connection-notice__signal span {
animation: none;
transition: none;
}
}
@media (max-width: 720px) {
.pad-page #save-state {
display: none;
+6
View File
@@ -116,6 +116,12 @@
<div class="view-switch"><button data-view="edit">Edit</button><button data-view="split"
class="active">Split</button><button data-view="preview">Preview</button></div>
</div>
<div id="connection-notice" class="connection-notice" role="status" aria-live="polite" hidden>
<span class="connection-notice__signal" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="connection-notice__content"><strong id="connection-notice-title">Connection
interrupted</strong><span id="connection-notice-message">Trying to reconnect
automatically.</span></span>
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span>
+24 -7
View File
@@ -13,10 +13,25 @@ function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c]));
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
let markdownFileRoutes = new Map();
function attachmentRoute(value) {
try {
const path = new URL(String(value || ""), location.origin).pathname;
return /^\/f\/[^/]+\/[^/]+$/.test(path) ? path : null;
} catch {
return null;
}
}
function safeUrl(value) {
const raw = String(value || "").trim();
let raw = String(value || "").trim();
if (!raw || raw.startsWith("//")) return "#";
if (raw.startsWith("#")) return escapeHtml(raw);
const route = attachmentRoute(raw);
if (route && markdownFileRoutes.has(route)) raw = markdownFileRoutes.get(route);
try {
const url = new URL(raw, location.origin);
if (url.protocol === "mailto:") return escapeHtml(url.href);
@@ -27,16 +42,18 @@ function safeUrl(value) {
}
}
const emoji = EMOJI_SHORTCODES;
let markdownFiles = new Map();
export function setMarkdownFiles(files) {
markdownFiles = new Map((Array.isArray(files) ? files : [])
const normalized = (Array.isArray(files) ? files : [])
.filter(file => file && file.filename && file.url)
.map(file => [String(file.filename), {
.map(file => ({
filename: String(file.filename),
url: String(file.url),
mimeType: String(file.mime_type || ""),
}]));
}));
markdownFiles = new Map(normalized.map(file => [file.filename, file]));
markdownFileRoutes = new Map(normalized
.map(file => [attachmentRoute(file.url), file.url])
.filter(([route]) => route));
}
export function unresolvedMarkdownFileAliases(value) {
+88 -3
View File
@@ -25,14 +25,14 @@ import { toast } from "@rustpad/toast";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
let unreadChat = 0;
const compactToggle = document.querySelector("#compact-toggle"), lineLinksToggle = document.querySelector("#line-links-toggle"), authorshipColorsToggle = document.querySelector("#authorship-colors-toggle"), authorshipColorsLabel = document.querySelector("#authorship-colors-label"), publicPageEnabled = document.querySelector("#public-page-enabled"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), mobileColorPicker = document.querySelector("#mobile-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const mobileFontFamily = document.querySelector("#mobile-font-family"), mobileFontSize = document.querySelector("#mobile-font-size"), mobileLineToggle = document.querySelector("#mobile-line-numbers-toggle"), mobilePreviewLineToggle = document.querySelector("#mobile-preview-line-numbers-toggle"), mobileCompactToggle = document.querySelector("#mobile-compact-toggle"), mobileLineLinksToggle = document.querySelector("#mobile-line-links-toggle");
const shareToken = new URLSearchParams(location.search).get("share");
const notePreferenceKey = name => `rustpad:${name}:${location.pathname}`;
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false;
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
@@ -149,6 +149,38 @@ export function startNoteEditor(adapter) {
function clearUnread() { unreadChat = 0; chatUnread.hidden = true; chatUnread.textContent = ""; if (mobileChatUnread) { mobileChatUnread.hidden = true; mobileChatUnread.textContent = ""; } document.title = document.title.replace(/^● /, ""); }
function setStatus(kind, text) { const className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-dot").className = className; document.querySelector("#status-text").textContent = text; const mobileDot = document.querySelector("#mobile-status-dot"); const mobileText = document.querySelector("#mobile-status-text"); if (mobileDot) mobileDot.className = className; if (mobileText) mobileText.textContent = text; }
function showConnectionNotice(title, message, restored = false) {
clearTimeout(connectionNoticeTimer);
connectionNoticeTitle.textContent = title;
connectionNoticeMessage.textContent = message;
connectionNotice.hidden = false;
connectionNotice.classList.toggle("is-restored", restored);
requestAnimationFrame(() => connectionNotice.classList.add("is-visible"));
if (restored) connectionNoticeTimer = window.setTimeout(() => {
connectionNotice.classList.remove("is-visible", "is-restored");
connectionNoticeTimer = window.setTimeout(() => { connectionNotice.hidden = true; }, 220);
}, 1800);
}
function hideConnectionNotice() {
clearTimeout(connectionNoticeTimer);
connectionNotice.classList.remove("is-visible", "is-restored");
connectionNotice.hidden = true;
}
function handleSocketStatus(status, details = {}) {
if (status === "online") {
setStatus("online", "Connected");
if (connectionWasInterrupted || details.restored) showConnectionNotice("Connection restored", "Live editing is active again.", true);
connectionWasInterrupted = false;
return;
}
if (status === "reconnecting") {
connectionWasInterrupted = true;
setStatus("offline", "Reconnecting…");
showConnectionNotice("Connection interrupted", details.message || "Trying to reconnect automatically.");
return;
}
setStatus(null, "Connecting…");
}
function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
@@ -619,7 +651,51 @@ export function startNoteEditor(adapter) {
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: null, guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
function connect() {
socket?.stop();
socket = adapter.createSocket({
password,
accessToken,
nickname,
color: currentUserColor() || null,
sessionToken: null,
guestId: getGuestId(),
onStatus: handleSocketStatus,
onAuthenticated: message => {
resourceUnlocked = true;
if (passwordDialog.open) passwordDialog.close();
const readOnly = message.access_level === "read_only";
editor.readOnly = readOnly;
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
applyRemote(message.content, message.owner_map);
if (!readOnly) editor.focus();
},
onDocument: message => {
applyRemote(message.content, message.owner_map);
document.querySelector("#save-state").textContent = `${message.author ? `${message.author} · ` : ""}${new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`;
},
onPresence: updatePresence,
onLatency: updateLatency,
onChat: appendChatMessage,
onError: message => {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
document.querySelector("#password-error").textContent = friendly;
if (/read-only access/i.test(message)) {
toast(friendly);
accessLevel.textContent = "Access: read only";
editor.readOnly = true;
return;
}
if (/nickname|session|account/i.test(message)) {
if (!identityDialog.open) identityDialog.showModal();
} else if (info?.protected && !passwordDialog.open) {
passwordDialog.showModal();
}
},
});
socket.connect();
}
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
async function showSystemNotFound() {
@@ -698,6 +774,15 @@ export function startNoteEditor(adapter) {
compactLayoutQuery.addEventListener("change", event => {
if (!event.matches) setHeaderMenuOpen(false);
});
const mobileEditorOptions = document.querySelector("#mobile-editor-options");
document.addEventListener("pointerdown", event => {
if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) {
mobileEditorOptions.open = false;
}
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && mobileEditorOptions?.open) mobileEditorOptions.open = false;
});
modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); });
lineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-numbers"), lineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
previewLineToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("preview-line-numbers"), previewLineToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); alignPreviewLineNumbers(preview); scheduleEditorSettingsSave({ personal: true }); });
+11 -4
View File
@@ -11,7 +11,7 @@ import { api, uploadWithProgress } from "@rustpad/api";
import { copyText } from "@rustpad/clipboard";
import { prepareImageFile } from "@rustpad/image-upload";
import { askConfirm } from "@rustpad/modal";
import { safeAppUrl } from "@rustpad/security";
import { safeAppUrl, safePublicUrl } from "@rustpad/security";
import { createUploadToast } from "@rustpad/toast";
function escapeHtml(value) {
@@ -40,6 +40,13 @@ function markdownCode(url, label, mimeType) {
return String(mimeType || "").startsWith("image/") ? `![${label}](${url})` : `[${label}](${url})`;
}
function safeAttachmentUrl(value) {
const raw = String(value || "").trim();
return raw.startsWith("/")
? safeAppUrl(raw)
: safePublicUrl(raw, { allowMailto: false });
}
export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, canUpload, toast, onFilesChanged = () => {} }) {
const dialog = document.querySelector("#files-dialog");
const list = document.querySelector("#files-list");
@@ -143,13 +150,13 @@ export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, ca
if (showButton) {
const panel = showButton.closest(".file-row").querySelector(".file-code");
const output = panel.querySelector("textarea");
const absolute = new URL(safeAppUrl(showButton.dataset.url), location.origin).href;
const safeUrl = safeAttachmentUrl(showButton.dataset.url);
const absolute = new URL(safeUrl, location.origin).href;
let text = absolute;
if (showButton.dataset.showFileCode === "alias") {
text = aliasCode(showButton.dataset.name, showButton.dataset.name, showButton.dataset.mime);
} else if (showButton.dataset.showFileCode === "markdown") {
const relative = safeAppUrl(showButton.dataset.url);
text = markdownCode(relative, showButton.dataset.name, showButton.dataset.mime);
text = markdownCode(safeUrl, showButton.dataset.name, showButton.dataset.mime);
}
output.value = text; panel.hidden = false; output.focus(); output.select(); return;
}
+145 -22
View File
@@ -9,33 +9,83 @@
import { logError, logInfo, logWarn } from "@rustpad/logger";
const HEARTBEAT_INTERVAL_MS = 10000;
const HEARTBEAT_TIMEOUT_MS = 30000;
const MAX_RECONNECT_DELAY_MS = 12000;
class RoomSocket {
constructor(options) {
Object.assign(this, options);
this.socket = null;
this.timer = null;
this.reconnectTimer = null;
this.pingTimer = null;
this.closed = false;
this.stopped = false;
this.intentionalClose = false;
this.reconnectAttempt = 0;
this.pendingPings = new Map();
this.handleOnline = () => this.reconnectNow("Network connection restored.");
this.handleOffline = () => this.handleNetworkOffline();
this.handleVisibility = () => this.checkHeartbeat();
window.addEventListener("online", this.handleOnline);
window.addEventListener("offline", this.handleOffline);
document.addEventListener("visibilitychange", this.handleVisibility);
}
get url() { throw new Error("Socket URL not implemented"); }
get kind() { return "room"; }
connect() {
clearTimeout(this.timer);
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
this.closed = false;
this.onStatus?.("connecting");
this.socket = new WebSocket(this.url);
this.socket.addEventListener("open", () => {
logInfo("websocket.open", { kind: this.kind });
this.send({ type: "authenticate", password: this.password || null, access_token: this.accessToken || null, nickname: this.nickname || null, guest_id: this.guestId || null, color: this.color || null });
if (this.stopped) return;
if (!navigator.onLine) {
this.scheduleReconnect("Your device is offline.");
return;
}
this.intentionalClose = false;
this.onStatus?.(this.reconnectAttempt ? "reconnecting" : "connecting", {
attempt: this.reconnectAttempt,
message: this.reconnectAttempt ? "Re-establishing the live connection." : "Opening the live connection.",
});
this.socket.addEventListener("message", event => {
let socket;
try {
socket = new WebSocket(this.url);
} catch (error) {
logError("websocket.create", error, { kind: this.kind });
this.scheduleReconnect("The live connection could not be opened.");
return;
}
this.socket = socket;
socket.addEventListener("open", () => {
if (socket !== this.socket || this.stopped) return;
logInfo("websocket.open", { kind: this.kind });
this.send({
type: "authenticate",
password: this.password || null,
access_token: this.accessToken || null,
nickname: this.nickname || null,
guest_id: this.guestId || null,
color: this.color || null,
});
});
socket.addEventListener("message", event => {
if (socket !== this.socket || this.stopped) return;
let message;
try { message = JSON.parse(event.data); } catch { return; }
if (message.type === "error") { this.onError?.(message.message); this.closed = true; this.socket.close(); return; }
if (message.type === "error") {
this.intentionalClose = true;
this.onError?.(message.message);
socket.close();
return;
}
if (message.type === "authenticated") {
this.onStatus?.("online");
const restored = this.reconnectAttempt > 0;
this.reconnectAttempt = 0;
this.onStatus?.("online", { restored });
this.onAuthenticated?.(message);
this.startPing();
return;
@@ -51,43 +101,116 @@ class RoomSocket {
}
}
});
this.socket.addEventListener("close", event => {
socket.addEventListener("close", event => {
if (socket !== this.socket) return;
clearInterval(this.pingTimer);
this.pendingPings.clear();
this.onPresence?.([]);
this.onLatency?.(null);
logWarn("websocket.close", { kind: this.kind, code: event.code, reason: event.reason || "", intentional: this.closed });
if (!this.closed) { this.onStatus?.("offline"); this.timer = setTimeout(() => this.connect(), 1500); }
logWarn("websocket.close", {
kind: this.kind,
code: event.code,
reason: event.reason || "",
intentional: this.intentionalClose || this.stopped,
});
if (!this.intentionalClose && !this.stopped) {
this.scheduleReconnect(this.closeMessage(event));
}
});
this.socket.addEventListener("error", event => {
socket.addEventListener("error", event => {
if (socket !== this.socket || this.stopped) return;
logError("websocket.error", event, { kind: this.kind });
this.onError?.("Failed to connect to the WebSocket server");
this.socket.close();
if (socket.readyState !== WebSocket.CLOSING && socket.readyState !== WebSocket.CLOSED) {
socket.close();
}
});
}
closeMessage(event) {
if (!navigator.onLine) return "Your device is offline.";
if (event.reason === "heartbeat timeout") return "The connection stopped responding after the tab was inactive.";
return "The server connection was interrupted.";
}
scheduleReconnect(message) {
if (this.stopped) return;
clearTimeout(this.reconnectTimer);
this.reconnectAttempt += 1;
const baseDelay = Math.min(MAX_RECONNECT_DELAY_MS, 750 * (2 ** Math.min(this.reconnectAttempt - 1, 4)));
const retryInMs = navigator.onLine ? baseDelay : 3000;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs });
this.reconnectTimer = window.setTimeout(() => this.connect(), retryInMs);
}
reconnectNow(message) {
if (this.stopped || this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) return;
clearTimeout(this.reconnectTimer);
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt, message, retryInMs: 0 });
this.connect();
}
handleNetworkOffline() {
if (this.stopped) return;
this.onStatus?.("reconnecting", { attempt: this.reconnectAttempt + 1, message: "Your device is offline.", retryInMs: 3000 });
if (this.socket?.readyState === WebSocket.OPEN || this.socket?.readyState === WebSocket.CONNECTING) {
this.socket.close();
} else {
this.scheduleReconnect("Your device is offline.");
}
}
checkHeartbeat() {
if (this.stopped || this.socket?.readyState !== WebSocket.OPEN) return;
const now = performance.now();
const expired = [...this.pendingPings.values()].some(started => now - started >= HEARTBEAT_TIMEOUT_MS);
if (expired) {
logWarn("websocket.heartbeat_timeout", { kind: this.kind });
this.socket.close(4000, "heartbeat timeout");
}
}
startPing() {
clearInterval(this.pingTimer);
const ping = () => {
this.checkHeartbeat();
if (this.socket?.readyState !== WebSocket.OPEN) return;
const nonce = Date.now();
this.pendingPings.set(nonce, performance.now());
for (const key of this.pendingPings.keys()) if (key < nonce - 30000) this.pendingPings.delete(key);
this.send({ type: "ping", nonce });
};
ping();
this.pingTimer = setInterval(ping, 10000);
this.pingTimer = window.setInterval(ping, HEARTBEAT_INTERVAL_MS);
}
send(message) { if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message)); }
send(message) {
if (this.socket?.readyState !== WebSocket.OPEN) return false;
this.socket.send(JSON.stringify(message));
return true;
}
update(content, ownerMap = "[]") { this.send({ type: "update", content, owner_map: ownerMap }); }
chat(text) { this.send({ type: "chat", text }); }
setColor(color) { this.color = color || null; this.send({ type: "set_color", color: this.color }); }
stop() { this.closed = true; clearTimeout(this.timer); clearInterval(this.pingTimer); this.socket?.close(); }
stop() {
this.stopped = true;
this.intentionalClose = true;
clearTimeout(this.reconnectTimer);
clearInterval(this.pingTimer);
window.removeEventListener("online", this.handleOnline);
window.removeEventListener("offline", this.handleOffline);
document.removeEventListener("visibilitychange", this.handleVisibility);
this.socket?.close();
}
}
export class NoteSocket extends RoomSocket {
get kind() { return "note"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/${encodeURIComponent(this.workspaceSlug)}/${encodeURIComponent(this.noteSlug)}`; }
}
export class PadSocket extends RoomSocket {
get kind() { return "pad"; }
get url() { const p = location.protocol === "https:" ? "wss:" : "ws:"; return `${p}//${location.host}/ws/p/${encodeURIComponent(this.slug)}`; }