This commit is contained in:
Mateusz Gruszczyński committed 2026-10-29 15:20:35 +01:00
1 parent d36a4b2451
commit 99a5700caa
8 files changed
+176 -56

No files matched your search

Generated
+1 -1
View File
@@ -2674,7 +2674,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.96" version = "0.2.97"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.96" version = "0.2.97"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+8
View File
@@ -2,6 +2,14 @@
RustPad is a collaborative Markdown editor with standalone notes and workspaces. RustPad is a collaborative Markdown editor with standalone notes and workspaces.
## Release 0.2.97 — proportional image resize and stable PDF pagination
- Image resize controls keep the source image proportions by default. Editing width automatically updates height and editing height updates width.
- The image toolbar adds a **Keep proportions** checkbox; disabling it allows independent width/height edits and free two-axis drag resizing.
- Drag resizing now uses the image's natural aspect ratio when available instead of preserving an accidentally distorted stored size.
- PDF pagination renders movable text blocks as atomic print fragments, eliminating Chromium's thin "ghost" text artifact when headings, paragraphs, blockquotes, or definition entries move to the next page.
- Oversized text blocks still fall back to normal page splitting so long content is never clipped or forced off-page.
## Release 0.2.96 — PDF layout, inline rename and PDF tags ## Release 0.2.96 — PDF layout, inline rename and PDF tags
- Pressing Enter while renaming a note title no longer bubbles to the header copy-link shortcut. - Pressing Enter while renaming a note title no longer bubbles to the header copy-link shortcut.
+1
View File
@@ -458,6 +458,7 @@
"editor.insertEmoji": "Insert emoji", "editor.insertEmoji": "Insert emoji",
"editor.italic": "Italic", "editor.italic": "Italic",
"editor.keyboardShortcuts": "Keyboard shortcuts", "editor.keyboardShortcuts": "Keyboard shortcuts",
"editor.keepAspectRatio": "Keep proportions",
"editor.layout.auto": "Auto", "editor.layout.auto": "Auto",
"editor.layout.center": "Center", "editor.layout.center": "Center",
"editor.layout.left": "Left", "editor.layout.left": "Left",
+1
View File
@@ -458,6 +458,7 @@
"editor.insertEmoji": "Wstaw emoji", "editor.insertEmoji": "Wstaw emoji",
"editor.italic": "Kursywa", "editor.italic": "Kursywa",
"editor.keyboardShortcuts": "Skróty klawiaturowe", "editor.keyboardShortcuts": "Skróty klawiaturowe",
"editor.keepAspectRatio": "Zachowaj proporcje",
"editor.layout.auto": "Automatycznie", "editor.layout.auto": "Automatycznie",
"editor.layout.center": "Wyśrodkowanie", "editor.layout.center": "Wyśrodkowanie",
"editor.layout.left": "Do lewej", "editor.layout.left": "Do lewej",
+27 -6
View File
@@ -2858,14 +2858,16 @@ dialog::backdrop {
} }
.image-alias-tools { .image-alias-tools {
position: absolute; position: fixed;
z-index: 4; z-index: 1000;
top: 8px; top: 8px;
right: 8px;
left: 8px; left: 8px;
width: max-content;
max-width: min(720px, calc(100vw - 24px));
box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
gap: 8px; gap: 8px;
flex-wrap: wrap; flex-wrap: wrap;
padding: 6px; padding: 6px;
@@ -2912,11 +2914,30 @@ dialog::backdrop {
font-size: .72rem; font-size: .72rem;
} }
.image-alias-size input { .image-alias-size input[type="number"] {
width: 66px; width: 66px;
padding: 3px 5px; padding: 3px 5px;
} }
.image-alias-size .image-alias-aspect {
gap: 5px;
padding: 0 4px;
white-space: nowrap;
cursor: pointer;
}
.image-alias-size .image-alias-aspect input {
width: 15px;
height: 15px;
min-height: 0;
margin: 0;
border: 0;
border-radius: 3px;
background: transparent;
accent-color: var(--accent);
cursor: pointer;
}
.image-alias-resize { .image-alias-resize {
position: absolute; position: absolute;
z-index: 5; z-index: 5;
@@ -2939,8 +2960,8 @@ dialog::backdrop {
@media(max-width:680px) { @media(max-width:680px) {
.image-alias-tools { .image-alias-tools {
top: 4px; top: 4px;
right: 4px;
left: 4px; left: 4px;
max-width: calc(100vw - 24px);
padding: 4px; padding: 4px;
} }
+97 -19
View File
@@ -1001,6 +1001,80 @@ export function startNoteEditor(adapter) {
}; };
} }
function imageFrameAspectRatio(frame) {
const image = frame?.querySelector("img");
if (image?.naturalWidth > 0 && image?.naturalHeight > 0) return image.naturalWidth / image.naturalHeight;
const dimensions = imageFrameDimensions(frame);
return Math.max(.01, dimensions.width / Math.max(1, dimensions.height));
}
function clampImageDimension(value) {
const number = Math.round(Number(value));
if (!Number.isFinite(number)) return null;
return Math.max(1, Math.min(10000, number));
}
function syncImageSizeInput(frame, changedInput) {
const tools = frame?.querySelector(".image-alias-tools");
const lock = tools?.querySelector("[data-image-aspect-lock]");
if (!tools || !lock?.checked || !(changedInput instanceof HTMLInputElement)) return;
const widthInput = tools.querySelector("[data-image-width-input]");
const heightInput = tools.querySelector("[data-image-height-input]");
const ratio = imageFrameAspectRatio(frame);
const changed = clampImageDimension(changedInput.value);
if (!changed || !Number.isFinite(ratio) || ratio <= 0) return;
if (changedInput === widthInput && heightInput) heightInput.value = String(clampImageDimension(changed / ratio));
if (changedInput === heightInput && widthInput) widthInput.value = String(clampImageDimension(changed * ratio));
}
function previewImageFrameSize(frame, width, height) {
frame.dataset.imageWidth = String(width);
frame.dataset.imageHeight = String(height);
frame.style.setProperty("--image-width", `${width}px`);
frame.style.setProperty("--image-height", `${height}px`);
frame.style.setProperty("--image-aspect", `${width} / ${height}`);
const widthInput = frame.querySelector("[data-image-width-input]");
const heightInput = frame.querySelector("[data-image-height-input]");
if (widthInput) widthInput.value = String(width);
if (heightInput) heightInput.value = String(height);
}
let keepImageAspectRatio = true;
function positionImageTools(frame) {
const tools = frame?.querySelector(".image-alias-tools");
if (!tools || !frame?.isConnected) return;
// Keep the floating toolbar completely inside the visual viewport.
// Using fixed positioning prevents the toolbar itself from increasing the
// document scroll width when the selected image is narrow or near an edge.
const margin = 12;
const viewport = window.visualViewport;
const viewportLeft = viewport?.offsetLeft || 0;
const viewportWidth = viewport?.width || document.documentElement.clientWidth || window.innerWidth;
const availableWidth = Math.max(1, Math.floor(viewportWidth - margin * 2));
const frameRect = frame.getBoundingClientRect();
tools.style.position = "fixed";
tools.style.maxWidth = `${availableWidth}px`;
tools.style.left = `${viewportLeft + margin}px`;
tools.style.top = `${Math.round(frameRect.top + 8)}px`;
const toolsRect = tools.getBoundingClientRect();
const minLeft = viewportLeft + margin;
const maxLeft = viewportLeft + viewportWidth - margin - toolsRect.width;
const preferredLeft = frameRect.left + Math.min(8, Math.max(0, frameRect.width - toolsRect.width) / 2);
const clampedLeft = Math.max(minLeft, Math.min(Math.max(minLeft, maxLeft), preferredLeft));
tools.style.left = `${Math.floor(clampedLeft)}px`;
}
function repositionSelectedImageTools() {
const frame = selectedImageFrame();
if (frame) positionImageTools(frame);
}
function selectImageFrame(frame) { function selectImageFrame(frame) {
if (!frame || !canEditDocument()) return; if (!frame || !canEditDocument()) return;
if (selectedImageFrame() === frame && frame.querySelector(".image-alias-tools")) return; if (selectedImageFrame() === frame && frame.querySelector(".image-alias-tools")) return;
@@ -1021,6 +1095,7 @@ export function startNoteEditor(adapter) {
<label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="${escapeHtml(t("files.imageWidth", {}, "Image width"))}"></label> <label>W<input type="number" min="1" max="10000" step="1" value="${dimensions.width}" data-image-width-input aria-label="${escapeHtml(t("files.imageWidth", {}, "Image width"))}"></label>
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
<label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="${escapeHtml(t("files.imageHeight", {}, "Image height"))}"></label> <label>H<input type="number" min="1" max="10000" step="1" value="${dimensions.height}" data-image-height-input aria-label="${escapeHtml(t("files.imageHeight", {}, "Image height"))}"></label>
<label class="image-alias-aspect"><input type="checkbox" data-image-aspect-lock${keepImageAspectRatio ? " checked" : ""}>${escapeHtml(t("editor.keepAspectRatio", {}, "Keep proportions"))}</label>
<button type="button" data-image-size-apply>${escapeHtml(t("common.set", {}, "Set"))}</button> <button type="button" data-image-size-apply>${escapeHtml(t("common.set", {}, "Set"))}</button>
<button type="button" data-image-size-reset>${escapeHtml(t("editor.layout.natural", {}, "Natural"))}</button> <button type="button" data-image-size-reset>${escapeHtml(t("editor.layout.natural", {}, "Natural"))}</button>
</div>`; </div>`;
@@ -1030,6 +1105,7 @@ export function startNoteEditor(adapter) {
resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image")); resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image"));
resize.title = t("editor.dragResize", {}, "Drag to resize"); resize.title = t("editor.dragResize", {}, "Drag to resize");
frame.append(tools, resize); frame.append(tools, resize);
requestAnimationFrame(() => positionImageTools(frame));
} }
function updateImageFrameAlias(frame, patch) { function updateImageFrameAlias(frame, patch) {
@@ -1069,36 +1145,26 @@ export function startNoteEditor(adapter) {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
const rect = frame.getBoundingClientRect(); const rect = frame.getBoundingClientRect();
const image = frame.querySelector("img"); const ratio = imageFrameAspectRatio(frame);
const storedWidth = Number(frame.dataset.imageWidth); const keepAspectRatio = frame.querySelector("[data-image-aspect-lock]")?.checked !== false;
const storedHeight = Number(frame.dataset.imageHeight);
const ratio = storedWidth > 0 && storedHeight > 0
? storedWidth / storedHeight
: image?.naturalWidth > 0 && image?.naturalHeight > 0
? image.naturalWidth / image.naturalHeight
: Math.max(.01, rect.width / Math.max(1, rect.height));
const parentWidth = frame.parentElement?.getBoundingClientRect().width || preview.clientWidth; const parentWidth = frame.parentElement?.getBoundingClientRect().width || preview.clientWidth;
const maxWidth = Math.max(1, Math.min(10000, Math.round(parentWidth))); const maxWidth = Math.max(1, Math.min(10000, Math.round(parentWidth)));
const minWidth = Math.min(80, maxWidth); const minWidth = Math.min(80, maxWidth);
const startX = event.clientX; const startX = event.clientX;
const startY = event.clientY;
const startWidth = Math.max(minWidth, Math.round(rect.width)); const startWidth = Math.max(minWidth, Math.round(rect.width));
const startHeight = Math.max(1, Math.round(rect.height));
let width = startWidth; let width = startWidth;
let height = Math.max(1, Math.round(width / ratio)); let height = startHeight;
handle.setPointerCapture(event.pointerId); handle.setPointerCapture(event.pointerId);
frame.classList.add("is-resizing", "markdown-alias-image--sized"); frame.classList.add("is-resizing", "markdown-alias-image--sized");
const move = moveEvent => { const move = moveEvent => {
width = Math.max(minWidth, Math.min(maxWidth, Math.round(startWidth + moveEvent.clientX - startX))); width = Math.max(minWidth, Math.min(maxWidth, Math.round(startWidth + moveEvent.clientX - startX)));
height = Math.max(1, Math.min(10000, Math.round(width / ratio))); height = keepAspectRatio
frame.dataset.imageWidth = String(width); ? Math.max(1, Math.min(10000, Math.round(width / ratio)))
frame.dataset.imageHeight = String(height); : Math.max(1, Math.min(10000, Math.round(startHeight + moveEvent.clientY - startY)));
frame.style.setProperty("--image-width", `${width}px`); previewImageFrameSize(frame, width, height);
frame.style.setProperty("--image-height", `${height}px`);
frame.style.setProperty("--image-aspect", `${width} / ${height}`);
const widthInput = frame.querySelector("[data-image-width-input]");
const heightInput = frame.querySelector("[data-image-height-input]");
if (widthInput) widthInput.value = String(width);
if (heightInput) heightInput.value = String(height);
}; };
const finish = () => { const finish = () => {
frame.classList.remove("is-resizing"); frame.classList.remove("is-resizing");
@@ -1269,8 +1335,10 @@ export function startNoteEditor(adapter) {
preview.addEventListener("load", invalidatePreviewPositions, true); preview.addEventListener("load", invalidatePreviewPositions, true);
window.addEventListener("resize", invalidatePreviewPositions); window.addEventListener("resize", invalidatePreviewPositions);
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
repositionSelectedImageTools();
if (virtualLayers.active) virtualLayers.sync(); if (virtualLayers.active) virtualLayers.sync();
}); });
window.addEventListener("scroll", repositionSelectedImageTools, { passive: true, capture: true });
document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions); document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions);
function previewLinePositions() { function previewLinePositions() {
const height = preview.scrollHeight, width = preview.clientWidth; const height = preview.scrollHeight, width = preview.clientWidth;
@@ -1845,6 +1913,16 @@ export function startNoteEditor(adapter) {
updateImageFrameAlias(frame, { width: null, height: null }); updateImageFrameAlias(frame, { width: null, height: null });
} }
}); });
preview.addEventListener("input", event => {
if (!(event.target instanceof HTMLInputElement)) return;
if (!event.target.matches("[data-image-width-input], [data-image-height-input]")) return;
const frame = event.target.closest(".markdown-alias-image");
if (frame) syncImageSizeInput(frame, event.target);
});
preview.addEventListener("change", event => {
if (!(event.target instanceof HTMLInputElement) || !event.target.matches("[data-image-aspect-lock]")) return;
keepImageAspectRatio = event.target.checked;
});
preview.addEventListener("keydown", event => { preview.addEventListener("keydown", event => {
const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null; const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null;
if (!frame) return; if (!frame) return;
+40 -29
View File
@@ -317,11 +317,18 @@ function groupHeadingsWithLead(root) {
}); });
} }
function markAtomicTextBlocks(root) {
root.querySelectorAll("h1, h2, h3, h4, h5, h6, p, blockquote, dt, dd").forEach(node => {
if (!node.closest(".pdf-keep-unit")) node.classList.add("pdf-atomic-text");
});
}
function preparePrintPagination(root) { function preparePrintPagination(root) {
collapseHeadingGaps(root); collapseHeadingGaps(root);
markShortKeepBlocks(root); markShortKeepBlocks(root);
groupAtomicVisuals(root); groupAtomicVisuals(root);
groupHeadingsWithLead(root); groupHeadingsWithLead(root);
markAtomicTextBlocks(root);
} }
async function ensurePrintableMermaid(root, darkMode = false) { async function ensurePrintableMermaid(root, darkMode = false) {
@@ -446,8 +453,17 @@ function alignFooterToLastPage(doc, pageHeightMm, marginMm) {
const minGap = 4 * pxPerMm; const minGap = 4 * pxPerMm;
if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return; if (!Number.isFinite(pageContentHeight) || pageContentHeight <= footerHeight || footerHeight <= 0) return;
const measuredFill = measureLastPageFill(doc, documentRoot, pageContentHeight); // The multicolumn pagination probe is only needed when the document contains
const positionOnPage = measuredFill ?? fallbackLastPageFill(documentRoot, spacer, pageContentHeight); // an explicit PDF page break. For ordinary flowing documents Chromium's
// column layout can report the sentinel at the end of a full column, which
// incorrectly makes the footer look as if it no longer fits and creates an
// otherwise empty extra page containing only the footer.
const fallbackFill = fallbackLastPageFill(documentRoot, spacer, pageContentHeight);
const hasExplicitPageBreak = Boolean(doc.querySelector(".pdf-content .pdf-page-break"));
const measuredFill = hasExplicitPageBreak
? measureLastPageFill(doc, documentRoot, pageContentHeight)
: null;
const positionOnPage = measuredFill ?? fallbackFill;
const available = Math.max(0, pageContentHeight - positionOnPage); const available = Math.max(0, pageContentHeight - positionOnPage);
const spacerHeight = available >= footerHeight + minGap + bottomGuard const spacerHeight = available >= footerHeight + minGap + bottomGuard
@@ -464,7 +480,7 @@ function fitOversizedKeepUnits(doc, pageHeightMm, marginMm) {
const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm; const pageContentHeight = (Number(pageHeightMm) - (2 * Number(marginMm))) * pxPerMm;
const maxHeight = Math.max(40, pageContentHeight - Math.max(2, pxPerMm)); const maxHeight = Math.max(40, pageContentHeight - Math.max(2, pxPerMm));
root.querySelectorAll(".pdf-keep-unit").forEach(unit => { root.querySelectorAll(".pdf-keep-unit, .pdf-keep-short, .pdf-atomic-text").forEach(unit => {
unit.classList.remove("pdf-keep-unit--oversized"); unit.classList.remove("pdf-keep-unit--oversized");
let rect = unit.getBoundingClientRect(); let rect = unit.getBoundingClientRect();
if (rect.height <= maxHeight) return; if (rect.height <= maxHeight) return;
@@ -659,25 +675,13 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
break-after: avoid-page; break-after: avoid-page;
page-break-after: avoid; 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 { .pdf-content .footnotes li {
break-inside: avoid-page; break-inside: auto;
page-break-inside: avoid; page-break-inside: auto;
} }
.pdf-content p, .pdf-content p,
.pdf-content li, .pdf-content li,
.pdf-content dd { orphans: 3; widows: 3; } .pdf-content dd { orphans: 3; widows: 3; }
.pdf-content p {
break-inside: avoid-page;
page-break-inside: avoid;
}
.pdf-content ul, .pdf-content ul,
.pdf-content ol, .pdf-content ol,
@@ -693,8 +697,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
page-break-inside: auto; page-break-inside: auto;
} }
.pdf-content li { .pdf-content li {
break-inside: avoid-page; break-inside: auto;
page-break-inside: avoid; page-break-inside: auto;
} }
.pdf-content li:has(> ul), .pdf-content li:has(> ul),
.pdf-content li:has(> ol) { .pdf-content li:has(> ol) {
@@ -736,31 +740,38 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) {
-webkit-box-decoration-break: clone; -webkit-box-decoration-break: clone;
} }
.pdf-content .pdf-keep-unit { .pdf-content .pdf-keep-unit,
.pdf-content .pdf-atomic-text,
.pdf-content .pdf-keep-short {
/* /*
* Chromium can leave a 1-2 px fragment of an avoided block on the * Chromium can paint a thin fragment of a block at the bottom of the
* previous page before moving the real block to the next page. * previous page before moving the actual block to the next page.
* Making the keep unit an atomic inline-block prevents that ghost * Atomic inline-blocks prevent that paint fragment instead of relying
* fragment while preserving book-like pagination. * only on break-inside: avoid, which triggers the Chromium artifact.
*/ */
display: inline-block; display: inline-block;
width: 100%; width: 100%;
max-width: 100%;
vertical-align: top; vertical-align: top;
box-sizing: border-box; box-sizing: border-box;
break-inside: avoid-page; break-inside: avoid-page;
page-break-inside: avoid; page-break-inside: avoid;
} }
.pdf-content dd.pdf-atomic-text {
width: calc(100% - 1.5em);
max-width: calc(100% - 1.5em);
}
.pdf-content .pdf-keep-unit > :first-child { margin-top: 0 !important; } .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 > :last-child { margin-bottom: 0 !important; }
.pdf-content .pdf-keep-unit.pdf-keep-unit--oversized { .pdf-content .pdf-keep-unit.pdf-keep-unit--oversized,
.pdf-content .pdf-atomic-text.pdf-keep-unit--oversized,
.pdf-content .pdf-keep-short.pdf-keep-unit--oversized {
display: block; display: block;
width: auto;
max-width: none;
break-inside: auto; break-inside: auto;
page-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 > h1,
.pdf-content .pdf-heading-lead > h2, .pdf-content .pdf-heading-lead > h2,
.pdf-content .pdf-heading-lead > h3 { margin-bottom: .18em; } .pdf-content .pdf-heading-lead > h3 { margin-bottom: .18em; }