From 99a5700caa9bd447ce284674517d8b446b0fe13a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 29 Oct 2026 15:20:35 +0100 Subject: [PATCH] v0.2.97 --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 8 +++ lang/en.json | 1 + lang/pl.json | 1 + static/css/styles.css | 33 +++++++++-- static/js/note-editor.js | 116 ++++++++++++++++++++++++++++++++------- static/js/pdf-export.js | 69 +++++++++++++---------- 8 files changed, 176 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4a6091..9b0d07b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2674,7 +2674,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.2.96" +version = "0.2.97" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index b70d8ab..7d7309f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.2.96" +version = "0.2.97" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index 10a3db0..f6420b8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,14 @@ 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 - Pressing Enter while renaming a note title no longer bubbles to the header copy-link shortcut. diff --git a/lang/en.json b/lang/en.json index 8f7ca58..ebab15a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -458,6 +458,7 @@ "editor.insertEmoji": "Insert emoji", "editor.italic": "Italic", "editor.keyboardShortcuts": "Keyboard shortcuts", + "editor.keepAspectRatio": "Keep proportions", "editor.layout.auto": "Auto", "editor.layout.center": "Center", "editor.layout.left": "Left", diff --git a/lang/pl.json b/lang/pl.json index 07c0f8c..bea8fd9 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -458,6 +458,7 @@ "editor.insertEmoji": "Wstaw emoji", "editor.italic": "Kursywa", "editor.keyboardShortcuts": "Skróty klawiaturowe", + "editor.keepAspectRatio": "Zachowaj proporcje", "editor.layout.auto": "Automatycznie", "editor.layout.center": "Wyśrodkowanie", "editor.layout.left": "Do lewej", diff --git a/static/css/styles.css b/static/css/styles.css index 6882018..10e441d 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -2858,14 +2858,16 @@ dialog::backdrop { } .image-alias-tools { - position: absolute; - z-index: 4; + position: fixed; + z-index: 1000; top: 8px; - right: 8px; left: 8px; + width: max-content; + max-width: min(720px, calc(100vw - 24px)); + box-sizing: border-box; display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; flex-wrap: wrap; padding: 6px; @@ -2912,11 +2914,30 @@ dialog::backdrop { font-size: .72rem; } -.image-alias-size input { +.image-alias-size input[type="number"] { width: 66px; 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 { position: absolute; z-index: 5; @@ -2939,8 +2960,8 @@ dialog::backdrop { @media(max-width:680px) { .image-alias-tools { top: 4px; - right: 4px; left: 4px; + max-width: calc(100vw - 24px); padding: 4px; } diff --git a/static/js/note-editor.js b/static/js/note-editor.js index 8951fd9..433e9c4 100644 --- a/static/js/note-editor.js +++ b/static/js/note-editor.js @@ -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) { if (!frame || !canEditDocument()) return; if (selectedImageFrame() === frame && frame.querySelector(".image-alias-tools")) return; @@ -1021,6 +1095,7 @@ export function startNoteEditor(adapter) { + `; @@ -1030,6 +1105,7 @@ export function startNoteEditor(adapter) { resize.setAttribute("aria-label", t("editor.resizeImage", {}, "Resize image")); resize.title = t("editor.dragResize", {}, "Drag to resize"); frame.append(tools, resize); + requestAnimationFrame(() => positionImageTools(frame)); } function updateImageFrameAlias(frame, patch) { @@ -1069,36 +1145,26 @@ export function startNoteEditor(adapter) { event.preventDefault(); event.stopImmediatePropagation(); const rect = frame.getBoundingClientRect(); - const image = frame.querySelector("img"); - const storedWidth = Number(frame.dataset.imageWidth); - 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 ratio = imageFrameAspectRatio(frame); + const keepAspectRatio = frame.querySelector("[data-image-aspect-lock]")?.checked !== false; const parentWidth = frame.parentElement?.getBoundingClientRect().width || preview.clientWidth; const maxWidth = Math.max(1, Math.min(10000, Math.round(parentWidth))); const minWidth = Math.min(80, maxWidth); const startX = event.clientX; + const startY = event.clientY; const startWidth = Math.max(minWidth, Math.round(rect.width)); + const startHeight = Math.max(1, Math.round(rect.height)); let width = startWidth; - let height = Math.max(1, Math.round(width / ratio)); + let height = startHeight; handle.setPointerCapture(event.pointerId); frame.classList.add("is-resizing", "markdown-alias-image--sized"); const move = moveEvent => { width = Math.max(minWidth, Math.min(maxWidth, Math.round(startWidth + moveEvent.clientX - startX))); - height = Math.max(1, Math.min(10000, Math.round(width / ratio))); - 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); + height = keepAspectRatio + ? Math.max(1, Math.min(10000, Math.round(width / ratio))) + : Math.max(1, Math.min(10000, Math.round(startHeight + moveEvent.clientY - startY))); + previewImageFrameSize(frame, width, height); }; const finish = () => { frame.classList.remove("is-resizing"); @@ -1269,8 +1335,10 @@ export function startNoteEditor(adapter) { preview.addEventListener("load", invalidatePreviewPositions, true); window.addEventListener("resize", invalidatePreviewPositions); window.addEventListener("resize", () => { + repositionSelectedImageTools(); if (virtualLayers.active) virtualLayers.sync(); }); + window.addEventListener("scroll", repositionSelectedImageTools, { passive: true, capture: true }); document.fonts?.addEventListener?.("loadingdone", invalidatePreviewPositions); function previewLinePositions() { const height = preview.scrollHeight, width = preview.clientWidth; @@ -1845,6 +1913,16 @@ export function startNoteEditor(adapter) { 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 => { const frame = event.target instanceof Element ? event.target.closest(".markdown-alias-image") : null; if (!frame) return; diff --git a/static/js/pdf-export.js b/static/js/pdf-export.js index 8b6a627..bc2e55e 100644 --- a/static/js/pdf-export.js +++ b/static/js/pdf-export.js @@ -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) { collapseHeadingGaps(root); markShortKeepBlocks(root); groupAtomicVisuals(root); groupHeadingsWithLead(root); + markAtomicTextBlocks(root); } async function ensurePrintableMermaid(root, darkMode = false) { @@ -446,8 +453,17 @@ function alignFooterToLastPage(doc, pageHeightMm, marginMm) { 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); + // The multicolumn pagination probe is only needed when the document contains + // 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 spacerHeight = available >= footerHeight + minGap + bottomGuard @@ -464,7 +480,7 @@ function fitOversizedKeepUnits(doc, pageHeightMm, marginMm) { 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 => { + root.querySelectorAll(".pdf-keep-unit, .pdf-keep-short, .pdf-atomic-text").forEach(unit => { unit.classList.remove("pdf-keep-unit--oversized"); let rect = unit.getBoundingClientRect(); if (rect.height <= maxHeight) return; @@ -659,25 +675,13 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { 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; + break-inside: auto; + page-break-inside: auto; } .pdf-content p, .pdf-content li, .pdf-content dd { orphans: 3; widows: 3; } - .pdf-content p { - break-inside: avoid-page; - page-break-inside: avoid; - } .pdf-content ul, .pdf-content ol, @@ -693,8 +697,8 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { page-break-inside: auto; } .pdf-content li { - break-inside: avoid-page; - page-break-inside: avoid; + break-inside: auto; + page-break-inside: auto; } .pdf-content li:has(> ul), .pdf-content li:has(> ol) { @@ -736,31 +740,38 @@ function printStyles(cssSize, pageWidthMm, pageHeightMm, marginMm, darkMode) { -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 - * previous page before moving the real block to the next page. - * Making the keep unit an atomic inline-block prevents that ghost - * fragment while preserving book-like pagination. + * Chromium can paint a thin fragment of a block at the bottom of the + * previous page before moving the actual block to the next page. + * Atomic inline-blocks prevent that paint fragment instead of relying + * only on break-inside: avoid, which triggers the Chromium artifact. */ display: inline-block; width: 100%; + max-width: 100%; vertical-align: top; box-sizing: border-box; break-inside: avoid-page; 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 > :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; + width: auto; + max-width: none; 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 { margin-bottom: .18em; }