fix(cv): align preview pagination with native PDF

This commit is contained in:
cesnimda
2026-10-07 08:46:43 +02:00
parent 046dc304aa
commit efc9e7bdd4
182 changed files with 16092 additions and 47 deletions
+12 -34
View File
@@ -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" }}
/>