Files
rustpad/static/js/note-editor.js
T

2068 lines
112 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 { identityOperation, operationFromEdit } from "@rustpad/collaboration";
import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session";
import { copyText } from "@rustpad/clipboard";
import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links";
import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format";
import { bindEmojiPicker } from "@rustpad/emoji-picker";
import { updateImageAliasInLineBySource } from "@rustpad/image-alias";
import { previewEditingHost } from "@rustpad/preview-edit";
import { createRenderQueue } from "@rustpad/render-queue";
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";
import { isResourceAccessError } from "@rustpad/security";
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");
const saveState = document.querySelector("#save-state");
editor.readOnly = true;
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}`;
const collaborationClientId = typeof crypto.randomUUID === "function"
? crypto.randomUUID().replaceAll("-", "")
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
const collaboration = new CollaborationSession(collaborationClientId);
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 = "", flushRequested = false;
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) {
if (!canEditDocument()) return false;
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) and (orientation: landscape)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
let renderedView = singlePaneQuery.matches ? compactView : uiState.view;
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 setDocumentReadOnly(readOnly, label = "Read only") {
editor.readOnly = readOnly;
document.body.classList.toggle("document-read-only", readOnly);
if (!readOnly) return;
clearTimeout(saveTimer);
saveState.textContent = label;
const activePreviewEdit = preview.querySelector('.preview-editable[contenteditable="true"]');
if (activePreviewEdit) deactivatePreviewEdit(activePreviewEdit);
}
function canEditDocument() { return !editor.readOnly; }
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 === "span" && fileAlias === "image-container") {
const alias = current.getAttribute("data-image-alias-source");
if (alias) return { open: "", close: "", atomic: alias };
}
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 || !canEditDocument()) 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 imageFramesForLine(lineNumber) {
return [...preview.querySelectorAll(".markdown-alias-image")].filter(frame =>
Number(frame.closest("[data-source-line]")?.dataset.sourceLine) === lineNumber
);
}
function imageFramePosition(frame) {
const lineNumber = Number(frame.closest("[data-source-line]")?.dataset.sourceLine);
const aliasSource = frame.dataset.imageAliasSource || "";
if (!Number.isInteger(lineNumber) || lineNumber < 1 || !aliasSource) return null;
const frames = imageFramesForLine(lineNumber);
const frameIndex = frames.indexOf(frame);
if (frameIndex < 0) return null;
const sourceOccurrence = frames.slice(0, frameIndex)
.filter(item => item.dataset.imageAliasSource === aliasSource).length;
return { lineNumber, frameIndex, aliasSource, sourceOccurrence };
}
function selectedImageFrame() {
return preview.querySelector(".markdown-alias-image.is-selected");
}
function clearSelectedImageFrame() {
const active = selectedImageFrame();
if (!active) return;
active.classList.remove("is-selected");
active.querySelector(".image-alias-tools")?.remove();
active.querySelector(".image-alias-resize")?.remove();
}
function imageFrameDimensions(frame) {
const rect = frame.getBoundingClientRect();
return {
width: Math.max(1, Math.round(Number(frame.dataset.imageWidth) || rect.width)),
height: Math.max(1, Math.round(Number(frame.dataset.imageHeight) || rect.height)),
};
}
function selectImageFrame(frame) {
if (!frame || !canEditDocument()) return;
if (selectedImageFrame() === frame && frame.querySelector(".image-alias-tools")) return;
clearSelectedImageFrame();
frame.classList.add("is-selected");
const dimensions = imageFrameDimensions(frame);
const explicitAlignment = frame.dataset.imageAlign || "";
const tools = document.createElement("div");
tools.className = "image-alias-tools";
tools.setAttribute("role", "toolbar");
tools.setAttribute("aria-label", "Image layout");
tools.innerHTML = `<div class="image-alias-align" role="group" aria-label="Image alignment">
<button type="button" data-image-align=""${explicitAlignment ? "" : ' class="active"'}>Auto</button>
<button type="button" data-image-align="left"${explicitAlignment === "left" ? ' class="active"' : ""}>Left</button>
<button type="button" data-image-align="center"${explicitAlignment === "center" ? ' class="active"' : ""}>Center</button>
<button type="button" data-image-align="right"${explicitAlignment === "right" ? ' class="active"' : ""}>Right</button>
</div><div class="image-alias-size">
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="Image width"></label>
<span aria-hidden="true">×</span>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="Image height"></label>
<button type="button" data-image-size-apply>Set</button>
<button type="button" data-image-size-reset>Natural</button>
</div>`;
const resize = document.createElement("button");
resize.type = "button";
resize.className = "image-alias-resize";
resize.setAttribute("aria-label", "Resize image");
resize.title = "Drag to resize";
frame.append(tools, resize);
}
function updateImageFrameAlias(frame, patch) {
if (!canEditDocument()) return false;
const position = imageFramePosition(frame);
if (!position) return false;
const bounds = sourceLineBounds(position.lineNumber - 1);
if (!bounds) return false;
const nextLine = updateImageAliasInLineBySource(
bounds.text,
position.aliasSource,
position.sourceOccurrence,
patch,
);
if (nextLine == null || nextLine === bounds.text) return false;
editor.setRangeText(nextLine, bounds.start, bounds.end, "preserve");
editor.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertReplacementText" }));
requestAnimationFrame(() => selectImageFrame(imageFramesForLine(position.lineNumber)[position.frameIndex]));
return true;
}
function applyImageSizeFromTools(frame) {
const tools = frame.querySelector(".image-alias-tools");
const width = Math.round(Number(tools?.querySelector("[data-image-width-input]")?.value));
const height = Math.round(Number(tools?.querySelector("[data-image-height-input]")?.value));
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1 || width > 10000 || height > 10000) {
toast("Image size must be between 1 and 10000 px.");
return;
}
updateImageFrameAlias(frame, { width, height });
}
function startImageResize(event, frame) {
if (!canEditDocument()) return;
const handle = event.target.closest(".image-alias-resize");
if (!handle) return;
event.preventDefault();
event.stopImmediatePropagation();
const rect = frame.getBoundingClientRect();
const image = frame.querySelector("img");
const storedWidth = Number(frame.dataset.imageWidth);
const storedHeight = Number(frame.dataset.imageHeight);
const ratio = storedWidth > 0 && storedHeight > 0
? storedWidth / storedHeight
: image?.naturalWidth > 0 && image?.naturalHeight > 0
? image.naturalWidth / image.naturalHeight
: Math.max(.01, rect.width / Math.max(1, rect.height));
const parentWidth = frame.parentElement?.getBoundingClientRect().width || preview.clientWidth;
const maxWidth = Math.max(1, Math.min(10000, Math.round(parentWidth)));
const minWidth = Math.min(80, maxWidth);
const startX = event.clientX;
const startWidth = Math.max(minWidth, Math.round(rect.width));
let width = startWidth;
let height = Math.max(1, Math.round(width / ratio));
handle.setPointerCapture(event.pointerId);
frame.classList.add("is-resizing", "markdown-alias-image--sized");
const move = moveEvent => {
width = Math.max(minWidth, Math.min(maxWidth, Math.round(startWidth + moveEvent.clientX - startX)));
height = Math.max(1, Math.min(10000, Math.round(width / ratio)));
frame.dataset.imageWidth = String(width);
frame.dataset.imageHeight = String(height);
frame.style.setProperty("--image-width", `${width}px`);
frame.style.setProperty("--image-height", `${height}px`);
frame.style.setProperty("--image-aspect", `${width} / ${height}`);
const widthInput = frame.querySelector("[data-image-width-input]");
const heightInput = frame.querySelector("[data-image-height-input]");
if (widthInput) widthInput.value = String(width);
if (heightInput) heightInput.value = String(height);
};
const finish = () => {
frame.classList.remove("is-resizing");
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", finish);
updateImageFrameAlias(frame, { width, height });
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", finish);
}
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() {
if (!canEditDocument()) return false;
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 editorLineHeight() {
const lineHeight = parseFloat(getComputedStyle(editor).lineHeight);
return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 20;
}
function scrollRatio(element) {
const range = Math.max(0, element.scrollHeight - element.clientHeight);
return range > 0 ? element.scrollTop / range : 0;
}
function editorScrollAnchor() {
return {
sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(),
ratio: scrollRatio(editor),
};
}
function previewLinePositions() {
const previewRect = preview.getBoundingClientRect();
const positions = [];
preview.querySelectorAll(".preview-source-line[data-source-line]").forEach(node => {
const sourceLine = Number(node.dataset.sourceLine);
if (!Number.isFinite(sourceLine) || sourceLine < 1 || !node.getClientRects().length) return;
const rect = node.getBoundingClientRect();
positions.push({
node,
sourceLine,
top: rect.top - previewRect.top + preview.scrollTop,
bottom: rect.bottom - previewRect.top + preview.scrollTop,
});
});
return positions;
}
function previewScrollAnchor() {
const positions = previewLinePositions();
if (!positions.length) return { sourceLine: null, ratio: scrollRatio(preview) };
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
const viewportTop = preview.scrollTop + paddingTop;
let currentIndex = positions.findIndex(position => position.bottom > viewportTop + 0.5);
if (currentIndex < 0) currentIndex = positions.length - 1;
const current = positions[currentIndex];
const next = positions.slice(currentIndex + 1).find(position => position.sourceLine > current.sourceLine);
const progress = Math.max(0, Math.min(1, (viewportTop - current.top) / Math.max(1, current.bottom - current.top)));
const sourceLine = next
? current.sourceLine + progress * (next.sourceLine - current.sourceLine)
: current.sourceLine;
return { sourceLine, ratio: scrollRatio(preview) };
}
function activeScrollAnchor(view = renderedView) {
return view === "preview" ? previewScrollAnchor() : editorScrollAnchor();
}
function setScrollRatio(element, ratio) {
const range = Math.max(0, element.scrollHeight - element.clientHeight);
element.scrollTop = Math.max(0, Math.min(range, ratio * range));
}
function scrollEditorToAnchor(anchor) {
if (Number.isFinite(anchor?.sourceLine)) {
const range = Math.max(0, editor.scrollHeight - editor.clientHeight);
editor.scrollTop = Math.max(0, Math.min(range, (anchor.sourceLine - 1) * editorLineHeight()));
} else setScrollRatio(editor, anchor?.ratio || 0);
syncEditorLayers();
}
function scrollPreviewToAnchor(anchor) {
const positions = previewLinePositions();
if (!Number.isFinite(anchor?.sourceLine) || !positions.length) {
setScrollRatio(preview, anchor?.ratio || 0);
return;
}
const sourceLine = Math.max(1, anchor.sourceLine);
const exact = positions.find(position => position.sourceLine === sourceLine);
let targetTop = exact?.top;
if (!Number.isFinite(targetTop)) {
const before = [...positions].reverse().find(position => position.sourceLine <= sourceLine);
const after = positions.find(position => position.sourceLine >= sourceLine);
if (before && after && after.sourceLine > before.sourceLine) {
const progress = (sourceLine - before.sourceLine) / (after.sourceLine - before.sourceLine);
targetTop = before.top + progress * (after.top - before.top);
} else targetTop = (before || after)?.top;
}
if (!Number.isFinite(targetTop)) {
setScrollRatio(preview, anchor.ratio || 0);
return;
}
const paddingTop = parseFloat(getComputedStyle(preview).paddingTop) || 0;
const range = Math.max(0, preview.scrollHeight - preview.clientHeight);
preview.scrollTop = Math.max(0, Math.min(range, targetTop - paddingTop));
}
function restoreScrollAnchor(anchor) {
requestAnimationFrame(() => {
const view = activeView();
if (view === "edit" || view === "split") scrollEditorToAnchor(anchor);
if (view === "preview" || view === "split") scrollPreviewToAnchor(anchor);
});
}
function applyUiPreservingScroll(options) {
const anchor = activeScrollAnchor();
applyUi(options);
restoreScrollAnchor(anchor);
}
function syncPreviewScroll() {
if (activeView() !== "split") return;
scrollPreviewToAnchor(editorScrollAnchor());
}
function renderNow() { 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"; } 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); }
const render = createRenderQueue(renderNow);
function queueCollaborativeOperation(operation, ownerReplacements = []) {
if (!collaboration.ready || !canEditDocument()) return false;
try {
collaboration.queue(operation, ownerReplacements);
return true;
} catch (error) {
console.error("Failed to queue collaborative operation", error);
saveState.textContent = "Synchronization error";
socket?.stop();
queueMicrotask(connect);
return false;
}
}
function flushCollaborativeUpdate() {
clearTimeout(saveTimer);
if (!canEditDocument()) {
saveState.textContent = "Read only";
return;
}
const pending = collaboration.sendable();
if (!pending) {
if (collaboration.outstanding) {
flushRequested = Boolean(collaboration.buffer);
saveState.textContent = "Saving…";
} else if (!collaboration.buffer) {
flushRequested = false;
saveState.textContent = "Changes are saved automatically";
}
return;
}
const sent = socket?.update(
pending.baseRevisionId,
pending.updateId,
pending.operation,
pending.ownerReplacements,
);
if (!sent) {
flushRequested = true;
saveState.textContent = "Waiting for connection…";
return;
}
collaboration.markSent(pending.updateId);
flushRequested = false;
saveState.textContent = "Saving…";
}
function scheduleDocumentSave() {
clearTimeout(saveTimer);
if (!canEditDocument()) {
saveState.textContent = "Read only";
return;
}
saveState.textContent = "Saving…";
saveTimer = setTimeout(flushCollaborativeUpdate, 250);
}
function queueOwnerReplacement(owner, replacement) {
if (!owner || !replacement) return;
if (queueCollaborativeOperation(identityOperation(editor.value.length), [{ owner, replacement }])) {
scheduleDocumentSave();
}
}
function restoreHistorySnapshot(snapshot) {
if (!snapshot || !canEditDocument()) return;
const previous = editor.value;
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 });
queueCollaborativeOperation(operationFromEdit(previous, snapshot.content, authorship));
scheduleDocumentSave();
}
function activeView() {
return singlePaneQuery.matches ? compactView : uiState.view;
}
function applyUi({ write = false, replace = false } = {}) {
const view = activeView();
renderedView = view;
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";
const modeName = markdown ? "Markdown" : "Text";
modeToggle.classList.toggle("active", markdown);
modeToggle.setAttribute("aria-pressed", String(markdown));
modeToggle.title = modeName;
modeToggle.setAttribute("aria-label", modeName);
modeToggle.querySelector(".control-label-full").textContent = modeName;
modeToggle.querySelector(".control-label-short").textContent = markdown ? "M" : "T";
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 applyEditorDocument(content, ownerMap, { resetHistory = true } = {}) {
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;
if (resetHistory) editHistory.reset();
else editHistory.syncCurrent();
render();
editor.scrollTop = scrollTop;
editor.scrollLeft = scrollLeft;
syncEditorLayers();
restorePreviewEdit(previewSnapshot);
requestAnimationFrame(revealLinkedLine);
}
function applyCollaborativeView({ resetHistory = false } = {}) {
const local = collaboration.localDocument();
applyEditorDocument(local.content, local.ownerMap, { resetHistory });
}
function recoverCollaborativeSnapshot(message, reason = "resync") {
try {
const result = collaboration.resynchronize(message);
if (result.replayed) {
flushRequested = true;
toast(reason === "resync" ? "Connection state was resynchronized; pending edits were merged." : "A missed update was merged with your local edits.");
}
} catch (error) {
console.error("Failed to transform pending changes during resynchronization", error);
const localContent = editor.value;
const serverContent = String(message?.content || "");
const serverOwnerMap = message?.owner_map == null ? "[]" : String(message.owner_map);
const revisionId = Number(message?.revision_id) || 0;
const separator = serverContent && !serverContent.endsWith("\n") ? "\n\n" : "";
const recoveryContent = `${serverContent}${separator}<!-- Rustpad local recovery -->\n${localContent}`;
collaboration.initialize(serverContent, serverOwnerMap, revisionId);
if (new TextEncoder().encode(recoveryContent).length <= 2_000_000) {
collaboration.queue(
operationFromEdit(serverContent, recoveryContent, parseAuthorship(recoveryContent, "[]")),
);
flushRequested = true;
toast("A synchronization conflict was preserved as a local recovery block.");
} else {
toast("Synchronization failed because the recoverable document exceeds the size limit.");
}
}
applyCollaborativeView({ resetHistory: true });
if (collaboration.buffer) scheduleDocumentSave();
}
function integrateCollaborativeEnvelope(message, { renderView = true } = {}) {
const result = collaboration.integrate(message);
if (result.duplicate) return result;
if (message.content != null && String(message.content) !== collaboration.serverContent) {
throw new Error("Server snapshot does not match the collaborative operation");
}
if (message.owner_map != null) collaboration.serverOwnerMap = String(message.owner_map);
if (renderView) applyCollaborativeView({ resetHistory: result.remote });
return result;
}
function handleCollaborativeAuthentication(message) {
const revisionId = Number(message.revision_id) || 0;
if (!collaboration.ready) {
collaboration.initialize(message.content, message.owner_map, revisionId);
applyCollaborativeView({ resetHistory: true });
return;
}
if (message.resync_required) {
recoverCollaborativeSnapshot(message, "resync");
return;
}
let resetHistory = false;
try {
for (const operation of message.catchup_operations || []) {
const result = integrateCollaborativeEnvelope(operation, { renderView: false });
resetHistory ||= result.remote;
}
collaboration.adoptCanonicalSnapshot(message.content, message.owner_map, revisionId);
applyCollaborativeView({ resetHistory });
} catch (error) {
console.error("Failed to apply collaborative catch-up", error);
recoverCollaborativeSnapshot(message, error instanceof CollaborationRevisionGapError ? "gap" : "resync");
}
}
function resendOutstandingUpdate() {
const pending = collaboration.outstanding;
if (!pending || !canEditDocument()) return;
const sent = socket?.update(
collaboration.revisionId,
pending.updateId,
pending.operation,
pending.ownerReplacements,
);
if (sent) saveState.textContent = "Saving…";
else flushRequested = true;
}
function handleCollaborativeDocument(message) {
try {
const result = integrateCollaborativeEnvelope(message);
if (result.duplicate) return;
const timestamp = new Date(message.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
if (collaboration.hasPending()) saveState.textContent = "Saving…";
else saveState.textContent = editor.readOnly ? "Read only" : `${message.author ? `${message.author} · ` : ""}${timestamp}`;
if (result.ownAck && collaboration.buffer && flushRequested) flushCollaborativeUpdate();
} catch (error) {
console.error("Failed to integrate collaborative update", error);
saveState.textContent = "Resynchronizing…";
socket?.stop();
queueMicrotask(connect);
}
}
const { loadFiles } = bindNoteFiles({
editor, toast, getAccessToken: () => accessToken,
getUploadMaxSize: () => Number(info?.upload_max_size_bytes) || 0,
canDelete: () => Boolean(info?.can_delete_files),
canUpload: () => Boolean(info?.can_upload_files),
canEdit: canEditDocument,
endpoints: adapter.fileEndpoints,
onFilesChanged: files => updateMarkdownFiles(files, { rerender: true }),
});
refreshFilesForAliases = () => loadFiles();
preview.addEventListener("pointerdown", event => {
const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
if (frame && event.target.closest(".image-alias-resize")) startImageResize(event, frame);
});
preview.addEventListener("click", event => {
const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
if (!frame) {
clearSelectedImageFrame();
return;
}
event.stopImmediatePropagation();
if (!canEditDocument()) return;
if (!(event.target instanceof HTMLInputElement)) event.preventDefault();
selectImageFrame(frame);
const alignment = event.target.closest("[data-image-align]");
if (alignment) {
updateImageFrameAlias(frame, { align: alignment.dataset.imageAlign || null });
return;
}
if (event.target.closest("[data-image-size-apply]")) {
applyImageSizeFromTools(frame);
return;
}
if (event.target.closest("[data-image-size-reset]")) {
updateImageFrameAlias(frame, { width: null, height: null });
}
});
preview.addEventListener("keydown", event => {
const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
if (!frame) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopImmediatePropagation();
clearSelectedImageFrame();
return;
}
if (event.key === "Enter" && event.target.matches("[data-image-width-input], [data-image-height-input]")) {
event.preventDefault();
event.stopImmediatePropagation();
applyImageSizeFromTools(frame);
}
});
document.addEventListener("pointerdown", event => {
const active = selectedImageFrame();
if (active && !(event.target instanceof Node && active.contains(event.target))) clearSelectedImageFrame();
}, { capture: true });
function connect() {
socket?.stop();
socket = adapter.createSocket({
password,
accessToken,
nickname,
color: currentUserColor() || null,
sessionToken: null,
guestId: getGuestId(),
clientId: collaborationClientId,
getKnownRevision: () => collaboration.ready ? collaboration.revisionId : null,
onStatus: handleSocketStatus,
onAuthenticated: message => {
resourceUnlocked = true;
if (passwordDialog.open) passwordDialog.close();
const readOnly = message.access_level === "read_only";
setDocumentReadOnly(readOnly);
accessLevel.textContent = readOnly ? "Access: read only" : "Access: full";
if (readOnly) {
collaboration.initialize(message.content, message.owner_map, message.revision_id);
applyCollaborativeView({ resetHistory: true });
flushRequested = false;
saveState.textContent = "Read only";
return;
}
handleCollaborativeAuthentication(message);
if (collaboration.outstanding) resendOutstandingUpdate();
else if (collaboration.buffer) flushCollaborativeUpdate();
else saveState.textContent = "Changes are saved automatically";
editor.focus();
},
onDocument: handleCollaborativeDocument,
onResync: message => recoverCollaborativeSnapshot(message, "resync"),
onPresence: updatePresence,
onLatency: updateLatency,
onDiagnostics: renderConnectionDiagnostics,
onChat: appendChatMessage,
onPasswordRequired: async () => {
resourceUnlocked = false;
info = { ...info, protected: true, access_level: "none", can_set_password: false };
updatePageControls();
clearTimeout(saveTimer);
saveState.textContent = collaboration.hasPending()
? "Password required — pending changes kept"
: "Password required";
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue.";
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
try {
await loadNoteInfo();
adapter.configureView?.(info);
updatePageControls();
} catch { }
},
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;
if (/read-only access/i.test(message)) {
toast(friendly);
accessLevel.textContent = "Access: read only";
setDocumentReadOnly(true, "Read only — changes not saved");
collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId);
applyCollaborativeView({ resetHistory: true });
flushRequested = false;
queueMicrotask(connect);
return;
}
clearTimeout(saveTimer);
if (saveState.textContent === "Saving…") saveState.textContent = "Save failed";
if (/nickname|session|account/i.test(message)) {
if (!identityDialog.open) identityDialog.showModal();
return;
}
if (info?.protected && isResourceAccessError(message)) {
resourceUnlocked = false;
setDocumentReadOnly(true, "Password required");
document.querySelector("#password-error").textContent = friendly;
if (!passwordDialog.open) passwordDialog.showModal();
document.querySelector("#open-password")?.focus();
return;
}
toast(friendly);
},
});
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 && info.access_level === "none") 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() {
applyUi();
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 && info.access_level === "none") 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", () => {
const anchor = activeScrollAnchor();
if (singlePaneQuery.matches) {
compactView = button.dataset.view === "preview" ? "preview" : "edit";
applyUi();
restoreScrollAnchor(anchor);
return;
}
uiState = { ...uiState, view: button.dataset.view };
applyUi({ write: true });
restoreScrollAnchor(anchor);
}));
singlePaneQuery.addEventListener("change", () => applyUiPreservingScroll());
compactLayoutQuery.addEventListener("change", () => applyUiPreservingScroll());
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 => {
const button = event.target.closest("button");
if (compactLayoutQuery.matches && button && !button.closest(".page-settings-menu")) {
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");
const markdownMore = document.querySelector(".markdown-more");
const mobileToolbarQuery = matchMedia("(max-width: 720px)");
document.addEventListener("pointerdown", event => {
if (mobileEditorOptions?.open && !event.target.closest("#mobile-editor-options")) {
mobileEditorOptions.open = false;
}
if (mobileToolbarQuery.matches && markdownMore?.open && !markdownMore.contains(event.target)) {
markdownMore.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" }; applyUiPreservingScroll({ 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(); applyUiPreservingScroll(); 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(); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: true }); });
fontSize.addEventListener("change", () => { localStorage.setItem(notePreferenceKey("font-size"), fontSize.value); syncMobileEditorControls(); applyUiPreservingScroll(); 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 } = {}) {
if (!canEditDocument()) { render(); return; }
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) {
if (!canEditDocument()) return;
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) {
if (!canEditDocument()) return;
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", () => { const anchor = activeScrollAnchor(); lastRevealedLineHash = ""; uiState = readEditorState(); applyUi(); restoreScrollAnchor(anchor); 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);
bindIndentationShortcuts(editor, { size: 2 });
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;
if (!canEditDocument()) {
checkbox.checked = !checkbox.checked;
saveState.textContent = "Read only";
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 = previewEditingHost(event.target);
if (!target) return;
target.dataset.originalHtml = target.innerHTML;
target.dataset.originalValue = markdownFromPreview(target);
});
preview.addEventListener("beforeinput", event => {
if (!previewEditingHost(event.target)) return;
if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault();
});
preview.addEventListener("keydown", event => {
const target = previewEditingHost(event.target);
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 = previewEditingHost(event.target);
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");
const setPagePasswordForm = document.querySelector("#set-page-password-form");
const setPagePasswordInput = document.querySelector("#set-page-password");
const setPagePasswordError = document.querySelector("#set-page-password-error");
const setPagePasswordLabel = document.querySelector("#set-page-password-label");
const setPagePasswordHelp = document.querySelector("#set-page-password-help");
const pagePasswordRequirement = document.querySelector("#page-password-requirement");
function updatePageControls() {
const passwordProtected = Boolean(info?.protected);
const workspacePassword = adapter.passwordScope === "workspace";
const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password);
if (!passwordProtected) {
publicPageEnabled.checked = false;
unprotectPublicPage.checked = false;
}
const requirementText = workspacePassword
? "Access to page options requires a password-protected workspace."
: "Access to page options requires a password-protected note.";
if (pagePasswordRequirement) {
pagePasswordRequirement.textContent = canSetPassword
? `Set a ${workspacePassword ? "workspace" : "note"} password here to enable Page publishing.`
: requirementText;
pagePasswordRequirement.hidden = passwordProtected;
}
if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Workspace password" : "Note password";
if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword
? "At least 8 characters. It protects the workspace and all notes."
: "At least 8 characters. It also protects editing access.";
setPagePasswordForm.hidden = !canSetPassword;
const enabled = passwordProtected && publicPageEnabled.checked;
publicPageEnabled.disabled = !passwordProtected;
publishPageButton.disabled = !enabled;
publicTaskUpdates.disabled = !enabled;
unprotectPublicPage.disabled = !enabled;
pageSettings?.querySelector("summary")?.setAttribute(
"aria-disabled",
"false",
);
pageSettings?.classList.toggle("is-enabled", enabled);
pageSettings?.classList.toggle("needs-password", canSetPassword);
pageSettings?.querySelector("summary")?.setAttribute(
"title",
!passwordProtected
? canSetPassword
? "Set a password before enabling the published page"
: requirementText
: enabled
? "Published page enabled"
: "Published page disabled",
);
}
pageSettings?.addEventListener("toggle", () => {
if (pageSettings.open && !setPagePasswordForm.hidden) {
requestAnimationFrame(() => setPagePasswordInput.focus());
}
});
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;
});
setPagePasswordForm?.addEventListener("submit", async event => {
event.preventDefault();
if (!adapter.setPassword) return;
const passwordValue = setPagePasswordInput.value;
setPagePasswordError.textContent = "";
if (passwordValue.length < 8) {
setPagePasswordError.textContent = "Password must contain at least 8 characters.";
return;
}
const submit = setPagePasswordForm.querySelector('button[type="submit"]');
submit.disabled = true;
try {
await adapter.setPassword(passwordValue, collaborationClientId);
const access = await adapter.requestAccess(passwordValue);
setAccessToken(adapter.access.kind, adapter.access.key, access.granted);
accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken;
password = "";
setPagePasswordInput.value = "";
await loadNoteInfo();
resourceUnlocked = false;
socket?.stop();
loadFiles();
connect();
toast("Password set. Page options are now available.");
pageSettings.open = true;
requestAnimationFrame(() => publicPageEnabled.focus());
} catch (error) {
setPagePasswordError.textContent = error.message;
} finally {
submit.disabled = false;
updatePageControls();
}
});
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 && canEditDocument()) queueOwnerReplacement(nickname, replacement);
}
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 && canEditDocument()) queueOwnerReplacement(nickname, replacement);
toast("Global profile color restored");
});
editor.addEventListener("keydown", continueIndentation);
editor.addEventListener("scroll", () => {
syncEditorLayers();
syncPreviewScroll();
});
editor.addEventListener("input", event => {
if (!canEditDocument() && !applyingRemote) {
applyCollaborativeView({ resetHistory: true });
saveState.textContent = "Read only";
return;
}
const previous = previousContent;
const nextContent = editor.value;
authorship = adoptCurrentOwnerAliases(authorship, previous.length);
authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length);
authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner());
previousContent = nextContent;
render();
if (applyingRemote || applyingHistory) return;
queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship));
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();
}