fix : dont break new line on export image
CI / Verify (push) Successful in 1m45s
CI / Build immutable images and deploy (push) Successful in 2m6s

This commit is contained in:
2026-09-26 13:48:29 +07:00 Unverified
parent 75a47aa14c
commit af879c03bd
2 changed files with 128 additions and 0 deletions
+113
View File
@@ -25,6 +25,117 @@ import { cn } from "@/lib/utils";
const EXPORT_WIDTH = 1200;
type OriginalTextStyles = {
fontSize: string;
overflowWrap: string;
textOverflow: string;
webkitLineClamp: string;
whiteSpace: string;
wordBreak: string;
};
const originalTextStyles = new WeakMap<HTMLElement, OriginalTextStyles>();
export function scaledExportFontSize(
fontSize: number,
contentWidth: number,
availableWidth: number,
): number {
if (fontSize <= 0 || contentWidth <= 0 || availableWidth <= 0) return fontSize;
return Math.min(fontSize, fontSize * availableWidth / contentWidth);
}
function directTextContent(element: HTMLElement): boolean {
return Array.from(element.childNodes).some(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
}
function restoreExportText(root: HTMLElement) {
root.querySelectorAll<HTMLElement>("[data-export-fitted-text]").forEach((element) => {
const original = originalTextStyles.get(element);
if (!original) return;
element.style.fontSize = original.fontSize;
element.style.overflowWrap = original.overflowWrap;
element.style.textOverflow = original.textOverflow;
element.style.setProperty("-webkit-line-clamp", original.webkitLineClamp);
element.style.whiteSpace = original.whiteSpace;
element.style.wordBreak = original.wordBreak;
element.removeAttribute("data-export-fitted-text");
originalTextStyles.delete(element);
});
}
function availableTextWidth(element: HTMLElement): number {
if (element.clientWidth > 0) {
const style = getComputedStyle(element);
return element.clientWidth
- Number.parseFloat(style.paddingLeft || "0")
- Number.parseFloat(style.paddingRight || "0");
}
const parent = element.parentElement;
if (!parent) return 0;
const style = getComputedStyle(parent);
return parent.clientWidth
- Number.parseFloat(style.paddingLeft || "0")
- Number.parseFloat(style.paddingRight || "0");
}
function textContentWidth(element: HTMLElement): number {
if (element.scrollWidth > element.clientWidth) return element.scrollWidth;
const range = document.createRange();
range.selectNodeContents(element);
return range.getBoundingClientRect().width;
}
function fitExportText(root: HTMLElement) {
restoreExportText(root);
const candidates = Array.from(root.querySelectorAll<HTMLElement>("*"))
.filter((element) => directTextContent(element))
.filter((element) => {
const style = getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
})
.filter((element, _index, elements) =>
!elements.some((parent) => parent !== element && parent.contains(element)),
);
const availableWidths = new Map(
candidates.map((element) => [element, availableTextWidth(element)]),
);
candidates.forEach((element) => {
originalTextStyles.set(element, {
fontSize: element.style.fontSize,
overflowWrap: element.style.overflowWrap,
textOverflow: element.style.textOverflow,
webkitLineClamp: element.style.getPropertyValue("-webkit-line-clamp"),
whiteSpace: element.style.whiteSpace,
wordBreak: element.style.wordBreak,
});
element.dataset.exportFittedText = "";
element.style.setProperty("overflow-wrap", "normal", "important");
element.style.setProperty("text-overflow", "clip", "important");
element.style.setProperty("-webkit-line-clamp", "unset", "important");
element.style.setProperty("white-space", "nowrap", "important");
element.style.setProperty("word-break", "normal", "important");
});
candidates.forEach((element) => {
const availableWidth = availableWidths.get(element) ?? 0;
const contentWidth = textContentWidth(element);
const fontSize = Number.parseFloat(getComputedStyle(element).fontSize);
const fittedSize = scaledExportFontSize(fontSize, contentWidth, availableWidth);
if (fittedSize < fontSize) {
element.style.setProperty("font-size", `${fittedSize}px`, "important");
}
});
}
function withoutNotesFileName(fileName: string): string {
return /\.[^.]+$/u.test(fileName)
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
@@ -219,6 +330,7 @@ export function GuideImageExport({
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
fitExportText(node);
const blob = await domToBlob(node, {
backgroundColor: getComputedStyle(node).backgroundColor,
type: `image/${format}`,
@@ -264,6 +376,7 @@ export function GuideImageExport({
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
fitExportText(node);
const blob = await domToBlob(node, {
backgroundColor: getComputedStyle(node).backgroundColor,
type: `image/${format}`,
+15
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import {
GuideImageExport,
imageExportFileName,
scaledExportFontSize,
} from "@/components/public/guide-image-export";
import { MaterialPoster } from "@/components/public/material-poster";
import { ConstellationImage } from "@/components/public/constellation-image";
@@ -104,6 +105,20 @@ describe("image export filenames", () => {
});
});
describe("image export text fitting", () => {
it("keeps the original font size when text already fits", () => {
expect(scaledExportFontSize(32, 180, 240)).toBe(32);
});
it("scales overflowing text to the available width", () => {
expect(scaledExportFontSize(32, 320, 240)).toBe(24);
});
it("has no minimum font-size floor for exceptionally long text", () => {
expect(scaledExportFontSize(32, 3200, 100)).toBe(1);
});
});
describe("material poster text", () => {
const props = {