|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|