diff --git a/package.json b/package.json index cc9d51aa..25b8b7c3 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "framer-motion": "^11.11.10", "html2canvas": "^1.4.1", "html2pdf.js": "^0.10.2", + "jspdf": "2.5.2", "lodash": "^4.17.21", "lucide-react": "^0.379.0", "mark.js": "^8.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d29667ba..5c9c5676 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,6 +146,9 @@ importers: html2pdf.js: specifier: ^0.10.2 version: 0.10.2 + jspdf: + specifier: 2.5.2 + version: 2.5.2 lodash: specifier: ^4.17.21 version: 4.17.21 diff --git a/src/components/preview/PreviewDock.tsx b/src/components/preview/PreviewDock.tsx index 1b1b9627..5eb3a6d2 100644 --- a/src/components/preview/PreviewDock.tsx +++ b/src/components/preview/PreviewDock.tsx @@ -11,7 +11,8 @@ import { FileJson, Loader2, Eye, - FileText + FileText, + EyeOff } from "lucide-react"; import { RiMarkdownLine } from "@remixicon/react"; import { toast } from "sonner"; @@ -106,6 +107,7 @@ const PreviewDock = ({ const { duplicateResume, setActiveResume, activeResumeId, activeResume, updateGlobalSettings } = useResumeStore(); const { globalSettings = {} } = activeResume || {}; + const pageBreakLinesVisible = globalSettings?.pageBreakLinesVisible !== false; const { checkConfiguration } = useAIConfiguration(); @@ -239,6 +241,39 @@ const PreviewDock = ({ + + + + { + updateGlobalSettings({ + pageBreakLinesVisible: !pageBreakLinesVisible + }); + toast.success( + pageBreakLinesVisible + ? t("pageBreakLine.hidden") + : t("pageBreakLine.visible") + ); + }} + > + + + + + {t("pageBreakLine.tooltip")} + + + diff --git a/src/components/preview/index.tsx b/src/components/preview/index.tsx index c8b48960..f58f6252 100644 --- a/src/components/preview/index.tsx +++ b/src/components/preview/index.tsx @@ -134,6 +134,8 @@ const PreviewPanel = React.forwardRef( const pagePadding = activeResume?.globalSettings?.pagePadding || 0; const autoOnePageEnabled = activeResume?.globalSettings?.autoOnePage || false; + const pageBreakLinesVisible = + activeResume?.globalSettings?.pageBreakLinesVisible !== false; const { scaleFactor, isScaled, cannotFit } = useAutoOnePage({ contentHeight, @@ -271,7 +273,7 @@ const PreviewPanel = React.forwardRef( } `} - {contentHeight > 0 && ( + {pageBreakLinesVisible && contentHeight > 0 && ( <> {Array.from( diff --git a/src/components/shared/GlassIcons.tsx b/src/components/shared/GlassIcons.tsx index 0cc23e47..c65442ab 100644 --- a/src/components/shared/GlassIcons.tsx +++ b/src/components/shared/GlassIcons.tsx @@ -116,3 +116,28 @@ export const MarkdownGlassIcon = ({ className, isLoading }: GlassIconProps) => ( ); + +export const ImageGlassIcon = ({ className, isLoading }: GlassIconProps) => ( + + + + + + + + + + + + + + + + + + + + {isLoading && } + + +); diff --git a/src/components/shared/PdfExport.tsx b/src/components/shared/PdfExport.tsx index ccac0159..288ea415 100644 --- a/src/components/shared/PdfExport.tsx +++ b/src/components/shared/PdfExport.tsx @@ -3,7 +3,13 @@ import { useTranslations } from "@/i18n/compat/client"; import { Download, Loader2, ChevronDown, ShieldCheck } from "lucide-react"; import { useResumeStore } from "@/store/useResumeStore"; import { Button } from "@/components/ui/button"; -import { exportResumeAsJson, exportResumeAsMarkdown, exportToPdf } from "@/utils/export"; +import { + exportResumeAsJson, + exportResumeAsMarkdown, + exportToLongPageImage, + exportToLongPagePdf, + exportToPdf +} from "@/utils/export"; import { exportResumeToBrowserPrint } from "@/utils/print"; import { cn } from "@/lib/utils"; import { @@ -17,6 +23,7 @@ import { import { PdfGlassIcon, + ImageGlassIcon, PrintGlassIcon, JsonGlassIcon, MarkdownGlassIcon, @@ -29,6 +36,7 @@ const ExportCard = ({ description, onClick, isLoading, + isDisabled, bgGradientClass, hoverBorderClass, }: { @@ -37,17 +45,20 @@ const ExportCard = ({ description: string, onClick: () => void, isLoading: boolean, + isDisabled?: boolean, bgGradientClass?: string, hoverBorderClass?: string, }) => { + const disabled = isLoading || isDisabled; + return ( { - if (!isLoading) onClick(); + if (!disabled) onClick(); }} className={cn( "group relative flex flex-col justify-center overflow-hidden p-6 pl-[136px] min-h-[130px] rounded-3xl border border-border/50 bg-card text-card-foreground shadow-sm transition-all duration-500", - isLoading ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass) + disabled ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass) )} > {/* 顶部内发光高光,增加卡片立体感 */} @@ -56,14 +67,14 @@ const ExportCard = ({ {/* 底部环境光晕,让图标色渗入背景 */} {/* 调整后的图标层:完全收入卡片内部,尺寸克制 */} @@ -82,6 +93,8 @@ const ExportCard = ({ const PdfExport = ({ children }: { children?: React.ReactNode }) => { const [isOpen, setIsOpen] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingLongPage, setIsExportingLongPage] = useState(false); + const [isExportingImage, setIsExportingImage] = useState(false); const [isPrinting, setIsPrinting] = useState(false); const [isExportingJson, setIsExportingJson] = useState(false); const [isExportingMarkdown, setIsExportingMarkdown] = useState(false); @@ -103,6 +116,32 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { }); }; + const handleLongPageExport = async () => { + await exportToLongPagePdf({ + elementId: "resume-preview", + title: title || "resume", + pagePadding: globalSettings?.pagePadding || 0, + fontFamily: globalSettings?.fontFamily, + onStart: () => setIsExportingLongPage(true), + onEnd: () => setIsExportingLongPage(false), + successMessage: t("toast.success"), + errorMessage: t("toast.error") + }); + }; + + const handleLongPageImageExport = async () => { + await exportToLongPageImage({ + elementId: "resume-preview", + title: title || "resume", + pagePadding: globalSettings?.pagePadding || 0, + fontFamily: globalSettings?.fontFamily, + onStart: () => setIsExportingImage(true), + onEnd: () => setIsExportingImage(false), + successMessage: t("toast.imageSuccess"), + errorMessage: t("toast.imageError") + }); + }; + const handleJsonExport = () => { exportResumeAsJson({ resume: activeResume, @@ -156,16 +195,21 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { } }; - const isLoading = isExporting || isExportingJson || isExportingMarkdown || isPrinting; - const loadingText = isExporting - ? t("button.exporting") + const isLoading = + isExporting || + isExportingLongPage || + isExportingImage || + isExportingJson || + isExportingMarkdown || + isPrinting; + const loadingText = + isExporting || isExportingLongPage || isExportingImage || isPrinting + ? t("button.exporting") : isExportingJson ? t("button.exportingJson") : isExportingMarkdown ? t("button.exportingMarkdown") - : isPrinting - ? t("button.exporting") - : ""; + : ""; return ( { @@ -218,15 +262,37 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { title={t("button.exportPdf")} description={t("modal.pdfDesc")} isLoading={isExporting} + isDisabled={isLoading} onClick={handleExport} bgGradientClass="from-rose-500/10 dark:from-rose-500/20" hoverBorderClass="hover:border-rose-500/40 hover:ring-1 hover:ring-rose-500/20" /> + + { title={t("button.exportJson")} description={t("modal.jsonDesc")} isLoading={isExportingJson} + isDisabled={isLoading} onClick={handleJsonExport} bgGradientClass="from-amber-500/10 dark:from-amber-500/20" hoverBorderClass="hover:border-amber-500/40 hover:ring-1 hover:ring-amber-500/20" @@ -245,6 +312,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => { title={t("button.exportMarkdown")} description={t("modal.markdownDesc")} isLoading={isExportingMarkdown} + isDisabled={isLoading} onClick={handleMarkdownExport} bgGradientClass="from-indigo-500/10 dark:from-indigo-500/20" hoverBorderClass="hover:border-indigo-500/40 hover:ring-1 hover:ring-indigo-500/20" diff --git a/src/config/initialResumeData.ts b/src/config/initialResumeData.ts index 160e28eb..84b39140 100644 --- a/src/config/initialResumeData.ts +++ b/src/config/initialResumeData.ts @@ -11,6 +11,7 @@ const initialGlobalSettings: GlobalSettings = { useIconMode: true, themeColor: "#000000", centerSubtitle: true, + pageBreakLinesVisible: true, }; export const initialResumeState = { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b25b2068..83e6480b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -88,7 +88,7 @@ }, { "question": "What export formats are supported?", - "answer": "We currently support PDF export, ensuring your resume maintains consistent formatting on any device. We plan to support more export formats in the future." + "answer": "We currently support PDF export, PNG long image export, browser print, JSON config export, and Markdown export. PDF is best for formal applications and archiving, while PNG long image is useful for mobile previews, portfolio display, or image-upload workflows." }, { "question": "How can I sync across devices?", @@ -258,6 +258,8 @@ "title": "Export Resume", "subtitle": "Select the format you want to export your resume", "pdfDesc": "High-precision rendering with 100% format accuracy. Recommended for job applications.", + "longPagePdfDesc": "Browser-rendered as a single long page. Best for resumes that do not need pagination.", + "longPageImageDesc": "Browser-rendered as a single PNG long image. Best for mobile previews, portfolio display, or image-upload workflows.", "printDesc": "Save via system print dialog. Use as a fallback or for custom margins.", "jsonDesc": "Export your resume data as JSON to backup or transfer between devices.", "markdownDesc": "Convert to plain Markdown text for easy sharing with AI models.", @@ -266,6 +268,8 @@ "button": { "export": "Export", "exportPdf": "Export PDF (Server)", + "exportLongPagePdf": "PDF (Long Page)", + "exportLongPageImage": "PNG (Long Image)", "exportJson": "Export JSON Config", "exportMarkdown": "Export Markdown", "exporting": "Exporting...", @@ -276,6 +280,8 @@ "toast": { "success": "PDF exported successfully", "error": "PDF export failed", + "imageSuccess": "PNG long image exported successfully", + "imageError": "PNG long image export failed", "jsonSuccess": "Configuration exported successfully", "jsonError": "Configuration export failed", "markdownSuccess": "Markdown exported successfully", @@ -323,6 +329,11 @@ "disabled": "One page mode disabled", "cannotFit": "Too much content. Optimized as much as possible but cannot fit on one page. Try simplifying the content or adjusting margins and font size in the sidebar." }, + "pageBreakLine": { + "tooltip": "Page Break Line", + "visible": "Page break line shown", + "hidden": "Page break line hidden" + }, "backup": { "configured": "Backed up", "notConfigured": "Not backed up", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index c823c5c6..d30c4921 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -259,6 +259,8 @@ "title": "导出简历", "subtitle": "选择您希望导出简历的格式", "pdfDesc": "通过高精度渲染生成,100% 还原格式,简历投递首选。", + "longPagePdfDesc": "通过浏览器渲染生成一页简历,适合不需要分页的简历。", + "longPageImageDesc": "通过浏览器渲染生成单张 PNG 长图,适合移动端预览、作品集展示或要求图片上传的场景。", "printDesc": "调用系统打印工具另存为 PDF,当主渲染拥堵或需手动干预边距时的备用方案。", "jsonDesc": "保存您的配置档案副本。日后更换设备或清理缓存后,可使用此文件一键无损导入恢复进度。", "markdownDesc": "转换为极简的文本标记语言文件,方便快速粘贴给各种 AI 模型或是其他文本编辑器。", @@ -267,6 +269,8 @@ "button": { "export": "导出", "exportPdf": "PDF", + "exportLongPagePdf": "PDF(长页)", + "exportLongPageImage": "PNG(长图)", "exportJson": "JSON配置", "exportMarkdown": "Markdown", "exporting": "导出中...", @@ -277,6 +281,8 @@ "toast": { "success": "PDF导出成功", "error": "PDF导出失败", + "imageSuccess": "PNG长图导出成功", + "imageError": "PNG长图导出失败", "jsonSuccess": "配置导出成功", "jsonError": "配置导出失败", "markdownSuccess": "Markdown 导出成功", @@ -736,6 +742,11 @@ "disabled": "已关闭一页纸模式", "cannotFit": "内容较多,已尽量压缩但无法完美一页,建议精简内容, 也可在此基础上调节页边距、字体大小等左侧设置栏选项" }, + "pageBreakLine": { + "tooltip": "分页线", + "visible": "已显示分页线", + "hidden": "已隐藏分页线" + }, "backup": { "configured": "已备份", "notConfigured": "未备份", @@ -805,8 +816,8 @@ "answer": "推荐使用最新版的 Chrome (谷歌浏览器) 或 Edge 浏览器以获得最佳排版和导出体验。部分不兼容的旧版浏览器可能会导致样式错乱。" }, "export-methods": { - "question": "两种导出方式有什么不同?", - "answer": "• PDF 导出:使用后端服务进行高精度渲染,100% 还原排版,推荐用于正式发送给 HR。\n\n• 浏览器打印:调用您当前浏览器的自带打印功能(另存为 PDF)。适合在后端导出服务繁忙或您需要通过浏览器微调打印边距时使用。" + "question": "有哪些导出方式?", + "answer": "• PDF 导出:使用后端服务进行高精度渲染,100% 还原排版,推荐用于正式投递和归档。\n\n• PNG 长图:使用浏览器渲染生成单张长图,适合移动端预览、作品集展示或要求图片上传的场景。\n\n• 浏览器打印:调用您当前浏览器的自带打印功能(另存为 PDF)。适合在后端导出服务繁忙或您需要通过浏览器微调打印边距时使用。\n\n• JSON 配置:导出简历数据副本,方便备份和迁移。\n\n• Markdown:导出为纯文本格式,方便粘贴到其他工具中。" }, "export-failure": { "question": "导出失败或样式错乱怎么办?", diff --git a/src/types/resume.ts b/src/types/resume.ts index e32e57cb..593e1b27 100644 --- a/src/types/resume.ts +++ b/src/types/resume.ts @@ -139,6 +139,7 @@ export type GlobalSettings = { centerSubtitle?: boolean | undefined; flexibleHeaderLayout?: boolean | undefined; autoOnePage?: boolean | undefined; + pageBreakLinesVisible?: boolean | undefined; }; export interface ResumeTheme { diff --git a/src/utils/export.ts b/src/utils/export.ts index 328be52b..2fe483cb 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -1,6 +1,7 @@ import { toast } from "sonner"; +import type { jsPDF as JsPDF } from "jspdf"; import { PDF_EXPORT_CONFIG } from "@/config"; -import { normalizeFontFamily } from "@/utils/fonts"; +import { getFontFaceCss, normalizeFontFamily } from "@/utils/fonts"; import { ResumeData } from "@/types/resume"; import { generateResumeMarkdown, ResumeMarkdownOptions } from "@/utils/markdown"; @@ -106,6 +107,28 @@ export interface ExportToPdfOptions { errorMessage?: string; } +const A4_WIDTH_MM = 210; +const PX_PER_MM = 96 / 25.4; +const LONG_PAGE_HEIGHT_BUFFER_MM = 2; +const LONG_PAGE_CAPTURE_BUFFER_PX = 8; +const LONG_PAGE_BOTTOM_SAFE_AREA_PX = 8; + +const keepOnlyFirstPage = (pdf: JsPDF) => { + const pdfWithPageControl = pdf as JsPDF & { + getNumberOfPages?: () => number; + deletePage?: (pageNumber: number) => void; + }; + + const totalPages = pdfWithPageControl.getNumberOfPages?.() ?? 1; + if (totalPages <= 1 || !pdfWithPageControl.deletePage) { + return; + } + + for (let pageNumber = totalPages; pageNumber > 1; pageNumber -= 1) { + pdfWithPageControl.deletePage(pageNumber); + } +}; + interface ExportResumeFileOptions { resume?: ResumeData | null; title?: string; @@ -174,6 +197,285 @@ export const exportResumeAsMarkdown = ({ } }; +const hidePageBreakLines = (element: HTMLElement) => { + const pageBreakLines = element.querySelectorAll(".page-break-line"); + pageBreakLines.forEach((line) => { + line.remove(); + }); +}; + +const removeLongPageHeightConstraints = (element: HTMLElement) => { + const rootElement = element.firstElementChild as HTMLElement | null; + if (rootElement) { + rootElement.style.setProperty("height", "auto", "important"); + rootElement.style.setProperty("min-height", "0", "important"); + } + + const constrainedElements = element.querySelectorAll(".min-h-screen, .min-h-full, .editorial-print-container"); + constrainedElements.forEach((node) => { + node.style.setProperty("height", "auto", "important"); + node.style.setProperty("min-height", "0", "important"); + }); +}; + +const getPreviewScale = (element: HTMLElement) => { + const transformValue = element.style.transform || ""; + const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/); + if (!scaleMatch) return 1; + + const scale = Number(scaleMatch[1]); + return Number.isFinite(scale) && scale > 0 ? scale : 1; +}; + +const waitForImages = async (element: HTMLElement) => { + const images = Array.from(element.getElementsByTagName("img")); + await Promise.all( + images + .filter((img) => !img.complete) + .map( + (img) => + new Promise((resolve) => { + img.onload = () => resolve(); + img.onerror = () => resolve(); + }) + ) + ); +}; + +interface LongPageCapture { + container: HTMLDivElement; + clonedElement: HTMLElement; + contentWidthPx: number; + contentHeightPx: number; + pageHeightMm: number; +} + +const prepareLongPageCapture = async ({ + elementId, + pagePadding, + fontFamily +}: Pick): Promise => { + let container: HTMLDivElement | null = null; + + try { + const pdfElement = document.querySelector(`#${elementId}`); + if (!pdfElement) { + throw new Error(`PDF element #${elementId} not found`); + } + + const selectedFontFamily = normalizeFontFamily(fontFamily); + const clonedElement = pdfElement.cloneNode(true) as HTMLElement; + const previewScale = getPreviewScale(clonedElement); + hidePageBreakLines(clonedElement); + removeLongPageHeightConstraints(clonedElement); + await optimizeImages(clonedElement); + + clonedElement.style.setProperty("padding", `${pagePadding}px`, "important"); + clonedElement.style.setProperty("box-sizing", "border-box", "important"); + clonedElement.style.setProperty("background", "white", "important"); + clonedElement.style.setProperty("font-family", selectedFontFamily, "important"); + + const bottomSpacer = document.createElement("div"); + bottomSpacer.setAttribute("aria-hidden", "true"); + bottomSpacer.style.width = "100%"; + bottomSpacer.style.height = `${Math.ceil(LONG_PAGE_BOTTOM_SAFE_AREA_PX / previewScale)}px`; + bottomSpacer.style.pointerEvents = "none"; + clonedElement.appendChild(bottomSpacer); + + container = document.createElement("div"); + container.style.position = "fixed"; + container.style.left = "-10000px"; + container.style.top = "0"; + container.style.width = `${A4_WIDTH_MM}mm`; + container.style.background = "white"; + container.style.pointerEvents = "none"; + container.style.zIndex = "-1"; + + const fontStyles = document.createElement("style"); + fontStyles.textContent = await getFontFaceCss(selectedFontFamily); + container.appendChild(fontStyles); + container.appendChild(clonedElement); + document.body.appendChild(container); + + await waitForImages(clonedElement); + if (document.fonts?.ready) { + await document.fonts.ready; + } + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const renderedRect = clonedElement.getBoundingClientRect(); + const contentWidthPx = + renderedRect.width || clonedElement.scrollWidth || A4_WIDTH_MM * PX_PER_MM; + const contentHeightPx = Math.max( + renderedRect.height, + clonedElement.scrollHeight * previewScale, + 1 + ); + const pageHeightMm = Math.max( + contentHeightPx * (A4_WIDTH_MM / contentWidthPx) + LONG_PAGE_HEIGHT_BUFFER_MM, + 1 + ); + + return { + container, + clonedElement, + contentWidthPx, + contentHeightPx, + pageHeightMm + }; + } catch (error) { + if (container?.parentNode) { + container.parentNode.removeChild(container); + } + throw error; + } +}; + +const renderLongPageCanvas = async ({ + clonedElement, + contentWidthPx, + contentHeightPx +}: Pick) => { + const { default: html2canvas } = await import("html2canvas"); + + return html2canvas(clonedElement, { + scale: 2, + useCORS: true, + allowTaint: true, + backgroundColor: "#ffffff", + scrollX: 0, + scrollY: 0, + windowWidth: Math.ceil(contentWidthPx), + windowHeight: Math.ceil(contentHeightPx + LONG_PAGE_CAPTURE_BUFFER_PX) + }); +}; + +const getCanvasPngBlob = (canvas: HTMLCanvasElement) => + new Promise((resolve, reject) => { + try { + canvas.toBlob((blob) => { + if (!blob) { + reject(new Error("PNG conversion failed")); + return; + } + + resolve(blob); + }, "image/png"); + } catch (error) { + reject(error); + } + }); + +export const exportToLongPagePdf = async ({ + elementId, + title, + pagePadding, + fontFamily, + onStart, + onEnd, + successMessage, + errorMessage +}: ExportToPdfOptions) => { + const exportStartTime = performance.now(); + onStart?.(); + + let capture: LongPageCapture | null = null; + let canvas: HTMLCanvasElement | null = null; + + try { + capture = await prepareLongPageCapture({ + elementId, + pagePadding, + fontFamily + }); + + const fileName = `${getSafeFileName(title)}.pdf`; + const [{ jsPDF }, renderedCanvas] = await Promise.all([ + import("jspdf"), + renderLongPageCanvas(capture) + ]); + canvas = renderedCanvas; + + const imageHeightMm = canvas.height * (A4_WIDTH_MM / canvas.width); + const canvasPageHeightMm = Math.max( + imageHeightMm + LONG_PAGE_HEIGHT_BUFFER_MM, + capture.pageHeightMm + ); + const pdf = new jsPDF({ + unit: "mm", + format: [A4_WIDTH_MM, canvasPageHeightMm], + orientation: "portrait", + compress: true + }); + const imageData = canvas.toDataURL("image/png"); + pdf.addImage(imageData, "PNG", 0, 0, A4_WIDTH_MM, imageHeightMm); + keepOnlyFirstPage(pdf); + pdf.save(fileName); + + if (successMessage) toast.success(successMessage); + console.log(`Total long page export took ${performance.now() - exportStartTime}ms`); + } catch (error) { + console.error("Long page export error:", error); + if (errorMessage) toast.error(errorMessage); + } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (capture?.container.parentNode) { + capture.container.parentNode.removeChild(capture.container); + } + onEnd?.(); + } +}; + +export const exportToLongPageImage = async ({ + elementId, + title, + pagePadding, + fontFamily, + onStart, + onEnd, + successMessage, + errorMessage +}: ExportToPdfOptions) => { + const exportStartTime = performance.now(); + onStart?.(); + + let capture: LongPageCapture | null = null; + let canvas: HTMLCanvasElement | null = null; + + try { + capture = await prepareLongPageCapture({ + elementId, + pagePadding, + fontFamily + }); + + canvas = await renderLongPageCanvas(capture); + const blob = await getCanvasPngBlob(canvas); + const fileName = `${getSafeFileName(title)}.png`; + downloadBlob(blob, fileName); + + if (successMessage) toast.success(successMessage); + console.log(`Total long page image export took ${performance.now() - exportStartTime}ms`); + } catch (error) { + console.error("Long page image export error:", error); + if (errorMessage) toast.error(errorMessage); + } finally { + if (canvas) { + canvas.width = 0; + canvas.height = 0; + } + if (capture?.container.parentNode) { + capture.container.parentNode.removeChild(capture.container); + } + onEnd?.(); + } +}; + export const exportToPdf = async ({ elementId, title, @@ -215,10 +517,7 @@ export const exportToPdf = async ({ clonedElement.style.setProperty("box-sizing", "border-box"); clonedElement.style.setProperty("font-family", selectedFontFamily, "important"); - const pageBreakLines = clonedElement.querySelectorAll(".page-break-line"); - pageBreakLines.forEach((line) => { - line.style.display = "none"; - }); + hidePageBreakLines(clonedElement); const [capturedStyles] = await Promise.all([ getOptimizedStyles(),
{t("pageBreakLine.tooltip")}