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:
@@ -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>' +
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
}
|
||||||
|
|||||||
@@ -141,6 +141,10 @@ export function cleanQuillHtml(html: string | null | undefined): string {
|
|||||||
);
|
);
|
||||||
// Replace with regular space (outside of tags)
|
// Replace with regular space (outside of tags)
|
||||||
s = s.replace(/( )/g, " ");
|
s = s.replace(/( )/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.
|
||||||
|
|||||||
Reference in New Issue
Block a user