hotfix pagebreak
This commit is contained in:
@@ -45,15 +45,23 @@ function toggleWrap(editor, before, after = before, placeholder = t("editor.form
|
||||
}
|
||||
|
||||
|
||||
export function insertPageBreak(editor) {
|
||||
function insertStandaloneLine(editor, content) {
|
||||
const { start, end } = selection(editor);
|
||||
const value = editor.value;
|
||||
const needsLeadingNewline = start > 0 && value[start - 1] !== "\n";
|
||||
const needsTrailingNewline = end >= value.length || value[end] !== "\n";
|
||||
const marker = `${needsLeadingNewline ? "\n" : ""}---${needsTrailingNewline ? "\n" : ""}`;
|
||||
const marker = `${needsLeadingNewline ? "\n" : ""}${content}${needsTrailingNewline ? "\n" : ""}`;
|
||||
editor.setRangeText(marker, start, end, "end");
|
||||
}
|
||||
|
||||
export function insertPageBreak(editor) {
|
||||
insertStandaloneLine(editor, "[PAGEBREAK]");
|
||||
}
|
||||
|
||||
export function insertHorizontalRule(editor) {
|
||||
insertStandaloneLine(editor, "---");
|
||||
}
|
||||
|
||||
function togglePrefix(editor, prefixFactory) {
|
||||
const { start, end } = selection(editor);
|
||||
const lineStart = editor.value.lastIndexOf("\n", start - 1) + 1;
|
||||
@@ -98,7 +106,8 @@ export function applyFormat(editor, format) {
|
||||
if (format === "table") toggleWrap(editor, `| ${t("editor.format.column1", {}, "Column 1")} | ${t("editor.format.column2", {}, "Column 2")} |\n| --- | --- |\n| `, ` | ${t("editor.format.value", {}, "value")} |`, t("editor.format.value", {}, "value"));
|
||||
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" || format === "horizontal-rule") insertPageBreak(editor);
|
||||
if (format === "page-break") insertPageBreak(editor);
|
||||
if (format === "horizontal-rule") insertHorizontalRule(editor);
|
||||
editor.focus();
|
||||
editor.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
@@ -553,7 +553,8 @@ export function renderMarkdown(source, lineOffset = 0) {
|
||||
html += `<dd data-source-line="${index + lineOffset + 1}">${inline(lines[index].replace(/^:\s+/, ""))}</dd>`;
|
||||
}
|
||||
html += `</dl>`;
|
||||
} else if (/^---+$/.test(line.trim())) html += `<hr${attrs(index, false, "", "", lineOffset).replace(' class="', ' class="page-break-marker ')} data-page-break="true">`;
|
||||
} 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.startsWith("> ")) html += `<blockquote${attrs(index, true, "> ", "", lineOffset)}>${inline(line.slice(2))}</blockquote>`;
|
||||
else if (line.trim()) html += `<p${attrs(index, true, "", "", lineOffset)}>${inline(line)}</p>`;
|
||||
else html += `<div${attrs(index, true, "", "", lineOffset)}><br></div>`;
|
||||
|
||||
+268
-45
@@ -94,6 +94,89 @@ function mmToPx(doc) {
|
||||
return pxPerMm;
|
||||
}
|
||||
|
||||
function nextFrame(view) {
|
||||
return new Promise(resolve => view.requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
async function waitForPrintableLayout(doc) {
|
||||
try { await doc.fonts?.ready; } catch { /* print with available fonts */ }
|
||||
|
||||
const images = [...doc.images];
|
||||
if (images.length) {
|
||||
const imageReady = Promise.all(images.map(image => {
|
||||
if (image.complete) {
|
||||
try { return image.decode?.() || Promise.resolve(); } catch { return Promise.resolve(); }
|
||||
}
|
||||
return new Promise(resolve => {
|
||||
image.addEventListener("load", resolve, { once: true });
|
||||
image.addEventListener("error", resolve, { once: true });
|
||||
});
|
||||
}));
|
||||
await Promise.race([
|
||||
imageReady,
|
||||
new Promise(resolve => setTimeout(resolve, 1500)),
|
||||
]);
|
||||
}
|
||||
|
||||
await nextFrame(doc.defaultView);
|
||||
await nextFrame(doc.defaultView);
|
||||
}
|
||||
|
||||
function measureLastPageFill(doc, documentRoot, pageContentHeight) {
|
||||
const content = documentRoot.querySelector(":scope > .pdf-content");
|
||||
if (!content) return null;
|
||||
|
||||
const width = documentRoot.getBoundingClientRect().width;
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(pageContentHeight) || pageContentHeight <= 0) return null;
|
||||
|
||||
const probe = doc.createElement("div");
|
||||
probe.setAttribute("aria-hidden", "true");
|
||||
probe.style.cssText = [
|
||||
"position:absolute",
|
||||
"visibility:hidden",
|
||||
"pointer-events:none",
|
||||
"top:0",
|
||||
`left:${-(Math.ceil(width) + 200)}px`,
|
||||
`width:${width}px`,
|
||||
`height:${pageContentHeight}px`,
|
||||
`column-width:${width}px`,
|
||||
"column-gap:0",
|
||||
"column-fill:auto",
|
||||
"overflow:visible",
|
||||
"box-sizing:border-box",
|
||||
"z-index:-1",
|
||||
].join(";");
|
||||
|
||||
const title = documentRoot.querySelector(":scope > .pdf-document-title");
|
||||
if (title) probe.appendChild(title.cloneNode(true));
|
||||
|
||||
const flow = content.cloneNode(true);
|
||||
flow.querySelectorAll(".pdf-page-break").forEach(pageBreak => {
|
||||
pageBreak.style.breakAfter = "column";
|
||||
pageBreak.style.pageBreakAfter = "auto";
|
||||
});
|
||||
probe.appendChild(flow);
|
||||
|
||||
const sentinel = doc.createElement("span");
|
||||
sentinel.style.cssText = "display:inline-block;width:1px;height:1px;margin:0;padding:0;border:0;vertical-align:top;";
|
||||
probe.appendChild(sentinel);
|
||||
doc.body.appendChild(probe);
|
||||
|
||||
const probeRect = probe.getBoundingClientRect();
|
||||
const sentinelRect = sentinel.getBoundingClientRect();
|
||||
const fill = sentinelRect.bottom - probeRect.top;
|
||||
probe.remove();
|
||||
|
||||
if (!Number.isFinite(fill) || fill < 0 || fill > pageContentHeight + 4) return null;
|
||||
return Math.max(0, Math.min(pageContentHeight, fill));
|
||||
}
|
||||
|
||||
function fallbackLastPageFill(documentRoot, spacer, pageContentHeight) {
|
||||
const rootTop = documentRoot.getBoundingClientRect().top;
|
||||
const contentEnd = Math.max(0, spacer.getBoundingClientRect().top - rootTop);
|
||||
return ((contentEnd % pageContentHeight) + pageContentHeight) % pageContentHeight;
|
||||
}
|
||||
|
||||
function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
|
||||
const documentRoot = doc.querySelector(".pdf-document");
|
||||
const spacer = doc.querySelector(".pdf-footer-spacer");
|
||||
@@ -107,38 +190,23 @@ function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
|
||||
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 = 1.5 * pxPerMm;
|
||||
const bottomGuard = 0.75 * pxPerMm;
|
||||
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
|
||||
|
||||
const rootTop = documentRoot.getBoundingClientRect().top;
|
||||
const footerTop = footer.getBoundingClientRect().top - rootTop;
|
||||
const pageBreaks = documentRoot.querySelectorAll(".pdf-page-break");
|
||||
const lastPageBreak = pageBreaks.length ? pageBreaks[pageBreaks.length - 1] : null;
|
||||
const finalSegmentStart = lastPageBreak
|
||||
? lastPageBreak.getBoundingClientRect().bottom - rootTop
|
||||
: 0;
|
||||
|
||||
// Measure the end of document content, not the footer box. The footer's
|
||||
// top margin may be reduced on a tight last page so it does not create an
|
||||
// otherwise unnecessary extra page.
|
||||
const contentEnd = Math.max(0, footerTop - footerMarginTop - finalSegmentStart);
|
||||
const positionOnPage = ((contentEnd % pageContentHeight) + pageContentHeight) % pageContentHeight;
|
||||
const available = pageContentHeight - positionOnPage;
|
||||
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;
|
||||
|
||||
if (available >= footerHeight + bottomGuard) {
|
||||
// Keep the normal footer gap when possible, but shrink it before moving
|
||||
// the footer to another page. Leave a small guard against print rounding.
|
||||
actualMarginTop = Math.min(
|
||||
footerMarginTop,
|
||||
Math.max(0, available - footerHeight - bottomGuard),
|
||||
);
|
||||
spacerHeight = available - footerHeight - actualMarginTop - bottomGuard;
|
||||
} else {
|
||||
// The footer itself cannot fit on the current page. Align it near the
|
||||
// bottom of the next page while preserving its normal top gap.
|
||||
spacerHeight = available + pageContentHeight - footerHeight - actualMarginTop - bottomGuard;
|
||||
}
|
||||
|
||||
@@ -159,12 +227,19 @@ function preparePreview(preview) {
|
||||
|
||||
clone.querySelectorAll('[contenteditable="true"]').forEach(node => node.removeAttribute("contenteditable"));
|
||||
clone.querySelectorAll(".image-alias-tools, .image-alias-resize-handle").forEach(node => node.remove());
|
||||
clone.querySelectorAll("details").forEach(node => { node.open = true; });
|
||||
clone.querySelectorAll("details").forEach(node => {
|
||||
node.open = true;
|
||||
const replacement = document.createElement("section");
|
||||
[...node.attributes].forEach(attribute => replacement.setAttribute(attribute.name, attribute.value));
|
||||
replacement.setAttribute("open", "");
|
||||
while (node.firstChild) replacement.appendChild(node.firstChild);
|
||||
node.replaceWith(replacement);
|
||||
});
|
||||
clone.querySelectorAll("input, button, select, textarea").forEach(node => { node.tabIndex = -1; });
|
||||
|
||||
if (clone.classList.contains("preview--raw")) {
|
||||
clone.querySelectorAll(".preview-source-line").forEach(line => {
|
||||
if (!/^---+$/.test(line.textContent.trim())) return;
|
||||
if (!/^\[PAGEBREAK\]$/i.test(line.textContent.trim())) return;
|
||||
const pageBreak = document.createElement("div");
|
||||
pageBreak.className = "pdf-page-break";
|
||||
pageBreak.setAttribute("aria-hidden", "true");
|
||||
@@ -182,12 +257,23 @@ function preparePreview(preview) {
|
||||
return clone;
|
||||
}
|
||||
|
||||
function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
|
||||
function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, preserveColors) {
|
||||
const pageContentWidthMm = Math.max(20, Number(pageWidthMm) - (2 * Number(marginMm)));
|
||||
const pageContentHeightMm = Math.max(20, Number(pageHeightMm) - (2 * Number(marginMm)));
|
||||
const atomicMaxHeightMm = Math.max(20, pageContentHeightMm - 8);
|
||||
const mediaMaxWidthMm = atomicMaxHeightMm * (16 / 9);
|
||||
|
||||
return `
|
||||
@page { size: ${cssSize}; margin: ${marginMm}mm; }
|
||||
html { color-scheme: light; background: #fff !important; }
|
||||
body { width: ${pageWidthMm - (2 * marginMm)}mm; margin: 0 !important; background: #fff !important; color: #16181d !important; }
|
||||
.pdf-document { width: ${pageWidthMm - (2 * marginMm)}mm; box-sizing: border-box; }
|
||||
body {
|
||||
width: ${pageContentWidthMm}mm;
|
||||
max-width: ${pageContentWidthMm}mm;
|
||||
margin: 0 !important;
|
||||
background: #fff !important;
|
||||
color: #16181d !important;
|
||||
}
|
||||
.pdf-document { width: ${pageContentWidthMm}mm; box-sizing: border-box; }
|
||||
.pdf-document-title { margin: 0 0 1.2em; padding: 0 0 .45em; border-bottom: 1px solid #d7dce2; }
|
||||
.pdf-document-title h1 { margin: 0; font: 700 1.7rem/1.18 Inter, ui-sans-serif, system-ui, sans-serif; color: #111318; }
|
||||
.pdf-footer-spacer {
|
||||
@@ -197,8 +283,6 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
break-after: avoid-page;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.pdf-generated-footer {
|
||||
box-sizing: border-box;
|
||||
@@ -209,17 +293,156 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
|
||||
font: 500 7.5pt/1.2 Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: .01em;
|
||||
text-align: center;
|
||||
break-before: avoid-page;
|
||||
page-break-before: avoid;
|
||||
break-inside: avoid;
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content { width: 100%; min-height: 0; margin: 0; padding: 0 !important; overflow: visible !important; color: #16181d !important; box-sizing: border-box; }
|
||||
.pdf-content {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
color: #16181d !important;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.pdf-content .preview-source-line::before { display: none !important; content: none !important; }
|
||||
.pdf-content .preview-source-line { min-height: 0; }
|
||||
.pdf-content .preview-editable:hover,
|
||||
.pdf-content .preview-editable:focus { background: transparent !important; box-shadow: none !important; }
|
||||
.pdf-content a { color: inherit !important; text-decoration: underline; }
|
||||
|
||||
/* Book-like pagination: keep small semantic units together, not whole sections. */
|
||||
.pdf-content h1,
|
||||
.pdf-content h2,
|
||||
.pdf-content h3,
|
||||
.pdf-content h4,
|
||||
.pdf-content h5,
|
||||
.pdf-content h6,
|
||||
.pdf-content .markdown-details > summary,
|
||||
.pdf-content dt {
|
||||
break-after: avoid-page;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.pdf-content h1,
|
||||
.pdf-content h2,
|
||||
.pdf-content h3,
|
||||
.pdf-content h4,
|
||||
.pdf-content h5,
|
||||
.pdf-content h6,
|
||||
.pdf-content blockquote,
|
||||
.pdf-content dd,
|
||||
.pdf-content .footnotes li {
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content p,
|
||||
.pdf-content li,
|
||||
.pdf-content dd { orphans: 3; widows: 3; }
|
||||
|
||||
.pdf-content ul,
|
||||
.pdf-content ol,
|
||||
.pdf-content dl,
|
||||
.pdf-content .table-wrap,
|
||||
.pdf-content table,
|
||||
.pdf-content .markdown-alert,
|
||||
.pdf-content .markdown-details,
|
||||
.pdf-content .markdown-details__content,
|
||||
.pdf-content .footnotes,
|
||||
.pdf-content pre {
|
||||
break-inside: auto;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
.pdf-content li {
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content li:has(> ul),
|
||||
.pdf-content li:has(> ol) {
|
||||
break-inside: auto;
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.pdf-content .table-wrap { overflow: visible !important; }
|
||||
.pdf-content table { width: 100% !important; }
|
||||
.pdf-content thead { display: table-header-group; }
|
||||
.pdf-content tfoot { display: table-footer-group; }
|
||||
.pdf-content tr {
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.pdf-content pre {
|
||||
overflow: visible !important;
|
||||
white-space: pre-wrap !important;
|
||||
orphans: 3;
|
||||
widows: 3;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
.pdf-content pre code,
|
||||
.pdf-content pre.code-with-lines .code-line {
|
||||
white-space: pre-wrap !important;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pdf-content pre.code-with-lines .code-line {
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.pdf-content .markdown-alert,
|
||||
.pdf-content .markdown-details {
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
|
||||
/* Atomic visual items are scaled to the printable page instead of being fragmented. */
|
||||
.pdf-content img {
|
||||
max-width: 100% !important;
|
||||
max-height: ${atomicMaxHeightMm}mm !important;
|
||||
width: auto;
|
||||
height: auto !important;
|
||||
object-fit: contain;
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content .markdown-alias-image--sized {
|
||||
height: auto !important;
|
||||
aspect-ratio: auto !important;
|
||||
}
|
||||
.pdf-content .markdown-alias-image--sized > img {
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
.pdf-content .mermaid {
|
||||
overflow: hidden !important;
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content .mermaid svg {
|
||||
display: block;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
max-height: ${atomicMaxHeightMm}mm !important;
|
||||
height: auto !important;
|
||||
margin-inline: auto;
|
||||
}
|
||||
.pdf-content .rustpad-media {
|
||||
width: min(100%, ${mediaMaxWidthMm}mm) !important;
|
||||
max-width: 100% !important;
|
||||
margin-inline: auto !important;
|
||||
box-shadow: none !important;
|
||||
break-inside: avoid-page;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.pdf-content .rustpad-media__player,
|
||||
.pdf-content .rustpad-media__player iframe,
|
||||
.pdf-content .rustpad-media__player video,
|
||||
.pdf-content video.rustpad-media__player {
|
||||
max-height: ${atomicMaxHeightMm}mm !important;
|
||||
}
|
||||
|
||||
${preserveColors ? `
|
||||
.pdf-document,
|
||||
.pdf-document * { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
|
||||
@@ -263,17 +486,17 @@ function printStyles(cssSize, pageWidthMm, marginMm, preserveColors) {
|
||||
.pdf-content .markdown-alert--danger { border-color: #a54b55 !important; background: rgba(155, 52, 67, .09) !important; }
|
||||
.pdf-content .task-checkbox { accent-color: #6859b5 !important; }
|
||||
` : ""}
|
||||
.pdf-content pre { overflow: visible !important; white-space: pre-wrap !important; break-inside: avoid; page-break-inside: avoid; }
|
||||
.pdf-content pre code,
|
||||
.pdf-content pre.code-with-lines .code-line { white-space: pre-wrap !important; overflow-wrap: anywhere; word-break: break-word; }
|
||||
.pdf-content table,
|
||||
.pdf-content img,
|
||||
.pdf-content svg,
|
||||
.pdf-content blockquote { break-inside: avoid; page-break-inside: avoid; }
|
||||
.pdf-content img,
|
||||
.pdf-content svg { max-width: 100% !important; height: auto; }
|
||||
.pdf-content .table-wrap { overflow: visible !important; }
|
||||
.pdf-page-break { display: block; height: 0; margin: 0; padding: 0; border: 0; break-after: page; page-break-after: always; }
|
||||
|
||||
.pdf-page-break {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
break-after: page;
|
||||
page-break-after: always;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -290,7 +513,7 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, pr
|
||||
: "";
|
||||
const lang = escapeHtml(document.documentElement.lang || "en");
|
||||
const base = escapeHtml(`${location.origin}/`);
|
||||
const css = printStyles(format.cssSize, format.widthMm, safeMargin, preserveColors);
|
||||
const css = printStyles(format.cssSize, format.widthMm, format.heightMm, safeMargin, preserveColors);
|
||||
const footerText = escapeHtml(t("editor.pdfGeneratedFooter", {}, "Generated by RustPad"));
|
||||
|
||||
return (async () => {
|
||||
@@ -301,11 +524,11 @@ function openPrintWindow({ preview, title, formatKey, marginMm, includeTitle, pr
|
||||
printWindow.document.close();
|
||||
|
||||
const runPrint = async () => {
|
||||
try { await printWindow.document.fonts?.ready; } catch { /* print with available fonts */ }
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
await waitForPrintableLayout(printWindow.document);
|
||||
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
||||
await new Promise(resolve => printWindow.requestAnimationFrame(() => resolve()));
|
||||
await nextFrame(printWindow);
|
||||
alignFooterToLastPage(printWindow.document, format.heightMm, safeMargin);
|
||||
await nextFrame(printWindow);
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user