some changes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -13,10 +13,25 @@ function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) {
|
||||
|
||||
@@ -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
@@ -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})`;
|
||||
}
|
||||
|
||||
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
@@ -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)}`; }
|
||||
|
||||
Reference in New Issue
Block a user