new functions and fixes

This commit is contained in:
Mateusz Gruszczyński
2026-08-06 09:43:49 +02:00
parent 7989a873e7
commit ce368cf3fd
8 changed files with 88 additions and 24 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.46" version = "0.2.48"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.46" version = "0.2.48"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+2 -2
View File
@@ -25,7 +25,7 @@ The script creates `data/db` and `data/files`, refreshes the generated browser l
- Markdown and Mermaid diagram rendering. - Markdown and Mermaid diagram rendering.
- History with snippets, previews, and version restore. - History with snippets, previews, and version restore.
- Alert blocks: `success`, `info`, `warning`, and `danger`. - Alert blocks: `success`, `info`, `warning`, and `danger`.
- Table of contents generated with `[TOC]`. - Table of contents generated with `[TOC]`, using headings after the marker and nesting them by level.
- Optional line numbers in fenced code blocks. - Optional line numbers in fenced code blocks.
## Fenced code blocks and language aliases ## Fenced code blocks and language aliases
@@ -82,7 +82,7 @@ In Docker, both directories are located under `/data`.
## Publishing a note as a page ## Publishing a note as a page
Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, video players, YouTube embeds, links, and Mermaid diagrams. Use the **Page** button in the editor. RustPad creates a permanent public `/s/<token>` URL, copies it to the clipboard, and opens it in a new tab. The page displays the current note and renders Markdown, images, video players, YouTube embeds, links, and Mermaid diagrams. Its header can toggle source line numbers and expand the document to the full browser width.
Publishing a protected note requires its password, but the generated public page itself is accessible without that password. Publishing a protected note requires its password, but the generated public page itself is accessible without that password.
+6
View File
@@ -15,6 +15,8 @@ Upload files with these exact names so every local attachment example can resolv
Until the files are uploaded, aliases such as `[image=TEST_IMAGE.png,...]` remain visible as plain text. This is expected. Until the files are uploaded, aliases such as `[image=TEST_IMAGE.png,...]` remain visible as plain text. This is expected.
The generated table of contents below should include only headings after the `[TOC]` marker, nest lower-level headings under their nearest parent, and avoid adding a second numeric sequence to headings that are already numbered.
[TOC] [TOC]
--- ---
@@ -950,6 +952,10 @@ In **Page options**:
- enable **Editable tasks**, - enable **Editable tasks**,
- enable or disable **Unprotect Page** when allowed, - enable or disable **Unprotect Page** when allowed,
- open the page and verify that its link is copied, - open the page and verify that its link is copied,
- toggle **Line numbers** and verify that source-line markers appear and disappear,
- toggle **Full width** and verify that the document expands to the available browser width,
- confirm that the generated table of contents is nested by heading level and does not show a second flat number sequence,
- click **Return to the top** and other internal anchors and verify that they scroll inside the same published page,
- confirm that the published page renders Markdown, Mermaid, highlighted code, images, files, video, and YouTube, - confirm that the published page renders Markdown, Mermaid, highlighted code, images, files, video, and YouTube,
- confirm that ordinary text is read-only, - confirm that ordinary text is read-only,
- when Editable tasks is enabled, click a public task checkbox and verify that the source note updates, - when Editable tasks is enabled, click a public task checkbox and verify that the source note updates,
+28 -12
View File
@@ -2101,6 +2101,15 @@ dialog::backdrop {
width: min(1180px, calc(100% - 32px)); width: min(1180px, calc(100% - 32px));
} }
.public-page.public-full-width .public-header {
padding-inline: 20px;
}
.public-page.public-full-width .public-document {
width: calc(100% - 32px);
max-width: none;
}
.public-content { .public-content {
padding: clamp(24px, 4vw, 52px); padding: clamp(24px, 4vw, 52px);
} }
@@ -4860,27 +4869,34 @@ dialog::backdrop {
background: var(--surface-2); background: var(--surface-2);
} }
.markdown-body .markdown-toc ol { .markdown-body .markdown-toc ul {
margin: 0; margin: 0;
padding-left: 1.4em; padding-left: 1.35em;
}
/* Top-level entries are already numbered by their headings, so omit redundant bullets. */
.markdown-body .markdown-toc > ul {
padding-left: 0;
}
.markdown-body .markdown-toc > ul > li {
list-style: none;
}
.markdown-body .markdown-toc ul ul {
margin-top: .2em;
} }
.markdown-body .markdown-toc li { .markdown-body .markdown-toc li {
margin: .25em 0; margin: .25em 0;
} }
.markdown-body .markdown-toc .toc-level-2 { .markdown-body .markdown-toc li::marker {
margin-left: 1em; color: var(--muted-2);
} }
.markdown-body .markdown-toc .toc-level-3 { .markdown-body .markdown-toc :is(.toc-level-2, .toc-level-3, .toc-level-4, .toc-level-5, .toc-level-6) {
margin-left: 2em; margin-left: 0;
}
.markdown-body .markdown-toc .toc-level-4,
.markdown-body .markdown-toc .toc-level-5,
.markdown-body .markdown-toc .toc-level-6 {
margin-left: 3em;
} }
/* Nested Markdown lists keep markers and source-line numbers in separate gutters. */ /* Nested Markdown lists keep markers and source-line numbers in separate gutters. */
+27 -5
View File
@@ -152,7 +152,10 @@ function inline(value) {
}); });
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
return stash(`<a href="${safeUrl(url)}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"${titleAttr}>${label}</a>`); const navigationAttrs = String(url).trim().startsWith("#")
? ""
: ' target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"';
return stash(`<a href="${safeUrl(url)}"${navigationAttrs}${titleAttr}>${label}</a>`);
}); });
html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`)); html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`<sup class="footnote-ref"><a href="#fn-${escapeHtml(id)}" id="fnref-${escapeHtml(id)}">?</a></sup>`));
@@ -335,6 +338,26 @@ function collectHeadings(lines) {
return headings; return headings;
} }
function tableOfContentsTree(headings) {
const roots = [];
const stack = [];
headings.forEach(item => {
while (stack.length && item.level <= stack[stack.length - 1].level) stack.pop();
const node = { ...item, children: [] };
const parent = stack[stack.length - 1];
(parent ? parent.children : roots).push(node);
stack.push(node);
});
return roots;
}
function renderTableOfContentsList(nodes) {
if (!nodes.length) return "";
return `<ul>${nodes.map(node => `<li class="toc-level-${node.level}"><a href="#${escapeHtml(node.id)}">${inline(node.text)}</a>${renderTableOfContentsList(node.children)}</li>`).join("")}</ul>`;
}
export function alignPreviewLineNumbers(root) { export function alignPreviewLineNumbers(root) {
if (!root) return; if (!root) return;
@@ -454,10 +477,9 @@ export function renderMarkdown(source, lineOffset = 0) {
if (/^\s*\[TOC\]\s*$/i.test(line)) { if (/^\s*\[TOC\]\s*$/i.test(line)) {
closeList(); closeList();
if (headings.length) { const tocHeadings = headings.filter(item => item.index > index);
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents"><ol>`; if (tocHeadings.length) {
headings.forEach(item => html += `<li class="toc-level-${item.level}"><a href="#${escapeHtml(item.id)}">${inline(item.text)}</a></li>`); html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
html += `</ol></nav>`;
} }
continue; continue;
} }
+22 -3
View File
@@ -20,6 +20,8 @@ import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-lib
const token = location.pathname.split("/").filter(Boolean)[1]; const token = location.pathname.split("/").filter(Boolean)[1];
const content = document.querySelector("#public-content"); const content = document.querySelector("#public-content");
const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle"); const lineNumbersToggle = document.querySelector("#public-line-numbers-toggle");
const fullWidthToggle = document.querySelector("#public-full-width-toggle");
const fullWidthStorageKey = "rustpad:public-full-width";
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 passwordDialog = document.querySelector("#public-password-dialog"), passwordForm = document.querySelector("#public-password-form"), passwordInput = document.querySelector("#public-password"), passwordError = document.querySelector("#public-password-error");
let pagePassword = ""; let pagePassword = "";
let mermaidRenderVersion = 0; let mermaidRenderVersion = 0;
@@ -57,21 +59,38 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
try { id = decodeURIComponent(rawId); } catch { id = rawId; } try { id = decodeURIComponent(rawId); } catch { id = rawId; }
const target = document.getElementById(id); const target = document.getElementById(id);
if (!target || !content.contains(target)) return false; if (!target || !content.contains(target)) return false;
for (let details = target.closest("details"); details; details = details.parentElement?.closest("details")) details.open = true;
target.scrollIntoView({ behavior, block: "start" }); target.scrollIntoView({ behavior, block: "start" });
return true; return true;
} }
function setFullWidth(enabled, { persist = true } = {}) {
const active = Boolean(enabled);
document.body.classList.toggle("public-full-width", active);
fullWidthToggle.checked = active;
if (persist) {
try { localStorage.setItem(fullWidthStorageKey, active ? "1" : "0"); } catch { }
}
requestAnimationFrame(() => alignPreviewLineNumbers(content));
}
function restoreFullWidth() {
let enabled = false;
try { enabled = localStorage.getItem(fullWidthStorageKey) === "1"; } catch { }
setFullWidth(enabled, { persist: false });
}
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`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); 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); } } 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`; setMarkdownFiles(page.files || []); content.innerHTML = renderMarkdown(page.content); alignPreviewLineNumbers(content); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); 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 => { content.addEventListener("click", event => {
const link = event.target.closest('.markdown-toc a[href^="#"]'); const link = event.target.closest('a[href^="#"]');
if (!link) return; if (!link || !content.contains(link)) return;
const hash = link.getAttribute("href"); const hash = link.getAttribute("href");
if (!scrollToPublicAnchor(hash, "smooth")) return;
event.preventDefault(); event.preventDefault();
if (!hash || hash === "#" || !scrollToPublicAnchor(hash, "smooth")) return;
history.replaceState(null, "", `${location.pathname}${location.search}${hash}`); history.replaceState(null, "", `${location.pathname}${location.search}${hash}`);
}); });
window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth")); 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", 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; } }); 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); }); lineNumbersToggle.addEventListener("change", () => { document.body.classList.toggle("hide-preview-line-numbers", !lineNumbersToggle.checked); });
fullWidthToggle.addEventListener("change", () => setFullWidth(fullWidthToggle.checked));
window.addEventListener("resize", () => requestAnimationFrame(() => alignPreviewLineNumbers(content)));
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); }); passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; await initialize(); });
passwordDialog.addEventListener("cancel", event => event.preventDefault()); 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); } }); document.querySelector("#copy-public-link").addEventListener("click", async () => { try { await copyText(location.href); toast("Link copied"); } catch (error) { toast(error.message); } });
+1
View File
@@ -17,6 +17,7 @@
<a class="brand home-brand" href="/">RustPad</a> <a class="brand home-brand" href="/">RustPad</a>
<div class="public-header__actions"> <div class="public-header__actions">
<label class="line-toggle"><input id="public-line-numbers-toggle" type="checkbox"> Line numbers</label> <label class="line-toggle"><input id="public-line-numbers-toggle" type="checkbox"> Line numbers</label>
<label class="line-toggle"><input id="public-full-width-toggle" type="checkbox"> Full width</label>
<button id="copy-public-link" class="secondary-button">Copy link</button> <button id="copy-public-link" class="secondary-button">Copy link</button>
</div> </div>
</header> </header>