feat: Implement template snapshot

This commit is contained in:
JOYCEQL
2026-03-15 15:01:21 +08:00
parent b6f01d9e0f
commit 0ec010ea21
4 changed files with 260 additions and 109 deletions
+1
View File
@@ -58,6 +58,7 @@
"date-fns": "^3.6.0",
"dayjs": "^1.11.12",
"framer-motion": "^11.11.10",
"html2canvas": "^1.4.1",
"html2pdf.js": "^0.10.2",
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
+3
View File
@@ -146,6 +146,9 @@ importers:
framer-motion:
specifier: ^11.11.10
version: 11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
html2canvas:
specifier: ^1.4.1
version: 1.4.1
html2pdf.js:
specifier: ^0.10.2
version: 0.10.2
+254 -107
View File
@@ -1,127 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { Layout, PanelsLeftBottom } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ImageIcon, Layout, PanelsLeftBottom } from "lucide-react";
import { motion } from "framer-motion";
import { useTranslations } from "@/i18n/compat/client";
import { useTranslations, useLocale } from "@/i18n/compat/client";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger
SheetTrigger,
} from "@/components/ui/sheet-no-overlay";
import { cn } from "@/lib/utils";
import { DEFAULT_TEMPLATES } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import { ScrollArea } from "@/components/ui/scroll-area";
import ResumeTemplateComponent from "@/components/templates";
import { useLocale } from "@/i18n/compat/client";
import { initialResumeState, initialResumeStateEn } from "@/config/initialResumeData";
import {
initialResumeState,
initialResumeStateEn,
} from "@/config/initialResumeData";
import { normalizeFontFamily } from "@/utils/fonts";
import type { ResumeData } from "@/types/resume";
const A4_WIDTH_PX = 794;
const A4_HEIGHT_PX = 1123;
const SNAPSHOT_CAPTURE_SCALE = 0.6;
type TemplateItem = (typeof DEFAULT_TEMPLATES)[number];
type SnapshotState = Record<string, string | null>;
interface TemplatePreviewProps {
template: TemplateItem;
isActive: boolean;
baseData: typeof initialResumeState;
snapshotUrl?: string | null;
onSelect: (templateId: string) => void;
}
const createPreviewData = (
template: TemplateItem,
baseData: typeof initialResumeState
): ResumeData =>
({
...baseData,
id: `preview-mock-sheet-${template.id}`,
templateId: template.id,
createdAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
}) as ResumeData;
const waitForStableRender = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
await new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
};
const scheduleIdle = (callback: () => void) => {
if ("requestIdleCallback" in window) {
return window.requestIdleCallback(callback, { timeout: 1200 });
}
return window.setTimeout(callback, 180);
};
const cancelIdle = (handle: number) => {
if ("cancelIdleCallback" in window) {
window.cancelIdleCallback(handle);
return;
}
window.clearTimeout(handle);
};
const TemplatePreview = ({
template,
isActive,
baseData,
snapshotUrl,
onSelect,
}: TemplatePreviewProps) => {
const paperRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.18);
useEffect(() => {
const paper = paperRef.current;
if (!paper) return;
const updateScale = () => {
const { width } = paper.getBoundingClientRect();
if (!width) return;
setScale(Math.min(width / A4_WIDTH_PX, 1));
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
resizeObserver.observe(paper);
return () => resizeObserver.disconnect();
}, []);
return (
<button
key={template.id}
onClick={() => onSelect(template.id)}
className={cn(
"relative group rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-[1.02] text-left",
isActive
? "border-primary dark:border-primary shadow-lg dark:shadow-primary/30"
: "dark:border-neutral-800 dark:hover:border-neutral-700 border-gray-100 hover:border-gray-200"
: "border-gray-100 hover:border-gray-200 dark:border-neutral-800 dark:hover:border-neutral-700"
)}
>
<div
className="relative aspect-[210/297] w-full overflow-hidden bg-gray-50 dark:bg-gray-900"
>
<div className="h-full w-full p-2 transition-all duration-300 group-hover:scale-[1.02] flex items-center justify-center pointer-events-none">
<div
ref={paperRef}
className="relative overflow-hidden bg-white shadow-sm ring-1 ring-gray-200/50 rounded-sm"
style={{
width: "min(210px, calc(100% - 8px))",
aspectRatio: "210 / 297",
}}
>
<div
className="absolute top-0 left-0 bg-white"
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
boxSizing: "border-box",
padding: `${template.spacing.contentPadding}px`,
fontFamily: normalizeFontFamily(baseData.globalSettings?.fontFamily),
}}
>
<ResumeTemplateComponent
data={{
...baseData,
id: `preview-mock-sheet-${template.id}`,
templateId: template.id,
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
} as any}
template={template}
/>
</div>
<div className="relative aspect-[210/297] w-full overflow-hidden bg-gray-50 dark:bg-gray-900">
{snapshotUrl ? (
<img
src={snapshotUrl}
alt={template.name}
className="h-full w-full object-cover object-top"
loading="lazy"
draggable={false}
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 bg-gradient-to-br from-gray-50 to-gray-100 text-gray-500 dark:from-neutral-900 dark:to-neutral-950 dark:text-neutral-400">
<ImageIcon className="h-8 w-8" />
<span className="px-4 text-center text-sm font-medium">
{template.name}
</span>
</div>
</div>
)}
</div>
{isActive && (
<motion.div
layoutId="template-selected"
className="absolute inset-0 flex items-center justify-center bg-black/10 dark:bg-black/40 pointer-events-none z-20"
className="absolute inset-0 z-20 flex items-center justify-center bg-black/10 dark:bg-black/40 pointer-events-none"
>
<Layout className="w-8 h-8 text-primary shadow-sm" />
<Layout className="h-8 w-8 text-primary shadow-sm" />
</motion.div>
)}
</button>
@@ -130,44 +136,185 @@ const TemplatePreview = ({
const TemplateSheet = () => {
const t = useTranslations("templates");
const locale = useLocale();
const { activeResume, setTemplate } = useResumeStore();
const [snapshotUrls, setSnapshotUrls] = useState<SnapshotState>({});
const [capturingTemplateId, setCapturingTemplateId] = useState<string | null>(
null
);
const captureRef = useRef<HTMLDivElement>(null);
const currentTemplate =
DEFAULT_TEMPLATES.find((t) => t.id === activeResume?.templateId) ||
DEFAULT_TEMPLATES.find((template) => template.id === activeResume?.templateId) ||
DEFAULT_TEMPLATES[0];
const locale = useLocale();
const baseData = locale === "en" ? initialResumeStateEn : initialResumeState;
const baseData = useMemo(
() => (locale === "en" ? initialResumeStateEn : initialResumeState),
[locale]
);
const previewDataMap = useMemo(
() =>
Object.fromEntries(
DEFAULT_TEMPLATES.map((template) => [
template.id,
createPreviewData(template, baseData),
])
) as Record<string, ResumeData>,
[baseData]
);
const capturingTemplate = useMemo(
() =>
DEFAULT_TEMPLATES.find((template) => template.id === capturingTemplateId) ??
null,
[capturingTemplateId]
);
useEffect(() => {
setSnapshotUrls({});
setCapturingTemplateId(null);
}, [locale]);
useEffect(() => {
if (capturingTemplateId) return;
const nextTemplate = DEFAULT_TEMPLATES.find(
(template) => !(template.id in snapshotUrls)
);
if (!nextTemplate) return;
let cancelled = false;
const idleHandle = scheduleIdle(() => {
if (!cancelled) {
setCapturingTemplateId(nextTemplate.id);
}
});
return () => {
cancelled = true;
cancelIdle(idleHandle);
};
}, [capturingTemplateId, snapshotUrls]);
useEffect(() => {
if (!capturingTemplateId || !capturingTemplate || !captureRef.current) return;
let cancelled = false;
const captureSnapshot = async () => {
try {
const { default: html2canvas } = await import("html2canvas");
await waitForStableRender();
if (cancelled || !captureRef.current) return;
const canvas = await html2canvas(captureRef.current, {
backgroundColor: "#ffffff",
scale: SNAPSHOT_CAPTURE_SCALE,
useCORS: true,
logging: false,
width: A4_WIDTH_PX,
height: A4_HEIGHT_PX,
cacheBust: true,
windowWidth: A4_WIDTH_PX,
windowHeight: A4_HEIGHT_PX,
});
if (cancelled) return;
const snapshotUrl = canvas.toDataURL("image/png");
setSnapshotUrls((prev) => ({
...prev,
[capturingTemplateId]: snapshotUrl,
}));
} catch (error) {
console.error("Template snapshot capture failed:", error);
if (!cancelled) {
setSnapshotUrls((prev) => ({
...prev,
[capturingTemplateId]: null,
}));
}
} finally {
if (!cancelled) {
setCapturingTemplateId(null);
}
}
};
void captureSnapshot();
return () => {
cancelled = true;
};
}, [capturingTemplate, capturingTemplateId]);
return (
<Sheet>
<SheetTrigger asChild>
<PanelsLeftBottom size={20} />
</SheetTrigger>
<SheetContent side="left" className="w-1/2 sm:max-w-1/2">
<SheetHeader>
<SheetTitle>{t("switchTemplate")}</SheetTitle>
</SheetHeader>
<>
<Sheet>
<SheetTrigger asChild>
<PanelsLeftBottom size={20} />
</SheetTrigger>
<SheetContent side="left" className="w-1/2 sm:max-w-1/2">
<SheetHeader>
<SheetTitle>{t("switchTemplate")}</SheetTitle>
</SheetHeader>
<SheetDescription />
{/* 解决警告问题 */}
<SheetDescription></SheetDescription>
<div className="mt-4 h-[calc(100vh-8rem)]">
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<TemplatePreview
key={template.id}
template={template}
isActive={template.id === currentTemplate.id}
snapshotUrl={snapshotUrls[template.id]}
onSelect={setTemplate}
/>
))}
</div>
</ScrollArea>
</div>
</SheetContent>
</Sheet>
<div className="h-[calc(100vh-8rem)] mt-4">
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<TemplatePreview
key={template.id}
template={template}
isActive={template.id === currentTemplate.id}
baseData={baseData}
onSelect={setTemplate}
/>
))}
</div>
</ScrollArea>
{capturingTemplate && (
<div
aria-hidden="true"
className="fixed top-0 left-0 pointer-events-none"
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
transform: "translate(-200vw, 0)",
overflow: "hidden",
background: "#ffffff",
}}
>
<div
ref={captureRef}
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
boxSizing: "border-box",
padding: `${capturingTemplate.spacing.contentPadding}px`,
background: "#ffffff",
fontFamily: normalizeFontFamily(
previewDataMap[capturingTemplate.id].globalSettings?.fontFamily
),
}}
>
<ResumeTemplateComponent
data={previewDataMap[capturingTemplate.id]}
template={capturingTemplate}
/>
</div>
</div>
</SheetContent>
</Sheet>
)}
</>
);
};
+2 -2
View File
@@ -218,7 +218,7 @@
"title": "模板",
"useTemplate": "使用此模板",
"preview": "预览",
"switchTemplate": "切换模",
"switchTemplate": "切换模",
"classic": {
"name": "经典模板",
"description": "传统简约的简历布局,适合大多数求职场景"
@@ -673,7 +673,7 @@
}
},
"previewDock": {
"switchTemplate": "切换模",
"switchTemplate": "切换模",
"grammarCheck": {
"idle": "AI语法纠错",
"checking": "检查中...",