From af879c03bdfb191a1e37e54f08338d8914ea8b1b Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 26 Sep 2026 13:48:29 +0700 Subject: [PATCH] fix : dont break new line on export image --- components/public/guide-image-export.tsx | 113 +++++++++++++++++++++++ lib/guides/image-export.test.tsx | 15 +++ 2 files changed, 128 insertions(+) diff --git a/components/public/guide-image-export.tsx b/components/public/guide-image-export.tsx index e589b94..866d9cd 100644 --- a/components/public/guide-image-export.tsx +++ b/components/public/guide-image-export.tsx @@ -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(); + +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("[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("*")) + .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}`, diff --git a/lib/guides/image-export.test.tsx b/lib/guides/image-export.test.tsx index 6e008ac..c94c430 100644 --- a/lib/guides/image-export.test.tsx +++ b/lib/guides/image-export.test.tsx @@ -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 = {