feat(ui): refine live CV preview presentation

This commit is contained in:
cesnimda
2026-10-07 19:05:09 +02:00
parent fc9598be8e
commit 673cff1145
64 changed files with 21719 additions and 20 deletions
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import CvBuilderEditor from './views/CvBuilderEditor';
@@ -655,12 +655,99 @@ test('preview failure is visible and retryable without leaving the editor', asyn
renderAt(3);
expect(await screen.findByText('Preview unavailable')).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Live preview' })).toHaveAttribute('aria-busy', 'false');
expect(screen.queryByText('updating…')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry preview' }));
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>retry</p>'));
expect(screen.getByTitle('CV preview')).toHaveAttribute('sandbox', 'allow-same-origin allow-scripts');
await waitFor(() => expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument());
});
function settlePreview(count: number) {
const frame = screen.getByTitle('CV preview') as HTMLIFrameElement;
const doc = frame.contentDocument!;
doc.body.innerHTML = '<div class="page">Measured document</div>';
delete doc.documentElement.dataset.cvPreviewReady;
fireEvent.load(frame);
act(() => {
doc.documentElement.dataset.cvPreviewPages = String(count);
doc.documentElement.dataset.cvPreviewReady = 'true';
frame.contentWindow!.dispatchEvent(new Event('cv-preview-ready'));
});
}
test('[P5-11 PRES-C06 E08] automatic fit attaches when the asynchronously loaded preview pane appears', async () => {
const width = jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('aria-label') === 'Live preview' ? 400 : 0;
});
try {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await screen.findByDisplayValue('Backend CV');
expect(await screen.findByText('Fit width · 48%')).toBeInTheDocument();
} finally { width.mockRestore(); }
});
test('[P5-11 PRES-C06 C12] page count is announced only after the renderer has settled', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
const region = await screen.findByRole('region', { name: 'Live preview' });
await waitFor(() => expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('<p>cv</p>'));
expect(region).toHaveAttribute('aria-busy', 'true');
expect(screen.queryByText('1-page CV')).not.toBeInTheDocument();
settlePreview(3);
expect(region).toHaveAttribute('aria-busy', 'false');
expect(screen.getByText('3-page CV')).toBeInTheDocument();
expect(region.querySelectorAll('[data-cv-page]')).toHaveLength(3);
});
test('[P5-11 PRES-C06 E09] overlapping preview successes and failures cannot replace the newest document', async () => {
const pending: { resolve: (value: any) => void; reject: (reason: Error) => void }[] = [];
mockedApi.post.mockImplementation(() => new Promise((resolve, reject) => { pending.push({ resolve, reject }); }));
try {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
const region = screen.getByRole('region', { name: 'Live preview' });
await waitFor(() => expect(pending).toHaveLength(1));
fireEvent.change(headline, { target: { value: 'Newer draft' } });
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => { pending[1].resolve({ data: { html: '<p>Newer document</p>' } }); });
settlePreview(2);
await act(async () => { pending[0].resolve({ data: { html: '<p>Stale document</p>' } }); });
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newer document');
expect(region).toHaveAttribute('aria-busy', 'false');
fireEvent.change(headline, { target: { value: 'Intermediate draft' } });
await waitFor(() => expect(pending).toHaveLength(3));
fireEvent.change(headline, { target: { value: 'Newest draft' } });
await waitFor(() => expect(pending).toHaveLength(4));
await act(async () => { pending[3].resolve({ data: { html: '<p>Newest document</p>' } }); });
settlePreview(4);
await act(async () => { pending[2].reject(new Error('stale failure')); });
expect(screen.getByTitle('CV preview').getAttribute('srcdoc')).toContain('Newest document');
expect(screen.queryByText('Preview unavailable')).not.toBeInTheDocument();
expect(region).toHaveAttribute('aria-busy', 'false');
expect(screen.getByText('4-page CV')).toBeInTheDocument();
expect(headline).toHaveValue('Newest draft');
} finally { mockedApi.post.mockResolvedValue({ data: { html: '<p>cv</p>' } } as any); }
});
test('[P5-11 PRES-C06 C10 E08] returning from preview focuses the existing editor without changing the pending draft', async () => {
routeGet(() => Promise.resolve({ data: variant }));
renderAt(3);
await openContentTab();
const headline = screen.getByLabelText('Headline override');
fireEvent.change(headline, { target: { value: 'Retained preview draft' } });
const editor = screen.getByRole('region', { name: 'CV editing controls' });
editor.scrollIntoView = jest.fn();
fireEvent.click(screen.getByRole('button', { name: 'Go to editor' }));
await waitFor(() => expect(editor).toHaveFocus());
expect(headline).toHaveValue('Retained preview draft');
expect(screen.getByText('Unsaved')).toBeInTheDocument();
expect(mockedApi.put).not.toHaveBeenCalled();
});
async function reviewDocumentSuggestion(result: string) {
routeGet(() => Promise.resolve({ data: variant }), { headline: 'Canonical headline', sections: [] });
mockedApi.post.mockImplementation((url: string) => Promise.resolve({ data: url === '/cv/ai/assist'
+10
View File
@@ -555,6 +555,11 @@ export const translations = {
cvEditorAiTools: "AI Tools",
cvEditorHistory: "History",
cvEditorLivePreview: "Live preview",
cvEditorPreviewScale: "Fit width · {percent}%",
cvEditorFocusEditor: "Go to editor",
cvEditorPreviewHelp: "Use Page Up/Down to scroll. Tab moves into or out of the preview.",
cvEditorPreviewPrevious: "Showing the last successful preview. You can continue editing; PDF export is separate.",
cvEditorPreviewEmpty: "You can continue editing and retry the preview. PDF export is separate.",
cvEditorUpdatingPreview: "updating…",
cvEditorPreviewUnavailable: "Preview unavailable",
cvEditorRetryPreview: "Retry preview",
@@ -3109,6 +3114,11 @@ export const translations = {
cvEditorAiTools: "AI-verktøy",
cvEditorHistory: "Historikk",
cvEditorLivePreview: "Direkte forhåndsvisning",
cvEditorPreviewScale: "Tilpass bredden · {percent} %",
cvEditorFocusEditor: "Gå til redigering",
cvEditorPreviewHelp: "Bruk Page Up/Down for å rulle. Tab flytter fokus inn i eller ut av forhåndsvisningen.",
cvEditorPreviewPrevious: "Viser siste vellykkede forhåndsvisning. Du kan fortsette å redigere; PDF-eksport er separat.",
cvEditorPreviewEmpty: "Du kan fortsette å redigere og prøve forhåndsvisningen på nytt. PDF-eksport er separat.",
cvEditorUpdatingPreview: "oppdaterer…",
cvEditorPreviewUnavailable: "Forhåndsvisning utilgjengelig",
cvEditorRetryPreview: "Prøv forhåndsvisning på nytt",
+37 -16
View File
@@ -64,6 +64,7 @@ export default function CvBuilderEditor() {
const [actionsAnchor, setActionsAnchor] = useState<HTMLElement | null>(null);
const [tab, setTab] = useState(1);
const [html, setHtml] = useState("");
const [measuredHtml, setMeasuredHtml] = useState<string | null>(null);
const [zoom, setZoom] = useState(0.62);
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState(false);
@@ -90,10 +91,14 @@ export default function CvBuilderEditor() {
const previewRequest = useRef(0);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<HTMLDivElement | null>(null);
const blockerPromptOpen = useRef(false);
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
const editorReady = settings !== null;
const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html));
const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError;
useEffect(() => {
let alive = true;
@@ -411,6 +416,7 @@ export default function CvBuilderEditor() {
try {
setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1);
setPreviewOverflow(doc?.documentElement.dataset.cvPreviewOverflow === "true");
setMeasuredHtml(html);
} catch {
setPages(1);
setPreviewOverflow(false);
@@ -436,7 +442,7 @@ export default function CvBuilderEditor() {
window.removeEventListener("resize", resize);
observer?.disconnect();
};
}, [compactEditor, fitPreview, mobilePane]);
}, [compactEditor, editorReady, fitPreview, mobilePane]);
if (loadError) {
return (
@@ -495,7 +501,7 @@ export default function CvBuilderEditor() {
</Paper>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 1fr) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
<Paper variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Paper ref={editorRef} tabIndex={-1} variant="outlined" role="region" aria-label={t("cvEditorEditingRegion")} onFocusCapture={() => setMobilePane("edit")} sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ px: 1.5, py: 0.5, borderBottom: "1px solid", borderColor: "divider" }}>
<Typography component="h2" variant="subtitle2">{t("cvEditorEditingRegion")}</Typography>
<Stack direction="row">
@@ -524,23 +530,38 @@ export default function CvBuilderEditor() {
</Box>
</Paper>
<Box onFocusCapture={() => setMobilePane("preview")} 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 }} />
<Typography variant="caption" color="text.secondary">{t("cvEditorPageCount", { count: pages })}</Typography>
<Paper variant="outlined" onFocusCapture={() => setMobilePane("preview")} sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", minWidth: 0 }}>
<Stack direction="row" alignItems="center" gap={1} sx={{ px: 1.5, py: 1, flexWrap: "wrap", borderBottom: "1px solid", borderColor: "divider" }}>
<Box sx={{ flex: "1 1 140px", minWidth: 0 }}>
<Typography component="h2" variant="subtitle2">{t("cvEditorLivePreview")}</Typography>
<Typography variant="caption" color="text.secondary">{t("cvEditorPreviewScale", { percent: Math.round(zoom * 100) })}</Typography>
</Box>
<Typography variant="caption" color="text.secondary" role="status">
{previewBusy ? t("cvEditorUpdatingPreview") : previewReady ? t("cvEditorPageCount", { count: pages }) : null}
</Typography>
<Button size="small" onClick={() => {
setMobilePane("edit");
requestAnimationFrame(() => {
editorRef.current?.focus({ preventScroll: true });
editorRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
}}>{t("cvEditorFocusEditor")}</Button>
</Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
{previewError && !previewing && <Alert severity="error" action={<Button size="small" onClick={() => setPreviewRevision((revision) => revision + 1)}>{t("cvEditorRetryPreview")}</Button>} sx={{ m: 1, "& .MuiAlert-action": { alignSelf: "center", flexShrink: 1 } }}>
<Typography variant="body2" fontWeight={600}>{t("cvEditorPreviewUnavailable")}</Typography>
<Typography variant="body2">{t(html ? "cvEditorPreviewPrevious" : "cvEditorPreviewEmpty")}</Typography>
</Alert>}
{previewReady && previewOverflow && <Alert severity="warning" sx={{ m: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{previewReady && pages >= 3 && <Alert severity="info" sx={{ m: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<Typography id="cv-preview-help" variant="caption" color="text.secondary" sx={{ display: "block", px: 1.5, py: 0.75 }}>{t("cvEditorPreviewHelp")}</Typography>
<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" } }}>
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} aria-describedby="cv-preview-help" aria-busy={previewBusy} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", px: 1, py: 1.5, bgcolor: "background.default", scrollbarWidth: "thin" }}>
<Stack spacing={2} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
{!html && previewBusy && <Skeleton variant="rectangular" width="100%" height={320} />}
{!!html && Array.from({ length: pages }).map((_, index) => (
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
<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" }}>
<Typography aria-hidden={!previewReady} variant="caption" color="text.secondary" sx={{ display: "block", mb: 0.5, visibility: previewReady ? "visible" : "hidden" }}>{t("cvEditorPageOf", { page: index + 1, pages })}</Typography>
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "common.white", boxShadow: 1, outline: "1px solid", outlineColor: "divider", overflow: "hidden" }}>
<iframe
title={t("cvEditorPreviewPage", { page: index + 1 })}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
@@ -552,7 +573,7 @@ export default function CvBuilderEditor() {
))}
</Stack>
</Box>
</Box>
</Paper>
</Box>
</Stack>
);