Files
rustpad/static/js/note-editor.js
T
2026-08-01 09:41:10 +02:00

1418 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
installGlobalDiagnostics();
import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship";
import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown";
import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui";
import { bindNoteFiles } from "@rustpad/note-files";
import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
import { toast } from "@rustpad/toast";
import { getTheme } from "@rustpad/theme";
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"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), 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, applyingHistory = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = "simple", authorshipColorsEnabled = true, lastRevealedLineHash = "";
let editorSettingsSaveTimer, editorSettingsSaveInFlight = false, pendingPersonalSettingsSave = false, pendingAuthorshipSettingsSave = false, connectionNoticeTimer = 0, connectionWasInterrupted = false;
const editHistory = {
entries: [],
index: -1,
lastKind: "",
lastRecordedAt: 0,
snapshot() {
return {
content: editor.value,
ownerMap: serializeAuthorship(authorship, editor.value.length),
selectionStart: editor.selectionStart,
selectionEnd: editor.selectionEnd,
selectionDirection: editor.selectionDirection,
scrollTop: editor.scrollTop,
scrollLeft: editor.scrollLeft,
};
},
reset() {
this.entries = [this.snapshot()];
this.index = 0;
this.lastKind = "";
this.lastRecordedAt = 0;
},
syncCurrent() {
if (this.index < 0) {
this.reset();
return;
}
this.entries[this.index] = this.snapshot();
},
record(inputType = "") {
const snapshot = this.snapshot();
if (this.index < 0) {
this.entries = [snapshot];
this.index = 0;
return;
}
if (this.entries[this.index]?.content === snapshot.content) {
this.entries[this.index] = snapshot;
return;
}
if (this.index < this.entries.length - 1) this.entries.splice(this.index + 1);
const kind = inputType === "insertText" || inputType === "insertCompositionText"
? "typing"
: inputType === "deleteContentBackward" || inputType === "deleteContentForward"
? "deleting"
: "action";
const now = Date.now();
const merge = kind !== "action" && kind === this.lastKind && now - this.lastRecordedAt < 900 && this.index > 0;
if (merge) this.entries[this.index] = snapshot;
else {
this.entries.push(snapshot);
this.index += 1;
if (this.entries.length > 100) {
this.entries.shift();
this.index -= 1;
}
}
this.lastKind = kind;
this.lastRecordedAt = now;
},
move(offset) {
const nextIndex = this.index + offset;
if (nextIndex < 0 || nextIndex >= this.entries.length) return false;
this.index = nextIndex;
this.lastKind = "";
this.lastRecordedAt = 0;
restoreHistorySnapshot(this.entries[this.index]);
return true;
},
undo() { return this.move(-1); },
redo() { return this.move(1); },
};
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
const singlePaneQuery = window.matchMedia("(max-width: 760px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
let refreshFilesForAliases = () => { };
let aliasRefreshTimer = 0;
let lastUnresolvedAliasKey = "";
let markdownFileSignature = "";
function updateMarkdownFiles(files, { rerender = false } = {}) {
const normalized = (Array.isArray(files) ? files : []).map(file => ({
filename: String(file?.filename || ""),
url: String(file?.url || ""),
mime_type: String(file?.mime_type || ""),
})).sort((left, right) => left.filename.localeCompare(right.filename));
const nextSignature = JSON.stringify(normalized);
const changed = nextSignature !== markdownFileSignature;
markdownFileSignature = nextSignature;
setMarkdownFiles(normalized);
if (rerender && changed) render();
}
function scheduleAliasFileRefresh(content) {
const key = unresolvedMarkdownFileAliases(content).sort().join("\u0000");
if (!key) {
lastUnresolvedAliasKey = "";
return;
}
if (key === lastUnresolvedAliasKey) return;
lastUnresolvedAliasKey = key;
clearTimeout(aliasRefreshTimer);
aliasRefreshTimer = window.setTimeout(() => refreshFilesForAliases(), 200);
}
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem(notePreferenceKey("line-numbers")) !== "off";
previewLineToggle.checked = localStorage.getItem(notePreferenceKey("preview-line-numbers")) === "on";
compactToggle.checked = localStorage.getItem(notePreferenceKey("compact")) !== "off";
lineLinksToggle.checked = localStorage.getItem(notePreferenceKey("line-links")) === "on";
fontFamily.value = localStorage.getItem(notePreferenceKey("font-family")) || "mono";
fontSize.value = localStorage.getItem(notePreferenceKey("font-size")) || "14";
authorshipColorsToggle.checked = authorshipColorsEnabled;
function syncMobileEditorControls() {
if (mobileFontFamily) mobileFontFamily.value = fontFamily.value;
if (mobileFontSize) mobileFontSize.value = fontSize.value;
if (mobileLineToggle) mobileLineToggle.checked = lineToggle.checked;
if (mobilePreviewLineToggle) mobilePreviewLineToggle.checked = previewLineToggle.checked;
if (mobileCompactToggle) mobileCompactToggle.checked = compactToggle.checked;
if (mobileLineLinksToggle) mobileLineLinksToggle.checked = lineLinksToggle.checked;
}
syncMobileEditorControls();
function updateAuthorshipControls() {
const canManage = info?.can_manage_authorship === true;
authorshipColorsToggle.checked = authorshipColorsEnabled;
authorshipColorsToggle.disabled = !canManage;
authorshipColorsLabel.textContent = authorshipColorsEnabled ? "Colors on" : "Colors off";
document.querySelectorAll("[data-authorship-mode]").forEach(button => {
button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode);
button.disabled = !canManage;
});
const controls = document.querySelector(".authorship-controls");
if (controls) controls.title = canManage ? "Global authorship settings" : "Only the owner can change authorship settings";
}
function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
function ownerParts(owner) { const raw = String(owner || ""); const split = raw.lastIndexOf("\u001f"); return split < 0 ? { name: raw, color: "" } : { name: raw.slice(0, split), color: raw.slice(split + 1) }; }
function ownerName(owner) { return ownerParts(owner).name; }
function colorFor(owner) { const parts = ownerParts(owner); const ownColor = parts.name === nickname ? currentUserColor() : ""; return /^#[0-9a-f]{6}$/i.test(ownColor) ? ownColor : /^#[0-9a-f]{6}$/i.test(parts.color) ? parts.color : defaultColorFor(parts.name); }
const guestColorKey = `rustpad:guest-color:${adapter.access.kind}:${adapter.access.key}`;
function readGuestColor() { return sessionStorage.getItem(guestColorKey) || ""; }
function writeGuestColor(color) { if (color) sessionStorage.setItem(guestColorKey, color); else sessionStorage.removeItem(guestColorKey); }
function globalUserColor() { return globalColor || ""; }
function noteUserColor() { return noteColor || ""; }
function currentUserColor() { return noteUserColor() || globalUserColor(); }
function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
function adoptCurrentOwnerAliases(model, contentLength) {
const color = currentUserColor();
if (!getAuthToken() || !/^#[0-9a-f]{6}$/i.test(color)) return model;
const replacement = currentOwner();
return replaceAuthorshipOwner(model, owner => {
const parts = ownerParts(owner);
return /^#[0-9a-f]{6}$/i.test(parts.color) && parts.color.toLowerCase() === color.toLowerCase();
}, replacement, contentLength);
}
function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); const pickerColor = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; const overridden = Boolean(noteUserColor()); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); currentUser.title = overridden ? "Note color override" : "Global profile color"; userColorPicker.value = pickerColor; if (mobileColorPicker) mobileColorPicker.value = pickerColor; useGlobalColorButton.hidden = !overridden; document.querySelector(".mobile-editor-bubble")?.style.setProperty("--owner", color); }
function sessionHeaders() {
return accessToken && accessToken !== "cookie"
? { Authorization: `Bearer ${accessToken}` }
: {};
}
function accountHeaders() { return {}; }
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (getAuthToken()) {
const colors = await adapter.loadColor(accountHeaders());
globalColor = colors.global_color || ""; noteColor = colors.note_color || "";
} else {
noteColor = readGuestColor();
}
updateMarkdownFiles(info.files || []);
if (info.personal_editor_settings) {
compactToggle.checked = info.compact_view !== false;
lineToggle.checked = info.editor_line_numbers !== false;
previewLineToggle.checked = info.preview_line_numbers === true;
lineLinksToggle.checked = info.line_links === true;
if (["mono", "system", "serif", "arial", "georgia"].includes(info.font_family)) fontFamily.value = info.font_family;
if (["14", "16", "18", "20", "22"].includes(String(info.font_size))) fontSize.value = String(info.font_size);
}
authorshipMode = info.authorship_mode === "full" ? "full" : "simple";
authorshipColorsEnabled = info.colors_enabled !== false;
updateAuthorshipControls();
syncMobileEditorControls();
updateCurrentUser(); return info;
}
function updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) {
const text = Number.isFinite(ms) ? `${ms} ms` : "— ms";
socketLatency.textContent = text;
const mobileLatency = document.querySelector("#mobile-socket-latency");
if (mobileLatency) mobileLatency.textContent = text;
}
function setDiagnosticField(name, value) {
document.querySelectorAll(`[data-connection-diagnostic="${name}"]`).forEach(node => { node.textContent = value; });
}
function formatDiagnosticDuration(milliseconds) {
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
function renderConnectionDiagnostics(snapshot = {}) {
const server = snapshot.server || {};
const runtime = snapshot.runtime || {};
const latency = runtime.latency || {};
const client = server.client || {};
const quality = latency.quality || (runtime.state === "open" ? "measuring" : runtime.state || "waiting");
const qualityLabel = quality.charAt(0).toUpperCase() + quality.slice(1);
setDiagnosticField("quality", qualityLabel);
setDiagnosticField("latency", Number.isFinite(latency.current)
? `${latency.current} ms · avg ${latency.average} ms · ${latency.minimum}${latency.maximum} ms`
: "Waiting for heartbeat");
setDiagnosticField("jitter", Number.isFinite(latency.jitter) ? `${latency.jitter} ms` : "—");
setDiagnosticField("uptime", runtime.authenticated_at
? formatDiagnosticDuration(runtime.uptime_ms)
: runtime.last_connection_uptime_ms ? `last ${formatDiagnosticDuration(runtime.last_connection_uptime_ms)}` : "—");
setDiagnosticField("reconnects", `${runtime.total_reconnects || 0}${runtime.reconnect_attempt ? ` · attempt ${runtime.reconnect_attempt}` : ""}`);
const clientParts = [client.platform, client.timezone, client.language || client.accept_language, client.id ? `id ${client.id}` : null, client.user_agent];
setDiagnosticField("client", clientParts.filter(Boolean).join(" · ") || "Waiting for server data");
const lastEvent = runtime.last_close
? `Closed ${runtime.last_close.code}${runtime.last_close.reason ? `: ${runtime.last_close.reason}` : ""}`
: runtime.last_message_at ? `Message ${new Date(runtime.last_message_at).toLocaleTimeString()}` : "No messages yet";
const traffic = `${formatBytes(runtime.bytes_received)} received · ${formatBytes(runtime.bytes_sent)} sent`;
const buffered = runtime.buffered_amount ? ` · ${formatBytes(runtime.buffered_amount)} buffered` : "";
setDiagnosticField("last-event", `${lastEvent} · ${runtime.visibility || document.visibilityState} · ${traffic}${buffered}`);
for (const details of [document.querySelector("#connection-details"), document.querySelector("#mobile-connection-details")]) {
if (!details) continue;
details.classList.remove("is-quality-excellent", "is-quality-good", "is-quality-degraded", "is-quality-poor");
if (["excellent", "good", "degraded", "poor"].includes(quality)) details.classList.add(`is-quality-${quality}`);
}
}
function appendLinkifiedText(container, value) { const text = String(value || ""); const urlPattern = /https?:\/\/[^\s<>{}\[\]"'`]+/gi; let index = 0; for (const match of text.matchAll(urlPattern)) { const start = match.index ?? 0; if (start > index) container.append(document.createTextNode(text.slice(index, start))); let raw = match[0], trail = ""; while (/[),.!?:;]$/.test(raw)) { trail = raw.slice(-1) + trail; raw = raw.slice(0, -1); } try { const url = new URL(raw); if (url.protocol === "http:" || url.protocol === "https:") { const link = document.createElement("a"); link.href = url.href; link.textContent = raw; link.target = "_blank"; link.rel = "noopener noreferrer"; container.append(link); } else container.append(document.createTextNode(raw)); } catch { container.append(document.createTextNode(raw)); } if (trail) container.append(document.createTextNode(trail)); index = start + match[0].length; } if (index < text.length) container.append(document.createTextNode(text.slice(index))); }
function appendChatMessage(message) { const empty = chatMessages.querySelector(".chat-empty"); empty?.remove(); const row = document.createElement("p"); row.className = "chat-message"; const author = document.createElement("strong"); author.textContent = message.sender; const text = document.createElement("span"); appendLinkifiedText(text, message.text); row.append(author, text); chatMessages.append(row); while (chatMessages.children.length > 100) chatMessages.firstElementChild.remove(); chatMessages.scrollTop = chatMessages.scrollHeight; if (message.sender !== nickname && !roomDetails.open) { unreadChat++; chatUnread.hidden = false; chatUnread.textContent = unreadChat > 99 ? "99+" : String(unreadChat); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `● ${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
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: getTheme() === "dark" ? "dark" : "default", 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 { } }
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
for (const user of presenceUsers) {
const name = user.name || "Guest";
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
people.set(name, { name, compactName: user.compact_name || name, color });
}
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
const compact = people.size > 4;
participantBadges.replaceChildren(...[...people.values()].map(person => {
const badge = document.createElement("span");
badge.className = "participant-badge";
badge.style.setProperty("--owner", person.color);
badge.textContent = compact && person.compactName ? person.compactName : person.name;
badge.title = person.name;
return badge;
}));
}
function syncOwnerLabels() {
ownerLabels.querySelectorAll(".owner-label-group[data-content-top]").forEach(group => {
group.style.top = `${Number(group.dataset.contentTop) - editor.scrollTop}px`;
});
}
function syncEditorLayers() {
gutter.scrollTop = editor.scrollTop;
syncAuthorshipLayer(authorshipLayer, editor);
syncOwnerLabels();
}
function renderGutter() {
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
document.body.classList.toggle("line-links-enabled", lineLinksToggle.checked);
gutter.setAttribute("aria-hidden", String(!lineLinksToggle.checked));
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const lines = Array.from({ length: lineCount });
const owners = authorshipOwners(authorship);
const showAuthorship = authorshipColorsEnabled && owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !full || !showAuthorship;
renderParticipantBadges(authorshipColorsEnabled ? owners : []);
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
editorWorkspace.dataset.authorshipMode = authorshipMode;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, fontSize = parseFloat(style.fontSize) || 14, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div class="line-number-row" style="height:${lineHeight}px"><button class="line-number-button" type="button" tabindex="-1" data-line="${i + 1}" aria-label="Copy link to line ${i + 1}">${i + 1}</button></div>`).join("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
ownerLabels.style.setProperty("--editor-rendered-font-size", `${fontSize}px`);
if (full) {
let previousAuthorSignature = null;
ownerLabels.innerHTML = lines.map((_, i) => {
const authors = authorsByLine[i] || [];
if (!authors.length) return "";
const top = paddingTop + i * lineHeight + lineHeight / 2;
const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
if (signature === previousAuthorSignature) return "";
previousAuthorSignature = signature;
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-label-group" data-content-top="${top}">${badges}</span>`;
}).join("");
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
const linkedLine = lineFromHash(location.hash, lineCount);
gutter.querySelector(`[data-line="${linkedLine}"]`)?.classList.add("is-linked");
syncEditorLayers();
}
function revealLinkedLine() {
if (!location.hash || location.hash === lastRevealedLineHash) return;
const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
const line = lineFromHash(location.hash, lineCount);
if (!line) return;
const offset = lineStartOffset(editor.value, line);
if (offset == null) return;
const style = getComputedStyle(editor);
const lineHeight = parseFloat(style.lineHeight) || 29;
const paddingTop = parseFloat(style.paddingTop) || 24;
const lineTop = paddingTop + (line - 1) * lineHeight;
const lineBottom = lineTop + lineHeight;
if (lineTop < editor.scrollTop || lineBottom > editor.scrollTop + editor.clientHeight) {
editor.scrollTop = Math.max(0, lineTop - Math.max(lineHeight, editor.clientHeight * 0.25));
}
editor.setSelectionRange(offset, offset);
lastRevealedLineHash = location.hash;
gutter.querySelectorAll(".line-number-button.is-linked").forEach(button => button.classList.remove("is-linked"));
gutter.querySelector(`[data-line="${line}"]`)?.classList.add("is-linked");
syncEditorLayers();
}
function escapeHtml(v) { return String(v).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#039;" }[c])); } function formatDate(value) { const raw = String(value ?? "").trim(); let normalized = raw; if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T") + ":00"; else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?[+-]\d{2}:\d{2}$/.test(normalized)) normalized = normalized.replace(" ", "T"); else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(normalized)) normalized = normalized.replace(" ", "T") + "Z"; const date = new Date(normalized); return Number.isNaN(date.getTime()) ? raw : date.toLocaleString("pl-PL", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); }
function previewNodeMarkdownParts(current) {
if (current.nodeType !== Node.ELEMENT_NODE) return { open: "", close: "", atomic: null };
const tag = current.tagName.toLowerCase();
if (tag === "strong" || tag === "b") return { open: "**", close: "**", atomic: null };
if (tag === "em" || tag === "i") return { open: "*", close: "*", atomic: null };
if (tag === "s" || tag === "del") return { open: "~~", close: "~~", atomic: null };
if (tag === "mark") return { open: "==", close: "==", atomic: null };
if (tag === "code") return { open: "`", close: "`", atomic: null };
if (tag === "sub") return { open: "~", close: "~", atomic: null };
if (tag === "sup" && !current.classList.contains("footnote-ref")) return { open: "^", close: "^", atomic: null };
const fileAlias = current.getAttribute("data-file-alias");
const fileName = current.getAttribute("data-file-name");
if (tag === "a" && fileAlias === "file" && fileName) return { open: `[file=${fileName},`, close: "]", atomic: null };
if (tag === "a") return { open: "[", close: `](${current.getAttribute("href") || "#"})`, atomic: null };
if (tag === "img") {
const alt = current.getAttribute("alt") || "";
if (fileAlias === "image" && fileName) {
const safeAlt = alt.replace(/\]/g, ")").replace(/[\r\n]+/g, " ");
return { open: "", close: "", atomic: `[image=${fileName},${safeAlt}]` };
}
const src = current.getAttribute("src") || "";
const title = current.getAttribute("title");
return { open: "", close: "", atomic: `![${alt}](${src}${title ? ` "${title.replace(/"/g, "&quot;")}"` : ""})` };
}
if (tag === "br") return { open: "", close: "", atomic: "\n" };
return { open: "", close: "", atomic: null };
}
function previewNodeMarkdown(current) {
if (current.nodeType === Node.TEXT_NODE) return (current.nodeValue || "").replace(/\u00a0/g, " ");
if (current.nodeType !== Node.ELEMENT_NODE) return "";
const parts = previewNodeMarkdownParts(current);
if (parts.atomic !== null) return parts.atomic;
const body = [...current.childNodes].map(previewNodeMarkdown).join("");
return `${parts.open}${body}${parts.close}`;
}
function markdownFromPreview(node) {
return [...node.childNodes].map(previewNodeMarkdown).join("");
}
function markdownPointOffset(root, container, offset) {
let result = 0;
let found = false;
const contains = (parent, child) => parent === child || (parent.nodeType === Node.ELEMENT_NODE && parent.contains(child));
const walk = (current, isRoot = false) => {
if (found) return;
if (current === container) {
if (current.nodeType === Node.TEXT_NODE) result += Math.max(0, Math.min(offset, (current.nodeValue || "").length));
else if (current.nodeType === Node.ELEMENT_NODE) {
const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current);
if (parts.atomic !== null) result += offset > 0 ? parts.atomic.length : 0;
else {
result += parts.open.length;
const children = [...current.childNodes];
for (let index = 0; index < Math.min(offset, children.length); index++) result += previewNodeMarkdown(children[index]).length;
}
}
found = true;
return;
}
if (current.nodeType === Node.TEXT_NODE) {
result += (current.nodeValue || "").length;
return;
}
if (current.nodeType !== Node.ELEMENT_NODE) return;
const parts = isRoot ? { open: "", close: "", atomic: null } : previewNodeMarkdownParts(current);
if (parts.atomic !== null) {
result += parts.atomic.length;
return;
}
result += parts.open.length;
for (const child of current.childNodes) {
if (contains(child, container)) {
walk(child);
return;
}
result += previewNodeMarkdown(child).length;
}
result += parts.close.length;
};
walk(root, true);
return found ? result : 0;
}
function previewCaretOffset(target) {
const selection = window.getSelection();
if (!selection?.rangeCount) return 0;
const range = selection.getRangeAt(0);
if (!target.contains(range.startContainer)) return 0;
const prefix = range.cloneRange();
prefix.selectNodeContents(target);
prefix.setEnd(range.startContainer, range.startOffset);
return prefix.toString().length;
}
function placePreviewCaret(target, offset) {
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
let remaining = Math.max(0, offset), node;
while ((node = walker.nextNode())) {
if (remaining <= node.nodeValue.length) {
const range = document.createRange(); range.setStart(node, remaining); range.collapse(true);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return;
}
remaining -= node.nodeValue.length;
}
const range = document.createRange(); range.selectNodeContents(target); range.collapse(false);
const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range);
}
function deactivatePreviewEdit(target) {
if (!target) return;
target.removeAttribute("contenteditable");
target.removeAttribute("spellcheck");
target.classList.remove("preview-editable--active");
}
function activatePreviewEdit(target, offset = null) {
if (!target) return;
const active = preview.querySelector('.preview-editable[contenteditable="true"]');
if (active && active !== target) {
const targetIndex = [...preview.querySelectorAll(".preview-editable")].indexOf(target);
active.blur();
target = preview.querySelectorAll(".preview-editable")[targetIndex];
if (!target) return;
}
target.setAttribute("contenteditable", "true");
target.setAttribute("spellcheck", "true");
target.classList.add("preview-editable--active");
target.focus({ preventScroll: true });
placePreviewCaret(target, offset == null ? previewCaretOffset(target) : offset);
}
function movePreviewCaret(target, direction) {
const editables = [...preview.querySelectorAll(".preview-editable")];
const index = editables.indexOf(target);
if (index < 0 || !editables[index + direction]) return false;
const offset = previewCaretOffset(target);
target.blur();
const next = [...preview.querySelectorAll(".preview-editable")][index + direction];
if (!next) return false;
activatePreviewEdit(next, offset);
next.scrollIntoView({ block: "nearest" });
return true;
}
function continueIndentation(event) {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
const start = editor.selectionStart, end = editor.selectionEnd;
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
const current = editor.value.slice(lineStart, start);
const indent = (current.match(/^[ \t]*/) || [""])[0];
if (!indent) return;
event.preventDefault();
editor.setRangeText(`\n${indent}`, start, end, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
}
function formatBytes(bytes) { const value = Math.max(0, Number(bytes) || 0), units = ["B", "KB", "MB", "GB", "TB"]; let size = value, index = 0; while (size >= 1024 && index < units.length - 1) { size /= 1024; index++; } return `${index === 0 ? Math.round(size) : size.toFixed(size >= 10 ? 1 : 2)} ${units[index]}`; }
function replaceTableCell(line, index, value) {
const leading = line.trimStart().startsWith("|"), trailing = line.trimEnd().endsWith("|");
let body = line.trim(); if (leading) body = body.slice(1); if (trailing) body = body.slice(0, -1);
const cells = body.split("|").map(cell => cell.trim()); while (cells.length <= index) cells.push(""); cells[index] = value.replace(/\|/g, "&#124;");
return `${leading ? "| " : ""}${cells.join(" | ")}${trailing ? " |" : ""}`;
}
function sourceLineBounds(lineIndex) {
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return null;
let start = 0;
for (let index = 0; index < lineIndex; index++) start += lines[index].length + 1;
return { start, end: start + lines[lineIndex].length, text: lines[lineIndex], lineIndex };
}
function tableCellBounds(line, cellIndex) {
const first = line.search(/\S|$/);
const trailingWhitespace = (line.match(/\s*$/) || [""])[0].length;
let bodyStart = first;
let bodyEnd = line.length - trailingWhitespace;
if (line[bodyStart] === "|") bodyStart++;
if (bodyEnd > bodyStart && line[bodyEnd - 1] === "|") bodyEnd--;
const body = line.slice(bodyStart, bodyEnd);
const segments = [];
let segmentStart = 0;
for (let index = 0; index <= body.length; index++) {
if (index === body.length || body[index] === "|") {
const raw = body.slice(segmentStart, index);
const left = (raw.match(/^\s*/) || [""])[0].length;
const right = (raw.match(/\s*$/) || [""])[0].length;
segments.push({ start: bodyStart + segmentStart + left, end: bodyStart + index - right });
segmentStart = index + 1;
}
}
return segments[cellIndex] || { start: bodyStart, end: bodyStart };
}
function editableSourceBounds(target) {
const lineIndex = Number(target?.dataset.sourceLine) - 1;
const line = sourceLineBounds(lineIndex);
if (!line) return null;
if (target.dataset.tableCell !== undefined) {
const cell = tableCellBounds(line.text, Number(target.dataset.tableCell));
return { start: line.start + cell.start, end: line.start + cell.end, line };
}
const prefix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourcePrefix || "");
const suffix = target.dataset.rawSourceEdit === "true" ? "" : (target.dataset.sourceSuffix || "");
return {
start: Math.min(line.end, line.start + prefix.length),
end: Math.max(line.start, line.end - suffix.length),
line,
};
}
function editableAtBoundary(container, offset, preferPrevious = false) {
const element = container.nodeType === Node.ELEMENT_NODE ? container : container.parentElement;
const direct = element?.closest?.(".preview-editable");
if (direct && preview.contains(direct)) return direct;
if (container.nodeType !== Node.ELEMENT_NODE) return null;
const children = [...container.childNodes];
const candidate = preferPrevious ? children[Math.max(0, offset - 1)] : children[Math.min(offset, children.length - 1)];
const candidates = candidate ? [candidate] : [];
for (const node of candidates) {
const candidateElement = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
if (candidateElement?.matches?.(".preview-editable")) return candidateElement;
const nested = candidateElement?.querySelectorAll?.(".preview-editable");
if (nested?.length) return preferPrevious ? nested[nested.length - 1] : nested[0];
}
const source = element?.closest?.(".preview-source-line");
const nested = source?.querySelectorAll?.(".preview-editable");
return nested?.length ? (preferPrevious ? nested[nested.length - 1] : nested[0]) : null;
}
function previewPointSourceOffset(container, offset, preferPrevious = false) {
const target = editableAtBoundary(container, offset, preferPrevious);
const bounds = editableSourceBounds(target);
if (!target || !bounds) return null;
let bodyOffset;
if (target === container || target.contains(container)) bodyOffset = markdownPointOffset(target, container, offset);
else bodyOffset = preferPrevious ? bounds.end - bounds.start : 0;
return {
offset: Math.max(bounds.start, Math.min(bounds.end, bounds.start + bodyOffset)),
target,
bounds,
};
}
function previewSelectionSourceRange({ expandWholeLines = false } = {}) {
const selection = window.getSelection();
if (!selection?.rangeCount || selection.isCollapsed) return null;
const range = selection.getRangeAt(0);
const startInside = range.startContainer === preview || preview.contains(range.startContainer);
const endInside = range.endContainer === preview || preview.contains(range.endContainer);
if (!startInside || !endInside) return null;
const startPoint = previewPointSourceOffset(range.startContainer, range.startOffset, false);
const endPoint = previewPointSourceOffset(range.endContainer, range.endOffset, true);
if (!startPoint || !endPoint) return null;
let start = Math.min(startPoint.offset, endPoint.offset);
let end = Math.max(startPoint.offset, endPoint.offset);
if (expandWholeLines && startPoint.bounds.line.lineIndex !== endPoint.bounds.line.lineIndex) {
if (start === startPoint.bounds.start) start = startPoint.bounds.line.start;
if (end === endPoint.bounds.end) {
end = endPoint.bounds.line.end;
if (end < editor.value.length && editor.value[end] === "\n") end++;
}
}
return { start, end };
}
function deletePreviewSelection() {
const range = previewSelectionSourceRange({ expandWholeLines: true });
if (!range || range.end <= range.start) return false;
editor.setRangeText("", range.start, range.end, "end");
editor.dispatchEvent(new Event("input", { bubbles: true }));
return true;
}
function previewShortcutFormat(event) {
const primary = event.ctrlKey || event.metaKey;
if (primary && !event.shiftKey && event.key.toLowerCase() === "b") return "bold";
if (primary && !event.shiftKey && event.key.toLowerCase() === "i") return "italic";
if (primary && event.shiftKey && event.key.toLowerCase() === "x") return "strike";
if (primary && !event.shiftKey && event.key.toLowerCase() === "k") return "link";
if (primary && event.shiftKey && event.key === "7") return "number";
if (primary && event.shiftKey && event.key === "8") return "bullet";
if (primary && event.shiftKey && event.key === "9") return "task";
if (event.altKey && /^[1-4]$/.test(event.key)) return `heading${event.key}`;
return null;
}
function syncPreviewScroll() {
if (activeView() !== "split") return;
const editorRange = Math.max(0, editor.scrollHeight - editor.clientHeight);
const previewRange = Math.max(0, preview.scrollHeight - preview.clientHeight);
const ratio = editorRange > 0 ? editor.scrollTop / editorRange : 0;
preview.scrollTop = ratio * previewRange;
}
function render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `<div class="preview-source-line preview-editable" data-source-line="${index + 1}">${escapeHtml(line) || "<br>"}</div>`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); requestAnimationFrame(syncPreviewScroll); }
function scheduleDocumentSave() {
clearTimeout(saveTimer);
document.querySelector("#save-state").textContent = "Saving…";
saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250);
}
function restoreHistorySnapshot(snapshot) {
if (!snapshot) return;
const maxOffset = snapshot.content.length;
const selectionStart = Math.min(snapshot.selectionStart ?? maxOffset, maxOffset);
const selectionEnd = Math.min(snapshot.selectionEnd ?? selectionStart, maxOffset);
applyingHistory = true;
editor.value = snapshot.content;
authorship = parseAuthorship(snapshot.content, snapshot.ownerMap);
previousContent = snapshot.content;
editor.setSelectionRange(selectionStart, selectionEnd, snapshot.selectionDirection || "none");
render();
editor.scrollTop = snapshot.scrollTop || 0;
editor.scrollLeft = snapshot.scrollLeft || 0;
syncEditorLayers();
applyingHistory = false;
editor.focus({ preventScroll: true });
scheduleDocumentSave();
}
function activeView() {
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyUi({ write = false, replace = false } = {}) {
const view = activeView();
editorWorkspace.className = `workspace view-${view} editor-workspace-font-${fontFamily.value}`;
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
});
const markdown = uiState.mode === "markdown";
modeToggle.classList.toggle("active", markdown);
modeToggle.textContent = markdown ? "Markdown" : "Text";
render();
if (write) writeEditorState(uiState, { replace });
updateAddressLabel();
}
function previewEditSnapshot() {
const target = document.activeElement?.closest?.('.preview-editable[contenteditable="true"]');
if (!target || !preview.contains(target)) return null;
return {
index: [...preview.querySelectorAll(".preview-editable")].indexOf(target),
offset: previewCaretOffset(target),
raw: target.dataset.rawSourceEdit === "true",
};
}
function restorePreviewEdit(snapshot) {
if (!snapshot || snapshot.index < 0) return;
const target = preview.querySelectorAll(".preview-editable")[snapshot.index];
if (!target) return;
activatePreviewEdit(target, snapshot.offset);
if (snapshot.raw) editRawPreviewLine(target);
}
function applyRemote(content, ownerMap) {
if (content === editor.value) {
if (ownerMap != null) authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
editHistory.syncCurrent();
renderGutter();
requestAnimationFrame(revealLinkedLine);
return;
}
const previewSnapshot = previewEditSnapshot();
const previous = editor.value, start = editor.selectionStart, end = editor.selectionEnd, direction = editor.selectionDirection, scrollTop = editor.scrollTop, scrollLeft = editor.scrollLeft;
const mapped = mapSelectionThroughEdit(previous, content, start, end);
applyingRemote = true;
editor.value = content;
authorship = adoptCurrentOwnerAliases(parseAuthorship(content, ownerMap), content.length);
previousContent = content;
editor.setSelectionRange(mapped.start, mapped.end, direction);
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
applyingRemote = false;
editHistory.reset();
render();
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
syncEditorLayers();
restorePreviewEdit(previewSnapshot);
requestAnimationFrame(revealLinkedLine);
}
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
canDelete: () => Boolean(info?.can_delete_files),
canUpload: () => Boolean(info?.can_upload_files),
endpoints: adapter.fileEndpoints,
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: 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,
onDiagnostics: renderConnectionDiagnostics,
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() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
async function initialize() {
try {
const session = await validateCurrentSession();
nickname = session?.nickname || getNickname();
if (!nickname) {
if (!identityDialog.open) identityDialog.showModal();
return;
}
accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo();
document.title = adapter.title(info);
publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls();
adapter.configureView?.(info);
applyUi({ write: true, replace: true });
updateCurrentUser();
if (info.protected && !accessToken && !getAuthToken()) passwordDialog.showModal();
else { loadFiles(); connect(); }
} catch (e) {
if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
return;
}
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
}
}
document.querySelectorAll("[data-view]").forEach(button => button.addEventListener("click", () => {
if (singlePaneQuery.matches) {
compactView = button.dataset.view === "preview" ? "preview" : "edit";
applyUi();
return;
}
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
}));
singlePaneQuery.addEventListener("change", () => applyUi());
compactLayoutQuery.addEventListener("change", () => applyUi());
const headerMenuToggle = document.querySelector("#header-menu-toggle");
const headerActions = document.querySelector("#header-actions");
const setHeaderMenuOpen = open => {
headerActions.classList.toggle("is-open", open);
headerMenuToggle.setAttribute("aria-expanded", String(open));
headerMenuToggle.setAttribute("aria-label", open ? "Close navigation menu" : "Open navigation menu");
};
headerMenuToggle.addEventListener("click", event => {
event.stopPropagation();
setHeaderMenuOpen(!headerActions.classList.contains("is-open"));
});
headerActions.addEventListener("click", event => {
if (compactLayoutQuery.matches && event.target.closest("button")) setHeaderMenuOpen(false);
});
document.addEventListener("click", event => {
if (!event.target.closest(".header-navigation")) setHeaderMenuOpen(false);
});
document.addEventListener("keydown", event => {
if (event.key === "Escape") setHeaderMenuOpen(false);
});
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 }); });
compactToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("compact"), compactToggle.checked ? "on" : "off"); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
lineLinksToggle.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("line-links"), lineLinksToggle.checked ? "on" : "off"); syncMobileEditorControls(); renderGutter(); scheduleEditorSettingsSave({ personal: true }); });
fontFamily.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-family"), fontFamily.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUi(); scheduleEditorSettingsSave({ personal: true }); });
const mirrorMobileControl = (mobileControl, desktopControl) => mobileControl?.addEventListener("change", () => {
if (desktopControl instanceof HTMLInputElement && desktopControl.type === "checkbox") desktopControl.checked = mobileControl.checked;
else desktopControl.value = mobileControl.value;
desktopControl.dispatchEvent(new Event("change", { bubbles: true }));
});
mirrorMobileControl(mobileFontFamily, fontFamily);
mirrorMobileControl(mobileFontSize, fontSize);
mirrorMobileControl(mobileLineToggle, lineToggle);
mirrorMobileControl(mobilePreviewLineToggle, previewLineToggle);
mirrorMobileControl(mobileCompactToggle, compactToggle);
mirrorMobileControl(mobileLineLinksToggle, lineLinksToggle);
document.querySelector("#mobile-files-button")?.addEventListener("click", () => document.querySelector("#files-button")?.click());
const compactBubbleQuery = matchMedia("(max-width: 1499px)");
const roomPopover = roomDetails.querySelector(".room-popover");
function setMobileChatOpen(open) {
if (!roomPopover) return;
if (open && compactBubbleQuery.matches) {
roomDetails.classList.add("is-mobile-open");
roomPopover.classList.add("is-mobile-open");
document.body.append(roomPopover);
roomDetails.open = true;
clearUnread();
requestAnimationFrame(() => chatInput.focus());
return;
}
roomDetails.open = false;
roomDetails.classList.remove("is-mobile-open");
roomPopover.classList.remove("is-mobile-open");
if (roomPopover.parentElement !== roomDetails) roomDetails.append(roomPopover);
}
document.querySelector("#mobile-chat-button")?.addEventListener("click", event => {
event.stopPropagation();
setMobileChatOpen(true);
});
document.addEventListener("click", event => {
if (roomDetails.classList.contains("is-mobile-open") && !event.target.closest("#mobile-chat-button, #room-details, .room-popover.is-mobile-open")) setMobileChatOpen(false);
});
compactBubbleQuery.addEventListener("change", event => { if (!event.matches && roomDetails.classList.contains("is-mobile-open")) setMobileChatOpen(false); });
const mobileBubble = document.querySelector("#mobile-editor-bubble");
const mobileBubbleDrag = document.querySelector("#mobile-bubble-drag");
const bubblePositionKey = `rustpad:mobile-bubble:${adapter.access.kind}`;
function clampBubblePosition(left, top) {
const rect = mobileBubble.getBoundingClientRect();
const margin = 8;
return {
left: Math.min(Math.max(margin, left), Math.max(margin, innerWidth - rect.width - margin)),
top: Math.min(Math.max(margin, top), Math.max(margin, innerHeight - rect.height - margin)),
};
}
function placeMobileBubble(position) {
if (!mobileBubble || !position) return;
const next = clampBubblePosition(Number(position.left), Number(position.top));
if (!Number.isFinite(next.left) || !Number.isFinite(next.top)) return;
mobileBubble.style.left = `${next.left}px`;
mobileBubble.style.top = `${next.top}px`;
mobileBubble.style.right = "auto";
mobileBubble.style.bottom = "auto";
}
try { placeMobileBubble(JSON.parse(localStorage.getItem(bubblePositionKey) || "null")); } catch { }
mobileBubbleDrag?.addEventListener("pointerdown", event => {
if (!mobileBubble || !compactBubbleQuery.matches) return;
event.preventDefault();
const rect = mobileBubble.getBoundingClientRect();
const offsetX = event.clientX - rect.left, offsetY = event.clientY - rect.top;
mobileBubble.classList.add("is-dragging");
mobileBubbleDrag.setPointerCapture(event.pointerId);
const move = moveEvent => placeMobileBubble({ left: moveEvent.clientX - offsetX, top: moveEvent.clientY - offsetY });
const end = () => {
mobileBubble.classList.remove("is-dragging");
mobileBubbleDrag.removeEventListener("pointermove", move);
mobileBubbleDrag.removeEventListener("pointerup", end);
mobileBubbleDrag.removeEventListener("pointercancel", end);
const finalRect = mobileBubble.getBoundingClientRect();
localStorage.setItem(bubblePositionKey, JSON.stringify({ left: finalRect.left, top: finalRect.top }));
};
mobileBubbleDrag.addEventListener("pointermove", move);
mobileBubbleDrag.addEventListener("pointerup", end);
mobileBubbleDrag.addEventListener("pointercancel", end);
});
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
const cancelledPreviewEdits = new WeakSet();
function commitPreviewEdit(target, { focusNextLine = false } = {}) {
const lineIndex = Number(target.dataset.sourceLine) - 1;
if (lineIndex < 0) return;
const value = markdownFromPreview(target);
const lines = editor.value.split("\n");
if (target.dataset.rawSourceEdit === "true") {
if (value === lines[lineIndex]) {
render();
return;
}
lines[lineIndex] = value.replace(/\n/g, "");
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
return;
}
if (!focusNextLine && value === target.dataset.originalValue) {
deactivatePreviewEdit(target);
return;
}
if (focusNextLine) cancelledPreviewEdits.add(target);
if (target.dataset.tableCell !== undefined) {
lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " "));
if (focusNextLine) lines.splice(lineIndex + 1, 0, "");
} else {
const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || "";
const editedLines = value.split("\n");
const replacements = editedLines.map((part, index) => `${index === 0 ? prefix : ""}${part}${index === editedLines.length - 1 ? suffix : ""}`);
lines.splice(lineIndex, 1, ...replacements);
}
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
if (focusNextLine) {
const nextLine = lineIndex + Math.max(2, value.split("\n").length);
const next = preview.querySelector(`[data-source-line="${nextLine}"].preview-editable`);
if (next) activatePreviewEdit(next, 0);
}
}
function editRawPreviewLine(target) {
const lineIndex = Number(target.dataset.sourceLine) - 1;
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
const caretOffset = Math.min(previewCaretOffset(target), lines[lineIndex].length);
target.setAttribute("contenteditable", "true");
target.setAttribute("spellcheck", "true");
target.classList.add("preview-editable--active");
target.dataset.rawSourceEdit = "true";
target.dataset.originalValue = lines[lineIndex];
target.textContent = lines[lineIndex];
target.classList.add("preview-editable--source");
target.focus({ preventScroll: true });
placePreviewCaret(target, caretOffset);
}
function insertPreviewLineBreak(target) {
const lineIndex = Number(target.dataset.sourceLine) - 1;
if (lineIndex < 0) return;
const lines = editor.value.split("\n");
const value = markdownFromPreview(target);
let insertedLineIndex;
if (target.dataset.tableCell !== undefined) {
lines[lineIndex] = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value.replace(/\n/g, " "));
insertedLineIndex = lineIndex + 1;
lines.splice(insertedLineIndex, 0, "");
} else {
const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || "";
const editedLines = value.split("\n");
const replacements = editedLines.map((part, index) => `${index === 0 ? prefix : ""}${part}${index === editedLines.length - 1 ? suffix : ""}`);
insertedLineIndex = lineIndex + replacements.length;
lines.splice(lineIndex, 1, ...replacements, "");
}
cancelledPreviewEdits.add(target);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
const sourceLine = insertedLineIndex + 1;
const restoreFocus = () => {
const next = preview.querySelector(`[data-source-line="${sourceLine}"].preview-editable`);
if (!next) return;
activatePreviewEdit(next, 0);
};
restoreFocus();
queueMicrotask(() => {
const active = document.activeElement;
if (!active || active === document.body || !preview.contains(active)) restoreFocus();
});
}
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
if (!info?.can_manage_authorship) return;
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
}));
authorshipColorsToggle?.addEventListener("change", () => {
if (!info?.can_manage_authorship) return;
authorshipColorsEnabled = authorshipColorsToggle.checked;
updateAuthorshipControls();
syncMobileEditorControls();
renderGutter();
scheduleEditorSettingsSave({ authorship: true });
});
function personalEditorSettingsPayload() {
return {
compact_view: compactToggle.checked,
editor_line_numbers: lineToggle.checked,
preview_line_numbers: previewLineToggle.checked,
line_links: lineLinksToggle.checked,
font_family: fontFamily.value,
font_size: Number(fontSize.value),
};
}
function scheduleEditorSettingsSave({ personal = false, authorship = false } = {}) {
if (personal && info?.personal_editor_settings) pendingPersonalSettingsSave = true;
if (authorship && info?.can_manage_authorship) pendingAuthorshipSettingsSave = true;
if (!info?.can_save_editor_settings || (!pendingPersonalSettingsSave && !pendingAuthorshipSettingsSave)) return;
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
async function flushEditorSettingsSave() {
if (editorSettingsSaveInFlight || !info?.can_save_editor_settings) return;
const savePersonal = pendingPersonalSettingsSave;
const saveAuthorship = pendingAuthorshipSettingsSave && info.can_manage_authorship;
if (!savePersonal && !saveAuthorship) return;
pendingPersonalSettingsSave = false;
pendingAuthorshipSettingsSave = false;
editorSettingsSaveInFlight = true;
const settings = savePersonal ? personalEditorSettingsPayload() : {};
if (saveAuthorship) {
settings.authorship_mode = authorshipMode;
settings.colors_enabled = authorshipColorsEnabled;
}
try {
await adapter.saveEditorSettings(sessionHeaders(), settings);
if (savePersonal) info.personal_editor_settings = true;
} catch (error) {
toast(error.message);
} finally {
editorSettingsSaveInFlight = false;
if (pendingPersonalSettingsSave || pendingAuthorshipSettingsSave) {
clearTimeout(editorSettingsSaveTimer);
editorSettingsSaveTimer = window.setTimeout(flushEditorSettingsSave, 250);
}
}
}
window.addEventListener("popstate", () => { lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("hashchange", () => { lastRevealedLineHash = ""; renderGutter(); requestAnimationFrame(revealLinkedLine); });
window.addEventListener("rustpad:urlchange", updateAddressLabel);
gutter.addEventListener("click", async event => {
const button = event.target.closest(".line-number-button[data-line]");
if (!button || !lineLinksToggle.checked) return;
const line = Number(button.dataset.line);
try {
await copyText(lineLink(currentShareUrl(uiState), line));
gutter.querySelectorAll(".line-number-button.is-copied").forEach(item => item.classList.remove("is-copied"));
button.classList.add("is-copied");
setTimeout(() => button.classList.remove("is-copied"), 900);
toast(`Link to line ${line} copied`);
} catch (error) {
toast(error.message);
}
});
async function copyCurrentLink() {
try { await copyText(currentShareUrl(uiState)); toast("Link copied"); }
catch (error) { toast(error.message); }
}
document.querySelector("#copy-link").addEventListener("click", copyCurrentLink);
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
});
let pendingPreviewFormatRange = null;
document.querySelectorAll("[data-format]").forEach(button => {
button.addEventListener("pointerdown", event => {
pendingPreviewFormatRange = previewSelectionSourceRange();
if (pendingPreviewFormatRange) event.preventDefault();
});
button.addEventListener("click", () => {
const range = pendingPreviewFormatRange || previewSelectionSourceRange();
pendingPreviewFormatRange = null;
if (range) editor.setSelectionRange(range.start, range.end);
applyFormat(editor, button.dataset.format);
button.closest("details")?.removeAttribute("open");
});
});
function handleHistoryShortcut(event) {
const primary = event.ctrlKey || event.metaKey;
if (!primary || event.altKey) return;
const key = event.key.toLowerCase();
const undo = key === "z" && !event.shiftKey;
const redo = (key === "z" && event.shiftKey) || (key === "y" && !event.shiftKey);
if (!undo && !redo) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('.preview-editable[contenteditable="true"]')) return;
if (target && target !== editor && target.matches("input, textarea, select, [contenteditable='true']")) return;
if (document.querySelector("dialog[open]") && target !== editor) return;
event.preventDefault();
event.stopPropagation();
if (redo) editHistory.redo();
else editHistory.undo();
}
document.addEventListener("keydown", handleHistoryShortcut, true);
editor.addEventListener("beforeinput", event => {
if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return;
event.preventDefault();
if (event.inputType === "historyRedo") editHistory.redo();
else editHistory.undo();
});
bindFormatShortcuts(editor);
bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") });
document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal());
document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close());
preview.addEventListener("click", event => {
const target = event.target.closest(".preview-editable");
if (!target || event.target.closest("a, button, input, img")) return;
const selection = window.getSelection();
if (selection && !selection.isCollapsed) return;
const offset = previewCaretOffset(target);
activatePreviewEdit(target, offset);
});
preview.addEventListener("change", event => {
const checkbox = event.target.closest(".task-checkbox");
if (!checkbox) return;
const lineIndex = Number(checkbox.dataset.sourceLine) - 1;
const lines = editor.value.split("\n");
if (lineIndex < 0 || lineIndex >= lines.length) return;
lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`);
editor.value = lines.join("\n");
editor.dispatchEvent(new Event("input", { bubbles: true }));
});
preview.addEventListener("focusin", event => {
const target = event.target.closest('.preview-editable[contenteditable="true"]');
if (!target) return;
target.dataset.originalHtml = target.innerHTML;
target.dataset.originalValue = markdownFromPreview(target);
});
preview.addEventListener("beforeinput", event => {
if (!event.target.closest('.preview-editable[contenteditable="true"]')) return;
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault();
});
preview.addEventListener("keydown", event => {
const target = event.target.closest('.preview-editable[contenteditable="true"]');
if (!target) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (target.dataset.rawSourceEdit === "true") {
cancelledPreviewEdits.add(target);
render();
} else editRawPreviewLine(target);
return;
}
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (event.altKey) insertPreviewLineBreak(target);
else target.blur();
return;
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault();
}
});
preview.addEventListener("blur", event => {
const target = event.target.closest(".preview-editable");
if (!target) return;
if (cancelledPreviewEdits.has(target)) {
cancelledPreviewEdits.delete(target);
return;
}
commitPreviewEdit(target);
}, { capture: true });
document.addEventListener("keydown", event => {
if (document.activeElement?.closest?.('.preview-editable[contenteditable="true"]')) return;
const range = previewSelectionSourceRange();
if (!range) return;
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
deletePreviewSelection();
return;
}
const format = previewShortcutFormat(event);
if (!format) return;
event.preventDefault();
editor.setSelectionRange(range.start, range.end);
applyFormat(editor, format);
});
document.addEventListener("cut", event => {
const range = previewSelectionSourceRange();
if (!range) return;
event.preventDefault();
event.clipboardData?.setData("text/plain", window.getSelection()?.toString() || "");
deletePreviewSelection();
});
const publishPageButton = document.querySelector("#publish-page");
const pageSettings = document.querySelector(".page-settings");
function updatePageControls() {
const enabled = publicPageEnabled.checked;
publishPageButton.disabled = !enabled;
publicTaskUpdates.disabled = !enabled;
unprotectPublicPage.disabled = !enabled;
pageSettings?.classList.toggle("is-enabled", enabled);
pageSettings?.querySelector("summary")?.setAttribute("title", enabled ? "Published page enabled" : "Published page disabled");
}
document.addEventListener("pointerdown", event => {
const target = event.target instanceof Element ? event.target : null;
if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false;
if (mobileConnectionDetails?.open && (!target || !mobileConnectionDetails.contains(target))) mobileConnectionDetails.open = false;
}, { passive: true });
document.addEventListener("keydown", event => {
if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false;
if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false;
});
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked);
publicPageEnabled.addEventListener("change", async () => {
const previous = !publicPageEnabled.checked;
updatePageControls();
publicPageEnabled.disabled = true;
try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); }
catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); }
finally { publicPageEnabled.disabled = false; }
});
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } });
unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } });
publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } });
document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); });
chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); });
if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); }
currentUser.addEventListener("click", () => {
if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker();
else userColorPicker.click();
});
async function saveUserColor(color) {
noteColor = color;
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; }
} else {
writeGuestColor(noteColor);
toast("Color saved for this tab");
}
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(noteColor);
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
}
userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value));
mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value));
useGlobalColorButton.addEventListener("click", async () => {
if (getAuthToken()) {
try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; }
}
noteColor = "";
if (!getAuthToken()) writeGuestColor("");
const replacement = currentOwner();
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
updateCurrentUser(); render();
socket?.setColor(currentUserColor() || null);
if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
toast("Global profile color restored");
});
editor.addEventListener("keydown", continueIndentation);
editor.addEventListener("scroll", () => {
syncEditorLayers();
syncPreviewScroll();
});
editor.addEventListener("input", event => {
const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previousContent.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length);
authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner());
previousContent = nextContent;
render();
if (applyingRemote || applyingHistory) return;
editHistory.record(event.inputType || "");
scheduleDocumentSave();
});
passwordDialog.addEventListener("cancel", event => {
if (info?.protected && !resourceUnlocked) {
event.preventDefault();
document.querySelector("#open-password")?.focus();
}
});
document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '<p class="empty">Loading…</p>'; try { const revisions = await adapter.loadHistory(accessToken); list.innerHTML = revisions.length ? revisions.map((r, i) => { const snippet = escapeHtml(r.content.trim().split("\n").slice(0, 3).join(" · ").slice(0, 150) || "Empty note"); const author = r.author || "Unknown author"; return `<article class="revision"><span class="revision__marker" style="--owner:${colorFor(author)}"></span><div><div class="revision__meta"><strong>${escapeHtml(author)}</strong><time>${formatDate(r.created_at)}</time></div><p class="revision__snippet">${snippet}</p><button data-preview="${r.id}">Preview</button><button data-revision="${r.id}">Restore</button><div class="revision__preview" id="preview-${r.id}" hidden></div></div></article>`; }).join("") : '<p class="empty">No history yet.</p>'; for (const r of revisions) { list.querySelector(`[data-preview="${r.id}"]`)?.addEventListener("click", () => { const el = list.querySelector(`#preview-${r.id}`); el.hidden = !el.hidden; el.textContent = r.content; }); list.querySelector(`[data-revision="${r.id}"]`)?.addEventListener("click", async () => { await adapter.restoreRevision(r.id, accessToken); toast("Version restored"); }); } } catch (e) { list.innerHTML = `<p class="error">${escapeHtml(e.message)}</p>`; } }); document.querySelector("#close-history").addEventListener("click", () => { historyPanel.classList.remove("open"); historyPanel.setAttribute("aria-hidden", "true"); document.body.classList.remove("history-open"); });
const deleteNoteButton = document.querySelector("#delete-note"); if (deleteNoteButton && adapter.deleteNote) deleteNoteButton.addEventListener("click", async () => { try { await adapter.deleteNote(info, accessToken); } catch (error) { toast(error.message); } });
window.addEventListener("error", event => { setStatus("offline", "Application error"); console.error(event.error || event.message); });
window.addEventListener("unhandledrejection", event => { setStatus("offline", "Application error"); console.error(event.reason); });
initialize();
}