diff --git a/Cargo.lock b/Cargo.lock index c0585d7..eb99dc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.46" +version = "0.2.48" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index fb55f72..4e3c8f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 78d0eca..e6f050a 100644 --- a/README.md +++ b/README.md @@ -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/` 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/` 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. diff --git a/TEST_NOTE_FEATURES.txt b/TEST_NOTE_FEATURES.txt index 4aa9010..07cbef1 100644 --- a/TEST_NOTE_FEATURES.txt +++ b/TEST_NOTE_FEATURES.txt @@ -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, diff --git a/static/css/styles.css b/static/css/styles.css index fbf86d4..3479ad3 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -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. */ diff --git a/static/js/markdown.js b/static/js/markdown.js index adb949a..c9932e5 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -152,7 +152,10 @@ function inline(value) { }); html = html.replace(/\[([^\]]+)\]\(([^\s)]+)(?:\s+["']([^"']*)["'])?\)/g, (_, label, url, title) => { const titleAttr = title ? ` title="${escapeHtml(title)}"` : ""; - return stash(`${label}`); + const navigationAttrs = String(url).trim().startsWith("#") + ? "" + : ' target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"'; + return stash(`${label}`); }); html = html.replace(/\[\^([^\]\s]+)\]/g, (_, id) => stash(`?`)); @@ -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 ``; +} + 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 += ``; + const tocHeadings = headings.filter(item => item.index > index); + if (tocHeadings.length) { + html += ``; } continue; } diff --git a/static/js/public.js b/static/js/public.js index 423e896..5c235a1 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -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); } }); diff --git a/static/public.html b/static/public.html index 36936b1..e3d8789 100644 --- a/static/public.html +++ b/static/public.html @@ -17,6 +17,7 @@ RustPad
+