fix(cv): restore distinct frameless previews
CI and Deploy / test (push) Failing after 7m53s
CI and Deploy / deploy (push) Has been skipped

This commit is contained in:
cesnimda
2026-09-01 16:37:51 +02:00
parent 4c384b76ad
commit e912b72110
5 changed files with 46 additions and 45 deletions
@@ -184,6 +184,7 @@ test('choosing a template restores its own structure instead of inheriting the p
sidebarWidthMm: 72,
sidebarSections: ['skills'],
accentColor: '#123456',
pageMarginMm: 9,
},
};
mockedApi.get.mockImplementation((url: string) => {
@@ -212,7 +213,8 @@ test('choosing a template restores its own structure instead of inheriting the p
headingStyle: null,
sidebarWidthMm: null,
sidebarSections: null,
accentColor: '#123456',
accentColor: null,
pageMarginMm: null,
}),
})));
});
+9 -3
View File
@@ -63,12 +63,12 @@ describe("CV language compatibility", () => {
test("fits the physical page to the available preview width", () => {
expect(getCvPreviewFitZoom(500, 800)).toBeCloseTo(0.595);
expect(getCvPreviewFitZoom(1200, 800)).toBe(1);
expect(getCvPreviewFitZoom(120, 800)).toBe(0.32);
expect(getCvPreviewFitZoom(120, 800, 24, 0.2)).toBe(0.2);
});
});
describe("CV template selection", () => {
test("restores the selected template structure without discarding user content or design choices", () => {
test("restores the selected template visual defaults without discarding user content", () => {
const settings = {
themeId: "minimal",
layout: "single" as const,
@@ -77,6 +77,9 @@ describe("CV template selection", () => {
sidebarWidthMm: 70,
sidebarSections: ["skills"],
accentColor: "#123456",
headingFont: "Georgia, 'Times New Roman', serif",
pageMarginMm: 9,
skillsStyle: "text" as const,
showPhoto: false,
showPageNumbers: false,
showIcons: true,
@@ -92,7 +95,10 @@ describe("CV template selection", () => {
headingStyle: null,
sidebarWidthMm: null,
sidebarSections: null,
accentColor: "#123456",
accentColor: null,
headingFont: null,
pageMarginMm: null,
skillsStyle: null,
sections: [{ key: "experience", title: "Career" }],
});
});
+17 -3
View File
@@ -182,16 +182,30 @@ export function getCvPreviewFitZoom(containerWidthPx: number, pageWidthPx: numbe
return Math.min(1, Math.max(minZoom, usableWidth / pageWidthPx));
}
// Selecting a template is a structural choice. Clear only the presentation overrides that can
// flatten every template into the previous template's layout; keep content, colour, typography,
// spacing and document settings intact.
// Selecting a template restores that template's complete visual identity. CV content and document
// metadata remain untouched; users can customise the newly selected template afterwards.
export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVariantSettings {
return {
...settings,
themeId,
accentColor: null,
headingFont: null,
bodyFont: null,
density: null,
textColor: null,
mutedColor: null,
headingColor: null,
backgroundColor: null,
baseFontSizePt: null,
headingSizePt: null,
lineHeight: null,
pageMarginMm: null,
sectionGapMm: null,
entryGapMm: null,
layout: null,
headerStyle: null,
headingStyle: null,
skillsStyle: null,
sidebarWidthMm: null,
sidebarSections: null,
};
+4
View File
@@ -506,6 +506,8 @@ export const translations = {
cvEditorUpdating: "Updating…",
cvEditorPublic: "Public",
cvEditorPrivate: "Private",
cvEditorShare: "Share",
cvEditorStopSharing: "Stop sharing",
cvEditorCopyLink: "Copy link",
cvEditorExporting: "Exporting…",
cvEditorDownloadPdf: "Download PDF",
@@ -2926,6 +2928,8 @@ export const translations = {
cvEditorUpdating: "Oppdaterer…",
cvEditorPublic: "Offentlig",
cvEditorPrivate: "Privat",
cvEditorShare: "Del",
cvEditorStopSharing: "Stopp deling",
cvEditorCopyLink: "Kopier lenke",
cvEditorExporting: "Eksporterer…",
cvEditorDownloadPdf: "Last ned PDF",
+13 -38
View File
@@ -3,7 +3,7 @@ import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-ro
import {
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton,
MenuItem, Paper, Skeleton, Slider, Stack, Tab, Tabs, TextField, Tooltip, Typography,
MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
import useMediaQuery from "@mui/material/useMediaQuery";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
@@ -18,8 +18,6 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh";
@@ -42,14 +40,14 @@ import {
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
const MIN_PREVIEW_ZOOM = 0.32;
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
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:#fff!important;}.page{position:relative;transform-origin:top left;}.page::after{content:"";position:absolute;z-index:20;pointer-events:none;inset:0;background:repeating-linear-gradient(to bottom,var(--cv-paper,#fff) 0 ${margin}px,transparent ${margin}px calc(${height}px - ${margin}px),var(--cv-paper,#fff) calc(${height}px - ${margin}px) ${height}px);}</style>`;
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>`;
@@ -75,13 +73,11 @@ export default function CvBuilderEditor() {
const [tab, setTab] = useState(0);
const [html, setHtml] = useState("");
const [zoom, setZoom] = useState(0.62);
const [autoFitPreview, setAutoFitPreview] = useState(true);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [previewRevision, setPreviewRevision] = useState(0);
const [previewOverflow, setPreviewOverflow] = useState(false);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
const [saveState, setSaveState] = useState<SaveState>("idle");
const [exporting, setExporting] = useState(false);
const [publishing, setPublishing] = useState(false);
@@ -381,7 +377,6 @@ export default function CvBuilderEditor() {
);
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
setPages(pageCount);
setPage((current) => Math.min(current, pageCount));
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0);
setPreviewOverflow(contentWidth > viewportWidth + 2);
@@ -394,19 +389,11 @@ export default function CvBuilderEditor() {
else measure();
};
const goToPage = (p: number) => {
const clamped = Math.min(Math.max(1, p), pages);
setPage(clamped);
const target = scrollRef.current?.querySelector<HTMLElement>(`[data-cv-page="${clamped}"]`);
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
};
const fitPreview = useCallback(() => {
setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 24, MIN_PREVIEW_ZOOM));
setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 16, MIN_PREVIEW_ZOOM));
}, [pageMetrics.widthPx]);
useEffect(() => {
if (!autoFitPreview) return;
const container = scrollRef.current;
if (!container) return;
const resize = () => fitPreview();
@@ -418,7 +405,7 @@ export default function CvBuilderEditor() {
window.removeEventListener("resize", resize);
observer?.disconnect();
};
}, [autoFitPreview, compactEditor, fitPreview, mobilePane]);
}, [compactEditor, fitPreview, mobilePane]);
if (loadError) {
return (
@@ -453,7 +440,7 @@ export default function CvBuilderEditor() {
<Box sx={{ flex: { sm: 1 } }} />
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>{t("cvEditorDuplicate")}</Button>
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorPublic") : t("cvEditorPrivate")}
{publishing ? t("cvEditorUpdating") : isPublic ? t("cvEditorStopSharing") : t("cvEditorShare")}
</Button>
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>{t("cvEditorCopyLink")}</Button>}
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? t("cvEditorExporting") : t("cvEditorDownloadPdf")}</Button>
@@ -478,35 +465,23 @@ export default function CvBuilderEditor() {
{tab === 4 && <HistoryTab versions={versions} onRestore={restore} />}
</Paper>
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
<Box sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, flexWrap: "wrap" }}>
<Typography variant="caption" sx={{ fontWeight: 700 }}>{t("cvEditorLivePreview")}</Typography>
{previewing && <Chip size="small" label={t("cvEditorUpdatingPreview")} variant="outlined" />}
{previewError && <Chip size="small" label={t("cvEditorPreviewUnavailable")} color="error" variant="outlined" />}
{previewError && <Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>}
<Box sx={{ flex: 1 }} />
{pages >= 3 && <Chip size="small" color="warning" variant="outlined" label={t("cvEditorPageCount", { count: pages })} />}
<Stack direction="row" alignItems="center" spacing={0.5}>
<Button size="small" disabled={page <= 1} onClick={() => goToPage(page - 1)}>{t("cvEditorPrevious")}</Button>
<Typography variant="caption">{t("cvEditorPageOf", { page, pages })}</Typography>
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
</Stack>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2))); }}><ZoomOutIcon fontSize="small" /></IconButton>
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => { setAutoFitPreview(false); setZoom(v as number); }} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2))); }}><ZoomInIcon fontSize="small" /></IconButton>
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
<Button size="small" variant={autoFitPreview ? "outlined" : "text"} onClick={() => { setAutoFitPreview(true); fitPreview(); }}>{t("cvEditorFit")}</Button>
<Typography variant="caption" color="text.secondary">{t("cvEditorPageCount", { count: pages })}</Typography>
</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" }} />
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
<Stack spacing={3} alignItems="center">
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5 }}>
<Stack spacing={2} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
<Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>{t("cvEditorPage", { page: index + 1 })}</Typography>
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
<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)}
@@ -518,7 +493,7 @@ export default function CvBuilderEditor() {
))}
</Stack>
</Box>
</Paper>
</Box>
</Box>
</Stack>
);