This commit is contained in:
Mateusz Gruszczyński
2026-07-28 23:53:37 +02:00
parent bc25e49bd6
commit 09669ff330
28 changed files with 412 additions and 89 deletions
+146 -2
View File
@@ -449,11 +449,18 @@ textarea:focus {
.editor-column,
.preview-column {
display: grid;
grid-template-rows: 30px minmax(0, 1fr);
min-width: 0;
min-height: 0;
}
.editor-column {
grid-template-rows: 30px auto minmax(0, 1fr);
}
.preview-column {
grid-template-rows: 30px minmax(0, 1fr);
}
.preview-column {
border-left: 1px solid var(--border);
}
@@ -3993,7 +4000,6 @@ dialog::backdrop {
.authorship-fragment {
border-radius: 2px;
background: color-mix(in srgb, var(--owner) 18%, transparent);
box-shadow: inset 0 -2px color-mix(in srgb, var(--owner) 72%, transparent);
color: transparent;
}
@@ -4554,3 +4560,141 @@ dialog::backdrop {
display: none;
}
}
/* Authorship display modes */
.editor-column-label { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.authorship-mode-control { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: color-mix(in srgb, var(--panel) 88%, transparent); }
.authorship-mode-control button { min-height: 26px; padding: 0 9px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: 11px; }
.authorship-mode-control button.active { background: var(--surface-strong, #262b35); color: var(--text); }
.participant-badges { display: flex; flex-wrap: wrap; align-items: center; align-content: center; gap: 6px; min-width: 0; min-height: 0; padding: 7px 12px; border-bottom: 1px solid var(--border); }
.participant-badges[hidden] { display: none; }
.participant-badge { display: inline-flex; flex: 0 0 auto; align-items: center; width: auto; max-width: 100%; min-height: 0; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, transparent); font: 600 11px/1.3 system-ui, sans-serif; }
@media (max-width: 720px) {
.editor-column-label { align-items: flex-start; }
.authorship-mode-control button { padding-inline: 7px; }
.participant-badges { padding: 6px 8px; }
.public-task-toggle { width: 100%; }
}
.public-page-options { display: grid; gap: 4px; align-content: center; }
.public-page-options .public-task-toggle { min-height: 24px; }
@media (max-width: 720px) { .public-page-options { width: 100%; } }
/* Keep the whole editor surface consistent in Simple and Full modes. */
.editor-shell {
background: #0d1015;
}
.editor-shell textarea {
z-index: 3;
background: transparent;
}
.authorship-layer {
z-index: 2;
background: transparent;
}
.owner-labels {
z-index: 4;
}
.line-gutter {
z-index: 5;
}
/* Stable editor canvas in both authorship modes. */
.pad-page .editor-column,
.pad-page .editor-shell {
background: #0d1015;
}
.pad-page .editor-shell {
isolation: isolate;
}
.pad-page .editor-shell::before {
position: absolute;
z-index: 0;
inset: 0;
background: #0d1015;
content: "";
pointer-events: none;
}
.pad-page .editor-shell textarea,
.pad-page .owner-labels {
background: transparent !important;
}
/* The authorship canvas must paint the whole editable area, not only text rows. */
.pad-page .authorship-layer {
width: auto;
height: auto;
min-width: 0;
min-height: 0;
background: #0d1015 !important;
}
.pad-page .line-gutter {
background: #0d1015;
}
.resources-access-rules {
margin: 10px 0 0;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, .02);
color: var(--muted);
font-size: .78rem;
line-height: 1.45;
}
/* Full-height editor/gutter separator, independent of the number of text lines. */
.pad-page .editor-shell::after {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
left: 48px;
width: 1px;
background: var(--border);
content: "";
pointer-events: none;
}
.pad-page .line-gutter {
align-self: stretch;
height: 100%;
min-height: 100%;
border-right: 0;
}
.pad-page.hide-editor-line-numbers .editor-shell::after {
display: none;
}
@media (max-width: 720px) {
.pad-page .editor-shell::after {
left: 38px;
}
}
/* Fill the editor column with the actual editor shell.
The grid version could size the shell to its content in Full authorship mode,
so the gutter separator stopped after the last rendered line. */
.pad-page .editor-column {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.pad-page .editor-column > .column-label,
.pad-page .editor-column > .participant-badges {
flex: 0 0 auto;
}
.pad-page .editor-column > .editor-shell {
flex: 1 1 auto;
width: 100%;
min-height: 0;
}
+1
View File
@@ -118,6 +118,7 @@
<header class="resources-panel__header">
<h2>My notes and workspaces</h2>
<p class="dialog-copy">Items created while signed in are assigned to your account.</p>
<p class="resources-access-rules"><strong>Access rules:</strong> Public items open from their link; a password adds link-based protection. Private items are visible only to their owner and explicitly shared accounts or valid share links. Unauthorized visitors receive a not-found response.</p>
</header>
<div id="resources-list" class="resources-list"></div>
<p id="resources-error" class="form-message error" role="alert"></p>
+15 -3
View File
@@ -126,9 +126,21 @@ async function loadResources() {
message.textContent = text;
};
row.querySelector("[data-privacy]")?.addEventListener("click", async () => {
try { await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: !Boolean(item.private) }) }); await loadResources(); }
catch (e) { resourcesError.textContent = e.message; }
row.querySelector("[data-privacy]")?.addEventListener("click", async event => {
const button = event.currentTarget;
const nextPrivate = !Boolean(item.private);
button.disabled = true;
try {
await api("/api/auth/resources/privacy", { method: "POST", headers: authHeaders(), body: JSON.stringify({ kind: item.kind, slug: item.slug, private: nextPrivate }) });
item.private = nextPrivate;
button.textContent = nextPrivate ? "Make public" : "Make private";
const meta = row.querySelector(".resource-copy small");
meta.textContent = `${item.kind === "workspace" ? "Workspace" : "Note"}${item.private ? " · private" : ""}${!item.owned ? ` · ${permissionLabel}` : item.protected ? " · password protected" : ""}`;
} catch (e) {
resourcesError.textContent = e.message;
} finally {
button.disabled = false;
}
});
row.querySelector("[data-share]")?.addEventListener("click", async () => {
const dialog = document.createElement("dialog");
+4 -4
View File
@@ -27,9 +27,9 @@ export function createPadAdapter() {
method: "POST",
body: JSON.stringify({ kind: "pad", slug, password }),
}),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
}),
loadHistory: accessToken => api(`${base}/history`, {
method: "POST",
@@ -67,9 +67,9 @@ export function createWorkspaceNoteAdapter() {
method: "POST",
body: JSON.stringify({ kind: "workspace", slug: workspaceSlug, password }),
}),
publish: (accessToken, allowTaskUpdates) => api(`${base}/publish`, {
publish: (accessToken, allowTaskUpdates, unprotectPage) => api(`${base}/publish`, {
method: "POST",
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates }),
body: JSON.stringify({ access_token: accessToken || null, allow_task_updates: allowTaskUpdates, unprotect_page: unprotectPage }),
}),
loadHistory: accessToken => api(`${base}/history`, {
method: "POST",
+64 -32
View File
@@ -17,9 +17,9 @@ export function startNoteEditor(adapter) {
const modeToggle = document.querySelector("#mode-toggle"), passwordDialog = document.querySelector("#password-dialog"), identityDialog = document.querySelector("#identity-dialog");
const accessLevel = document.querySelector("#access-level"), roomDetails = document.querySelector("#room-details"), roomUsers = document.querySelector("#room-users"), roomCount = document.querySelector("#room-count"), socketLatency = document.querySelector("#socket-latency"), chatMessages = document.querySelector("#chat-messages"), chatForm = document.querySelector("#chat-form"), chatInput = document.querySelector("#chat-input"), chatUnread = document.querySelector("#chat-unread"), mobileChatUnread = document.querySelector("#mobile-chat-unread");
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"), useGlobalColorButton = document.querySelector("#use-global-color");
const compactToggle = document.querySelector("#compact-toggle"), publicTaskUpdates = document.querySelector("#public-task-updates"), unprotectPublicPage = document.querySelector("#unprotect-public-page"), participantBadges = document.querySelector("#participant-badges"), fontFamily = document.querySelector("#font-family"), fontSize = document.querySelector("#font-size"), currentUser = document.querySelector("#current-user"), userColorPicker = document.querySelector("#user-color-picker"), useGlobalColorButton = document.querySelector("#use-global-color");
const shareToken = new URLSearchParams(location.search).get("share"); if (shareToken) setAccessToken(adapter.access.kind, adapter.access.key, shareToken);
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "";
let accessToken = shareToken || getAccessToken(adapter.access.kind, adapter.access.key), password = "", nickname = getNickname(), info, socket, saveTimer, applyingRemote = false, resourceUnlocked = false, uiState = readEditorState(), authorship = parseAuthorship("", "[]"), previousContent = "", globalColor = "", noteColor = "", presenceUsers = [], authorshipMode = ["full", "advanced"].includes(localStorage.getItem("rustpad:authorship-mode")) ? "full" : "simple";
const compactLayoutQuery = window.matchMedia("(max-width: 1499px)");
let compactView = uiState.view === "preview" ? "preview" : "edit";
const lineToggle = document.querySelector("#line-numbers-toggle"), previewLineToggle = document.querySelector("#preview-line-numbers-toggle"); lineToggle.checked = localStorage.getItem("rustpad:line-numbers") !== "off";
@@ -61,7 +61,7 @@ export function startNoteEditor(adapter) {
}
updateCurrentUser(); 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 updatePresence(users) { const entries = Array.isArray(users) ? users : []; presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {}); roomCount.textContent = `${entries.length} ${entries.length === 1 ? "user" : "users"}`; roomUsers.replaceChildren(...presenceUsers.map(user => { const li = document.createElement("li"), dot = document.createElement("span"), label = document.createElement("span"); li.className = "room-user"; dot.className = "room-user__dot"; dot.style.setProperty("--owner", /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(user.name)); label.textContent = user.name || "Guest"; li.title = label.textContent; li.append(dot, label); return li; })); if (!entries.length) { const li = document.createElement("li"); li.textContent = "No active users"; roomUsers.append(li); } renderGutter(); }
function updateLatency(ms) { 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); if (mobileChatUnread) { mobileChatUnread.hidden = false; mobileChatUnread.textContent = chatUnread.textContent; } const oldTitle = document.title; if (!document.title.startsWith("● ")) document.title = `${oldTitle}`; if (document.hidden && Notification.permission === "granted") new Notification(`${message.sender} wrote in RustPad`, { body: message.text.slice(0, 160), tag: "rustpad-room-chat" }); } }
@@ -71,48 +71,55 @@ export function startNoteEditor(adapter) {
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", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const nodes = preview.querySelectorAll('pre code[class^="language-"]:not(.language-mermaid)'); if (!nodes.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); nodes.forEach(node => { const lines = node.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(node); return; } const language = [...node.classList].find(name => name.startsWith("language-"))?.slice(9); lines.forEach(line => { try { line.innerHTML = hljs.default.highlight(line.textContent, { language, ignoreIllegals: true }).value; } catch { line.innerHTML = hljs.default.highlightAuto(line.textContent).value; } }); node.classList.add("hljs"); }); } catch { } }
function renderParticipantBadges(owners) {
if (!participantBadges) return;
const people = new Map();
for (const owner of owners) people.set(ownerName(owner), { name: ownerName(owner), compactName: "", color: colorFor(owner) });
for (const user of presenceUsers) {
const name = user.name || "Guest";
const color = /^#[0-9a-f]{6}$/i.test(user.color || "") ? user.color : defaultColorFor(name);
people.set(name, { name, compactName: user.compact_name || name, color });
}
participantBadges.hidden = authorshipMode !== "simple" || people.size < 2;
const compact = people.size > 4;
participantBadges.replaceChildren(...[...people.values()].map(person => {
const badge = document.createElement("span");
badge.className = "participant-badge";
badge.style.setProperty("--owner", person.color);
badge.textContent = compact && person.compactName ? person.compactName : person.name;
badge.title = person.name;
return badge;
}));
}
function 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 showAuthorship = owners.length > 0;
const authorsByLine = showAuthorship ? lineAuthors(editor.value, authorship) : [];
const full = authorshipMode === "full";
authorshipLayer.hidden = !showAuthorship;
ownerLabels.hidden = !(showSingleOwner || showAuthorship);
ownerLabels.hidden = !full || !showAuthorship;
renderParticipantBadges(owners);
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.classList.toggle("active", button.dataset.authorshipMode === authorshipMode));
editorWorkspace.dataset.authorshipMode = authorshipMode;
const style = getComputedStyle(editor), lineHeight = parseFloat(style.lineHeight) || 29, paddingTop = parseFloat(style.paddingTop) || 24, paddingBottom = parseFloat(style.paddingBottom) || 24;
gutter.style.paddingTop = `${paddingTop}px`; gutter.style.paddingBottom = `${paddingBottom}px`; gutter.style.lineHeight = `${lineHeight}px`;
gutter.innerHTML = lines.map((_, i) => `<div style="height:${lineHeight}px">${i + 1}</div>`).join("");
ownerLabels.style.setProperty("--editor-line-height", `${lineHeight}px`);
if (showSingleOwner) {
const owner = owners[0];
const top = paddingTop - editor.scrollTop;
const markerHeight = Math.max(lineHeight, lineCount * lineHeight);
const ownerColor = colorFor(owner);
ownerLabels.innerHTML = `<span class="owner-line owner-line--document" style="top:${top}px;height:${markerHeight}px;--owner:${ownerColor}"></span><span class="owner-label-group" style="top:${top}px"><span class="owner-label" style="--owner:${ownerColor}">${escapeHtml(ownerName(owner))}</span></span>`;
} else {
if (full) {
let previousAuthorSignature = null;
ownerLabels.innerHTML = lines.map((_, i) => {
const authors = authorsByLine[i] || [];
if (!authors.length) return "";
const top = paddingTop + i * lineHeight - editor.scrollTop;
const signature = authors
.map(owner => ownerName(owner))
.sort((a, b) => a.localeCompare(b))
.join("\u0000");
const startsOwnershipBlock = signature !== previousAuthorSignature;
const signature = authors.map(owner => ownerName(owner)).sort((a, b) => a.localeCompare(b)).join("\u0000");
if (signature === previousAuthorSignature) return "";
previousAuthorSignature = signature;
const lineMarker = `<span class="owner-line" style="top:${top}px;--owner:${colorFor(authors[0])}"></span>`;
if (!startsOwnershipBlock) return lineMarker;
const badges = authors
.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`)
.join("");
return `${lineMarker}<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
const badges = authors.map(owner => `<span class="owner-label" style="--owner:${colorFor(owner)}">${escapeHtml(ownerName(owner))}</span>`).join("");
return `<span class="owner-label-group" style="top:${top}px">${badges}</span>`;
}).join("");
}
} else ownerLabels.replaceChildren();
if (showAuthorship) renderAuthorshipLayer(authorshipLayer, editor, authorship, colorFor);
else authorshipLayer.replaceChildren();
document.body.classList.toggle("hide-editor-line-numbers", !lineToggle.checked);
@@ -212,7 +219,12 @@ export function startNoteEditor(adapter) {
editorWorkspace.style.setProperty("--editor-font-size", `${fontSize.value}px`);
document.body.classList.toggle("compact-editor", compactToggle.checked);
document.body.classList.toggle("compact-note-layout", compactLayoutQuery.matches);
document.querySelectorAll("[data-view]").forEach(button => {
document.querySelectorAll("[data-authorship-mode]").forEach(button => button.addEventListener("click", () => {
authorshipMode = button.dataset.authorshipMode === "full" ? "full" : "simple";
localStorage.setItem("rustpad:authorship-mode", authorshipMode);
renderGutter();
}));
document.querySelectorAll("[data-view]").forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle("active", active);
button.setAttribute("aria-pressed", String(active));
@@ -233,6 +245,21 @@ export function startNoteEditor(adapter) {
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 => { resourceUnlocked = true; if (passwordDialog.open) passwordDialog.close(); const readOnly = m.access_level === "read_only"; editor.readOnly = readOnly; accessLevel.textContent = readOnly ? "Access: read only" : "Access: full"; applyRemote(m.content, m.owner_map); if (!readOnly) 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 => { const friendly = /read-only access/i.test(m) ? "This note is read only. Enter the password or ask the owner to grant write access." : m; document.querySelector("#password-error").textContent = friendly; if (/read-only access/i.test(m)) { toast(friendly); accessLevel.textContent = "Access: read only"; editor.readOnly = true; return; } 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; accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key); 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 showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
async function initialize() {
try {
if (getAuthToken()) {
@@ -247,14 +274,18 @@ export function startNoteEditor(adapter) {
accessToken = shareToken || getAuthToken() || getAccessToken(adapter.access.kind, adapter.access.key);
await loadNoteInfo();
document.title = adapter.title(info);
publicTaskUpdates.checked = Boolean(info.allow_public_task_updates);
publicTaskUpdates.checked = Boolean(info.allow_public_task_updates); unprotectPublicPage.checked = Boolean(info.public_page_unprotected);
adapter.configureView?.(info);
applyUi({ write: true, replace: true });
updateCurrentUser();
if (info.protected && !accessToken) passwordDialog.showModal();
else { loadFiles(); connect(); }
} catch (e) {
document.body.innerHTML = `<main class="error-page"><div><h1>Note not found</h1><p>${escapeHtml(e.message)}</p></div></main>`;
if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
return;
}
document.body.innerHTML = `<main class="error-page"><div><h1>Page could not be loaded</h1><p>${escapeHtml(e.message)}</p></div></main>`;
}
}
@@ -364,7 +395,8 @@ export function startNoteEditor(adapter) {
});
window.addEventListener("resize", () => { if (mobileBubble?.style.left) placeMobileBubble(mobileBubble.getBoundingClientRect()); });
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); } });
const savePublicOptions = async () => adapter.publish(accessToken, publicTaskUpdates.checked, unprotectPublicPage.checked);
publicTaskUpdates.addEventListener("change", async () => { publicTaskUpdates.disabled = true; try { await savePublicOptions(); toast(publicTaskUpdates.checked ? "Public task updates enabled" : "Public task updates disabled"); } catch (error) { publicTaskUpdates.checked = !publicTaskUpdates.checked; toast(error.message); } finally { publicTaskUpdates.disabled = false; } }); unprotectPublicPage.addEventListener("change", async () => { unprotectPublicPage.disabled = true; try { await savePublicOptions(); toast(unprotectPublicPage.checked ? "Published page is now unprotected" : "Published page protection enabled"); } catch (error) { unprotectPublicPage.checked = !unprotectPublicPage.checked; toast(error.message); } finally { unprotectPublicPage.disabled = false; } }); document.querySelector("#publish-page").addEventListener("click", async () => { try { const result = await savePublicOptions(); const url = new URL(result.url, location.origin).href; await copyText(url); toast("Page link copied"); window.open(url, "_blank", "noopener"); } catch (error) { toast(error.message); } });
roomDetails.addEventListener("toggle", () => { if (roomDetails.open) { clearUnread(); chatInput.focus(); if ("Notification" in window && Notification.permission === "default") Notification.requestPermission().catch(() => { }); } else { roomDetails.classList.remove("is-mobile-open"); } });
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(); });
+8 -2
View File
@@ -9,6 +9,10 @@ import { toast } from "@rustpad/toast";
const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content");
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
const passwordKey = `rustpad:public-page-password:${token}`;
let pagePassword = sessionStorage.getItem(passwordKey) || "";
function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; }
async function renderMermaid() { const nodes = content.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", '<p class="error">Failed to load Mermaid.</p>')); } }
async function renderCodeHighlight() { const blocks = content.querySelectorAll('pre code[class^="language-"]'); if (!blocks.length) return; try { const hljs = await import("https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm"); blocks.forEach(block => { const lines = block.querySelectorAll(".code-line"); if (!lines.length) { hljs.default.highlightElement(block); return; } const language = [...block.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; } }); block.classList.add("hljs"); }); } catch { } }
function lockPublicContent(allowTaskUpdates) {
@@ -26,7 +30,7 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
target.scrollIntoView({ behavior, block: "start" });
return true;
}
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`); document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
async function initialize() { try { const page = await api(`/api/public/${encodeURIComponent(token)}`, { headers: pageHeaders() }); if (passwordDialog.open) passwordDialog.close(); passwordError.textContent = ""; document.querySelector("#public-title").textContent = page.title; document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")}${page.allow_task_updates ? " · tasks can be updated" : ""}`; document.title = `${page.title} · RustPad`; content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight()]); requestAnimationFrame(() => scrollToPublicAnchor(location.hash)); } catch (error) { if (error.status === 401 || error.status === 403) { passwordError.textContent = error.status === 403 ? "Sign in with an authorized account or enter the resource password." : "Enter the correct password."; if (!passwordDialog.open) passwordDialog.showModal(); passwordInput.focus(); return; } content.replaceChildren(); const message = document.createElement("p"); message.className = "error"; message.textContent = String(error.message); content.append(message); } }
content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]');
if (!link) return;
@@ -36,7 +40,9 @@ content.addEventListener("click", event => {
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
});
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth"));
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
content.addEventListener("change", async event => { const box = event.target.closest(".task-checkbox"); if (!box || box.disabled) return; const previous = !box.checked; box.disabled = true; try { const page = await api(`/api/public/${encodeURIComponent(token)}/tasks`, { method: "POST", headers: pageHeaders(), body: JSON.stringify({ source_line: Number(box.dataset.sourceLine), checked: box.checked }) }); document.querySelector("#public-meta").textContent = `Updated: ${new Date(page.updated_at).toLocaleString("en-US")} · tasks can be updated`; toast("Task saved"); } catch (error) { box.checked = previous; toast(error.message); } finally { box.disabled = false; } });
lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; sessionStorage.setItem(passwordKey, pagePassword); await initialize(); });
passwordDialog.addEventListener("cancel", event => event.preventDefault());
document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
initialize();
+17 -3
View File
@@ -92,6 +92,20 @@ function renderNotes(notes = notesCache) {
${deleteButton(note)}
</article>`).join("");
}
async function showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
cache: "no-store",
credentials: "same-origin",
});
const html = await response.text();
document.open();
document.write(html);
document.close();
} catch {
document.body.textContent = "404 Not Found";
}
}
async function openWorkspace() {
try {
const data = await api(`/api/workspaces/${encodeURIComponent(slug)}/open`, { method: "POST", body: JSON.stringify({ access_token: accessToken || null }) });
@@ -106,8 +120,8 @@ async function openWorkspace() {
if (info?.protected || e.message.toLowerCase().includes("password")) {
document.querySelector("#password-error").textContent = e.message;
if (!dialog.open) dialog.showModal();
} else if (e.status === 403) {
location.replace("/errors/private-workspace");
} else if (e.status === 403 || e.status === 404) {
await showSystemNotFound();
} else document.querySelector("#workspace-error").textContent = e.message;
}
}
@@ -119,7 +133,7 @@ async function init() {
document.querySelector("#workspace-url").textContent = location.pathname;
if (info.protected && !accessToken) dialog.showModal(); else openWorkspace();
} catch (e) {
if (e.status === 403) location.replace("/errors/private-workspace");
if (e.status === 403 || e.status === 404) await showSystemNotFound();
else document.querySelector("#workspace-error").textContent = e.message;
}
}
+3 -3
View File
@@ -31,9 +31,9 @@
</button>
<div id="header-actions" class="header-actions"><button id="copy-link"
class="secondary-button">Copy link</button><button id="publish-page"
class="secondary-button">Page</button><label class="public-task-toggle"
class="secondary-button">Page</button><div class="public-page-options"><label class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input id="public-task-updates"
type="checkbox"> Editable tasks on Page</label><button id="files-button"
type="checkbox"> Editable tasks on Page</label><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><button id="files-button"
class="secondary-button">Files</button><button id="delete-note" class="secondary-button danger-button"
hidden>Delete</button><button id="history-button" class="secondary-button">History</button></div>
</div>
@@ -104,7 +104,7 @@
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span></div>
<div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div><div id="owner-labels" class="owner-labels" aria-hidden="true"></div><textarea id="editor"
+3 -3
View File
@@ -32,10 +32,10 @@
<span aria-hidden="true"></span><span aria-hidden="true"></span><span aria-hidden="true"></span>
</button>
<div id="header-actions" class="header-actions"><button id="copy-link" class="secondary-button">Copy
link</button><button id="publish-page" class="secondary-button">Page</button><label
link</button><button id="publish-page" class="secondary-button">Page</button><div class="public-page-options"><label
class="public-task-toggle"
title="Allow visitors to update task checkboxes on the published page"><input
id="public-task-updates" type="checkbox"> Editable tasks on Page</label><button
id="public-task-updates" type="checkbox"> Editable tasks on Page</label><label class="public-task-toggle" title="Allow the published page to open without the note password or private access"><input id="unprotect-public-page" type="checkbox"> Unprotect Page</label></div><button
id="files-button" class="secondary-button">Files</button><button id="history-button"
class="secondary-button">History</button></div>
</div>
@@ -107,7 +107,7 @@
</div>
<div id="editor-workspace" class="workspace view-split">
<div class="editor-column">
<div class="column-label editor-column-label"><span>Editor</span></div>
<div class="column-label editor-column-label"><span>Editor</span><div class="authorship-mode-control" role="group" aria-label="Authorship display"><button type="button" data-authorship-mode="simple" class="active">Simple</button><button type="button" data-authorship-mode="full">Full</button></div></div><div id="participant-badges" class="participant-badges" aria-label="Participants"></div>
<div class="editor-shell">
<div id="line-gutter" class="line-gutter" aria-hidden="true"></div>
<div id="authorship-layer" class="authorship-layer" aria-hidden="true"></div>
+10
View File
@@ -24,6 +24,16 @@
<p id="public-meta" class="public-meta"></p>
<article id="public-content" class="markdown-body public-content"></article>
</main>
<dialog id="public-password-dialog">
<form id="public-password-form" class="dialog-panel">
<h2>Protected page</h2>
<p>Enter the note password or sign in with an account that has access.</p>
<input id="public-password" type="password" autocomplete="current-password" minlength="8" maxlength="128" placeholder="Password">
<p id="public-password-error" class="form-message error"></p>
<button class="primary-button">Open page</button>
<a class="text-button" href="/">Back to home</a>
</form>
</dialog>
<div id="toast" class="toast"></div>
</body>