/* * Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship, syncAuthorshipLayer } from "@rustpad/authorship"; import { identityOperation, operationFromEdit } from "@rustpad/collaboration"; import { CollaborationRevisionGapError, CollaborationSession } from "@rustpad/collaboration-session"; import { copyText } from "@rustpad/clipboard"; import { lineFromHash, lineLink, lineStartOffset } from "@rustpad/line-links"; import { applyFormat, bindFormatShortcuts, bindIndentationShortcuts } from "@rustpad/editor-format"; import { bindEmojiPicker } from "@rustpad/emoji-picker"; import { updateImageAliasInLineBySource } from "@rustpad/image-alias"; import { previewEditingHost } from "@rustpad/preview-edit"; import { createRenderQueue } from "@rustpad/render-queue"; import { alignPreviewLineNumbers, renderMarkdown, setMarkdownFiles, unresolvedMarkdownFileAliases } from "@rustpad/markdown"; import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session"; import { bindIdentityDialog, validateCurrentSession } from "@rustpad/auth-ui"; import { bindNoteFiles } from "@rustpad/note-files"; import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state"; import { toast } from "@rustpad/toast"; import { getTheme } from "@rustpad/theme"; import { isResourceAccessError } from "@rustpad/security"; import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs"; 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"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-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}`; let toolbarCollapsed = localStorage.getItem(notePreferenceKey("toolbar-collapsed")) === "on"; 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; let pendingPreviewViewport = null; 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; toolbarCollapsed = info.toolbar_collapsed === 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 mermaid = await loadMermaid(); 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 loadHighlight(); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } } async function renderMediaPlayers() { const nodes = preview.querySelectorAll("[data-rustpad-player]"); if (!nodes.length) return; try { const { hydrateMediaPlayers } = await loadMediaPlayer(); hydrateMediaPlayers(preview); } 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: `![${alt}](${src}${title ? ` "${title.replace(/"/g, """)}"` : ""})` }; } 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 scrollState(element) { const range = Math.max(0, element.scrollHeight - element.clientHeight); const top = Math.max(0, Math.min(range, element.scrollTop)); return { ratio: range > 0 ? top / range : 0, atStart: top <= 2, atEnd: range > 0 && range - top <= 2, }; } function editorScrollAnchor() { const state = scrollState(editor); return { sourceLine: 1 + Math.max(0, editor.scrollTop) / editorLineHeight(), ...state, }; } 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 state = scrollState(preview); const positions = previewLinePositions(); if (!positions.length) return { sourceLine: null, ...state }; 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, ...state }; } 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 (anchor?.atEnd) setScrollRatio(editor, 1); else if (anchor?.atStart) setScrollRatio(editor, 0); else 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) { if (anchor?.atEnd) { setScrollRatio(preview, 1); return; } if (anchor?.atStart) { setScrollRatio(preview, 0); return; } 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 capturePreviewViewport(target) { if (!target || !preview.contains(target)) return null; const sourceLine = Number(target.dataset.sourceLine); const previewRect = preview.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); return { sourceLine: Number.isFinite(sourceLine) ? sourceLine : null, viewportOffset: targetRect.top - previewRect.top, scrollTop: preview.scrollTop, focused: document.activeElement === target, }; } function restorePreviewViewport(snapshot) { if (!snapshot) return; const range = Math.max(0, preview.scrollHeight - preview.clientHeight); if (Number.isFinite(snapshot.sourceLine)) { const target = preview.querySelector(`.task-checkbox[data-source-line="${snapshot.sourceLine}"]`) || preview.querySelector(`.preview-source-line[data-source-line="${snapshot.sourceLine}"]`); if (target) { const previewRect = preview.getBoundingClientRect(); const currentOffset = target.getBoundingClientRect().top - previewRect.top; preview.scrollTop = Math.max(0, Math.min(range, preview.scrollTop + currentOffset - snapshot.viewportOffset)); if (snapshot.focused) target.focus({ preventScroll: true }); return; } } preview.scrollTop = Math.max(0, Math.min(range, snapshot.scrollTop || 0)); } function renderNow() { const previewViewport = pendingPreviewViewport; pendingPreviewViewport = null; if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); scheduleAliasFileRefresh(editor.value); document.querySelector("#preview-label").textContent = "Preview (media / mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); renderMediaPlayers(); } 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(() => { if (previewViewport) restorePreviewViewport(previewViewport); else 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 applyToolbarCollapsed() { document.body.classList.toggle("toolbar-collapsed", toolbarCollapsed); if (!toolbarCollapseToggle) return; const label = toolbarCollapsed ? "Show editor toolbar" : "Hide editor toolbar"; toolbarCollapseToggle.setAttribute("aria-pressed", String(toolbarCollapsed)); toolbarCollapseToggle.setAttribute("aria-label", label); toolbarCollapseToggle.title = label; toolbarCollapseToggle.querySelector(".toolbar-collapse-toggle__icon").textContent = toolbarCollapsed ? "⌄" : "⌃"; } 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); applyToolbarCollapsed(); 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, onPasswordRequired: async () => { resourceUnlocked = false; info = { ...info, protected: true, access_level: "none", can_set_password: false }; updatePageControls(); clearTimeout(saveTimer); saveState.textContent = collaboration.hasPending() ? "Password required — pending changes kept" : "Password required"; setDocumentReadOnly(true, "Password required"); document.querySelector("#password-error").textContent = "A password was set for this note. Enter it to continue."; if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); try { await loadNoteInfo(); adapter.configureView?.(info); updatePageControls(); } catch { } }, onError: message => { hideConnectionNotice(); const friendly = /read-only access/i.test(message) ? "This note is read only. Enter the password or ask the owner to grant write access." : message; if (/read-only access/i.test(message)) { toast(friendly); accessLevel.textContent = "Access: read only"; setDocumentReadOnly(true, "Read only — changes not saved"); collaboration.initialize(collaboration.serverContent, collaboration.serverOwnerMap, collaboration.revisionId); applyCollaborativeView({ resetHistory: true }); flushRequested = false; queueMicrotask(connect); return; } clearTimeout(saveTimer); if (saveState.textContent === "Saving…") saveState.textContent = "Save failed"; if (/nickname|session|account/i.test(message)) { if (!identityDialog.open) identityDialog.showModal(); return; } if (info?.protected && isResourceAccessError(message)) { resourceUnlocked = false; setDocumentReadOnly(true, "Password required"); document.querySelector("#password-error").textContent = friendly; if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } toast(friendly); }, }); socket.connect(); } bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } }); identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); }); async function showSystemNotFound() { try { const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, { cache: "no-store", credentials: "same-origin", }); const html = await response.text(); document.open(); document.write(html); document.close(); } catch { document.body.textContent = "404 Not Found"; } } async function initialize() { applyUi(); try { const session = await validateCurrentSession(); nickname = session?.nickname || getNickname(); if (!nickname) { if (!identityDialog.open) identityDialog.showModal(); return; } accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key); await loadNoteInfo(); document.title = adapter.title(info); publicPageEnabled.checked = Boolean(info.public_page_enabled); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected); updatePageControls(); adapter.configureView?.(info); applyUi({ write: true, replace: true }); updateCurrentUser(); if (info.protected && info.access_level === "none") passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { if (e.status === 403 || e.status === 404) { await showSystemNotFound(); return; } document.body.innerHTML = `

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 }); }); toolbarCollapseToggle?.addEventListener("click", () => { toolbarCollapsed = !toolbarCollapsed; localStorage.setItem(notePreferenceKey("toolbar-collapsed"), toolbarCollapsed ? "on" : "off"); applyUiPreservingScroll(); scheduleEditorSettingsSave({ personal: 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, toolbar_collapsed: toolbarCollapsed, 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; pendingPreviewViewport = capturePreviewViewport(checkbox); lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("focusin", event => { const target = previewEditingHost(event.target); if (!target) return; target.dataset.originalHtml = target.innerHTML; target.dataset.originalValue = markdownFromPreview(target); }); preview.addEventListener("beforeinput", event => { if (!previewEditingHost(event.target)) return; if (event.inputType === "insertParagraph" || event.inputType === "insertLineBreak") event.preventDefault(); }); preview.addEventListener("keydown", event => { const target = previewEditingHost(event.target); if (!target) return; if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); if (target.dataset.rawSourceEdit === "true") { cancelledPreviewEdits.add(target); render(); } else editRawPreviewLine(target); return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); if (event.altKey) insertPreviewLineBreak(target); else target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = previewEditingHost(event.target); if (!target) return; if (cancelledPreviewEdits.has(target)) { cancelledPreviewEdits.delete(target); return; } commitPreviewEdit(target); }, { capture: true }); document.addEventListener("keydown", event => { if (document.activeElement?.closest?.('.preview-editable[contenteditable="true"]')) return; const range = previewSelectionSourceRange(); if (!range) return; if (event.key === "Backspace" || event.key === "Delete") { event.preventDefault(); deletePreviewSelection(); return; } const format = previewShortcutFormat(event); if (!format) return; event.preventDefault(); editor.setSelectionRange(range.start, range.end); applyFormat(editor, format); }); document.addEventListener("cut", event => { const range = previewSelectionSourceRange(); if (!range) return; event.preventDefault(); event.clipboardData?.setData("text/plain", window.getSelection()?.toString() || ""); deletePreviewSelection(); }); const publishPageButton = document.querySelector("#publish-page"); const pageSettings = document.querySelector(".page-settings"); const setPagePasswordForm = document.querySelector("#set-page-password-form"); const setPagePasswordInput = document.querySelector("#set-page-password"); const setPagePasswordError = document.querySelector("#set-page-password-error"); const setPagePasswordLabel = document.querySelector("#set-page-password-label"); const setPagePasswordHelp = document.querySelector("#set-page-password-help"); const pagePasswordRequirement = document.querySelector("#page-password-requirement"); function updatePageControls() { const passwordProtected = Boolean(info?.protected); const workspacePassword = adapter.passwordScope === "workspace"; const canSetPassword = !passwordProtected && Boolean(adapter.setPassword) && Boolean(info?.can_set_password); if (!passwordProtected) { publicPageEnabled.checked = false; unprotectPublicPage.checked = false; } const requirementText = workspacePassword ? "Access to page options requires a password-protected workspace." : "Access to page options requires a password-protected note."; if (pagePasswordRequirement) { pagePasswordRequirement.textContent = canSetPassword ? `Set a ${workspacePassword ? "workspace" : "note"} password here to enable Page publishing.` : requirementText; pagePasswordRequirement.hidden = passwordProtected; } if (setPagePasswordLabel) setPagePasswordLabel.textContent = workspacePassword ? "Workspace password" : "Note password"; if (setPagePasswordHelp) setPagePasswordHelp.textContent = workspacePassword ? "At least 8 characters. It protects the workspace and all notes." : "At least 8 characters. It also protects editing access."; setPagePasswordForm.hidden = !canSetPassword; const enabled = passwordProtected && publicPageEnabled.checked; publicPageEnabled.disabled = !passwordProtected; publishPageButton.disabled = !enabled; publicTaskUpdates.disabled = !enabled; unprotectPublicPage.disabled = !enabled; pageSettings?.querySelector("summary")?.setAttribute( "aria-disabled", "false", ); pageSettings?.classList.toggle("is-enabled", enabled); pageSettings?.classList.toggle("needs-password", canSetPassword); pageSettings?.querySelector("summary")?.setAttribute( "title", !passwordProtected ? canSetPassword ? "Set a password before enabling the published page" : requirementText : enabled ? "Published page enabled" : "Published page disabled", ); } pageSettings?.addEventListener("toggle", () => { if (pageSettings.open && !setPagePasswordForm.hidden) { requestAnimationFrame(() => setPagePasswordInput.focus()); } }); document.addEventListener("pointerdown", event => { const target = event.target instanceof Element ? event.target : null; if (pageSettings?.open && !target?.closest(".page-settings")) pageSettings.open = false; if (mobileConnectionDetails?.open && (!target || !mobileConnectionDetails.contains(target))) mobileConnectionDetails.open = false; }, { passive: true }); document.addEventListener("keydown", event => { if (event.key === "Escape" && pageSettings?.open) pageSettings.open = false; if (event.key === "Escape" && mobileConnectionDetails?.open) mobileConnectionDetails.open = false; }); setPagePasswordForm?.addEventListener("submit", async event => { event.preventDefault(); if (!adapter.setPassword) return; const passwordValue = setPagePasswordInput.value; setPagePasswordError.textContent = ""; if (passwordValue.length < 8) { setPagePasswordError.textContent = "Password must contain at least 8 characters."; return; } const submit = setPagePasswordForm.querySelector('button[type="submit"]'); submit.disabled = true; try { await adapter.setPassword(passwordValue, collaborationClientId); const access = await adapter.requestAccess(passwordValue); setAccessToken(adapter.access.kind, adapter.access.key, access.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key) || shareToken; password = ""; setPagePasswordInput.value = ""; await loadNoteInfo(); resourceUnlocked = false; socket?.stop(); loadFiles(); connect(); toast("Password set. Page options are now available."); pageSettings.open = true; requestAnimationFrame(() => publicPageEnabled.focus()); } catch (error) { setPagePasswordError.textContent = error.message; } finally { submit.disabled = false; updatePageControls(); } }); const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked, publicPageEnabled.checked); publicPageEnabled.addEventListener("change", async () => { const previous = !publicPageEnabled.checked; updatePageControls(); publicPageEnabled.disabled = true; try { await savePublicOptions(); toast(publicPageEnabled.checked ? "Page enabled" : "Page disabled"); } catch (error) { publicPageEnabled.checked = previous; updatePageControls(); toast(error.message); } finally { publicPageEnabled.disabled = false; } }); publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { updatePageControls(); } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { updatePageControls(); } }); publishPageButton.addEventListener("click", async () => { if (!publicPageEnabled.checked) return; try { const result = await savePublicOptions(); if (!result.url) throw new Error("Page is disabled"); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } }); roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else if (roomDetails.classList.contains("is-mobile-open")) { setMobileChatOpen(false); } }); document.addEventListener("visibilitychange", () => { if (!document.hidden && roomDetails.open) clearUnread(); }); chatForm.addEventListener("submit", event => { event.preventDefault(); const text = chatInput.value.trim(); if (!text || !socket) return; socket.chat(text); chatInput.value = ""; chatInput.focus(); }); if (!chatMessages.children.length) { const empty = document.createElement("p"); empty.className = "chat-empty"; empty.textContent = "No messages yet"; chatMessages.append(empty); } currentUser.addEventListener("click", () => { if (typeof userColorPicker.showPicker === "function") userColorPicker.showPicker(); else userColorPicker.click(); }); async function saveUserColor(color) { noteColor = color; if (getAuthToken()) { try { await adapter.saveColor(accountHeaders(), noteColor); } catch (error) { toast(error.message); await loadNoteInfo(); return; } } else { writeGuestColor(noteColor); toast("Color saved for this tab"); } const replacement = currentOwner(); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(noteColor); if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement); } userColorPicker.addEventListener("change", () => saveUserColor(userColorPicker.value)); mobileColorPicker?.addEventListener("change", () => saveUserColor(mobileColorPicker.value)); useGlobalColorButton.addEventListener("click", async () => { if (getAuthToken()) { try { await adapter.saveColor(accountHeaders(), null); } catch (error) { toast(error.message); return; } } noteColor = ""; if (!getAuthToken()) writeGuestColor(""); const replacement = currentOwner(); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length); updateCurrentUser(); render(); socket?.setColor(currentUserColor() || null); if (socket && canEditDocument()) queueOwnerReplacement(nickname, replacement); toast("Global profile color restored"); }); editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { syncEditorLayers(); syncPreviewScroll(); }); editor.addEventListener("input", event => { if (!canEditDocument() && !applyingRemote) { applyCollaborativeView({ resetHistory: true }); saveState.textContent = "Read only"; return; } const previous = previousContent; const nextContent = editor.value; authorship = adoptCurrentOwnerAliases(authorship, previous.length); authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previous.length); authorship = applyAuthorshipEdit(authorship, previous, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote || applyingHistory) return; queueCollaborativeOperation(operationFromEdit(previous, nextContent, authorship)); editHistory.record(event.inputType || ""); scheduleDocumentSave(); }); passwordDialog.addEventListener("cancel", event => { if (info?.protected && !resourceUnlocked) { event.preventDefault(); document.querySelector("#open-password")?.focus(); } }); document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); setAccessToken(adapter.access.kind, adapter.access.key, result.granted); accessToken = getAccessToken(adapter.access.kind, adapter.access.key); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; await loadNoteInfo(); loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } }); const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { if (info?.protected && !resourceUnlocked) { if (!passwordDialog.open) passwordDialog.showModal(); document.querySelector("#open-password")?.focus(); return; } historyPanel.classList.add("open"); historyPanel.setAttribute("aria-hidden", "false"); document.body.classList.add("history-open"); const list = document.querySelector("#history-list"); list.innerHTML = '

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(); }