fix: previewDock bar export

This commit is contained in:
JOYCEQL
2026-03-09 17:21:24 +08:00
parent 108b8238c2
commit dee081e2e3
4 changed files with 211 additions and 475 deletions
+10 -144
View File
@@ -17,6 +17,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/i18n/compat/client";
import { useRouter } from "@/lib/navigation";
import { exportResumeToBrowserPrint } from "@/utils/print";
import { exportToPdf } from "@/utils/export";
import { Dock, DockIcon } from "@/components/magicui/dock";
import { Button } from "@/components/ui/button";
import {
@@ -92,151 +93,16 @@ const PreviewDock = ({
const { duplicateResume, setActiveResume, activeResumeId, activeResume, updateGlobalSettings } = useResumeStore();
const { globalSettings = {}, title } = activeResume || {};
const getOptimizedStyles = () => {
const styleCache = new Map();
const startTime = performance.now();
const styles = Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.filter((rule) => {
const ruleText = rule.cssText;
const normalizedRuleText = ruleText.toLowerCase();
if (styleCache.has(ruleText)) return false;
styleCache.set(ruleText, true);
if (rule instanceof CSSFontFaceRule) return false;
if (rule instanceof CSSImportRule) return false;
if (normalizedRuleText.includes("fonts.googleapis.com")) return false;
if (normalizedRuleText.includes("fonts.gstatic.com")) return false;
if (ruleText.includes("font-family")) return false;
if (ruleText.includes("@keyframes")) return false;
if (ruleText.includes("animation")) return false;
if (ruleText.includes("transition")) return false;
if (ruleText.includes("hover")) return false;
return true;
})
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
console.warn("Style processing error:", e);
return "";
}
})
.join("\n");
console.log(`Style processing took ${performance.now() - startTime}ms`);
return styles;
};
const optimizeImages = async (element: HTMLElement) => {
const startTime = performance.now();
const images = element.getElementsByTagName("img");
const imagePromises = Array.from(images)
.filter((img) => !img.src.startsWith("data:"))
.map(async (img) => {
try {
const response = await fetch(img.src);
const blob = await response.blob();
return new Promise<void>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result as string;
resolve();
};
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Image conversion error:", error);
return Promise.resolve();
}
});
await Promise.all(imagePromises);
console.log(`Image processing took ${performance.now() - startTime}ms`);
};
const handleExportPdf = async () => {
const exportStartTime = performance.now();
setIsExporting(true);
try {
const pdfElement = document.querySelector<HTMLElement>("#resume-preview");
if (!pdfElement) {
throw new Error("PDF element not found");
}
const clonedElement = pdfElement.cloneNode(true) as HTMLElement;
const pagePadding = globalSettings?.pagePadding || 0;
const transformValue = clonedElement.style.transform || "";
const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/);
if (scaleMatch) {
const scale = Number(scaleMatch[1]);
if (Number.isFinite(scale) && scale > 0 && scale < 1) {
// 服务端导出前将 transform 缩放转为 zoom,避免分页计算偏差
clonedElement.style.removeProperty("transform");
clonedElement.style.removeProperty("transform-origin");
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("zoom", String(scale));
}
} else {
// 未开启一页纸模式时,宽度需减去 padding
clonedElement.style.setProperty(
"width",
`calc(210mm - ${2 * pagePadding}px)`,
"important"
);
}
clonedElement.style.setProperty("padding", "0", "important");
clonedElement.style.setProperty("box-sizing", "border-box");
const pageBreakLines =
clonedElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
const [styles] = await Promise.all([
getOptimizedStyles(),
optimizeImages(clonedElement)
]);
const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content: clonedElement.outerHTML,
styles,
margin: globalSettings.pagePadding
}),
mode: "cors",
signal: AbortSignal.timeout(PDF_EXPORT_CONFIG.TIMEOUT)
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title}.pdf`;
link.click();
window.URL.revokeObjectURL(url);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
toast.success(tPdf("toast.success"));
} catch (error) {
console.error("Export error:", error);
toast.error(tPdf("toast.error"));
} finally {
setIsExporting(false);
}
await exportToPdf({
elementId: "resume-preview",
title: title || "resume",
pagePadding: globalSettings?.pagePadding || 0,
onStart: () => setIsExporting(true),
onEnd: () => setIsExporting(false),
successMessage: tPdf("toast.success"),
errorMessage: tPdf("toast.error")
});
};
const handleExportJson = () => {
+15 -324
View File
@@ -10,7 +10,8 @@ import {
import { toast } from "sonner";
import { useResumeStore } from "@/store/useResumeStore";
import { Button } from "@/components/ui/button";
import { PDF_EXPORT_CONFIG } from "@/config";
import { exportToPdf } from "@/utils/export";
import { exportResumeToBrowserPrint } from "@/utils/print";
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,72 +19,6 @@ import {
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
const getOptimizedStyles = () => {
const styleCache = new Map();
const startTime = performance.now();
const styles = Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.filter((rule) => {
const ruleText = rule.cssText;
const normalizedRuleText = ruleText.toLowerCase();
if (styleCache.has(ruleText)) return false;
styleCache.set(ruleText, true);
if (rule instanceof CSSFontFaceRule) return false;
if (rule instanceof CSSImportRule) return false;
if (normalizedRuleText.includes("fonts.googleapis.com")) return false;
if (normalizedRuleText.includes("fonts.gstatic.com")) return false;
if (ruleText.includes("font-family")) return false;
if (ruleText.includes("@keyframes")) return false;
if (ruleText.includes("animation")) return false;
if (ruleText.includes("transition")) return false;
if (ruleText.includes("hover")) return false;
return true;
})
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
console.warn("Style processing error:", e);
return "";
}
})
.join("\n");
console.log(`Style processing took ${performance.now() - startTime}ms`);
return styles;
};
const optimizeImages = async (element: HTMLElement) => {
const startTime = performance.now();
const images = element.getElementsByTagName("img");
const imagePromises = Array.from(images)
.filter((img) => !img.src.startsWith("data:"))
.map(async (img) => {
try {
const response = await fetch(img.src);
const blob = await response.blob();
return new Promise<void>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result as string;
resolve();
};
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Image conversion error:", error);
return Promise.resolve();
}
});
await Promise.all(imagePromises);
console.log(`Image processing took ${performance.now() - startTime}ms`);
};
const PdfExport = () => {
const [isExporting, setIsExporting] = useState(false);
const [isExportingJson, setIsExportingJson] = useState(false);
@@ -93,93 +28,15 @@ const PdfExport = () => {
const printFrameRef = useRef<HTMLIFrameElement>(null);
const handleExport = async () => {
const exportStartTime = performance.now();
setIsExporting(true);
try {
const pdfElement = document.querySelector<HTMLElement>("#resume-preview");
if (!pdfElement) {
throw new Error("PDF element not found");
}
const clonedElement = pdfElement.cloneNode(true) as HTMLElement;
const transformValue = clonedElement.style.transform || "";
const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/);
if (scaleMatch) {
const scale = Number(scaleMatch[1]);
if (Number.isFinite(scale) && scale > 0 && scale < 1) {
// 服务端导出前将 transform 缩放转为 zoom,避免分页计算偏差
clonedElement.style.removeProperty("transform");
clonedElement.style.removeProperty("transform-origin");
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("zoom", String(scale));
}
}
// 服务端会强制 #resume-preview { width: 210mm; padding: 0 !important }
// 预览中内容宽度 = 210mm(父容器)- 2*pagePadding(padding)
// 通过 inline style 覆盖服务端样式,使内容宽度与预览一致
const pagePadding = globalSettings?.pagePadding || 0;
clonedElement.style.setProperty(
"width",
"100%",
"important"
);
clonedElement.style.setProperty("padding", "0", "important");
clonedElement.style.setProperty("box-sizing", "border-box");
const pageBreakLines =
clonedElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
const [capturedStyles] = await Promise.all([
getOptimizedStyles(),
optimizeImages(clonedElement)
]);
const styles = `
${capturedStyles}
html, body { background: white !important; background-color: white !important; }
#resume-preview { background: white !important; background-color: white !important; }
`;
const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content: clonedElement.outerHTML,
styles,
margin: globalSettings.pagePadding
}),
// 允许跨域请求
mode: "cors",
signal: AbortSignal.timeout(PDF_EXPORT_CONFIG.TIMEOUT)
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title}.pdf`;
link.click();
window.URL.revokeObjectURL(url);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
toast.success(t("toast.success"));
} catch (error) {
console.error("Export error:", error);
toast.error(t("toast.error"));
} finally {
setIsExporting(false);
}
await exportToPdf({
elementId: "resume-preview",
title: title || "resume",
pagePadding: globalSettings?.pagePadding || 0,
onStart: () => setIsExporting(true),
onEnd: () => setIsExporting(false),
successMessage: t("toast.success"),
errorMessage: t("toast.error")
});
};
const handleJsonExport = () => {
@@ -207,12 +64,7 @@ const PdfExport = () => {
}
};
const handlePrint = () => {
if (!printFrameRef.current) {
console.error("Print frame not found");
return;
}
const handlePrint = async () => {
const resumeContent = document.getElementById("resume-preview");
if (!resumeContent) {
console.error("Resume content not found");
@@ -220,163 +72,15 @@ const PdfExport = () => {
}
const pagePadding = globalSettings?.pagePadding || 0;
const iframeWindow = printFrameRef.current.contentWindow;
if (!iframeWindow) {
console.error("IFrame window not found");
return;
}
try {
iframeWindow.document.open();
const clonedContent = resumeContent.cloneNode(true) as HTMLElement;
const transformValue = clonedContent.style.transform || "";
const match = transformValue.match(/scale\(([\d.]+)\)/);
if (match) {
const scale = Number(match[1]);
if (Number.isFinite(scale) && scale > 0 && scale < 1) {
// 打印时使用 zoom 参与分页布局计算,比 transform 更接近最终分页效果
clonedContent.style.removeProperty("transform");
clonedContent.style.removeProperty("transform-origin");
clonedContent.style.setProperty("width", "100%");
clonedContent.style.setProperty("zoom", String(scale));
}
}
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<title>Print Resume</title>
<style>
@font-face {
font-family: "Alibaba PuHuiTi";
src: url("/fonts/AlibabaPuHuiTi-3-55-Regular.ttf") format("truetype");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@page {
size: A4;
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
background: white;
height: auto !important;
overflow: visible !important;
}
body {
font-family: sans-serif;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
#resume-preview {
margin: 0 !important;
padding: ${pagePadding}px !important;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
font-family: "Alibaba PuHuiTi", sans-serif !important;
}
#print-content {
width: 210mm;
margin: 0 auto;
padding: 0;
background: white;
box-shadow: none;
}
#print-content * {
box-shadow: none !important;
}
#resume-preview .min-h-screen,
#resume-preview .min-h-full,
#resume-preview [style*="min-height"] {
min-height: 0 !important;
}
.page-break-line {
display: none;
}
${Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
console.warn("Could not copy styles from sheet:", e);
return "";
}
})
.join("\n")}
</style>
</head>
<body>
<div id="print-content">
${clonedContent.outerHTML}
</div>
</body>
</html>
`;
iframeWindow.document.write(htmlContent);
iframeWindow.document.close();
const printWhenReady = async () => {
try {
const doc = iframeWindow.document;
if (doc.fonts?.ready) {
await doc.fonts.ready;
}
const images = Array.from(doc.images);
await Promise.all(
images
.filter((img) => !img.complete)
.map(
(img) =>
new Promise<void>((resolve) => {
img.onload = () => resolve();
img.onerror = () => resolve();
})
)
);
await new Promise<void>((resolve) => {
iframeWindow.requestAnimationFrame(() => {
iframeWindow.requestAnimationFrame(() => resolve());
});
});
iframeWindow.focus();
iframeWindow.print();
} catch (error) {
console.error("Error print:", error);
}
};
void printWhenReady();
} catch (error) {
console.error("Error setting up print:", error);
}
await exportResumeToBrowserPrint(resumeContent, pagePadding);
};
const isLoading = isExporting || isExportingJson;
const loadingText = isExporting
? t("button.exporting")
: isExportingJson
? t("button.exportingJson")
: "";
? t("button.exportingJson")
: "";
return (
<>
@@ -416,19 +120,6 @@ const PdfExport = () => {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<iframe
ref={printFrameRef}
style={{
position: "absolute",
width: "1px",
height: "1px",
left: "-9999px",
top: 0,
visibility: "hidden",
zIndex: -1
}}
title="Print Frame"
/>
</>
);
};
+169
View File
@@ -0,0 +1,169 @@
import { toast } from "sonner";
import { PDF_EXPORT_CONFIG } from "@/config";
export const getOptimizedStyles = () => {
const styleCache = new Map();
const startTime = performance.now();
const styles = Array.from(document.styleSheets)
.map((sheet) => {
try {
return Array.from(sheet.cssRules)
.filter((rule) => {
const ruleText = rule.cssText;
const normalizedRuleText = ruleText.toLowerCase();
if (styleCache.has(ruleText)) return false;
styleCache.set(ruleText, true);
if (rule instanceof CSSFontFaceRule) return false;
if (rule instanceof CSSImportRule) return false;
if (normalizedRuleText.includes("fonts.googleapis.com")) return false;
if (normalizedRuleText.includes("fonts.gstatic.com")) return false;
if (ruleText.includes("font-family")) return false;
if (ruleText.includes("@keyframes")) return false;
if (ruleText.includes("animation")) return false;
if (ruleText.includes("transition")) return false;
if (ruleText.includes("hover")) return false;
return true;
})
.map((rule) => rule.cssText)
.join("\n");
} catch (e) {
console.warn("Style processing error:", e);
return "";
}
})
.join("\n");
console.log(`Style processing took ${performance.now() - startTime}ms`);
return styles;
};
export const optimizeImages = async (element: HTMLElement) => {
const startTime = performance.now();
const images = element.getElementsByTagName("img");
const imagePromises = Array.from(images)
.filter((img) => !img.src.startsWith("data:"))
.map(async (img) => {
try {
const response = await fetch(img.src);
const blob = await response.blob();
return new Promise<void>((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
img.src = reader.result as string;
resolve();
};
reader.readAsDataURL(blob);
});
} catch (error) {
console.error("Image conversion error:", error);
return Promise.resolve();
}
});
await Promise.all(imagePromises);
console.log(`Image processing took ${performance.now() - startTime}ms`);
};
export interface ExportToPdfOptions {
elementId: string;
title: string;
pagePadding: number;
onStart?: () => void;
onEnd?: () => void;
successMessage?: string;
errorMessage?: string;
}
export const exportToPdf = async ({
elementId,
title,
pagePadding,
onStart,
onEnd,
successMessage,
errorMessage
}: ExportToPdfOptions) => {
const exportStartTime = performance.now();
onStart?.();
try {
const pdfElement = document.querySelector<HTMLElement>(`#${elementId}`);
if (!pdfElement) {
throw new Error(`PDF element #${elementId} not found`);
}
const clonedElement = pdfElement.cloneNode(true) as HTMLElement;
const transformValue = clonedElement.style.transform || "";
const scaleMatch = transformValue.match(/scale\(([\d.]+)\)/);
if (scaleMatch) {
const scale = Number(scaleMatch[1]);
if (Number.isFinite(scale) && scale > 0 && scale < 1) {
// 服务端导出前将 transform 缩放转为 zoom,避免分页计算偏差
clonedElement.style.removeProperty("transform");
clonedElement.style.removeProperty("transform-origin");
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("zoom", String(scale));
}
}
// 采用 PdfExport.tsx 中的逻辑,统一宽度和 padding 处理
clonedElement.style.setProperty("width", "100%", "important");
clonedElement.style.setProperty("padding", "0", "important");
clonedElement.style.setProperty("box-sizing", "border-box");
const pageBreakLines = clonedElement.querySelectorAll<HTMLElement>(".page-break-line");
pageBreakLines.forEach((line) => {
line.style.display = "none";
});
const [capturedStyles] = await Promise.all([
getOptimizedStyles(),
optimizeImages(clonedElement)
]);
// 注入 PdfExport.tsx 中的样式增强
const styles = `
${capturedStyles}
html, body { background: white !important; background-color: white !important; }
#${elementId} { background: white !important; background-color: white !important; }
`;
const response = await fetch(PDF_EXPORT_CONFIG.SERVER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
content: clonedElement.outerHTML,
styles,
margin: pagePadding
}),
mode: "cors",
signal: AbortSignal.timeout(PDF_EXPORT_CONFIG.TIMEOUT)
});
if (!response.ok) {
throw new Error(`PDF generation failed: ${response.status}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${title}.pdf`;
link.click();
window.URL.revokeObjectURL(url);
if (successMessage) toast.success(successMessage);
console.log(`Total export took ${performance.now() - exportStartTime}ms`);
} catch (error) {
console.error("Export error:", error);
if (errorMessage) toast.error(errorMessage);
} finally {
onEnd?.();
}
};
+17 -7
View File
@@ -1,4 +1,4 @@
export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePadding: number) => {
export const exportResumeToBrowserPrint = async (resumeContent: HTMLElement, pagePadding: number) => {
const printFrame = document.createElement("iframe");
printFrame.style.position = "absolute";
printFrame.style.width = "1px";
@@ -59,7 +59,7 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
margin: 0;
padding: 0;
width: 100%;
background: white;
background: white !important;
height: auto !important;
overflow: visible !important;
}
@@ -75,6 +75,7 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
font-family: "Alibaba PuHuiTi", sans-serif !important;
background: white !important;
}
#print-content {
@@ -89,7 +90,8 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
}
#resume-preview .min-h-screen,
#resume-preview .min-h-full {
#resume-preview .min-h-full,
#resume-preview [style*="min-height"] {
min-height: 0 !important;
}
@@ -102,13 +104,13 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
try {
return Array.from(sheet.cssRules)
.map((rule) => rule.cssText)
.join("\\n");
.join("\n");
} catch (e) {
console.warn("Could not copy styles from sheet:", e);
return "";
}
})
.join("\\n")}
.join("\n")}
</style>
</head>
<body>
@@ -125,10 +127,13 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
const printWhenReady = async () => {
try {
const doc = iframeWindow.document;
// 等待字体加载
if (doc.fonts?.ready) {
await doc.fonts.ready;
}
// 等待所有图片加载完成
const images = Array.from(doc.images);
await Promise.all(
images
@@ -142,6 +147,7 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
)
);
// 给予额外的渲染帧缓冲
await new Promise<void>((resolve) => {
iframeWindow.requestAnimationFrame(() => {
iframeWindow.requestAnimationFrame(() => resolve());
@@ -159,13 +165,17 @@ export const exportResumeToBrowserPrint = (resumeContent: HTMLElement, pagePaddi
}, 1000);
} catch (error) {
console.error("Error print:", error);
document.body.removeChild(printFrame);
if (document.body.contains(printFrame)) {
document.body.removeChild(printFrame);
}
}
};
void printWhenReady();
} catch (error) {
console.error("Error setting up print:", error);
document.body.removeChild(printFrame);
if (document.body.contains(printFrame)) {
document.body.removeChild(printFrame);
}
}
};