From 017513db8a8ebe9f2e9c9955c9160c89f13011df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 6 Aug 2026 14:04:21 +0200 Subject: [PATCH] public pages links --- Cargo.lock | 2 +- Cargo.toml | 2 +- static/js/public.js | 101 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a83650..ca8c4bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.49" +version = "0.2.50" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 15bc8d7..3406fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.49" +version = "0.2.50" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/static/js/public.js b/static/js/public.js index d678656..cace4cb 100644 --- a/static/js/public.js +++ b/static/js/public.js @@ -26,6 +26,7 @@ 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; +let publicAnchorSettleCleanup = null; function pageHeaders() { return pagePassword ? { "X-RustPad-Page-Password": pagePassword } : {}; } async function renderMermaid() { const renderVersion = ++mermaidRenderVersion; @@ -75,9 +76,74 @@ function scrollToPublicAnchor(hash, behavior = "auto") { target.scrollIntoView({ behavior, block: "start" }); return true; } +function settleInitialPublicAnchor(hash) { + publicAnchorSettleCleanup?.(); + publicAnchorSettleCleanup = null; + if (!hash) return; + + let stopped = false; + let frame = 0; + let observer = null; + const cleanups = []; + const cleanup = () => { + if (stopped) return; + stopped = true; + cancelAnimationFrame(frame); + observer?.disconnect(); + cleanups.splice(0).forEach(remove => remove()); + if (publicAnchorSettleCleanup === cleanup) publicAnchorSettleCleanup = null; + }; + const correct = () => { + frame = 0; + if (stopped || location.hash !== hash) return; + scrollToPublicAnchor(hash); + }; + const scheduleCorrection = () => { + if (stopped || frame) return; + frame = requestAnimationFrame(correct); + }; + const stopOnUserInput = () => cleanup(); + + ["wheel", "touchstart", "pointerdown", "keydown"].forEach(type => { + window.addEventListener(type, stopOnUserInput, { capture: true, passive: true }); + cleanups.push(() => window.removeEventListener(type, stopOnUserInput, true)); + }); + + content.querySelectorAll("img, video, iframe").forEach(asset => { + ["load", "error", "loadedmetadata"].forEach(type => { + asset.addEventListener(type, scheduleCorrection, { once: true }); + cleanups.push(() => asset.removeEventListener(type, scheduleCorrection)); + }); + }); + + if (document.readyState !== "complete") { + window.addEventListener("load", scheduleCorrection, { once: true }); + cleanups.push(() => window.removeEventListener("load", scheduleCorrection)); + } + document.fonts?.ready?.then(scheduleCorrection).catch(() => {}); + + if (typeof ResizeObserver === "function") { + observer = new ResizeObserver(scheduleCorrection); + observer.observe(content); + } + + publicAnchorSettleCleanup = cleanup; + scheduleCorrection(); + requestAnimationFrame(() => requestAnimationFrame(scheduleCorrection)); + const timeout = window.setTimeout(cleanup, 6000); + cleanups.push(() => clearTimeout(timeout)); +} +function isBlankPublicSourceLine(target) { + if (!target.matches("div.preview-source-line")) return false; + return ![...target.childNodes].some(node => { + if (node.nodeType === Node.TEXT_NODE) return Boolean(node.textContent?.trim()); + if (!(node instanceof Element)) return false; + return !node.matches("br, .public-permalink"); + }); +} function installPublicPermalinks() { content.querySelectorAll(".preview-source-line[data-source-line]").forEach(target => { - if (target.matches("hr") || target.querySelector(":scope > .public-permalink")) return; + if (target.matches("hr") || isBlankPublicSourceLine(target) || target.querySelector(":scope > .public-permalink")) return; const line = Number(target.dataset.sourceLine); if (!Number.isSafeInteger(line) || line < 1) return; const heading = target.matches("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]"); @@ -106,7 +172,36 @@ function restoreFullWidth() { 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); lockPublicContent(page.allow_task_updates); await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); installPublicPermalinks(); alignPreviewLineNumbers(content); 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); + lockPublicContent(page.allow_task_updates); + await Promise.all([renderMermaid(), renderCodeHighlight(), renderMediaPlayers()]); + installPublicPermalinks(); + alignPreviewLineNumbers(content); + settleInitialPublicAnchor(location.hash); + } catch (error) { + publicAnchorSettleCleanup?.(); + 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", async event => { const link = event.target.closest('a[href^="#"]'); if (!link || !content.contains(link)) return; @@ -123,7 +218,7 @@ content.addEventListener("click", async event => { toast(link.dataset.linkKind === "heading" ? "Heading link copied" : `Link to line ${link.dataset.line} copied`); } catch (error) { toast(error.message); } }); -window.addEventListener("hashchange", () => scrollToPublicAnchor(location.hash, "smooth")); +window.addEventListener("hashchange", () => { publicAnchorSettleCleanup?.(); 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));