fix : dont break new line on export image
This commit is contained in:
@@ -25,6 +25,117 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
const EXPORT_WIDTH = 1200;
|
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 {
|
function withoutNotesFileName(fileName: string): string {
|
||||||
return /\.[^.]+$/u.test(fileName)
|
return /\.[^.]+$/u.test(fileName)
|
||||||
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
|
? fileName.replace(/(\.[^.]+)$/u, "-without-notes$1")
|
||||||
@@ -219,6 +330,7 @@ export function GuideImageExport({
|
|||||||
|
|
||||||
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
||||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||||
|
fitExportText(node);
|
||||||
const blob = await domToBlob(node, {
|
const blob = await domToBlob(node, {
|
||||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||||
type: `image/${format}`,
|
type: `image/${format}`,
|
||||||
@@ -264,6 +376,7 @@ export function GuideImageExport({
|
|||||||
|
|
||||||
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
const { domToBlob, waitUntilLoad } = await import("modern-screenshot");
|
||||||
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
await Promise.all([document.fonts.ready, waitUntilLoad(node)]);
|
||||||
|
fitExportText(node);
|
||||||
const blob = await domToBlob(node, {
|
const blob = await domToBlob(node, {
|
||||||
backgroundColor: getComputedStyle(node).backgroundColor,
|
backgroundColor: getComputedStyle(node).backgroundColor,
|
||||||
type: `image/${format}`,
|
type: `image/${format}`,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
GuideImageExport,
|
GuideImageExport,
|
||||||
imageExportFileName,
|
imageExportFileName,
|
||||||
|
scaledExportFontSize,
|
||||||
} from "@/components/public/guide-image-export";
|
} from "@/components/public/guide-image-export";
|
||||||
import { MaterialPoster } from "@/components/public/material-poster";
|
import { MaterialPoster } from "@/components/public/material-poster";
|
||||||
import { ConstellationImage } from "@/components/public/constellation-image";
|
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", () => {
|
describe("material poster text", () => {
|
||||||
const props = {
|
const props = {
|
||||||
|
|||||||
Reference in New Issue
Block a user