From 1fcd6866f43b85face8afc1c8bfafe1199a4f454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 25 Jul 2026 18:05:15 +0200 Subject: [PATCH] line multi authors --- src/assets.rs | 1 + static/js/authorship.js | 4 ++ static/js/note-files.js | 103 ++++++++++++++++++++++++++++++++++++++++ static/js/note.js | 52 +++++++------------- static/js/pad.js | 63 +++++++++--------------- static/pad.html | 12 ++++- 6 files changed, 158 insertions(+), 77 deletions(-) create mode 100644 static/js/note-files.js diff --git a/src/assets.rs b/src/assets.rs index 7d98f51..634e496 100644 --- a/src/assets.rs +++ b/src/assets.rs @@ -15,6 +15,7 @@ const MODULES: &[&str] = &[ "logger", "markdown", "modal", + "note-files", "session", "socket", "url-state", diff --git a/static/js/authorship.js b/static/js/authorship.js index cf6d9c4..e709d72 100644 --- a/static/js/authorship.js +++ b/static/js/authorship.js @@ -92,6 +92,10 @@ export function replaceAuthorshipOwner(model, matcher, replacement, contentLengt } +export function authorshipOwners(model) { + return [...new Set((model?.spans || []).map(span => span.owner).filter(Boolean))]; +} + export function lineAuthors(content, model) { const starts = [0]; for (let i = 0; i < content.length; i++) if (content.charCodeAt(i) === 10) starts.push(i + 1); diff --git a/static/js/note-files.js b/static/js/note-files.js new file mode 100644 index 0000000..55dfe08 --- /dev/null +++ b/static/js/note-files.js @@ -0,0 +1,103 @@ +import { api } from "@rustpad/api"; +import { copyText } from "@rustpad/clipboard"; +import { prepareImageFile } from "@rustpad/image-upload"; +import { askConfirm } from "@rustpad/modal"; +import { getAuthToken } from "@rustpad/session"; + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, character => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character])); +} + +function formatBytes(value) { + const bytes = Number(value) || 0; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function formatDate(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleString("pl-PL"); +} + +export function bindNoteFiles({ editor, endpoints, getAccessToken, canDelete, toast }) { + const dialog = document.querySelector("#files-dialog"); + const list = document.querySelector("#files-list"); + const input = document.querySelector("#file-input"); + const footer = document.querySelector("#footer-files"); + + async function loadFiles({ open = false } = {}) { + try { + const files = await api(endpoints.list, { method: "PUT", body: JSON.stringify({ access_token: getAccessToken() || null }) }); + const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); + footer.textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`; + list.innerHTML = files.length ? files.map(file => ` +
+
+
${escapeHtml(file.filename)}
+
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)}${file.created_at ? ` · ${formatDate(file.created_at)}` : ""} · ${file.is_attached ? "in note" : "removed from content"}
+
+
+ + + + ${canDelete() ? `` : ""} +
+ +
`).join("") : '

No files uploaded.

'; + if (open && !dialog.open) dialog.showModal(); + } catch (error) { + if (open) toast(error.message); + } + } + + document.querySelector("#upload-button").addEventListener("click", () => input.click()); + input.addEventListener("change", async event => { + let file = event.target.files[0]; + if (!file) return; + if (file.type.startsWith("image/")) { + file = await prepareImageFile(file); + if (!file) { input.value = ""; return; } + } + const form = new FormData(); + form.append("access_token", getAccessToken() || ""); + form.append("file", file); + try { + const result = await api(endpoints.upload, { method: "POST", body: form, headers: {} }); + const text = file.type.startsWith("image/") ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; + editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); + editor.dispatchEvent(new Event("input", { bubbles: true })); + toast("File uploaded"); + await loadFiles(); + } catch (error) { toast(error.message); } + input.value = ""; + }); + + document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true })); + footer.addEventListener("click", () => loadFiles({ open: true })); + document.querySelector("#close-files").addEventListener("click", () => dialog.close()); + list.addEventListener("click", async event => { + const showButton = event.target.closest("[data-show-file-code]"); + if (showButton) { + const panel = showButton.closest(".file-row").querySelector(".file-code"); + const output = panel.querySelector("textarea"); + const absolute = new URL(showButton.dataset.url, location.origin).href; + let text = absolute; + if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`; + if (showButton.dataset.showFileCode === "html") text = showButton.dataset.mime?.startsWith("image/") ? `${showButton.dataset.name}` : `${showButton.dataset.name}`; + output.value = text; panel.hidden = false; output.focus(); output.select(); return; + } + const copyButton = event.target.closest("[data-copy-generated]"); + if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; } + const deleteButton = event.target.closest("[data-delete-file]"); + if (!deleteButton) return; + if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return; + try { + await api(endpoints.remove(deleteButton.dataset.deleteFile), { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: getAccessToken() || null }) }); + toast("File deleted"); + await loadFiles(); + } catch (error) { toast(error.message); } + }); + + return { loadFiles }; +} diff --git a/static/js/note.js b/static/js/note.js index 9ea9083..7faa180 100644 --- a/static/js/note.js +++ b/static/js/note.js @@ -2,14 +2,14 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); import { api } from "@rustpad/api"; -import { applyAuthorshipEdit, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship"; +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 { prepareImageFile } from "@rustpad/image-upload"; 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"; @@ -52,7 +52,10 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p function renderGutter() { const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); - const authorsByLine = lineAuthors(editor.value, authorship); + const showAuthorship = authorshipOwners(authorship).length > 1; + const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; + authorshipLayer.hidden = !showAuthorship; + ownerLabels.hidden = !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(""); @@ -64,7 +67,8 @@ function renderGutter() { const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join(""); return `${badges}`; }).join(""); - renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); + 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); } @@ -145,19 +149,17 @@ function replaceTableCell(line, index, value) { 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]}`; } -async function loadFiles({ open = false } = {}) { - try { - const files = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) }); - const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); - document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`; - const list = document.querySelector("#files-list"); - list.innerHTML = files.length ? files.map(file => `
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${file.is_attached ? "in note" : "removed from content"}
${info?.can_delete_files ? `` : ""}
`).join("") : '

No files uploaded.

'; - if (open && !document.querySelector("#files-dialog").open) document.querySelector("#files-dialog").showModal(); - } catch (error) { toast(error.message); } -} 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)}

`; } } @@ -189,28 +191,6 @@ window.addEventListener("storage", event => { 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 = 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("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; }); -document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true })); -document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true })); -document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close()); -document.querySelector("#files-list").addEventListener("click", async event => { - const showButton = event.target.closest("[data-show-file-code]"); - if (showButton) { - const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea"); - const absolute = new URL(showButton.dataset.url, location.origin).href; - let text = absolute; - if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`; - if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `${showButton.dataset.name}` : `${showButton.dataset.name}`; - output.value = text; panel.hidden = false; output.focus(); output.select(); return; - } - const copyButton = event.target.closest("[data-copy-generated]"); - if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; } - const deleteButton = event.target.closest("[data-delete-file]"); - if (deleteButton) { - if (!await askConfirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`, { title: "Delete file", confirmText: "Delete", danger: true })) return; - try { await api(`/api/workspaces/${encodeURIComponent(workspaceSlug)}/notes/${encodeURIComponent(noteSlug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return; - } -}); 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); }); diff --git a/static/js/pad.js b/static/js/pad.js index c2438e1..4d24955 100644 --- a/static/js/pad.js +++ b/static/js/pad.js @@ -2,13 +2,14 @@ import { installGlobalDiagnostics, logInfo } from "@rustpad/logger"; installGlobalDiagnostics(); import { api } from "@rustpad/api"; -import { applyAuthorshipEdit, lineAuthors, mapSelectionThroughEdit, parseAuthorship, renderAuthorshipLayer, replaceAuthorshipOwner, serializeAuthorship } from "@rustpad/authorship"; +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 { prepareImageFile } from "@rustpad/image-upload"; 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"; @@ -20,7 +21,8 @@ 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"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off"; +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"; @@ -48,7 +50,10 @@ async function renderCodeHighlight() { const nodes = preview.querySelectorAll('p function renderGutter() { const lineCount = Math.max(1, (editor.value.match(/\n/g) || []).length + 1); const lines = Array.from({ length: lineCount }); - const authorsByLine = lineAuthors(editor.value, authorship); + const showAuthorship = authorshipOwners(authorship).length > 1; + const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : []; + authorshipLayer.hidden = !showAuthorship; + ownerLabels.hidden = !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(""); @@ -60,8 +65,10 @@ function renderGutter() { const badges = authors.map(owner => `${escapeHtml(ownerName(owner))}`).join(""); return `${badges}`; }).join(""); - renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor); - document.body.classList.toggle("hide-line-numbers", !lineToggle.checked); + 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" }); } @@ -143,22 +150,21 @@ function render() { if (uiState.mode === "markdown") { preview.classList.remove( 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; } -async function loadFiles({ open = false } = {}) { - try { - const files = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "PUT", body: JSON.stringify({ access_token: accessToken || null }) }); - const totalSize = files.reduce((sum, file) => sum + (Number(file.size_bytes) || 0), 0); - document.querySelector("#footer-files").textContent = `${files.length} ${files.length === 1 ? "file" : "files"} · ${formatBytes(totalSize)}`; - document.querySelector("#files-list").innerHTML = files.length ? files.map(file => `
${escapeHtml(file.filename)}
${formatBytes(file.size_bytes)} · ${escapeHtml(file.mime_type)} · ${formatDate(file.created_at)} · ${file.is_attached ? "in note" : "removed from content"}
${info?.can_delete_files ? `` : ""}
`).join("") : '

No files uploaded.

'; - if (open) document.querySelector("#files-dialog").showModal(); - } catch (error) { if (open) toast(error.message); } -} +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(); }); 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); 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 }); +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(); }); @@ -176,27 +182,4 @@ userColorPicker.addEventListener("input", () => { 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 = 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"); }); -document.querySelector("#upload-button").addEventListener("click", () => document.querySelector("#file-input").click()); document.querySelector("#file-input").addEventListener("change", async e => { let file = e.target.files[0]; if (!file) return; if (file.type.startsWith("image/")) { file = await prepareImageFile(file); if (!file) { e.target.value = ""; return; } } const form = new FormData(); form.append("access_token", accessToken || ""); form.append("file", file); try { const result = await api(`/api/pads/${encodeURIComponent(slug)}/files`, { method: "POST", body: form, headers: {} }); const image = file.type.startsWith("image/"); const text = image ? `![${file.name}](${result.url})` : `[${file.name}](${result.url})`; editor.setRangeText(text, editor.selectionStart, editor.selectionEnd, "end"); editor.dispatchEvent(new Event("input")); toast("File uploaded"); loadFiles(); } catch (err) { toast(err.message); } e.target.value = ""; }); -document.querySelector("#files-button").addEventListener("click", () => loadFiles({ open: true })); -document.querySelector("#footer-files").addEventListener("click", () => loadFiles({ open: true })); -document.querySelector("#close-files").addEventListener("click", () => document.querySelector("#files-dialog").close()); -document.querySelector("#files-list").addEventListener("click", async event => { - const showButton = event.target.closest("[data-show-file-code]"); - if (showButton) { - const row = showButton.closest(".file-row"), panel = row.querySelector(".file-code"), output = panel.querySelector("textarea"); - const absolute = new URL(showButton.dataset.url, location.origin).href; - let text = absolute; - if (showButton.dataset.showFileCode === "markdown") text = showButton.dataset.mime?.startsWith("image/") ? `![${showButton.dataset.name}](${absolute})` : `[${showButton.dataset.name}](${absolute})`; - if (showButton.dataset.showFileCode === "html") text = (showButton.dataset.mime || "").startsWith("image/") ? `${showButton.dataset.name}` : `${showButton.dataset.name}`; - output.value = text; panel.hidden = false; output.focus(); output.select(); return; - } - const copyButton = event.target.closest("[data-copy-generated]"); - if (copyButton) { try { await copyText(copyButton.closest(".file-code").querySelector("textarea").value); toast("Copied"); } catch (error) { toast(error.message); } return; } - const deleteButton = event.target.closest("[data-delete-file]"); - if (deleteButton) { - if (!confirm(`Delete file "${deleteButton.dataset.fileName}" permanently?`)) return; - try { await api(`/api/pads/${encodeURIComponent(slug)}/files/${encodeURIComponent(deleteButton.dataset.deleteFile)}`, { method: "DELETE", headers: getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {}, body: JSON.stringify({ access_token: accessToken || null }) }); toast("File deleted"); await loadFiles(); } catch (error) { toast(error.message); } return; - } -}); -initialize(); diff --git a/static/pad.html b/static/pad.html index a84bf9f..c960e41 100644 --- a/static/pad.html +++ b/static/pad.html @@ -41,6 +41,15 @@ data-format="number" title="Numbered list · Ctrl/Cmd+Shift+7">1. List +
+ 😀 Emoji +
+ +
+
+ +
+
More