diff --git a/src/assets.rs b/src/assets.rs
index 634e496..066a0e3 100644
--- a/src/assets.rs
+++ b/src/assets.rs
@@ -15,6 +15,8 @@ const MODULES: &[&str] = &[
"logger",
"markdown",
"modal",
+ "note-api",
+ "note-editor",
"note-files",
"session",
"socket",
diff --git a/static/js/note-api.js b/static/js/note-api.js
new file mode 100644
index 0000000..90b537e
--- /dev/null
+++ b/static/js/note-api.js
@@ -0,0 +1,95 @@
+import { api } from "@rustpad/api";
+import { askConfirm } from "@rustpad/modal";
+import { NoteSocket, PadSocket } from "@rustpad/socket";
+
+function encode(value) {
+ return encodeURIComponent(value);
+}
+
+export function createPadAdapter() {
+ const slug = location.pathname.split("/").filter(Boolean)[1];
+ const base = `/api/pads/${encode(slug)}`;
+
+ return {
+ access: { kind: "pad", key: slug },
+ addressSelector: "#pad-url",
+ title: info => `${info.title} · RustPad`,
+ loadInfo: headers => api(base, { headers }),
+ fileEndpoints: {
+ list: `${base}/files`,
+ upload: `${base}/files`,
+ remove: fileId => `${base}/files/${encode(fileId)}`,
+ },
+ createSocket: options => new PadSocket({ slug, ...options }),
+ requestAccess: password => api("/api/access-token", {
+ method: "POST",
+ body: JSON.stringify({ kind: "pad", slug, password }),
+ }),
+ publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
+ }),
+ loadHistory: accessToken => api(`${base}/history`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null }),
+ }),
+ restoreRevision: (revisionId, accessToken) => api(`${base}/restore`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null, revision_id: revisionId }),
+ }),
+ configureView() {},
+ deleteNote: null,
+ };
+}
+
+export function createWorkspaceNoteAdapter() {
+ const parts = location.pathname.split("/").filter(Boolean);
+ const workspaceSlug = parts[1];
+ const noteSlug = parts[3];
+ const base = `/api/workspaces/${encode(workspaceSlug)}/notes/${encode(noteSlug)}`;
+
+ return {
+ access: { kind: "workspace", key: workspaceSlug },
+ addressSelector: "#note-url",
+ title: info => `${info.title} · ${info.workspace_title}`,
+ loadInfo: headers => api(base, { headers }),
+ fileEndpoints: {
+ list: `${base}/files`,
+ upload: `${base}/files`,
+ remove: fileId => `${base}/files/${encode(fileId)}`,
+ },
+ createSocket: options => new NoteSocket({ workspaceSlug, noteSlug, ...options }),
+ requestAccess: password => api("/api/access-token", {
+ method: "POST",
+ body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
+ }),
+ publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
+ }),
+ loadHistory: accessToken => api(`${base}/history`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null }),
+ }),
+ restoreRevision: (revisionId, accessToken) => api(`${base}/restore`, {
+ method: "POST",
+ body: JSON.stringify({ access_token: accessToken || null, revision_id: revisionId }),
+ }),
+ configureView(info) {
+ const button = document.querySelector("#delete-note");
+ if (button) button.hidden = info.note_protected;
+ },
+ async deleteNote(info, accessToken) {
+ if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, {
+ title: "Delete note",
+ confirmText: "Delete",
+ danger: true,
+ })) return;
+ await api(base, {
+ method: "DELETE",
+ body: JSON.stringify({ access_token: accessToken || null }),
+ });
+ location.assign(`/w/${encode(workspaceSlug)}`);
+ },
+ };
+}
diff --git a/static/js/note-editor.js b/static/js/note-editor.js
new file mode 100644
index 0000000..4df9df8
--- /dev/null
+++ b/static/js/note-editor.js
@@ -0,0 +1,200 @@
+import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
+installGlobalDiagnostics();
+
+import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
+import { copyText } from "@rustpad/clipboard";
+import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
+import { bindEmojiPicker } from "@rustpad/emoji-picker";
+import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
+import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
+import { bindIdentityDialog } from "@rustpad/auth-ui";
+import { bindNoteFiles } from "@rustpad/note-files";
+import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
+
+export function startNoteEditor(adapter) {
+const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
+const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
+const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
+let unreadChat = 0;
+const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
+const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
+let accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
+const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
+previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
+compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
+fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
+fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
+function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
+function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
+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); }
+function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
+function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
+function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
+function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
+function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
+async function loadNoteInfo() { info = await adapter.loadInfo(sessionHeaders()); return info; }
+function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } }
+function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
+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); 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 = ""; document.title = document.title.replace(/^● /, ""); }
+
+function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
+function updateAddressLabel() { document.querySelector(adapter.addressSelector).textContent = `${location.pathname}${location.search}`; }
+async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", '
Failed to load Mermaid.
')); } }
+async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
+function renderGutter() {
+ const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
+ const lines = Array.from({ length: lineCount });
+ const owners = authorshipOwners(authorship);
+ const showSingleOwner = owners.length === 1;
+ const showAuthorship = owners.length > 1;
+ const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
+ authorshipLayer.hidden = !showAuthorship;
+ ownerLabels.hidden = !(showSingleOwner || showAuthorship);
+ const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, 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) => `${i + 1}
`).join("");
+ ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
+ if (showSingleOwner) {
+ const owner = owners[0];
+ const top = paddingTop - editor.scrollTop;
+ ownerLabels.innerHTML = `${escapeHtml(ownerName(owner))}`;
+ } else {
+ ownerLabels.innerHTML = lines.map((_, i) => {
+ const authors = authorsByLine[i] || [];
+ if (!authors.length) return "";
+ const top = paddingTop + i * lineHeight - editor.scrollTop;
+ const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join("");
+ return `${badges}`;
+ }).join("");
+ }
+ if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
+ else authorshipLayer.replaceChildren();
+ document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
+ document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
+}
+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 markdownFromPreview(node) {
+ const walk = current => {
+ if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
+ if (current.nodeType !== Node.ELEMENT_NODE) return "";
+ const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
+ if (tag === "strong" || tag === "b") return `**${body}**`;
+ if (tag === "em" || tag === "i") return `*${body}*`;
+ if (tag === "s" || tag === "del") return `~~${body}~~`;
+ if (tag === "mark") return `==${body}==`;
+ if (tag === "code") return "`" + body + "`";
+ if (tag === "sub") return `~${body}~`;
+ if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
+ if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
+ if (tag === "img") {
+ const src = current.getAttribute("src") || "";
+ const alt = current.getAttribute("alt") || "";
+ const title = current.getAttribute("title");
+ return `}"` : ""})`;
+ }
+ if (tag === "br") return " ";
+ return body;
+ };
+ return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim();
+}
+
+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 movePreviewCaret(target, direction) {
+ const editables = [...preview.querySelectorAll(".preview-editable")];
+ const index = editables.indexOf(target), next = editables[index + direction];
+ if (!next) return false;
+ const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(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 render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
+function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
+function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; 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 = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
+
+const { loadFiles } = bindNoteFiles({
+ editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
+ endpoints: adapter.fileEndpoints,
+});
+function connect() { socket?.stop(); socket = adapter.createSocket({ password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
+bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
+identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
+async function initialize() { try { await loadNoteInfo(); document.title = adapter.title(info); publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); adapter.configureView?.(info); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `Note not found
${escapeHtml(e.message)}
`; } }
+
+document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
+window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
+publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await adapter.publish(accessToken, publicTaskUpdates.checked); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await adapter.publish(accessToken, publicTaskUpdates.checked); 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(() => { }); } });
+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", () => userColorPicker.click());
+userColorPicker.addEventListener("input", () => {
+ localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
+ const replacement = currentOwner();
+ authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
+ updateCurrentUser(); render();
+ socket?.setColor(userColorPicker.value);
+ if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
+});
+editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
+document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await adapter.requestAccess(password); accessToken = result.access_token; setAccessToken(adapter.access.kind, adapter.access.key, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
+const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { 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"); });
+
+window.addEventListener("storage", event => {
+ if (event.key !== storedColorKey(nickname)) return;
+ const replacement = currentOwner();
+ authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
+ updateCurrentUser(); render();
+ socket?.setColor(currentUserColor() || null);
+ if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
+});
+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();
+}
diff --git a/static/js/note.js b/static/js/note.js
index ab589b4..f3741c4 100644
--- a/static/js/note.js
+++ b/static/js/note.js
@@ -1,205 +1,4 @@
-import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
-installGlobalDiagnostics();
+import { createWorkspaceNoteAdapter } from "@rustpad/note-api";
+import { startNoteEditor } from "@rustpad/note-editor";
-import { api } from "@rustpad/api";
-import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
-import { copyText } from "@rustpad/clipboard";
-import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
-import { bindEmojiPicker } from "@rustpad/emoji-picker";
-import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
-import { getNickname, getGuestId, getAccessToken, getAuthToken, setAccessToken } from "@rustpad/session";
-import { bindIdentityDialog } from "@rustpad/auth-ui";
-import { bindNoteFiles } from "@rustpad/note-files";
-import { NoteSocket } from "@rustpad/socket";
-import { askConfirm } from "@rustpad/modal";
-import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
-
-const parts = location.pathname.split("/").filter(Boolean), workspaceSlug = parts[1], noteSlug = parts[3];
-const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
-const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
-const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
-let unreadChat = 0;
-const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
-const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("workspace", workspaceSlug, shareToken);
-let accessToken = shareToken || getAuthToken() || getAccessToken("workspace", workspaceSlug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
-const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle");
-lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
-previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
-compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
-fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
-fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
-function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
-function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
-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); }
-function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
-function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
-function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
-function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
-function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
-async function loadNoteInfo() { info = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { headers: sessionHeaders() }); return info; }
-function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } }
-function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
-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); 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 = ""; document.title = document.title.replace(/^● /, ""); }
-
-function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
-function updateAddressLabel() { document.querySelector("#note-url").textContent = `${location.pathname}${location.search}`; }
-async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", 'Failed to load Mermaid.
')); } }
-async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
-function renderGutter() {
- const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
- const lines = Array.from({ length: lineCount });
- const owners = authorshipOwners(authorship);
- const showSingleOwner = owners.length === 1;
- const showAuthorship = owners.length > 1;
- const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
- authorshipLayer.hidden = !showAuthorship;
- ownerLabels.hidden = !(showSingleOwner || showAuthorship);
- const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, 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) => `${i + 1}
`).join("");
- ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
- if (showSingleOwner) {
- const owner = owners[0];
- const top = paddingTop - editor.scrollTop;
- ownerLabels.innerHTML = `${escapeHtml(ownerName(owner))}`;
- } else {
- ownerLabels.innerHTML = lines.map((_, i) => {
- const authors = authorsByLine[i] || [];
- if (!authors.length) return "";
- const top = paddingTop + i * lineHeight - editor.scrollTop;
- const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join("");
- return `${badges}`;
- }).join("");
- }
- if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
- else authorshipLayer.replaceChildren();
- document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
- document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
-}
-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 markdownFromPreview(node) {
- const walk = current => {
- if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
- if (current.nodeType !== Node.ELEMENT_NODE) return "";
- const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
- if (tag === "strong" || tag === "b") return `**${body}**`;
- if (tag === "em" || tag === "i") return `*${body}*`;
- if (tag === "s" || tag === "del") return `~~${body}~~`;
- if (tag === "mark") return `==${body}==`;
- if (tag === "code") return "`" + body + "`";
- if (tag === "sub") return `~${body}~`;
- if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
- if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
- if (tag === "img") {
- const src = current.getAttribute("src") || "";
- const alt = current.getAttribute("alt") || "";
- const title = current.getAttribute("title");
- return `}"` : ""})`;
- }
- if (tag === "br") return " ";
- return body;
- };
- return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim();
-}
-
-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 movePreviewCaret(target, direction) {
- const editables = [...preview.querySelectorAll(".preview-editable")];
- const index = editables.indexOf(target), next = editables[index + direction];
- if (!next) return false;
- const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(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 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 render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Markdown + Mermaid preview · text and headings are editable"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
-function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
-function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; 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 = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
-const { loadFiles } = bindNoteFiles({
- editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
- endpoints: {
- list: `/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,
- upload: `/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`,
- remove: fileId => `/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(fileId)}`,
- },
-});
-function connect() { socket?.stop(); socket = new NoteSocket({ workspaceSlug, noteSlug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
-
-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]}`; }
-bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); await loadNoteInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
-identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
-async function initialize() { try { await loadNoteInfo(); document.title = `${info.title} · ${info.workspace_title}`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); document.querySelector("#delete-note").hidden = info.note_protected; if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `Note not found
${escapeHtml(e.message)}
`; } }
-
-document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
-window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
-publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); 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(() => { }); } });
-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", () => userColorPicker.click());
-userColorPicker.addEventListener("input", () => {
- localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
- const replacement = currentOwner();
- authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
- updateCurrentUser(); render();
- socket?.setColor(userColorPicker.value);
- if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
-});
-window.addEventListener("storage", event => {
- if (event.key !== storedColorKey(nickname)) return;
- const replacement = currentOwner();
- authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
- updateCurrentUser(); render();
- socket?.setColor(currentUserColor() || null);
- if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
-});
-editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
-document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }) }); accessToken = result.access_token; setAccessToken("workspace", workspaceSlug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; loadFiles(); connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
-const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { 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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); 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 api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); 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"); });
-document.querySelector("#delete-note").addEventListener("click", async () => { if (!await askConfirm(`Delete note “${info.title}”? This cannot be undone.`, { title: "Delete note", confirmText: "Delete", danger: true })) return; try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}`, { method: "DELETE", body: JSON.stringify({ access_token: accessToken || null }) }); location.assign(`/w/${encodeURIComponent(workspaceSlug)}`); } 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();
+startNoteEditor(createWorkspaceNoteAdapter());
diff --git a/static/js/pad.js b/static/js/pad.js
index 9f64178..6706b9c 100644
--- a/static/js/pad.js
+++ b/static/js/pad.js
@@ -1,204 +1,4 @@
-import { installGlobalDiagnostics, logInfo } from "@rustpad/logger";
-installGlobalDiagnostics();
+import { createPadAdapter } from "@rustpad/note-api";
+import { startNoteEditor } from "@rustpad/note-editor";
-import { api } from "@rustpad/api";
-import { applyAuthorshipEdit, authorshipOwners, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship";
-import { copyText } from "@rustpad/clipboard";
-import { applyFormat, bindFormatShortcuts } from "@rustpad/editor-format";
-import { bindEmojiPicker } from "@rustpad/emoji-picker";
-import { alignPreviewLineNumbers, renderMarkdown } from "@rustpad/markdown";
-import { getNickname, getGuestId, getAuthToken, getAccessToken, setAccessToken } from "@rustpad/session";
-import { bindIdentityDialog } from "@rustpad/auth-ui";
-import { bindNoteFiles } from "@rustpad/note-files";
-import { PadSocket } from "@rustpad/socket";
-import { currentShareUrl, readEditorState, writeEditorState } from "@rustpad/url-state";
-
-const slug = location.pathname.split("/").filter(Boolean)[1];
-const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
-const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
-const roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread");
-let unreadChat = 0;
-const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker");
-const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken("pad", slug, shareToken);
-let accessToken = shareToken || getAuthToken() || getAccessToken("pad", slug), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "";
-const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
-previewLineToggle.checked = localStorage.getItem("rustpad:preview-line-numbers") === "on";
-compactToggle.checked = localStorage.getItem("rustpad:compact") !== "off";
-fontFamily.value = localStorage.getItem("rustpad:font-family") || "mono";
-fontSize.value = localStorage.getItem("rustpad:font-size") || "14";
-function defaultColorFor(name) { let h = 0; for (const c of name || "?") h = (h * 31 + c.charCodeAt(0)) % 360; return `hsl(${h} 70% 62%)`; }
-function storedColorKey(name) { return `rustpad:user-color:${encodeURIComponent(name || "")}`; }
-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); }
-function currentUserColor() { return localStorage.getItem(storedColorKey(nickname)) || ""; }
-function currentOwner() { const color = currentUserColor(); return color ? `${nickname}\u001f${color}` : nickname; }
-function updateCurrentUser() { const color = currentUserColor() || defaultColorFor(nickname); currentUser.querySelector(".user-chip__name").textContent = nickname; currentUser.style.setProperty("--owner", color); userColorPicker.value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#7c6cff"; }
-function toast(text) { const el = document.querySelector("#toast"); el.textContent = text; el.classList.add("visible"); setTimeout(() => el.classList.remove("visible"), 1800); }
-function sessionHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; }
-async function loadPadInfo() { info = await api(`/api/pads/${encodeURIComponent(slug)}`, { headers: sessionHeaders() }); return info; }
-function updatePresence(users) { const entries = Array.isArray(users) ? users : []; roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...entries.map(entry => { const user = typeof entry === "string" ? { name: entry, color: "" } : entry || {}; 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); } }
-function updateLatency(ms) { socketLatency.textContent = Number.isFinite(ms) ? `${ms} ms` : "— ms"; }
-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); 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 = ""; document.title = document.title.replace(/^● /, ""); }
-
-function setStatus(kind, text) { document.querySelector("#status-dot").className = `status__dot${kind ? ` is-${kind}` : ""}`; document.querySelector("#status-text").textContent = text; }
-function updateAddressLabel() { document.querySelector("#pad-url").textContent = `${location.pathname}${location.search}`; }
-async function renderMermaid() { const nodes = preview.querySelectorAll(".mermaid"); if (!nodes.length) return; try { const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"); mermaid.initialize({ startOnLoad: false, theme: "dark", securityLevel: "strict" }); await mermaid.run({ nodes: [...nodes] }); } catch { nodes.forEach(n => n.insertAdjacentHTML("beforebegin", 'Failed to load Mermaid.
')); } }
-async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
-function renderGutter() {
- const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1);
- const lines = Array.from({ length: lineCount });
- const owners = authorshipOwners(authorship);
- const showSingleOwner = owners.length === 1;
- const showAuthorship = owners.length > 1;
- const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
- authorshipLayer.hidden = !showAuthorship;
- ownerLabels.hidden = !(showSingleOwner || showAuthorship);
- const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, 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) => `${i + 1}
`).join("");
- ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
- if (showSingleOwner) {
- const owner = owners[0];
- const top = paddingTop - editor.scrollTop;
- ownerLabels.innerHTML = `${escapeHtml(ownerName(owner))}`;
- } else {
- ownerLabels.innerHTML = lines.map((_, i) => {
- const authors = authorsByLine[i] || [];
- if (!authors.length) return "";
- const top = paddingTop + i * lineHeight - editor.scrollTop;
- const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join("");
- return `${badges}`;
- }).join("");
- }
- if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
- else authorshipLayer.replaceChildren();
- document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
- document.body.classList.toggle("hide-preview-line-numbers", !previewLineToggle.checked);
-}
-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 markdownFromPreview(node) {
- const walk = current => {
- if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || "";
- if (current.nodeType !== Node.ELEMENT_NODE) return "";
- const tag = current.tagName.toLowerCase(), body = [...current.childNodes].map(walk).join("");
- if (tag === "strong" || tag === "b") return `**${body}**`;
- if (tag === "em" || tag === "i") return `*${body}*`;
- if (tag === "s" || tag === "del") return `~~${body}~~`;
- if (tag === "mark") return `==${body}==`;
- if (tag === "code") return "`" + body + "`";
- if (tag === "sub") return `~${body}~`;
- if (tag === "sup" && !current.classList.contains("footnote-ref")) return `^${body}^`;
- if (tag === "a") return `[${body}](${current.getAttribute("href") || "#"})`;
- if (tag === "img") {
- const src = current.getAttribute("src") || "";
- const alt = current.getAttribute("alt") || "";
- const title = current.getAttribute("title");
- return `}"` : ""})`;
- }
- if (tag === "br") return " ";
- return body;
- };
- return [...node.childNodes].map(walk).join("").replace(/\n/g, " ").trim();
-}
-
-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 movePreviewCaret(target, direction) {
- const editables = [...preview.querySelectorAll(".preview-editable")];
- const index = editables.indexOf(target), next = editables[index + direction];
- if (!next) return false;
- const offset = previewCaretOffset(target); next.focus(); placePreviewCaret(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 render() { if (uiState.mode === "markdown") { preview.classList.remove("preview--raw"); preview.innerHTML = renderMarkdown(editor.value); document.querySelector("#preview-label").textContent = "Preview (mermaid / markdown)"; renderMermaid(); renderCodeHighlight(); } else { preview.classList.add("preview--raw"); preview.innerHTML = editor.value.split("\n").map((line, index) => `${escapeHtml(line) || "
"}
`).join(""); document.querySelector("#preview-label").textContent = "Text preview · editable"; } alignPreviewLineNumbers(preview); document.querySelector("#characters").textContent = `${editor.value.length} characters`; document.querySelector("#words").textContent = `${editor.value.trim() ? editor.value.trim().split(/\s+/).length : 0} words`; renderGutter(); }
-function applyUi({ write = false, replace = false } = {}) { editorWorkspace.className = `workspace view-${uiState.view} editor-workspace-font-${fontFamily.value}`; editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`); document.body.classList.toggle("compact-editor", compactToggle.checked); document.querySelectorAll("[data-view]").forEach(b => { const a = b.dataset.view === uiState.view; b.classList.toggle("active", a); b.setAttribute("aria-pressed", String(a)); }); const markdown = uiState.mode === "markdown"; modeToggle.classList.toggle("active", markdown); modeToggle.textContent = markdown ? "Markdown" : "Text"; render(); if (write) writeEditorState(uiState, { replace }); updateAddressLabel(); }
-function applyRemote(content, ownerMap) { if (content === editor.value && ownerMap == null) return; 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 = parseAuthorship(content, ownerMap); previousContent = content; editor.setSelectionRange(mapped.start, mapped.end, direction); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; applyingRemote = false; render(); editor.scrollTop = scrollTop; editor.scrollLeft = scrollLeft; authorshipLayer.scrollTop = scrollTop; authorshipLayer.scrollLeft = scrollLeft; }
-
-const { loadFiles } = bindNoteFiles({
- editor, toast, getAccessToken: () => accessToken, canDelete: () => Boolean(info?.can_delete_files),
- endpoints: {
- list: `/api/pads/${encodeURIComponent(slug)}/files`,
- upload: `/api/pads/${encodeURIComponent(slug)}/files`,
- remove: fileId => `/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(fileId)}`,
- },
-});
-function connect() { socket?.stop(); socket = new PadSocket({ slug, password, accessToken, nickname, color: currentUserColor() || null, sessionToken: getAuthToken(), guestId: getGuestId(), onStatus: s => setStatus(s === "online" ? "online" : s === "offline" ? "offline" : null, s === "online" ? "Connected" : s === "offline" ? "Reconnecting…" : "Connecting…"), onAuthenticated: m => { if (passwordDialog.open) passwordDialog.close(); applyRemote(m.content, m.owner_map); editor.focus(); }, onDocument: m => { applyRemote(m.content, m.owner_map); document.querySelector("#save-state").textContent = `${m.author ? `${m.author} · ` : ""}${new Date(m.updated_at).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}`; }, onPresence: updatePresence, onLatency: updateLatency, onChat: appendChatMessage, onError: m => { document.querySelector("#password-error").textContent = m; if (/nickname|session|account/i.test(m)) { if (!identityDialog.open) identityDialog.showModal(); } else if (info?.protected && !passwordDialog.open) passwordDialog.showModal(); } }); socket.connect(); }
-bindIdentityDialog({ dialog: identityDialog, onIdentity: async value => { nickname = value; identityDialog.close(); updateCurrentUser(); await loadPadInfo(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } });
-identityDialog.addEventListener("close", () => { if (!nickname) queueMicrotask(() => { if (!identityDialog.open) identityDialog.showModal(); }); });
-async function initialize() { try { await loadPadInfo(); document.title = `${info.title} · RustPad`; publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); applyUi({ write: true, replace: true }); if (!nickname) { identityDialog.showModal(); return; } updateCurrentUser(); if (info.protected && !accessToken) passwordDialog.showModal(); else { loadFiles(); connect(); } } catch (e) { document.body.innerHTML = `Note not found
${escapeHtml(e.message)}
`; } }
-
-document.querySelectorAll("[data-view]").forEach(b => b.addEventListener("click", () => { uiState = { ...uiState, view: b.dataset.view }; applyUi({ write: true }); })); modeToggle.addEventListener("click", () => { uiState = { ...uiState, mode: uiState.mode === "markdown" ? "text" : "markdown" }; applyUi({ write: true }); }); lineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:line-numbers", lineToggle.checked ? "on" : "off"); renderGutter(); }); previewLineToggle.addEventListener("change", () => { localStorage.setItem("rustpad:preview-line-numbers", previewLineToggle.checked ? "on" : "off"); renderGutter(); alignPreviewLineNumbers(preview); }); compactToggle.addEventListener("change", () => { localStorage.setItem("rustpad:compact", compactToggle.checked ? "on" : "off"); applyUi(); }); fontFamily.addEventListener("change", () => { localStorage.setItem("rustpad:font-family", fontFamily.value); applyUi(); }); fontSize.addEventListener("change", () => { localStorage.setItem("rustpad:font-size", fontSize.value); applyUi(); });
-window.addEventListener("popstate", () => { uiState = readEditorState(); applyUi(); }); window.addEventListener("rustpad:urlchange", updateAddressLabel); document.querySelector("#copy-link").addEventListener("click", async () => { try { await copyText(currentShareUrl(uiState)); toast("Link copied"); } catch (e) { toast(e.message); } }); document.querySelectorAll("[data-format]").forEach(b => b.addEventListener("click", () => { applyFormat(editor, b.dataset.format); b.closest("details")?.removeAttribute("open"); })); bindFormatShortcuts(editor); bindEmojiPicker({ editor, details: document.querySelector("#emoji-picker"), search: document.querySelector("#emoji-search"), categories: document.querySelector("#emoji-categories"), grid: document.querySelector("#emoji-grid"), empty: document.querySelector("#emoji-empty") }); document.querySelector("#shortcuts-button").addEventListener("click", () => document.querySelector("#shortcuts-dialog").showModal()); document.querySelector("#close-shortcuts").addEventListener("click", () => document.querySelector("#shortcuts-dialog").close()); preview.addEventListener("change", event => { const checkbox = event.target.closest(".task-checkbox"); if (!checkbox) return; const lineIndex = Number(checkbox.dataset.sourceLine) - 1; const lines = editor.value.split("\n"); if (lineIndex < 0 || lineIndex >= lines.length) return; lines[lineIndex] = lines[lineIndex].replace(/^(\s*[-*+]\s+\[)[ xX](\])/, `$1${checkbox.checked ? "x" : " "}$2`); editor.value = lines.join("\n"); editor.dispatchEvent(new Event("input", { bubbles: true })); }); preview.addEventListener("keydown", event => { const target = event.target.closest(".preview-editable"); if (!target) return; if (event.key === "Enter") { event.preventDefault(); target.blur(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (movePreviewCaret(target, event.key === "ArrowUp" ? -1 : 1)) event.preventDefault(); } }); preview.addEventListener("blur", event => { const target = event.target.closest(".preview-editable"); if (!target) return; const lineIndex = Number(target.dataset.sourceLine) - 1; if (lineIndex < 0) return; const lines = editor.value.split("\n"); const value = markdownFromPreview(target); let next; if (target.dataset.tableCell !== undefined) next = replaceTableCell(lines[lineIndex], Number(target.dataset.tableCell), value); else { const prefix = target.dataset.sourcePrefix || "", suffix = target.dataset.sourceSuffix || ""; next = prefix + value + suffix; } if (lines[lineIndex] === next) return; lines[lineIndex] = next; editor.value = lines.join("\n"); editor.setSelectionRange(editor.value.length, editor.value.length); editor.dispatchEvent(new Event("input", { bubbles: true })); }, { capture: true });
-publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await api(`/api/pads/${encodeURIComponent(slug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await api(`/api/pads/${encodeURIComponent(slug)}/publish`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: publicTaskUpdates.checked }) }); 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(() => { }); } });
-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", () => userColorPicker.click());
-userColorPicker.addEventListener("input", () => {
- localStorage.setItem(storedColorKey(nickname), userColorPicker.value);
- const replacement = currentOwner();
- authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
- updateCurrentUser(); render();
- socket?.setColor(userColorPicker.value);
- if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
-});
-editor.addEventListener("keydown", continueIndentation); editor.addEventListener("scroll", () => { gutter.scrollTop = editor.scrollTop; authorshipLayer.scrollTop = editor.scrollTop; authorshipLayer.scrollLeft = editor.scrollLeft; renderGutter(); }); editor.addEventListener("input", () => { const nextContent = editor.value; authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, currentOwner(), previousContent.length); authorship = applyAuthorshipEdit(authorship, previousContent, nextContent, currentOwner()); previousContent = nextContent; render(); if (applyingRemote) return; clearTimeout(saveTimer); document.querySelector("#save-state").textContent = "Saving…"; saveTimer = setTimeout(() => socket?.update(editor.value, serializeAuthorship(authorship, editor.value.length)), 250); });
-document.querySelector("#password-form").addEventListener("submit", async e => { e.preventDefault(); try { password = document.querySelector("#open-password").value; const result = await api("/api/access-token", { method: "POST", body: JSON.stringify({ kind: "pad", slug, password }) }); accessToken = result.access_token; setAccessToken("pad", slug, accessToken); password = ""; document.querySelector("#open-password").value = ""; document.querySelector("#password-error").textContent = ""; connect(); } catch (error) { document.querySelector("#password-error").textContent = error.message; } });
-const historyPanel = document.querySelector("#history-panel"); document.querySelector("#history-button").addEventListener("click", async () => { 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 api(`/api/pads/${encodeURIComponent(slug)}/history`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) }); 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 api(`/api/pads/${encodeURIComponent(slug)}/restore`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null, revision_id: r.id }) }); 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"); });
-
-window.addEventListener("storage", event => {
- if (event.key !== storedColorKey(nickname)) return;
- const replacement = currentOwner();
- authorship = replaceAuthorshipOwner(authorship, owner => ownerName(owner) === nickname, replacement, editor.value.length);
- updateCurrentUser(); render();
- socket?.setColor(currentUserColor() || null);
- if (socket) socket.update(editor.value, serializeAuthorship(authorship, editor.value.length));
-});
-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();
+startNoteEditor(createPadAdapter());