new functions and fixes
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.46"
|
||||
version = "0.2.48"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.46"
|
||||
version = "0.2.48"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
@@ -25,7 +25,7 @@ The script creates `data/db` and `data/files`, refreshes the generated browser l
|
||||
- Markdown and Mermaid diagram rendering.
|
||||
- History with snippets, previews, and version restore.
|
||||
- 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.
|
||||
|
||||
## Fenced code blocks and language aliases
|
||||
@@ -82,7 +82,7 @@ In Docker, both directories are located under `/data`.
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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]
|
||||
|
||||
---
|
||||
@@ -950,6 +952,10 @@ In **Page options**:
|
||||
- enable **Editable tasks**,
|
||||
- enable or disable **Unprotect Page** when allowed,
|
||||
- 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 ordinary text is read-only,
|
||||
- when Editable tasks is enabled, click a public task checkbox and verify that the source note updates,
|
||||
|
||||
+28
-12
@@ -2101,6 +2101,15 @@ dialog::backdrop {
|
||||
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 {
|
||||
padding: clamp(24px, 4vw, 52px);
|
||||
}
|
||||
@@ -4860,27 +4869,34 @@ dialog::backdrop {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.markdown-body .markdown-toc ol {
|
||||
.markdown-body .markdown-toc ul {
|
||||
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 {
|
||||
margin: .25em 0;
|
||||
}
|
||||
|
||||
.markdown-body .markdown-toc .toc-level-2 {
|
||||
margin-left: 1em;
|
||||
.markdown-body .markdown-toc li::marker {
|
||||
color: var(--muted-2);
|
||||
}
|
||||
|
||||
.markdown-body .markdown-toc .toc-level-3 {
|
||||
margin-left: 2em;
|
||||
}
|
||||
|
||||
.markdown-body .markdown-toc .toc-level-4,
|
||||
.markdown-body .markdown-toc .toc-level-5,
|
||||
.markdown-body .markdown-toc .toc-level-6 {
|
||||
margin-left: 3em;
|
||||
.markdown-body .markdown-toc :is(.toc-level-2, .toc-level-3, .toc-level-4, .toc-level-5, .toc-level-6) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Nested Markdown lists keep markers and source-line numbers in separate gutters. */
|
||||
|
||||
+27
-5
@@ -152,7 +152,10 @@ function inline(value) {
|
||||
});
|
||||
html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, 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>`));
|
||||
|
||||
@@ -335,6 +338,26 @@ function collectHeadings(lines) {
|
||||
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) {
|
||||
if (!root) return;
|
||||
@@ -454,10 +477,9 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
|
||||
if (/^\s*\[TOC\]\s*$/i.test(line)) {
|
||||
closeList();
|
||||
if (headings.length) {
|
||||
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents"><ol>`;
|
||||
headings.forEach(item => html += `<li class="toc-level-${item.level}"><a href="#${escapeHtml(item.id)}">${inline(item.text)}</a></li>`);
|
||||
html += `</ol></nav>`;
|
||||
const tocHeadings = headings.filter(item => item.index > index);
|
||||
if (tocHeadings.length) {
|
||||
html += `<nav class="markdown-toc preview-source-line" data-source-line="${index + lineOffset + 1}" aria-label="Table of contents">${renderTableOfContentsList(tableOfContentsTree(tocHeadings))}</nav>`;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
+22
-3
@@ -20,6 +20,8 @@ import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-lib
|
||||
const token = location.pathname.split("/").filter(Boolean)[1];
|
||||
const content = document.querySelector("#public-content");
|
||||
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");
|
||||
let pagePassword = "";
|
||||
let mermaidRenderVersion = 0;
|
||||
@@ -57,21 +59,38 @@ function scrollToPublicAnchor(hash, behavior = "auto") {
|
||||
try { id = decodeURIComponent(rawId); } catch { id = rawId; }
|
||||
const target = document.getElementById(id);
|
||||
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" });
|
||||
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); } }
|
||||
content.addEventListener("click", event => {
|
||||
const link = event.target.closest('.markdown-toc a[href^="#"]');
|
||||
if (!link) return;
|
||||
const link = event.target.closest('a[href^="#"]');
|
||||
if (!link || !content.contains(link)) return;
|
||||
const hash = link.getAttribute("href");
|
||||
if (!scrollToPublicAnchor(hash, "smooth")) return;
|
||||
event.preventDefault();
|
||||
if (!hash || hash === "#" || !scrollToPublicAnchor(hash, "smooth")) return;
|
||||
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", 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); });
|
||||
fullWidthToggle.addEventListener("change", () => setFullWidth(fullWidthToggle.checked));
|
||||
window.addEventListener("resize", () => requestAnimationFrame(() => alignPreviewLineNumbers(content)));
|
||||
passwordForm.addEventListener("submit", async event => { event.preventDefault(); pagePassword = passwordInput.value; 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); } });
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<a class="brand home-brand" href="/">RustPad</a>
|
||||
<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-full-width-toggle" type="checkbox"> Full width</label>
|
||||
<button id="copy-public-link" class="secondary-button">Copy link</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
Reference in New Issue
Block a user