/*
* 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";
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", '
Failed to load Mermaid.
')); } }
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) => ``).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 => `${escapeHtml(ownerName(owner))}`).join("");
return `${badges}`;
}).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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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: `}"` : ""})` };
}
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, "|");
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 = `
×
`;
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) => `${escapeHtml(line) || "
"}
`).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}\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,
onError: message => {
hideConnectionNotice();
const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message;
document.querySelector("#password-error").textContent = friendly;
if (/read-only access/i.test(message)) {
toast(friendly);
accessLevel.textContent = "Access: read only";
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();
} else if (info?.protected && !passwordDialog.open) {
passwordDialog.showModal();
}
},
});
socket.connect();
}
bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && 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 = `Page could not be loaded
${escapeHtml(e.message)}
`;
}
}
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);
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 = 'Loading…
'; 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 `${escapeHtml(author)}
${snippet}
`; }).join("") : 'No history yet.
'; 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 = `${escapeHtml(e.message)}
`; } }); 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();
}