fix(richtext): blank lines survive into PDFs and read-only previews

Quill 2's semantic HTML saves a blank line as a truly empty <p></p> (no
<br> placeholder) — verified in the DB (invoice_sections hold <p></p>,
zero <p><br>) — which collapses to nothing outside the editor.
cleanQuillHtml (PDF path) and the new restoreQuillBlankLines (order/
invoice read-only previews) restore the placeholder at render time, so
existing stored content is covered retroactively.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-12 08:35:46 +02:00
parent 08af4cd0ae
commit 7e4469b29a
5 changed files with 42 additions and 4 deletions

View File

@@ -32,6 +32,17 @@ describe("cleanQuillHtml — inline-style whitelist", () => {
); );
}); });
it("restores Quill 2's empty <p></p> blank lines to <p><br></p>", () => {
// Quill 2's semantic HTML drops the <br> placeholder from blank lines.
expect(cleanQuillHtml("<p>A</p><p></p><p>B</p>")).toBe(
"<p>A</p><p><br></p><p>B</p>",
);
// Attribute-carrying and whitespace-only empties too.
expect(cleanQuillHtml('<p class="ql-align-center"> </p>')).toBe(
'<p class="ql-align-center"><br></p>',
);
});
it("keeps stripping scripts, event handlers and js: URLs", () => { it("keeps stripping scripts, event handlers and js: URLs", () => {
const out = cleanQuillHtml( const out = cleanQuillHtml(
'<p onclick="alert(1)"><script>alert(1)</script>' + '<p onclick="alert(1)"><script>alert(1)</script>' +

View File

@@ -58,6 +58,7 @@ import {
formatCurrency, formatCurrency,
formatDate, formatDate,
numberOr, numberOr,
restoreQuillBlankLines,
todayLocalStr, todayLocalStr,
} from "../utils/formatters"; } from "../utils/formatters";
import { normalizeDateStr } from "../utils/attendanceHelpers"; import { normalizeDateStr } from "../utils/attendanceHelpers";
@@ -1670,7 +1671,9 @@ export default function InvoiceDetail() {
{(s.content || "").replace(/<[^>]*>/g, "").trim() && ( {(s.content || "").replace(/<[^>]*>/g, "").trim() && (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(s.content || ""), __html: DOMPurify.sanitize(
restoreQuillBlankLines(s.content),
),
}} }}
/> />
)} )}
@@ -1690,7 +1693,9 @@ export default function InvoiceDetail() {
invoice.internal_notes !== "<p><br></p>" ? ( invoice.internal_notes !== "<p><br></p>" ? (
<RichTextView <RichTextView
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(invoice.internal_notes), __html: DOMPurify.sanitize(
restoreQuillBlankLines(invoice.internal_notes),
),
}} }}
/> />
) : ( ) : (

View File

@@ -18,7 +18,11 @@ import Typography from "@mui/material/Typography";
import OrderConfirmationModal from "../components/OrderConfirmationModal"; import OrderConfirmationModal from "../components/OrderConfirmationModal";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api"; import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters"; import {
formatCurrency,
formatDate,
restoreQuillBlankLines,
} from "../utils/formatters";
import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus"; import { ORDER_STATUS, statusLabel, statusColor } from "../lib/documentStatus";
import { import {
Button, Button,
@@ -713,7 +717,9 @@ export default function OrderDetail() {
<RichTextView <RichTextView
sx={{ p: 2 }} sx={{ p: 2 }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(section.content), __html: DOMPurify.sanitize(
restoreQuillBlankLines(section.content),
),
}} }}
/> />
)} )}

View File

@@ -76,3 +76,15 @@ export function czechPlural(
if (n >= 2 && n <= 4) return few; if (n >= 2 && n <= 4) return few;
return many; return many;
} }
/**
* Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
* collapse to zero height outside the editor — restore the <br> placeholder
* before rendering stored rich text read-only. (The PDF path does the same
* in cleanQuillHtml.)
*/
export function restoreQuillBlankLines(
html: string | null | undefined,
): string {
return (html || "").replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
}

View File

@@ -141,6 +141,10 @@ export function cleanQuillHtml(html: string | null | undefined): string {
); );
// Replace &nbsp; with regular space (outside of tags) // Replace &nbsp; with regular space (outside of tags)
s = s.replace(/(&nbsp;)/g, " "); s = s.replace(/(&nbsp;)/g, " ");
// Quill 2's semantic HTML saves blank lines as truly empty <p></p>, which
// collapse to zero height in the PDF — restore the <br> placeholder so an
// editor blank line stays a blank line.
s = s.replace(/<p([^>]*)>\s*<\/p>/gi, "<p$1><br></p>");
// Inline styles: Quill writes text/background colors as style attributes. // Inline styles: Quill writes text/background colors as style attributes.
// Keep ONLY color/background-color with literal color values — everything // Keep ONLY color/background-color with literal color values — everything
// else (url(), expression(), positioning…) must never reach Puppeteer. // else (url(), expression(), positioning…) must never reach Puppeteer.