This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 13:07:22 +01:00
1 parent b15a6c54ce
commit d36a4b2451
22 files changed
+926 -114

No files matched your search

+5 -1
View File
@@ -55,7 +55,7 @@ function insertStandaloneLine(editor, content) {
}
export function insertPageBreak(editor) {
insertStandaloneLine(editor, "[PAGEBREAK]");
insertStandaloneLine(editor, "[PDF_PAGEBREAK]");
}
export function insertHorizontalRule(editor) {
@@ -107,6 +107,10 @@ export function applyFormat(editor, format) {
if (format === "footnote") toggleWrap(editor, "", `[^1]\n\n[^1]: ${t("editor.format.footnote", {}, "Footnote text")}`, t("editor.format.textWithFootnote", {}, "Text with footnote"));
if (format === "definition") toggleWrap(editor, "", `\n: ${t("editor.format.definition", {}, "Definition")}`, t("editor.format.term", {}, "Term"));
if (format === "page-break") insertPageBreak(editor);
if (format === "pdf-header") insertStandaloneLine(editor, "[PDF_HEADER]");
if (format === "pdf-footer") insertStandaloneLine(editor, "[PDF_FOOTER]");
if (format === "pdf-align-top") insertStandaloneLine(editor, "[PDF_ALIGN_TOP]");
if (format === "pdf-align-center") insertStandaloneLine(editor, "[PDF_ALIGN_CENTER]");
if (format === "horizontal-rule") insertHorizontalRule(editor);
editor.focus();
editor.dispatchEvent(new Event("input", { bubbles: true }));
+9 -5
View File
@@ -48,7 +48,6 @@ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"
function highlightMentions(html) {
if (!markdownMentionMatcher) return html;
// Process text nodes only, not markup, links or attributes.
return html.split(/(<[^>]*>)/g).map(part => part.startsWith("<") ? part : part.replace(markdownMentionMatcher, (match, before, name) => {
const user = markdownMentionLookup.get(name.toLocaleLowerCase());
return user ? `${before}<span class="markdown-mention" style="--owner:${user.color}" title="${escapeHtml(user.nickname)}">@${escapeHtml(user.nickname)}</span>` : match;
@@ -632,10 +631,15 @@ export function renderMarkdown(source, lineOffset = 0, documentHeadings = null)
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
}
html += `</dl>`;
} else if (/^\[PAGEBREAK\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
} else if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
else {
const pdfDirective = line.trim().match(/^\[PDF_(HEADER|FOOTER|ALIGN_TOP|ALIGN_CENTER)\]$/i);
if (pdfDirective) {
html += `<div class="pdf-layout-marker preview-source-line" data-pdf-directive="${pdfDirective[1].toLowerCase()}" data-source-line="${index + lineOffset + 1}" aria-hidden="true"></div>`;
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset)}>`;
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
}
}
}
+6
View File
@@ -27,6 +27,9 @@ export function createPadAdapter() {
addressSelector: "#document-url",
title: info => `${info.title} · RustPad`,
loadInfo: headers => api(base, { headers }),
renameTitle: (headers, name) => api(`${base}/rename`, {
method: "POST", headers, body: JSON.stringify({ name }),
}),
loadColor: headers => api(`${base}/editor-color`, { headers }),
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }),
@@ -74,6 +77,9 @@ export function createWorkspaceNoteAdapter() {
addressSelector: "#document-url",
title: info => `${info.title} · ${info.workspace_title}`,
loadInfo: headers => api(base, { headers }),
renameTitle: (headers, name) => api(`${base}/rename`, {
method: "POST", headers, body: JSON.stringify({ name }),
}),
loadColor: headers => api(`${base}/editor-color`, { headers }),
saveColor: (headers, color) => api(`${base}/editor-color`, { method: "POST", headers, body: JSON.stringify({ color }) }),
saveEditorSettings: (headers, settings) => api(`${base}/editor-settings`, { method: "POST", headers, body: JSON.stringify(settings) }),
+132 -6
View File
@@ -37,7 +37,7 @@ import { syncSelectControl } from "@rustpad/select";
import { loadHighlight, loadMediaPlayer, loadMermaid } from "@rustpad/vendor-libs";
export function startNoteEditor(adapter) {
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer");
const editor = document.querySelector("#editor"), preview = document.querySelector("#preview"), editorWorkspace = document.querySelector("#editor-workspace"), gutter = document.querySelector("#line-gutter"), ownerLabels = document.querySelector("#owner-labels"), authorshipLayer = document.querySelector("#authorship-layer"), documentTitle = document.querySelector("#document-title");
const modeToggle = document.querySelector("#mode-toggle"), toolbarCollapseToggle = document.querySelector("#toolbar-collapse-toggle"), navbarCollapseToggle = document.querySelector("#navbar-collapse-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"), mobileConnectionDetails = document.querySelector("#mobile-connection-details"), 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"), connectionNotice = document.querySelector("#connection-notice"), connectionNoticeTitle = document.querySelector("#connection-notice-title"), connectionNoticeMessage = document.querySelector("#connection-notice-message");
const saveState = document.querySelector("#save-state"), favoriteToggle = document.querySelector("#favorite-toggle");
@@ -309,13 +309,93 @@ export function startNoteEditor(adapter) {
render();
}
function syncInlineTitleRename() {
if (!documentTitle) return;
const enabled = Boolean(adapter.renameTitle && info?.can_rename);
documentTitle.classList.toggle("document-title--renamable", enabled);
documentTitle.closest("#document-link-copy")?.classList.toggle("document-heading--has-renamable-title", enabled);
if (enabled) documentTitle.title = t("editor.renameHint", {}, "Double-click to rename");
else documentTitle.removeAttribute("title");
}
function beginInlineTitleRename() {
if (!documentTitle || !adapter.renameTitle || !info?.can_rename || documentTitle.querySelector("input")) return;
const previous = String(info.title || documentTitle.textContent || "").trim();
const input = document.createElement("input");
input.className = "document-title-inline-input";
input.type = "text";
input.maxLength = 80;
input.value = previous;
input.setAttribute("aria-label", t("editor.renameLabel", {}, "Note name"));
documentTitle.replaceChildren(input);
documentTitle.classList.add("document-title--editing");
let finished = false;
const restore = title => {
documentTitle.classList.remove("document-title--editing");
documentTitle.textContent = title;
syncInlineTitleRename();
};
const finish = async save => {
if (finished) return;
finished = true;
const next = input.value.trim();
if (!save) { restore(previous); return; }
if (!next) {
restore(previous);
return;
}
if (next === previous) { restore(previous); return; }
input.disabled = true;
try {
const result = await adapter.renameTitle(sessionHeaders(), next);
info = { ...info, title: result.title || next, updated_at: result.updated_at || info.updated_at };
restore(info.title);
document.title = adapter.title(info);
toast.success(
t("editor.renamed", { title: info.title }, `Renamed to “${info.title}”.`),
{ title: t("editor.renamedTitle", {}, "Note renamed") },
);
} catch (error) {
restore(previous);
toast.danger(error.message, { title: t("editor.renameFailed", {}, "Could not rename note") });
}
};
input.addEventListener("keydown", event => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
void finish(true);
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
void finish(false);
}
});
input.addEventListener("blur", () => { void finish(true); }, { once: true });
input.focus();
input.select();
}
documentTitle?.addEventListener("click", event => {
if (!info?.can_rename) return;
event.stopPropagation();
});
documentTitle?.addEventListener("dblclick", event => {
if (!info?.can_rename) return;
event.preventDefault();
event.stopPropagation();
beginInlineTitleRename();
});
async function loadNoteInfo() {
info = await adapter.loadInfo(sessionHeaders());
const documentTitle = document.querySelector("#document-title");
if (documentTitle && info.title) {
documentTitle.removeAttribute("data-i18n");
documentTitle.textContent = info.title;
}
syncInlineTitleRename();
globalColor = info.global_color || ""; noteColor = info.note_color || "";
if (!getAuthToken()) noteColor = readGuestColor();
updateMarkdownFiles(info.files || []);
@@ -336,8 +416,7 @@ export function startNoteEditor(adapter) {
syncMobileEditorControls();
updateCurrentUser(); return info;
}
// Presence is independent of document text, authorship spans and the gutter.
// Match by name, preserving duplicates and their DOM nodes between snapshots.
function updatePresence(users) {
const entries = Array.isArray(users) ? users : [];
presenceUsers = entries.map(entry => typeof entry === "string" ? { name: entry, color: "" } : entry || {});
@@ -506,6 +585,7 @@ export function startNoteEditor(adapter) {
const renderVersion = ++mermaidRenderVersion;
const nodes = [...preview.querySelectorAll(".mermaid")];
if (!nodes.length) return;
preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
try {
nodes.forEach(node => {
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
@@ -513,10 +593,12 @@ export function startNoteEditor(adapter) {
const mermaid = await loadMermaid();
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
if (renderVersion === mermaidRenderVersion) preview.querySelectorAll(".mermaid-error").forEach(error => error.remove());
} catch {
if (renderVersion !== mermaidRenderVersion) return;
nodes.forEach(node => {
if (!node.isConnected || !preview.contains(node) || !node.parentNode) return;
if (node.previousElementSibling?.classList.contains("mermaid-error")) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
@@ -552,8 +634,6 @@ export function startNoteEditor(adapter) {
reconcileChildren(participantBadges, nodes);
}
function syncOwnerLabels() {
// One composited transform instead of rewriting every author's position
// on every scroll event (important for thousands of distinct authors).
const canvas = ownerLabels.firstElementChild;
if (canvas?.classList.contains("owner-labels-canvas")) {
canvas.style.transform = `translate3d(0, ${-editor.scrollTop}px, 0)`;
@@ -2268,6 +2348,7 @@ export function startNoteEditor(adapter) {
const documentLinkCopy = document.querySelector("#document-link-copy");
documentLinkCopy?.addEventListener("click", copyCurrentLink);
documentLinkCopy?.addEventListener("keydown", event => {
if (event.target instanceof Element && event.target.closest(".document-title--editing, input, textarea")) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
copyCurrentLink();
@@ -2306,6 +2387,51 @@ export function startNoteEditor(adapter) {
renderFavoriteButton(false);
});
function ensurePdfFormatButtons() {
const menu = document.querySelector(".markdown-more-menu");
if (!menu) return;
const formats = [
["page-break", "editor.pageBreak", "Page break (PDF)"],
["pdf-header", "editor.pdfHeaderTag", "PDF header"],
["pdf-footer", "editor.pdfFooterTag", "PDF footer"],
["pdf-align-top", "editor.pdfAlignTopTag", "PDF align top"],
["pdf-align-center", "editor.pdfAlignCenterTag", "PDF align center"],
];
const tocButton = menu.querySelector('[data-format="toc"]');
let insertAfter = tocButton;
for (const [format, key, fallback] of formats) {
let button = menu.querySelector(`[data-format="${format}"]`);
if (!button) {
button = document.createElement("button");
button.type = "button";
button.dataset.format = format;
button.dataset.i18n = key;
button.textContent = t(key, {}, fallback);
if (format === "page-break") {
button.dataset.i18nTitle = "editor.pageBreakShortcut";
button.title = t("editor.pageBreakShortcut", {}, "Page break (PDF) · Ctrl/Cmd+Enter");
}
if (insertAfter?.parentNode === menu) insertAfter.after(button);
else menu.prepend(button);
}
insertAfter = button;
}
let separator = menu.querySelector(".markdown-more-separator");
if (!separator) {
separator = document.createElement("div");
separator.className = "markdown-more-separator";
separator.setAttribute("role", "separator");
separator.setAttribute("aria-hidden", "true");
}
menu.append(separator);
for (const [format] of formats) {
const button = menu.querySelector(`[data-format="${format}"]`);
if (button) menu.append(button);
}
}
ensurePdfFormatButtons();
let pendingPreviewFormatRange = null;
document.querySelectorAll("[data-format]").forEach(button => {
button.addEventListener("pointerdown", event => {
+185 -48
View File
@@ -128,6 +128,57 @@ function isBlankPrintLine(node) {
return node.children.length === 1 && node.firstElementChild?.tagName === "BR";
}
function trimTrailingBlankPrintLines(root) {
let node = root.lastElementChild;
while (isBlankPrintLine(node)) {
const remove = node;
node = node.previousElementSibling;
remove.remove();
}
}
function makePdfLayoutMarker(doc, directive) {
const marker = doc.createElement("div");
marker.className = "pdf-layout-marker";
marker.dataset.pdfDirective = directive;
marker.setAttribute("aria-hidden", "true");
return marker;
}
function extractPdfLayout(root) {
const layout = { headerHtml: "", footerHtml: "", verticalAlign: null };
const headers = [];
const footers = [];
[...root.querySelectorAll(".pdf-layout-marker[data-pdf-directive]")].forEach(marker => {
const directive = String(marker.dataset.pdfDirective || "").toLowerCase();
if (directive === "align_top" || directive === "align_center") {
layout.verticalAlign = directive === "align_center" ? "center" : "top";
marker.remove();
return;
}
let block = marker.nextElementSibling;
while (isBlankPrintLine(block)) {
const remove = block;
block = block.nextElementSibling;
remove.remove();
}
marker.remove();
if (!block || block.classList?.contains("pdf-page-break")) return;
block.remove();
if (directive === "header") headers.push(block.outerHTML);
if (directive === "footer") footers.push(block.outerHTML);
});
root.querySelectorAll(".pdf-layout-marker").forEach(marker => marker.remove());
trimTrailingBlankPrintLines(root);
layout.headerHtml = headers.join("");
layout.footerHtml = footers.join("");
return layout;
}
function collapseHeadingGaps(root) {
root.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach(heading => {
let next = heading.nextElementSibling;
@@ -274,34 +325,47 @@ function preparePrintPagination(root) {
}
async function ensurePrintableMermaid(root, darkMode = false) {
const nodes = [...root.querySelectorAll(".mermaid[data-mermaid-source]")];
const nodes = [...root.querySelectorAll(".mermaid")];
if (!nodes.length) return;
root.querySelectorAll(".mermaid-error").forEach(error => error.remove());
const originals = nodes.map(node => ({
node,
html: node.innerHTML,
processed: node.getAttribute("data-processed"),
source: node.dataset.mermaidSource || node.textContent || "",
hadSvg: Boolean(node.querySelector("svg")),
}));
const renderable = originals.filter(item => item.node.dataset.mermaidSource || !item.hadSvg);
if (!renderable.length) return;
const staging = document.createElement("div");
staging.setAttribute("aria-hidden", "true");
staging.style.cssText = "position:fixed;left:-200vw;top:0;width:1200px;visibility:hidden;pointer-events:none;z-index:-1";
try {
const mermaid = await loadMermaid();
nodes.forEach(node => {
renderable.forEach(({ node, source }) => {
node.removeAttribute("data-processed");
node.textContent = node.dataset.mermaidSource || "";
node.textContent = source;
});
staging.appendChild(root);
document.body.appendChild(staging);
mermaid.initialize({ startOnLoad: false, theme: darkMode ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
await mermaid.run({ nodes: renderable.map(item => item.node) });
root.querySelectorAll(".mermaid-error").forEach(error => error.remove());
} catch {
originals.forEach(({ node, html, processed }) => {
originals.forEach(({ node, html, processed, hadSvg }) => {
node.innerHTML = html;
if (processed === null) node.removeAttribute("data-processed");
else node.setAttribute("data-processed", processed);
if (hadSvg) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
node.parentNode?.insertBefore(message, node);
});
} finally {
if (root.parentElement === staging) staging.removeChild(root);
@@ -334,8 +398,10 @@ function measureLastPageFill(doc, documentRoot, pageContentHeight) {
"z-index:-1",
].join(";");
const title = documentRoot.querySelector(":scope > .pdf-document-title");
if (title) probe.appendChild(title.cloneNode(true));
for (const child of documentRoot.children) {
if (child === content) break;
if (child.matches?.(".pdf-document-title, .pdf-custom-header")) probe.appendChild(child.cloneNode(true));
}
const flow = content.cloneNode(true);
flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => {
@@ -367,40 +433,82 @@ function fallbackLastPageFill(documentRoot, spacer, pageContentHeight) {
function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
const documentRoot = doc.querySelector(".pdf-document");
const spacer = doc.querySelector(".pdf-footer-spacer");
const footer = doc.querySelector(".pdf-generated-footer");
const footer = doc.querySelector(".pdf-document-footer");
if (!documentRoot || !spacer || !footer) return;
spacer.style.height = "0px";
footer.style.removeProperty("margin-top");
footer.style.marginTop = "0px";
const pxPerMm = mmToPx(doc);
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
const footerHeight = footer.getBoundingClientRect().height;
const footerMarginTop = Number.parseFloat(doc.defaultView?.getComputedStyle(footer).marginTop || "0") || 0;
const bottomGuard = 0.75 * pxPerMm;
const bottomGuard = Math.max(2, 1.5 * pxPerMm);
const minGap = 4 * pxPerMm;
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight);
const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight);
const available = Math.max(0, pageContentHeight - positionOnPage);
let actualMarginTop = footerMarginTop;
let spacerHeight;
const spacerHeight = available >= footerHeight + minGap + bottomGuard
? available - footerHeight - bottomGuard
: available + pageContentHeight - footerHeight - bottomGuard;
if (available >= footerHeight + bottomGuard) {
actualMarginTop = Math.min(
footerMarginTop,
Math.max(0, available - footerHeight - bottomGuard),
);
spacerHeight = available - footerHeight - actualMarginTop - bottomGuard;
} else {
spacerHeight = available + pageContentHeight - footerHeight - actualMarginTop - bottomGuard;
}
footer.style.marginTop = `${Math.max(0, actualMarginTop)}px`;
spacer.style.height = `${Math.max(0, spacerHeight)}px`;
}
function fitOversizedKeepUnits(doc, pageHeightMm, marginMm) {
const root = doc.querySelector(".pdf-content");
if (!root) return;
const pxPerMm = mmToPx(doc);
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
const maxHeight = Math.max(40, pageContentHeight - Math.max(2, pxPerMm));
root.querySelectorAll(".pdf-keep-unit").forEach(unit => {
unit.classList.remove("pdf-keep-unit--oversized");
let rect = unit.getBoundingClientRect();
if (rect.height <= maxHeight) return;
const scalable = unit.querySelector(".mermaid svg, img, .rustpad-media, video, canvas");
if (scalable) {
const scalableRect = scalable.getBoundingClientRect();
const overhead = Math.max(0, rect.height - scalableRect.height);
const targetHeight = Math.floor(maxHeight - overhead - 2);
if (targetHeight >= 48 && targetHeight < scalableRect.height) {
scalable.style.setProperty("max-height", `${targetHeight}px`, "important");
scalable.style.setProperty("height", "auto", "important");
}
rect = unit.getBoundingClientRect();
}
if (rect.height > maxHeight) unit.classList.add("pdf-keep-unit--oversized");
});
}
function alignContentVertically(doc, pageHeightMm, marginMm, mode) {
const documentRoot = doc.querySelector(".pdf-document");
const content = doc.querySelector(".pdf-content");
const footer = doc.querySelector(".pdf-document-footer");
if (!documentRoot || !content || !footer) return;
content.style.marginTop = "0px";
if (mode !== "center" || content.querySelector(".pdf-page-break")) return;
const pxPerMm = mmToPx(doc);
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
const rootRect = documentRoot.getBoundingClientRect();
const contentRect = content.getBoundingClientRect();
const footerHeight = footer.getBoundingClientRect().height;
const headerHeight = Math.max(0, contentRect.top - rootRect.top);
const bottomGuard = Math.max(2, 1.5 * pxPerMm);
const minGap = 4 * pxPerMm;
const used = headerHeight + contentRect.height + footerHeight + minGap + bottomGuard;
if (!Number.isFinite(pageContentHeight) || used >= pageContentHeight) return;
const freeSpace = pageContentHeight - headerHeight - footerHeight - bottomGuard - contentRect.height;
content.style.marginTop = `${Math.max(0, freeSpace / 2)}px`;
}
function preparePreview(preview) {
const clone = preview.cloneNode(true);
clone.removeAttribute("id");
@@ -426,11 +534,16 @@ function preparePreview(preview) {
if (clone.classList.contains("preview--raw")) {
clone.querySelectorAll(".preview-source-line").forEach(line => {
if (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return;
const pageBreak = document.createElement("div");
pageBreak.className = "pdf-page-break";
pageBreak.setAttribute("aria-hidden", "true");
line.replaceWith(pageBreak);
const text = line.textContent.trim();
if (/^\[(?:PDF_PAGEBREAK|PAGEBREAK)\]$/i.test(text)) {
const pageBreak = document.createElement("div");
pageBreak.className = "pdf-page-break";
pageBreak.setAttribute("aria-hidden", "true");
line.replaceWith(pageBreak);
return;
}
const directive = text.match(/^\[PDF_(HEADER|FOOTER|ALIGN_TOP|ALIGN_CENTER)\]$/i);
if (directive) line.replaceWith(makePdfLayoutMarker(document, directive[1].toLowerCase()));
});
}
@@ -441,7 +554,7 @@ function preparePreview(preview) {
marker.replaceWith(pageBreak);
});
preparePrintPagination(clone);
trimTrailingBlankPrintLines(clone);
return clone;
}
@@ -483,8 +596,17 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
background: ${pageBackground} !important;
color: ${textColor} !important;
}
.pdf-document-title { margin: 0 0 1.2em; padding: 0 0 .45em; border-bottom: 1px solid ${borderColor}; }
.pdf-document-title,
.pdf-custom-header {
margin: 0 0 1.2em;
padding: 0 0 .45em;
border-bottom: 1px solid ${borderColor};
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-document-title h1 { margin: 0; font: 700 1.7rem/1.18 Inter, ui-sans-serif, system-ui, sans-serif; color: ${headingColor}; }
.pdf-custom-header > :first-child { margin-top: 0 !important; }
.pdf-custom-header > :last-child { margin-bottom: 0 !important; }
.pdf-footer-spacer {
display: block;
width: 100%;
@@ -493,9 +615,9 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
padding: 0;
border: 0;
}
.pdf-generated-footer {
.pdf-document-footer {
box-sizing: border-box;
margin-top: 8mm;
margin-top: 0;
padding-top: 1.5mm;
border-top: .2mm solid ${borderColor};
color: ${footerColor};
@@ -505,6 +627,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-custom-footer > :first-child { margin-top: 0 !important; }
.pdf-custom-footer > :last-child { margin-bottom: 0 !important; }
.pdf-content {
width: 100%;
min-height: 0;
@@ -521,20 +645,11 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
.pdf-content a { color: inherit !important; text-decoration: underline; }
/* Book-like pagination: keep small semantic units together, not whole sections. */
/*
* Do not rely on break-after: avoid for the main Markdown headings.
* Chromium can first paint the heading at the end of the old page and
* then move it to the next page, leaving a clipped strip of glyphs behind.
* A small invisible reserve inside the heading keeps one following line
* with it without triggering that fragmentation bug.
*/
.pdf-content h1,
.pdf-content h2,
.pdf-content h3 {
break-after: auto;
page-break-after: auto;
padding-bottom: 1.15em;
margin-bottom: calc(.18em - 1.15em);
}
.pdf-content h4,
.pdf-content h5,
@@ -637,16 +752,18 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
}
.pdf-content .pdf-keep-unit > :first-child { margin-top: 0 !important; }
.pdf-content .pdf-keep-unit > :last-child { margin-bottom: 0 !important; }
.pdf-content .pdf-keep-unit.pdf-keep-unit--oversized {
display: block;
break-inside: auto;
page-break-inside: auto;
}
.pdf-content .pdf-keep-short {
break-inside: avoid-page !important;
page-break-inside: avoid !important;
}
.pdf-content .pdf-heading-lead > h1,
.pdf-content .pdf-heading-lead > h2,
.pdf-content .pdf-heading-lead > h3 {
padding-bottom: 0;
margin-bottom: .18em;
}
.pdf-content .pdf-heading-lead > h3 { margin-bottom: .18em; }
.pdf-content .pdf-atomic-visual {
display: inline-block;
width: 100%;
@@ -819,7 +936,7 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
`;
}
function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, darkMode }) {
function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, darkMode, verticalAlign }) {
const format = PAGE_FORMATS[formatKey] || PAGE_FORMATS["a4-portrait"];
const safeMargin = [0, 8, 12, 20].includes(Number(marginMm)) ? Number(marginMm) : 12;
const printWindow = window.open("", "_blank");
@@ -827,26 +944,39 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, da
try { printWindow.opener = null; } catch { /* same-origin hardening is best effort */ }
const content = preparePreview(preview);
const pdfLayout = extractPdfLayout(content);
const resolvedVerticalAlign = pdfLayout.verticalAlign || (verticalAlign === "center" ? "center" : "top");
const titleBlock = includeTitle && title
? `<header class="pdf-document-title"><h1>${escapeHtml(title)}</h1></header>`
: "";
const customHeader = pdfLayout.headerHtml
? `<header class="pdf-custom-header">${pdfLayout.headerHtml}</header>`
: "";
const lang = escapeHtml(document.documentElement.lang || "en");
const base = escapeHtml(`${location.origin}/`);
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, darkMode);
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
const footerBlock = pdfLayout.footerHtml
? `<footer class="pdf-document-footer pdf-custom-footer">${pdfLayout.footerHtml}</footer>`
: `<footer class="pdf-document-footer pdf-generated-footer" aria-hidden="true">${footerText}</footer>`;
return (async () => {
await ensurePrintableMermaid(content, darkMode);
await ensureSyntaxHighlight(content);
preparePrintPagination(content);
printWindow.document.open();
printWindow.document.write(`<!doctype html><html lang="${lang}" data-theme="${darkMode ? "dark" : "light"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${base}"><title>${escapeHtml(title || "RustPad")}</title>${stylesheetLinks()}<style>${css}</style></head><body><main class="pdf-document">${titleBlock}${content.outerHTML}<div class="pdf-footer-spacer" aria-hidden="true"></div><footer class="pdf-generated-footer" aria-hidden="true">${footerText}</footer></main></body></html>`);
printWindow.document.write(`<!doctype html><html lang="${lang}" data-theme="${darkMode ? "dark" : "light"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${base}"><title>${escapeHtml(title || "RustPad")}</title>${stylesheetLinks()}<style>${css}</style></head><body><main class="pdf-document">${customHeader}${titleBlock}${content.outerHTML}<div class="pdf-footer-spacer" aria-hidden="true"></div>${footerBlock}</main></body></html>`);
printWindow.document.close();
const runPrint = async () => {
await waitForPrintableLayout(printWindow.document);
fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin);
alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await nextFrame(printWindow);
fitOversizedKeepUnits(printWindow.document, format.heightMm, safeMargin);
alignContentVertically(printWindow.document, format.heightMm, safeMargin, resolvedVerticalAlign);
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
await nextFrame(printWindow);
printWindow.focus();
@@ -868,6 +998,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
if (!button || !dialog || !form || !preview) return;
const formatSelect = form.querySelector("#pdf-page-format");
const marginSelect = form.querySelector("#pdf-page-margin");
const verticalAlignSelect = form.querySelector("#pdf-vertical-align");
const darkMode = form.querySelector("#pdf-dark-mode");
const includeTitle = form.querySelector("#pdf-include-title");
const cancelButtons = form.querySelectorAll("[data-pdf-cancel]");
@@ -878,6 +1009,10 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
const savedMargin = saved("margin", "12");
marginSelect.value = ["0", "8", "12", "20"].includes(savedMargin) ? savedMargin : "12";
}
if (verticalAlignSelect) {
const savedVerticalAlign = saved("vertical-align", "top");
verticalAlignSelect.value = ["top", "center"].includes(savedVerticalAlign) ? savedVerticalAlign : "top";
}
if (darkMode) darkMode.checked = saved("dark-mode", "on") !== "off";
includeTitle.checked = saved("include-title", "off") === "on";
@@ -891,6 +1026,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
const marginMm = Number(marginSelect?.value ?? 12);
save("format", formatKey);
save("margin", marginMm);
save("vertical-align", verticalAlignSelect?.value === "center" ? "center" : "top");
save("dark-mode", darkMode?.checked === false ? "off" : "on");
save("include-title", includeTitle.checked ? "on" : "off");
try {
@@ -901,6 +1037,7 @@ export function bindPdfExport({ button, dialog, form, preview, title }) {
marginMm,
includeTitle: includeTitle.checked,
darkMode: darkMode?.checked !== false,
verticalAlign: verticalAlignSelect?.value === "center" ? "center" : "top",
});
dialog.close("confirm");
void printJob.catch(error => {
+3
View File
@@ -37,6 +37,7 @@ async function renderMermaid() {
const renderVersion = ++mermaidRenderVersion;
const nodes = [...content.querySelectorAll(".mermaid")];
if (!nodes.length) return;
content.querySelectorAll(".mermaid-error").forEach(error => error.remove());
try {
nodes.forEach(node => {
if (!node.dataset.mermaidSource) node.dataset.mermaidSource = node.textContent || "";
@@ -44,10 +45,12 @@ async function renderMermaid() {
const mermaid = await loadMermaid();
mermaid.initialize({ startOnLoad: false, theme: getTheme() === "dark" ? "dark" : "default", securityLevel: "strict" });
await mermaid.run({ nodes });
if (renderVersion === mermaidRenderVersion) content.querySelectorAll(".mermaid-error").forEach(error => error.remove());
} catch {
if (renderVersion !== mermaidRenderVersion) return;
nodes.forEach(node => {
if (!node.isConnected || !content.contains(node) || !node.parentNode) return;
if (node.previousElementSibling?.classList.contains("mermaid-error")) return;
const message = document.createElement("p");
message.className = "error mermaid-error";
message.textContent = t("public.mermaidFailed", {}, "Failed to load Mermaid.");
+7 -2
View File
@@ -19,8 +19,13 @@ function localAsset(path) {
}
function once(key, factory) {
if (!promiseCache.has(key)) promiseCache.set(key, Promise.resolve().then(factory));
return promiseCache.get(key);
if (promiseCache.has(key)) return promiseCache.get(key);
const promise = Promise.resolve().then(factory);
promiseCache.set(key, promise);
promise.catch(() => {
if (promiseCache.get(key) === promise) promiseCache.delete(key);
});
return promise;
}
function loadClassicScript(path, globalName) {
+97 -4
View File
@@ -58,6 +58,15 @@ const notesViewKey = `rustpad:workspace:${slug}:notes-view`;
let notesView = localStorage.getItem(notesViewKey) === "table" ? "table" : "grid";
let notesCache = [];
let archivedNotesCache = [];
let noteTitleOpenTimer = 0;
document.addEventListener("pointerdown", event => {
if (!noteTitleOpenTimer) return;
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-note-rename]")) return;
clearTimeout(noteTitleOpenTimer);
noteTitleOpenTimer = 0;
}, true);
function updateWorkspacePasswordControl() {
workspacePasswordForm.hidden = Boolean(info?.protected || !info?.can_set_password);
@@ -266,8 +275,6 @@ async function toggleNoteFavorite(button) {
{ title: t("favorites.title", {}, "Favorites") },
);
} catch (error) {
// The API client already rechecks the session after a 401.
// Only a 403 needs an additional check for an unrelated access restriction.
if (error.status === 403) {
try {
const session = await validateCurrentSession();
@@ -296,7 +303,7 @@ function renderNotes(notes = notesCache) {
notesList.innerHTML = `<div class="notes-table-scroll"><table><thead><tr>${favoriteHeader}<th>Name</th><th>Created by</th><th>Participants</th><th>Files</th><th>Revisions</th><th>Status</th><th>Updated</th><th class="notes-table-actions">Actions</th></tr></thead><tbody>${notes.map(note => `
<tr>
${accountSession ? `<td class="notes-table-favorite">${favoriteButton(note, true)}</td>` : ""}
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}">${escapeHtml(note.title)}</a></td>
<td><a class="note-table-link" href="${escapeHtml(safeAppUrl(note.url))}"><span class="note-list-inline-title${note.can_rename ? " note-list-inline-title--renamable" : ""}" ${note.can_rename ? `data-note-rename="${escapeHtml(note.slug)}" title="${escapeHtml(t("workspace.renameHint", {}, "Double-click to rename"))}"` : ""}>${escapeHtml(note.title)}</span></a></td>
<td class="note-author">${escapeHtml(note.created_by || "Unknown")}</td>
<td>${Number(note.participant_count) || 0}</td>
<td>${Number(note.file_count) || 0} <span class="note-status">(${formatBytes(note.file_size_bytes)})</span></td>
@@ -310,13 +317,70 @@ function renderNotes(notes = notesCache) {
notesList.innerHTML = notes.map(note => `
<article class="note-card-wrap${accountSession ? " note-card-wrap--favorite-enabled" : ""}">
<a class="note-card" href="${escapeHtml(safeAppUrl(note.url))}">
<div class="note-card-title"><h3>${escapeHtml(note.title)}</h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-title"><h3><span class="note-list-inline-title${note.can_rename ? " note-list-inline-title--renamable" : ""}" ${note.can_rename ? `data-note-rename="${escapeHtml(note.slug)}" title="${escapeHtml(t("workspace.renameHint", {}, "Double-click to rename"))}"` : ""}>${escapeHtml(note.title)}</span></h3>${note.protected ? '<span class="protect-badge">Protected</span>' : ''}</div>
<div class="note-card-meta"><span>Created by: ${escapeHtml(note.created_by || "Unknown")}</span>${noteStats(note)}<span>Updated: ${formatDate(note.updated_at)}</span></div>
</a>
${accountSession ? `<div class="note-card-favorite">${favoriteButton(note)}</div>` : ""}
${deleteButton(note)}
</article>`).join("");
}
function beginWorkspaceNoteRename(target, note) {
if (!target || !note?.can_rename || target.querySelector("input")) return;
const previous = note.title;
const input = document.createElement("input");
input.className = "note-title-inline-input";
input.type = "text";
input.maxLength = 80;
input.value = previous;
input.setAttribute("aria-label", t("workspace.renameLabel", {}, "Note name"));
target.replaceChildren(input);
target.classList.add("note-list-inline-title--editing");
let finished = false;
const restore = title => {
if (!target.isConnected) return;
target.classList.remove("note-list-inline-title--editing");
target.textContent = title;
};
const finish = async save => {
if (finished) return;
finished = true;
const next = input.value.trim();
if (!save) { restore(previous); return; }
if (!next) {
restore(previous);
toast.danger(t("workspace.renameEmpty", {}, "The note name cannot be empty."), { title: t("workspace.renameFailed", {}, "Could not rename note") });
return;
}
if (next === previous) { restore(previous); return; }
input.disabled = true;
try {
const result = await api(`/api/workspaces/${encodeURIComponent(slug)}/notes/${encodeURIComponent(note.slug)}/rename`, {
method: "POST",
body: JSON.stringify({ name: next, access_token: accessToken || null }),
});
note.title = result.title || next;
note.updated_at = result.updated_at || note.updated_at;
restore(note.title);
toast.success(t("workspace.renamed", { title: note.title }, `Renamed to “${note.title}”.`), { title: t("workspace.renamedTitle", {}, "Note renamed") });
await openWorkspace();
} catch (error) {
restore(previous);
toast.danger(error.message, { title: t("workspace.renameFailed", {}, "Could not rename note") });
}
};
input.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); });
input.addEventListener("dblclick", event => { event.preventDefault(); event.stopPropagation(); });
input.addEventListener("keydown", event => {
if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); void finish(true); }
else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); void finish(false); }
});
input.addEventListener("blur", () => { void finish(true); }, { once: true });
input.focus();
input.select();
}
async function showSystemNotFound() {
try {
const response = await fetch(`${location.pathname.replace(/\/$/, "")}/__not_found__`, {
@@ -519,6 +583,35 @@ document.querySelector("#note-form").addEventListener("submit", async e => {
} catch (err) { error.textContent = err.message; toast.danger(err.message, { title: "Could not create note" }); }
});
notesList.addEventListener("click", async event => {
const activeInput = event.target.closest(".note-title-inline-input");
if (activeInput) {
event.preventDefault();
event.stopPropagation();
return;
}
const editingTitle = notesList.querySelector(".note-list-inline-title--editing");
if (editingTitle) {
event.preventDefault();
return;
}
const renameTarget = event.target.closest("[data-note-rename]");
if (renameTarget) {
const note = notesCache.find(item => item.slug === renameTarget.dataset.noteRename);
if (note?.can_rename) {
event.preventDefault();
clearTimeout(noteTitleOpenTimer);
if (event.detail >= 2) {
noteTitleOpenTimer = 0;
beginWorkspaceNoteRename(renameTarget, note);
} else {
noteTitleOpenTimer = window.setTimeout(() => {
noteTitleOpenTimer = 0;
location.assign(safeAppUrl(note.url));
}, 400);
}
return;
}
}
const favorite = event.target.closest("[data-favorite-note]");
if (favorite) {
event.preventDefault();