mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-06-02 07:43:34 +02:00
feat: Implement template snapshot
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
"title": "模板",
|
||||
"useTemplate": "使用此模板",
|
||||
"preview": "预览",
|
||||
"switchTemplate": "切换模版",
|
||||
"switchTemplate": "切换模板",
|
||||
"classic": {
|
||||
"name": "经典模板",
|
||||
"description": "传统简约的简历布局,适合大多数求职场景"
|
||||
@@ -673,7 +673,7 @@
|
||||
}
|
||||
},
|
||||
"previewDock": {
|
||||
"switchTemplate": "切换模版",
|
||||
"switchTemplate": "切换模板",
|
||||
"grammarCheck": {
|
||||
"idle": "AI语法纠错",
|
||||
"checking": "检查中...",
|
||||
|
||||
Reference in New Issue
Block a user