fix(cv): align preview pagination with native PDF
This commit is contained in:
@@ -35,26 +35,15 @@ import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
import { previewDocumentHtml } from "../cvPreview";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
const MIN_PREVIEW_ZOOM = 0.2;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
|
||||
|
||||
function previewDocumentHtml(html: string, pageHeightPx: number, pageMarginMm: number, pageIndex = 0): string {
|
||||
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
|
||||
const margin = Math.max(0, Math.round(pageMarginMm * (96 / 25.4) * 100) / 100);
|
||||
const offset = Math.max(0, pageIndex) * height;
|
||||
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:transparent!important;}.page{position:relative;transform-origin:top left;padding-top:${margin}px;padding-bottom:${margin}px;}.header{padding-top:0!important;}.main,.sidebar{padding-bottom:0!important;}.cols{min-height:calc(${height}px - ${margin * 2}px)!important;}.cols>.main,.cols>.sidebar{padding-top:0!important;}</style>`;
|
||||
// Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important
|
||||
// keep-together decisions before each preview frame crops the selected physical page.
|
||||
const previewScript = `<script data-cv-preview-pagination>(()=>{const H=${height};const M=${margin};const OFFSET=${offset};const page=document.querySelector('.page');if(!page)return;const topOf=(el)=>{let top=0,node=el;while(node&&node!==document.body){top+=node.offsetTop||0;node=node.offsetParent;}return top;};const paginate=()=>{document.querySelectorAll('[data-cv-preview-pad]').forEach((el)=>{el.style.paddingTop='';el.removeAttribute('data-cv-preview-pad');});const pushToNextPage=(el,heightOverride)=>{const current=parseFloat(getComputedStyle(el).paddingTop)||0;let top=topOf(el)+current;const blockHeight=heightOverride||Math.max(0,el.offsetHeight-current);const usable=H-(M*2);if(blockHeight<=0||blockHeight>usable-2)return;let pageTop=Math.floor(top/H)*H;let contentTop=pageTop+M;let contentBottom=pageTop+H-M;let extra=0;if(top<contentTop){extra=contentTop-top;top=contentTop;}if(top+blockHeight>contentBottom+1){extra+=(pageTop+H+M)-top;}if(extra>0){el.style.paddingTop=(current+extra)+'px';el.setAttribute('data-cv-preview-pad','true');}};document.querySelectorAll('.section').forEach((section)=>{if(!section.classList.contains('section-flow')){pushToNextPage(section);return;}const title=section.querySelector(':scope > .section-title');const first=title&&title.nextElementSibling;if(title&&first){const groupedBottom=topOf(first)+first.offsetHeight;pushToNextPage(section,groupedBottom-topOf(section));}section.querySelectorAll(':scope > .entry:not(.entry-flow),:scope > .skill-groups > .skill-group,:scope > .bullets > li:not(.item-flow),:scope > .entry-flow .bullets > li:not(.item-flow),:scope > .paragraphs > p').forEach((block)=>pushToNextPage(block));});page.style.transform='translateY(-'+OFFSET+'px)';};paginate();requestAnimationFrame(()=>requestAnimationFrame(paginate));})();</script>`;
|
||||
const withCss = html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
||||
return withCss.includes("</body>") ? withCss.replace("</body>", `${previewScript}</body>`) : `${withCss}${previewScript}`;
|
||||
}
|
||||
|
||||
export default function CvBuilderEditor() {
|
||||
const { id } = useParams();
|
||||
const variantId = Number(id);
|
||||
@@ -102,11 +91,6 @@ export default function CvBuilderEditor() {
|
||||
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||
const pageMarginMm = useMemo(() => {
|
||||
const themeMargin = themes.find((theme) => theme.id === settings?.themeId)?.pageMarginMm;
|
||||
const density = settings?.density === "compact" ? 0.82 : settings?.density === "roomy" ? 1.18 : 1;
|
||||
return (settings?.pageMarginMm ?? themeMargin ?? 16) * density;
|
||||
}, [settings?.pageMarginMm, settings?.themeId, settings?.density, themes]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
@@ -415,27 +399,21 @@ export default function CvBuilderEditor() {
|
||||
|
||||
// Measure rendered content to drive page count + break indicators.
|
||||
const onIframeLoad = () => {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
const frame = iframeRef.current;
|
||||
const win = frame?.contentWindow;
|
||||
const doc = frame?.contentDocument;
|
||||
const measure = () => {
|
||||
if (frame !== iframeRef.current || doc !== frame?.contentDocument) return;
|
||||
try {
|
||||
const doc = iframeRef.current?.contentDocument;
|
||||
const h = Math.max(
|
||||
pageMetrics.heightPx,
|
||||
doc?.body?.scrollHeight ?? 0,
|
||||
doc?.documentElement?.scrollHeight ?? 0,
|
||||
);
|
||||
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
||||
setPages(pageCount);
|
||||
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
|
||||
const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0);
|
||||
setPreviewOverflow(contentWidth > viewportWidth + 2);
|
||||
setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1);
|
||||
setPreviewOverflow(doc?.documentElement.dataset.cvPreviewOverflow === "true");
|
||||
} catch {
|
||||
setPages(1);
|
||||
setPreviewOverflow(false);
|
||||
}
|
||||
};
|
||||
if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure));
|
||||
else measure();
|
||||
if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) measure();
|
||||
else win?.addEventListener("cv-preview-ready", measure, { once: true });
|
||||
};
|
||||
|
||||
const fitPreview = useCallback(() => {
|
||||
@@ -532,7 +510,7 @@ export default function CvBuilderEditor() {
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
||||
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
||||
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", visibility: "hidden", pointerEvents: "none" }} />
|
||||
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
{Array.from({ length: pages }).map((_, index) => (
|
||||
@@ -540,7 +518,7 @@ export default function CvBuilderEditor() {
|
||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 8px 24px rgba(15,23,42,.16)", overflow: "hidden" }}>
|
||||
<iframe
|
||||
title={t("cvEditorPreviewPage", { page: index + 1 })}
|
||||
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, index)}
|
||||
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user