mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
fix(journey): replace window.open with srcdoc iframe overlay for PDF preview
Rewrites downloadJourneyBookPDF to render the preview in an in-page srcdoc iframe overlay instead of calling window.open(), which Safari iOS PWA blocks in async callbacks. Matches the existing TripPDF pattern. Fixes #679.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
|
||||
//
|
||||
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
|
||||
// that opens a new browser window and writes a full HTML document into it.
|
||||
// It does NOT render a React component. Tests verify window.open behaviour.
|
||||
// that renders a PDF preview in an srcdoc iframe overlay (Safari-safe pattern).
|
||||
// Tests verify the overlay DOM structure and HTML content.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
@@ -77,55 +77,57 @@ function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
|
||||
} as unknown as JourneyDetail;
|
||||
}
|
||||
|
||||
// ── Mock window.open ─────────────────────────────────────────────────────────
|
||||
// ── Helpers to inspect the overlay ───────────────────────────────────────────
|
||||
|
||||
let mockWindow: {
|
||||
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
|
||||
focus: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
function getOverlay(): HTMLElement | null {
|
||||
return document.getElementById('journey-pdf-overlay');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWindow = {
|
||||
document: { write: vi.fn(), close: vi.fn() },
|
||||
focus: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
|
||||
});
|
||||
function getIframe(): HTMLIFrameElement | null {
|
||||
return getOverlay()?.querySelector('iframe') ?? null;
|
||||
}
|
||||
|
||||
// ── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('journey-pdf-overlay')?.remove();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(window.open).toHaveBeenCalledWith('', '_blank');
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
expect(document.body.contains(getOverlay())).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const iframe = getIframe();
|
||||
expect(iframe).not.toBeNull();
|
||||
const html = iframe!.srcdoc;
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
|
||||
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
|
||||
const overlay = getOverlay()!;
|
||||
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
|
||||
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
|
||||
await downloadJourneyBookPDF(buildJourney());
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Golden Circle');
|
||||
// Story text is rendered via markdown
|
||||
expect(html).toContain('An incredible day of geysers and waterfalls.');
|
||||
@@ -137,8 +139,8 @@ describe('downloadJourneyBookPDF', () => {
|
||||
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
|
||||
const journey = buildJourney({ entries: [] });
|
||||
await downloadJourneyBookPDF(journey);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
const html = mockWindow.document.write.mock.calls[0][0] as string;
|
||||
expect(getOverlay()).not.toBeNull();
|
||||
const html = getIframe()!.srcdoc;
|
||||
expect(html).toContain('Iceland Ring Road');
|
||||
// No entry pages, but cover and closing page are still present
|
||||
expect(html).toContain('Journey Book');
|
||||
|
||||
@@ -249,23 +249,9 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
|
||||
}
|
||||
|
||||
.print-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
||||
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
|
||||
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
|
||||
.print-bar .btn-save { background: white; color: #0f172a; }
|
||||
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
|
||||
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="print-bar">
|
||||
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<button class="btn-save" onclick="window.print()">Save as PDF</button>
|
||||
<button class="btn-close" onclick="window.close()">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Page 1: Cover -->
|
||||
<div class="cover-page">
|
||||
@@ -299,8 +285,37 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const win = window.open('', '_blank')
|
||||
if (!win) return
|
||||
win.document.write(html)
|
||||
win.document.close()
|
||||
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
|
||||
// This avoids window.open() which Safari iOS blocks in async callbacks
|
||||
// and window.close() which doesn't work reliably in standalone PWA mode.
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'journey-pdf-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
|
||||
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
|
||||
|
||||
const card = document.createElement('div')
|
||||
card.style.cssText = 'width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em">${esc(journey.title)} · ${totalPages} pages</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button id="journey-pdf-save" style="padding:7px 20px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;border:none;background:#fff;color:#0f172a;">Save as PDF</button>
|
||||
<button id="journey-pdf-close" style="padding:7px 12px;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7);">Close</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
|
||||
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user