mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
fix(pdf): hand the trip PDF to Safari from the iOS Home Screen app
An iPhone or iPad running TREK from the Home Screen ignores print(), in the page and in an iframe alike, so "Save as PDF" in the preview did nothing there. Mobile Safari prints the same preview fine. In the installed iOS app the preview now shows "Open in Safari" in place of the dead button, with a line saying why. The link opens the trip with ?export=pdf, and both shells open their export dialog or sheet for it, so the PDF is one tap away once Safari is up. The parameter is dropped once read and survives the login redirect. Everywhere else the preview prints as before. The preview overlay moves out of TripPDF.tsx into pdfPreview.ts, which TripPDF had no room left for under lint:size, and its backdrop and stacking now use the --overlay and --z-modal tokens.
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
"components/Map/locationMarkerMapbox.ts": 2,
|
||||
"components/Map/nightPauseMarker.ts": 4,
|
||||
"components/Map/ratingBadge.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 8,
|
||||
"components/PDF/TripPDF.tsx": 7,
|
||||
"components/PDF/tripMapImage.ts": 1,
|
||||
"components/Packing/PackingListPanelBagCard.tsx": 1,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 3,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"src/components/Map/MapView.tsx": 1385,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2218,
|
||||
"src/components/PDF/TripPDF.tsx": 1039,
|
||||
"src/components/Planner/DayDetailPanel.test.tsx": 2333,
|
||||
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
|
||||
"src/components/Planner/DayPlanSidebar.tsx": 2929,
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"components/Map/poiCategories.ts": 7,
|
||||
"components/Map/ratingBadge.ts": 2,
|
||||
"components/Map/reservationsMapbox.ts": 1,
|
||||
"components/PDF/TripPDF.tsx": 53,
|
||||
"components/PDF/TripPDF.tsx": 51,
|
||||
"components/Packing/PackingListPanelBagModal.tsx": 2,
|
||||
"components/Packing/PackingListPanelCategoryGroup.tsx": 5,
|
||||
"components/Packing/PackingListPanelHeader.tsx": 5,
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Trip, Day, Place, Category, AssignmentsMap, DayNote, DistanceUnit,
|
||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||
import { hidesOnMiddleDay, getTransportForDay, getMergedItems, getSpanPhase, getDisplayTimeForDay } from '../../utils/dayMerge'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { pdfSwitchOn, rememberPdfSwitch } from '../../utils/pdfSwitch'
|
||||
import { pdfSwitchOn } from '../../utils/pdfSwitch'
|
||||
import { openPdfPreview, PAGE_BREAK_KEY, TRANSPORT_NOTES_KEY } from './pdfPreview'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { formatMoney, formatMoneySum, formatClockTime, splitReservationDateTime, type MoneyEntry } from '../../utils/formatters'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -23,28 +24,6 @@ import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../utils/fligh
|
||||
import { onlyMyPlan, planAssignments } from './pdfScope'
|
||||
import { createPdfMarkdown, PDF_MARKDOWN_CSS } from './pdfMarkdown'
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day'
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes'
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK = 'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;'
|
||||
const TOGGLE_KNOB = 'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);'
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)')
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px')
|
||||
|
||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||
return renderIconMarkup(
|
||||
createElement(icon, props)
|
||||
@@ -891,80 +870,10 @@ ${pluginSectionsHtml}
|
||||
markdown.dispose()
|
||||
if (signal?.aborted) return
|
||||
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div')
|
||||
overlay.id = 'pdf-preview-overlay'
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);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:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
|
||||
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(trip?.title || tr('pdf.travelPlan'))}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
<button type="button" id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
||||
iframe.srcdoc = html
|
||||
|
||||
card.appendChild(header)
|
||||
card.appendChild(iframe)
|
||||
overlay.appendChild(card)
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn')
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove()
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn')
|
||||
if (printBtn) printBtn.onclick = () => { iframe.contentWindow?.print() }
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', on => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on)
|
||||
})
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', on => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on)
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on)
|
||||
})
|
||||
openPdfPreview({ html, title: trip?.title || tr('pdf.travelPlan'), tripId: trip?.id, tr, breaksPerDay, showTransportNotes, hasTransportNotes })
|
||||
}
|
||||
|
||||
function bodyClassAttr(breaksPerDay: boolean, showTransportNotes: boolean): string {
|
||||
const classes = [!breaksPerDay && 'pdf-flow', !showTransportNotes && 'pdf-no-transport-notes'].filter(Boolean)
|
||||
return classes.length ? ` class="${classes.join(' ')}"` : ''
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} left:${knobOffset(on)}"></span>
|
||||
</button>`
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`)
|
||||
if (!button) return
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true'
|
||||
button.setAttribute('aria-checked', String(on))
|
||||
button.style.background = trackColour(on)
|
||||
const knob = button.firstElementChild as HTMLElement | null
|
||||
if (knob) knob.style.left = knobOffset(on)
|
||||
onChange(on)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* FE-PDFPREVIEW-001 to FE-PDFPREVIEW-005: the trip PDF's preview and the control
|
||||
* that saves it. An iPhone or iPad ignores print() in the Home Screen app, so
|
||||
* there the preview hands the export to Safari instead of offering a button that
|
||||
* does nothing.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PAGE_BREAK_KEY, openPdfPreview, type PdfPreviewOptions } from './pdfPreview';
|
||||
|
||||
const tr = (key: string) => key;
|
||||
|
||||
function open(overrides: Partial<PdfPreviewOptions> = {}) {
|
||||
openPdfPreview({
|
||||
html: '<html><body>plan</body></html>',
|
||||
title: 'Lisbon',
|
||||
tripId: 9,
|
||||
tr,
|
||||
breaksPerDay: true,
|
||||
showTransportNotes: true,
|
||||
hasTransportNotes: false,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function standalone(value: boolean) {
|
||||
Object.defineProperty(navigator, 'standalone', { configurable: true, get: () => value });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.getElementById('pdf-preview-overlay')?.remove();
|
||||
delete (navigator as unknown as Record<string, unknown>).standalone;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('openPdfPreview', () => {
|
||||
it('FE-PDFPREVIEW-001: a browser gets the print button, and it prints the frame', () => {
|
||||
open();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
expect(document.getElementById('pdf-safari-hint')).toBeNull();
|
||||
|
||||
const iframe = document.querySelector<HTMLIFrameElement>('#pdf-preview-overlay iframe');
|
||||
const print = vi.fn();
|
||||
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true });
|
||||
const button = document.getElementById('pdf-print-btn');
|
||||
expect(button?.textContent).toBe('pdf.saveAsPdf');
|
||||
button?.click();
|
||||
expect(print).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-002: the iOS Home Screen app gets a Safari link to the trip export and says why', () => {
|
||||
standalone(true);
|
||||
open();
|
||||
expect(document.getElementById('pdf-print-btn')).toBeNull();
|
||||
|
||||
const link = document.getElementById('pdf-safari-link') as HTMLAnchorElement;
|
||||
expect(link.textContent).toBe('pdf.openInSafari');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener');
|
||||
const url = new URL(link.href);
|
||||
expect(url.pathname).toBe('/trips/9');
|
||||
expect(url.searchParams.get('export')).toBe('pdf');
|
||||
expect(document.getElementById('pdf-safari-hint')?.textContent).toBe('pdf.safariHint');
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-003: without a trip to hand over, the preview keeps the print button', () => {
|
||||
standalone(true);
|
||||
open({ tripId: undefined });
|
||||
expect(document.getElementById('pdf-print-btn')).not.toBeNull();
|
||||
expect(document.getElementById('pdf-safari-link')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-004: the title is escaped and the close button removes the preview', () => {
|
||||
open({ title: '<b>Lisbon</b>' });
|
||||
const overlay = document.getElementById('pdf-preview-overlay');
|
||||
expect(overlay?.querySelector('b')).toBeNull();
|
||||
expect(overlay?.textContent).toContain('<b>Lisbon</b>');
|
||||
document.getElementById('pdf-close-btn')?.click();
|
||||
expect(document.getElementById('pdf-preview-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-PDFPREVIEW-005: the page-break switch flips and remembers the choice', () => {
|
||||
open();
|
||||
const toggle = document.getElementById('pdf-daybreak-toggle');
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('true');
|
||||
toggle?.click();
|
||||
expect(toggle?.getAttribute('aria-checked')).toBe('false');
|
||||
expect(localStorage.getItem(PAGE_BREAK_KEY)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { escapeHtml } from '@trek/shared';
|
||||
|
||||
import { isIosStandalone } from '../../utils/iosDevice';
|
||||
import { pdfExportUrl } from '../../utils/pdfSafariHandoff';
|
||||
import { rememberPdfSwitch } from '../../utils/pdfSwitch';
|
||||
|
||||
/**
|
||||
* Every day starts a new page by default. On a trip of short days that prints
|
||||
* one sheet per handful of lines, so the preview offers the flowing layout the
|
||||
* in-app view already uses, and remembers which one was picked (#1292).
|
||||
*/
|
||||
export const PAGE_BREAK_KEY = 'trek_pdf_page_break_per_day';
|
||||
|
||||
/**
|
||||
* The notes of a flight, train or rental (#1571): printed by default, like the
|
||||
* notes of a place, and a switch in the preview leaves them out for a reader who
|
||||
* wants the plan without the fine print.
|
||||
*/
|
||||
export const TRANSPORT_NOTES_KEY = 'trek_pdf_transport_notes';
|
||||
|
||||
/* The overlay is imperative DOM, so the switch from Settings/ToggleSwitch is
|
||||
rebuilt here rather than rendered — as static markup it would carry no
|
||||
behaviour. Same geometry and the same tokens, so it reads as the same control. */
|
||||
const TOGGLE_TRACK =
|
||||
'position:relative;width:44px;height:24px;min-width:44px;flex-shrink:0;border-radius:12px;border:none;padding:0;cursor:pointer;transition:background 0.2s;';
|
||||
const TOGGLE_KNOB =
|
||||
'position:absolute;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:left 0.2s;box-shadow:0 1px 3px rgba(0,0,0,0.2);';
|
||||
const trackColour = (on: boolean) => (on ? 'var(--accent, #111827)' : 'var(--border-primary, #d1d5db)');
|
||||
const knobOffset = (on: boolean) => (on ? '22px' : '2px');
|
||||
|
||||
const HEADER_ACTION =
|
||||
'display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface PdfPreviewOptions {
|
||||
/** The finished document, shown in a sandboxed srcdoc frame. */
|
||||
html: string;
|
||||
title: string;
|
||||
/** The trip the document prints, for the hand-over to Safari; without it the preview only prints. */
|
||||
tripId?: number;
|
||||
tr: Translate;
|
||||
breaksPerDay: boolean;
|
||||
showTransportNotes: boolean;
|
||||
/** The transport-notes switch only shows when there is a note to hide. */
|
||||
hasTransportNotes: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that saves the document. A browser prints the frame and offers
|
||||
* "Save as PDF" in its dialog. An iPhone or iPad running TREK from the Home
|
||||
* Screen ignores print() altogether, so there the control opens the trip in
|
||||
* Safari with the export dialog up, and a line under the header says why.
|
||||
*/
|
||||
function saveControl(tripId: number | undefined, tr: Translate): { html: string; hint: string | null } {
|
||||
if (tripId == null || !isIosStandalone()) {
|
||||
return {
|
||||
html: `<button type="button" id="pdf-print-btn" style="${HEADER_ACTION}">${escapeHtml(tr('pdf.saveAsPdf'))}</button>`,
|
||||
hint: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
// target=_blank leaves the installed app for Safari; a link, not script, so the tap itself opens it.
|
||||
html: `<a id="pdf-safari-link" href="${escapeHtml(pdfExportUrl(tripId))}" target="_blank" rel="noopener" style="${HEADER_ACTION};text-decoration:none">${escapeHtml(tr('pdf.openInSafari'))}</a>`,
|
||||
hint: tr('pdf.safariHint'),
|
||||
};
|
||||
}
|
||||
|
||||
/** Opens the document in a modal over the app, with its layout switches and the control that saves it. */
|
||||
export function openPdfPreview({
|
||||
html,
|
||||
title,
|
||||
tripId,
|
||||
tr,
|
||||
breaksPerDay,
|
||||
showTransportNotes,
|
||||
hasTransportNotes,
|
||||
}: PdfPreviewOptions): void {
|
||||
// Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue)
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'pdf-preview-overlay';
|
||||
overlay.style.cssText =
|
||||
'position:fixed;inset:0;background:var(--overlay);z-index:var(--z-modal);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:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);';
|
||||
|
||||
const save = saveControl(tripId, tr);
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText =
|
||||
'display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
header.innerHTML = `
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(title)}</span>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasTransportNotes ? previewSwitch('pdf-transport-notes-toggle', tr('pdf.transportNotes'), showTransportNotes) : ''}
|
||||
${previewSwitch('pdf-daybreak-toggle', tr('pdf.pageBreakPerDay'), breaksPerDay)}
|
||||
${save.html}
|
||||
<button type="button" id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.cssText = 'flex:1;width:100%;border:none;';
|
||||
// No script runs inside the document (print is parent-initiated), so withhold
|
||||
// allow-scripts to keep the sandbox tight.
|
||||
iframe.sandbox = 'allow-same-origin allow-modals';
|
||||
iframe.srcdoc = html;
|
||||
|
||||
card.appendChild(header);
|
||||
if (save.hint) {
|
||||
const hint = document.createElement('p');
|
||||
hint.id = 'pdf-safari-hint';
|
||||
hint.style.cssText =
|
||||
'margin:0;padding:8px 16px;font-size:12px;line-height:1.4;color:var(--text-muted);background:var(--bg-secondary);border-bottom:1px solid var(--border-primary);flex-shrink:0;';
|
||||
hint.textContent = save.hint;
|
||||
card.appendChild(hint);
|
||||
}
|
||||
card.appendChild(iframe);
|
||||
overlay.appendChild(card);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const closeBtn = header.querySelector<HTMLElement>('#pdf-close-btn');
|
||||
if (closeBtn) closeBtn.onclick = () => overlay.remove();
|
||||
const printBtn = header.querySelector<HTMLElement>('#pdf-print-btn');
|
||||
if (printBtn)
|
||||
printBtn.onclick = () => {
|
||||
iframe.contentWindow?.print();
|
||||
};
|
||||
|
||||
// Both choices differ by one class on <body>, so switching is instant in the
|
||||
// preview and there is no need to re-fetch photos or rebuild the document.
|
||||
wirePreviewSwitch(header, 'pdf-daybreak-toggle', (on) => {
|
||||
rememberPdfSwitch(PAGE_BREAK_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-flow', !on);
|
||||
});
|
||||
wirePreviewSwitch(header, 'pdf-transport-notes-toggle', (on) => {
|
||||
rememberPdfSwitch(TRANSPORT_NOTES_KEY, on);
|
||||
iframe.contentDocument?.body.classList.toggle('pdf-no-transport-notes', !on);
|
||||
});
|
||||
}
|
||||
|
||||
/** A labelled switch for the preview header, as markup; wirePreviewSwitch gives it behaviour. */
|
||||
function previewSwitch(id: string, label: string, on: boolean): string {
|
||||
return `<label for="${id}" style="font-size:12px;color:var(--text-muted);cursor:pointer;user-select:none">${escapeHtml(label)}</label>
|
||||
<button id="${id}" type="button" role="switch" aria-checked="${on}" aria-label="${escapeHtml(label)}" style="${TOGGLE_TRACK} background:${trackColour(on)}">
|
||||
<span style="${TOGGLE_KNOB} inset-inline-start:${knobOffset(on)}"></span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function wirePreviewSwitch(header: HTMLElement, id: string, onChange: (on: boolean) => void): void {
|
||||
const button = header.querySelector<HTMLButtonElement>(`#${id}`);
|
||||
if (!button) return;
|
||||
button.onclick = () => {
|
||||
const on = button.getAttribute('aria-checked') !== 'true';
|
||||
button.setAttribute('aria-checked', String(on));
|
||||
button.style.background = trackColour(on);
|
||||
const knob = button.firstElementChild as HTMLElement | null;
|
||||
if (knob) knob.style.insetInlineStart = knobOffset(on);
|
||||
onChange(on);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-026
|
||||
// FE-PLANNER-DPTOOLBAR-001 to FE-PLANNER-DPTOOLBAR-027
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
@@ -84,6 +84,12 @@ describe('DayPlanSidebarToolbar', () => {
|
||||
expect(screen.queryByText('dayplan.pdf')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-027: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the dialog', () => {
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />, { initialEntries: ['/trips/1?export=pdf'] })
|
||||
expect(screen.getByRole('button', { name: 'dayplan.export' })).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DPTOOLBAR-002: the export button opens the dialog and closes again', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<DayPlanSidebarToolbar {...makeProps()} />)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { NEUTRAL_TINT, fs } from '../shared/DialogShell'
|
||||
import { BarButton, PANEL_BAR } from './planParts'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { TripExportModal } from './TripExportModal'
|
||||
import { useExportDeepLink } from '../../hooks/useExportDeepLink'
|
||||
import { isRoutableReservation } from '../../utils/reservationRoutes'
|
||||
import type { DayAddControls } from '../../utils/dayAdd'
|
||||
import type { DayDeleteQuestion } from '../../utils/dayImpactLines'
|
||||
@@ -63,6 +64,8 @@ export function DayPlanSidebarToolbar({
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => setExportOpen(true))
|
||||
|
||||
const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
|
||||
const expandLabel = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* FE-EXPORTLINK-001 to FE-EXPORTLINK-003: `?export=pdf` opens the trip's export
|
||||
* once, the way Safari lands after the iOS Home Screen app handed the PDF over.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MemoryRouter, useLocation } from 'react-router';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useExportDeepLink } from './useExportDeepLink';
|
||||
|
||||
function setup(entry: string) {
|
||||
const openExport = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[entry]}>{children}</MemoryRouter>
|
||||
);
|
||||
const { result, rerender } = renderHook(
|
||||
() => {
|
||||
useExportDeepLink(openExport);
|
||||
return useLocation();
|
||||
},
|
||||
{ wrapper }
|
||||
);
|
||||
return { openExport, result, rerender };
|
||||
}
|
||||
|
||||
describe('useExportDeepLink', () => {
|
||||
it('FE-EXPORTLINK-001: opens the export and drops the parameter, keeping the others', () => {
|
||||
const { openExport, result, rerender } = setup('/trips/7?tab=plan&export=pdf');
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.pathname).toBe('/trips/7');
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
|
||||
rerender();
|
||||
expect(openExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-002: leaves the trip alone without the parameter', () => {
|
||||
const { openExport, result } = setup('/trips/7?tab=plan');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?tab=plan');
|
||||
});
|
||||
|
||||
it('FE-EXPORTLINK-003: ignores an export it does not know', () => {
|
||||
const { openExport, result } = setup('/trips/7?export=gpx');
|
||||
expect(openExport).not.toHaveBeenCalled();
|
||||
expect(result.current.search).toBe('?export=gpx');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useEffectEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
import { EXPORT_PARAM, EXPORT_PDF } from '../utils/pdfSafariHandoff';
|
||||
|
||||
/**
|
||||
* Opens the trip's export when the address asks for it (`?export=pdf`), the way
|
||||
* the iOS Home Screen app hands a PDF over to Safari. The parameter is dropped
|
||||
* once read, so a reload or a copied link does not open the dialog again.
|
||||
*/
|
||||
export function useExportDeepLink(openExport: () => void): void {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requested = searchParams.get(EXPORT_PARAM) === EXPORT_PDF;
|
||||
// The shells pass a fresh closure on every render; the request is answered once.
|
||||
const open = useEffectEvent(openExport);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requested) return;
|
||||
open();
|
||||
setSearchParams(
|
||||
(p) => {
|
||||
p.delete(EXPORT_PARAM);
|
||||
return p;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
}, [requested, setSearchParams]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { IcsSubscribeModal } from '../../../../components/Planner/IcsSubscribeMo
|
||||
import { useTripStore } from '../../../../store/tripStore'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useTripExport } from '../../../../components/Planner/useTripExport'
|
||||
import { useExportDeepLink } from '../../../../hooks/useExportDeepLink'
|
||||
import { INNER_CLS, TileHeader } from './MTripSheetUi'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
@@ -20,6 +21,8 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const open = shell.sheet?.id === 'export'
|
||||
const dayNotes = useTripStore(s => s.dayNotes)
|
||||
const [subscribeOpen, setSubscribeOpen] = useState(false)
|
||||
// The iOS Home Screen app hands the PDF to Safari, which lands here with ?export=pdf.
|
||||
useExportDeepLink(() => shell.openSheet('export'))
|
||||
// Fed the way the desktop dialog feeds it: the store's assignments, and the export
|
||||
// applies the day plan's filter itself, so both shells print the same. The PDF stays
|
||||
// open here; a calendar or GPX file closes the sheet.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* FE-PDFSAFARI-001: the link the iOS Home Screen app hands to Safari.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { EXPORT_PARAM, EXPORT_PDF, pdfExportUrl } from './pdfSafariHandoff';
|
||||
|
||||
describe('pdfExportUrl', () => {
|
||||
it('FE-PDFSAFARI-001: is the absolute trip address with the export asked for', () => {
|
||||
const url = new URL(pdfExportUrl(42));
|
||||
expect(url.origin).toBe(window.location.origin);
|
||||
expect(url.pathname).toBe('/trips/42');
|
||||
expect(url.searchParams.get(EXPORT_PARAM)).toBe(EXPORT_PDF);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* An iPhone or iPad ignores print() in an app added to the Home Screen, in the
|
||||
* page and in an iframe alike, so the trip PDF cannot be saved from there. Safari
|
||||
* can print it. This is the link that hands the export over: it opens the trip in
|
||||
* Safari with the export dialog already up.
|
||||
*/
|
||||
|
||||
export const EXPORT_PARAM = 'export';
|
||||
export const EXPORT_PDF = 'pdf';
|
||||
|
||||
/** The trip, opened with its export dialog showing. Absolute, since it leaves the installed app. */
|
||||
export function pdfExportUrl(tripId: number): string {
|
||||
return `${window.location.origin}/trips/${tripId}?${EXPORT_PARAM}=${EXPORT_PDF}`;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { server } from '../../../helpers/msw/server'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
|
||||
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-015
|
||||
// FE-MOB-EXPSH-001 to FE-MOB-EXPSH-016
|
||||
// This sheet reads its copy from useTranslation(), so the assertions are English.
|
||||
|
||||
vi.mock('../../../../src/components/PDF/TripPDF', () => ({ downloadTripPDF: vi.fn() }))
|
||||
@@ -248,4 +248,10 @@ describe('MExportSheet', () => {
|
||||
expect(route?.signal?.aborted).toBe(true)
|
||||
expect(signal?.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MOB-EXPSH-016: ?export=pdf, as Safari opens it from the iOS Home Screen app, opens the sheet', () => {
|
||||
const shell = buildShell({ sheet: null })
|
||||
render(<MExportSheet planner={buildPlanner({ days: DAYS })} shell={shell} />, { initialEntries: ['/trips/1?export=pdf'] })
|
||||
expect(shell.openSheet).toHaveBeenCalledWith('export')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'التكلفة',
|
||||
'pdf.preview': 'معاينة PDF',
|
||||
'pdf.saveAsPdf': 'حفظ كـ PDF',
|
||||
'pdf.openInSafari': 'فتح في Safari',
|
||||
'pdf.safariHint':
|
||||
'لا يمكن لنظام iOS الطباعة من التطبيق على الشاشة الرئيسية. سيفتح Safari هذه الرحلة مع تجهيز التصدير، ويمكنك حفظ ملف PDF هناك.',
|
||||
'pdf.pageBreakPerDay': 'فاصل صفحة لكل يوم',
|
||||
'pdf.transportNotes': 'ملاحظات التنقل',
|
||||
'pdf.mapTitle': 'نظرة عامة على المسار',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Xərc',
|
||||
'pdf.preview': 'PDF önizləməsi',
|
||||
'pdf.saveAsPdf': 'PDF kimi yadda saxla',
|
||||
'pdf.openInSafari': 'Safari-də aç',
|
||||
'pdf.safariHint':
|
||||
'iOS Əsas ekrandakı tətbiqdən çap edə bilmir. Safari bu səfəri ixrac hazır vəziyyətdə açır, PDF-i orada saxlaya bilərsən.',
|
||||
'pdf.pageBreakPerDay': 'Hər gün üçün yeni səhifə',
|
||||
'pdf.transportNotes': 'Nəqliyyat qeydləri',
|
||||
'pdf.mapTitle': 'Marşrutun icmalı',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Custo',
|
||||
'pdf.preview': 'Pré-visualização do PDF',
|
||||
'pdf.saveAsPdf': 'Salvar como PDF',
|
||||
'pdf.openInSafari': 'Abrir no Safari',
|
||||
'pdf.safariHint':
|
||||
'O iOS não consegue imprimir pelo app na Tela de Início. O Safari abre esta viagem com a exportação pronta, e lá você pode salvar o PDF.',
|
||||
'pdf.pageBreakPerDay': 'Quebra de página por dia',
|
||||
'pdf.transportNotes': 'Notas de transporte',
|
||||
'pdf.mapTitle': 'Visão geral da rota',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost EUR',
|
||||
'pdf.preview': 'Vista prèvia del PDF',
|
||||
'pdf.saveAsPdf': 'Desa com a PDF',
|
||||
'pdf.openInSafari': 'Obre a Safari',
|
||||
'pdf.safariHint':
|
||||
"iOS no pot imprimir des de l'app de la pantalla d'inici. Safari obre aquest viatge amb l'exportació a punt i allà pots desar el PDF.",
|
||||
'pdf.pageBreakPerDay': 'Salt de pàgina per dia',
|
||||
'pdf.transportNotes': 'Notes del transport',
|
||||
'pdf.mapTitle': 'Resum de la ruta',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Náklady',
|
||||
'pdf.preview': 'Náhled PDF',
|
||||
'pdf.saveAsPdf': 'Uložit jako PDF',
|
||||
'pdf.openInSafari': 'Otevřít v Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS neumí tisknout z aplikace na ploše. Safari otevře tento výlet s připraveným exportem a tam si PDF uložíte.',
|
||||
'pdf.pageBreakPerDay': 'Zalomení stránky pro každý den',
|
||||
'pdf.transportNotes': 'Poznámky k dopravě',
|
||||
'pdf.mapTitle': 'Přehled trasy',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kosten',
|
||||
'pdf.preview': 'PDF Vorschau',
|
||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||
'pdf.openInSafari': 'In Safari öffnen',
|
||||
'pdf.safariHint':
|
||||
'iOS kann aus der App auf dem Home-Bildschirm nicht drucken. Safari öffnet diese Reise mit dem Export, dort kannst du das PDF speichern.',
|
||||
'pdf.pageBreakPerDay': 'Seitenumbruch pro Tag',
|
||||
'pdf.transportNotes': 'Notizen zu Transporten',
|
||||
'pdf.mapTitle': 'Routenübersicht',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Cost',
|
||||
'pdf.preview': 'PDF Preview',
|
||||
'pdf.saveAsPdf': 'Save as PDF',
|
||||
'pdf.openInSafari': 'Open in Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS cannot print from the app on your Home Screen. Safari opens this trip with the export ready, and there you can save the PDF.',
|
||||
'pdf.pageBreakPerDay': 'Page break per day',
|
||||
'pdf.transportNotes': 'Transport notes',
|
||||
'pdf.mapTitle': 'Route overview',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Coste',
|
||||
'pdf.preview': 'Vista previa PDF',
|
||||
'pdf.saveAsPdf': 'Guardar como PDF',
|
||||
'pdf.openInSafari': 'Abrir en Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS no puede imprimir desde la app de la pantalla de inicio. Safari abre este viaje con la exportación lista y allí puedes guardar el PDF.',
|
||||
'pdf.pageBreakPerDay': 'Salto de página por día',
|
||||
'pdf.transportNotes': 'Notas de transporte',
|
||||
'pdf.mapTitle': 'Resumen de la ruta',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Maksumus',
|
||||
'pdf.preview': 'PDF-i eelvaade',
|
||||
'pdf.saveAsPdf': 'Salvesta PDF-ina',
|
||||
'pdf.openInSafari': 'Ava Safaris',
|
||||
'pdf.safariHint':
|
||||
'iOS ei saa avakuval olevast rakendusest printida. Safari avab selle reisi valmis ekspordiga ja seal saad PDF-i salvestada.',
|
||||
'pdf.pageBreakPerDay': 'Iga päev uuel lehel',
|
||||
'pdf.transportNotes': 'Transpordi märkmed',
|
||||
'pdf.mapTitle': 'Marsruudi ülevaade',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Coût',
|
||||
'pdf.preview': 'Aperçu PDF',
|
||||
'pdf.saveAsPdf': 'Enregistrer en PDF',
|
||||
'pdf.openInSafari': 'Ouvrir dans Safari',
|
||||
'pdf.safariHint':
|
||||
"iOS ne peut pas imprimer depuis l'app de l'écran d'accueil. Safari ouvre ce voyage avec l'export prêt, et vous pourrez y enregistrer le PDF.",
|
||||
'pdf.pageBreakPerDay': 'Saut de page par jour',
|
||||
'pdf.transportNotes': 'Notes de transport',
|
||||
'pdf.mapTitle': "Aperçu de l'itinéraire",
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Κόστος',
|
||||
'pdf.preview': 'Προεπισκόπηση PDF',
|
||||
'pdf.saveAsPdf': 'Αποθήκευση ως PDF',
|
||||
'pdf.openInSafari': 'Άνοιγμα στο Safari',
|
||||
'pdf.safariHint':
|
||||
'Το iOS δεν μπορεί να εκτυπώσει από την εφαρμογή στην οθόνη Αφετηρίας. Το Safari ανοίγει αυτό το ταξίδι με έτοιμη την εξαγωγή, εκεί μπορείς να αποθηκεύσεις το PDF.',
|
||||
'pdf.pageBreakPerDay': 'Αλλαγή σελίδας ανά ημέρα',
|
||||
'pdf.transportNotes': 'Σημειώσεις μεταφορών',
|
||||
'pdf.mapTitle': 'Επισκόπηση διαδρομής',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'עלות',
|
||||
'pdf.preview': 'תצוגה מקדימה של PDF',
|
||||
'pdf.saveAsPdf': 'שמירה כ-PDF',
|
||||
'pdf.openInSafari': 'פתיחה ב-Safari',
|
||||
'pdf.safariHint':
|
||||
'מערכת iOS לא יכולה להדפיס מהאפליקציה שבמסך הבית. Safari יפתח את הטיול הזה עם הייצוא מוכן, ושם אפשר לשמור את ה-PDF.',
|
||||
'pdf.pageBreakPerDay': 'מעבר עמוד לכל יום',
|
||||
'pdf.transportNotes': 'הערות תחבורה',
|
||||
'pdf.mapTitle': 'סקירת המסלול',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Költség',
|
||||
'pdf.preview': 'PDF előnézet',
|
||||
'pdf.saveAsPdf': 'Mentés PDF-ként',
|
||||
'pdf.openInSafari': 'Megnyitás Safariban',
|
||||
'pdf.safariHint':
|
||||
'Az iOS nem tud nyomtatni a kezdőképernyőn lévő appból. A Safari megnyitja ezt az utat az előkészített exporttal, ott mentheted a PDF-et.',
|
||||
'pdf.pageBreakPerDay': 'Oldaltörés naponta',
|
||||
'pdf.transportNotes': 'Közlekedési jegyzetek',
|
||||
'pdf.mapTitle': 'Útvonal áttekintése',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Biaya',
|
||||
'pdf.preview': 'Pratinjau PDF',
|
||||
'pdf.saveAsPdf': 'Simpan sebagai PDF',
|
||||
'pdf.openInSafari': 'Buka di Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS tidak bisa mencetak dari aplikasi di Layar Utama. Safari membuka perjalanan ini dengan ekspor yang siap, di sana kamu bisa menyimpan PDF.',
|
||||
'pdf.pageBreakPerDay': 'Pemisah halaman per hari',
|
||||
'pdf.transportNotes': 'Catatan transportasi',
|
||||
'pdf.mapTitle': 'Ringkasan rute',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Costo',
|
||||
'pdf.preview': 'Anteprima PDF',
|
||||
'pdf.saveAsPdf': 'Salva come PDF',
|
||||
'pdf.openInSafari': 'Apri in Safari',
|
||||
'pdf.safariHint':
|
||||
"iOS non può stampare dall'app nella schermata Home. Safari apre questo viaggio con l'esportazione pronta, lì puoi salvare il PDF.",
|
||||
'pdf.pageBreakPerDay': 'Interruzione di pagina per giorno',
|
||||
'pdf.transportNotes': 'Note sui trasporti',
|
||||
'pdf.mapTitle': 'Panoramica del percorso',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '費用',
|
||||
'pdf.preview': 'PDFプレビュー',
|
||||
'pdf.saveAsPdf': 'PDFとして保存',
|
||||
'pdf.openInSafari': 'Safari で開く',
|
||||
'pdf.safariHint':
|
||||
'iOS ではホーム画面のアプリから印刷できません。Safari でこの旅行をエクスポートの準備ができた状態で開き、そこで PDF を保存できます。',
|
||||
'pdf.pageBreakPerDay': '日ごとに改ページ',
|
||||
'pdf.transportNotes': '交通機関のメモ',
|
||||
'pdf.mapTitle': 'ルート概要',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '비용',
|
||||
'pdf.preview': 'PDF 미리보기',
|
||||
'pdf.saveAsPdf': 'PDF로 저장',
|
||||
'pdf.openInSafari': 'Safari에서 열기',
|
||||
'pdf.safariHint':
|
||||
'iOS에서는 홈 화면 앱에서 인쇄할 수 없습니다. Safari에서 내보내기가 준비된 상태로 이 여행이 열리며, 거기서 PDF를 저장할 수 있습니다.',
|
||||
'pdf.pageBreakPerDay': '날짜별 페이지 나누기',
|
||||
'pdf.transportNotes': '교통편 메모',
|
||||
'pdf.mapTitle': '경로 개요',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kosten',
|
||||
'pdf.preview': 'PDF-voorbeeld',
|
||||
'pdf.saveAsPdf': 'Opslaan als PDF',
|
||||
'pdf.openInSafari': 'Openen in Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS kan niet afdrukken vanuit de app op je beginscherm. Safari opent deze reis met de export klaar, daar kun je de PDF opslaan.',
|
||||
'pdf.pageBreakPerDay': 'Pagina-einde per dag',
|
||||
'pdf.transportNotes': 'Notities bij vervoer',
|
||||
'pdf.mapTitle': 'Routeoverzicht',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Koszt',
|
||||
'pdf.preview': 'Podgląd PDF',
|
||||
'pdf.saveAsPdf': 'Zapisz jako PDF',
|
||||
'pdf.openInSafari': 'Otwórz w Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS nie może drukować z aplikacji na ekranie początkowym. Safari otworzy tę podróż z gotowym eksportem i tam zapiszesz PDF.',
|
||||
'pdf.pageBreakPerDay': 'Podział strony dla każdego dnia',
|
||||
'pdf.transportNotes': 'Notatki do transportu',
|
||||
'pdf.mapTitle': 'Przegląd trasy',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Стоимость',
|
||||
'pdf.preview': 'Предпросмотр PDF',
|
||||
'pdf.saveAsPdf': 'Сохранить как PDF',
|
||||
'pdf.openInSafari': 'Открыть в Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS не может печатать из приложения на экране «Домой». Safari откроет эту поездку с готовым экспортом, там можно сохранить PDF.',
|
||||
'pdf.pageBreakPerDay': 'Разрыв страницы для каждого дня',
|
||||
'pdf.transportNotes': 'Заметки к транспорту',
|
||||
'pdf.mapTitle': 'Обзор маршрута',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Náklady',
|
||||
'pdf.preview': 'Náhľad PDF',
|
||||
'pdf.saveAsPdf': 'Uložiť ako PDF',
|
||||
'pdf.openInSafari': 'Otvoriť v Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS nedokáže tlačiť z aplikácie na ploche. Safari otvorí tento výlet s pripraveným exportom a tam si PDF uložíte.',
|
||||
'pdf.pageBreakPerDay': 'Zalomenie strany pre každý deň',
|
||||
'pdf.transportNotes': 'Poznámky k doprave',
|
||||
'pdf.mapTitle': 'Prehľad trasy',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Kostnad',
|
||||
'pdf.preview': 'Förhandsgranskning av PDF',
|
||||
'pdf.saveAsPdf': 'Spara som PDF',
|
||||
'pdf.openInSafari': 'Öppna i Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS kan inte skriva ut från appen på hemskärmen. Safari öppnar resan med exporten redo, där kan du spara PDF:en.',
|
||||
'pdf.pageBreakPerDay': 'Sidbrytning per dag',
|
||||
'pdf.transportNotes': 'Anteckningar om transporter',
|
||||
'pdf.mapTitle': 'Ruttöversikt',
|
||||
|
||||
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'ราคา',
|
||||
'pdf.preview': 'ดูตัวอย่าง PDF',
|
||||
'pdf.saveAsPdf': 'บันทึกเป็น PDF',
|
||||
'pdf.openInSafari': 'เปิดใน Safari',
|
||||
'pdf.safariHint': 'iOS พิมพ์จากแอปบนหน้าจอโฮมไม่ได้ Safari จะเปิดทริปนี้พร้อมการส่งออก และคุณบันทึก PDF ได้ที่นั่น',
|
||||
'pdf.pageBreakPerDay': 'ขึ้นหน้าใหม่ทุกวัน',
|
||||
'pdf.transportNotes': 'บันทึกการเดินทาง',
|
||||
'pdf.mapTitle': 'ภาพรวมเส้นทาง',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Maliyet',
|
||||
'pdf.preview': 'PDF Önizleme',
|
||||
'pdf.saveAsPdf': 'PDF olarak Kaydet',
|
||||
'pdf.openInSafari': "Safari'de aç",
|
||||
'pdf.safariHint':
|
||||
"iOS, ana ekrandaki uygulamadan yazdıramaz. Safari bu geziyi dışa aktarma hazır şekilde açar, PDF'i orada kaydedebilirsin.",
|
||||
'pdf.pageBreakPerDay': 'Her gün için sayfa sonu',
|
||||
'pdf.transportNotes': 'Ulaşım notları',
|
||||
'pdf.mapTitle': 'Rota genel görünümü',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Вартість',
|
||||
'pdf.preview': 'Попередній перегляд PDF',
|
||||
'pdf.saveAsPdf': 'Зберегти як PDF',
|
||||
'pdf.openInSafari': 'Відкрити в Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS не може друкувати з застосунку на екрані «Додому». Safari відкриє цю подорож із готовим експортом, там можна зберегти PDF.',
|
||||
'pdf.pageBreakPerDay': 'Розрив сторінки для кожного дня',
|
||||
'pdf.transportNotes': 'Нотатки до транспорту',
|
||||
'pdf.mapTitle': 'Огляд маршруту',
|
||||
|
||||
@@ -6,6 +6,9 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': 'Chi phí',
|
||||
'pdf.preview': 'PDF Xem trước',
|
||||
'pdf.saveAsPdf': 'Lưu dưới dạng PDF',
|
||||
'pdf.openInSafari': 'Mở trong Safari',
|
||||
'pdf.safariHint':
|
||||
'iOS không thể in từ ứng dụng trên Màn hình chính. Safari sẽ mở chuyến đi này với bản xuất sẵn sàng, tại đó bạn có thể lưu PDF.',
|
||||
'pdf.pageBreakPerDay': 'Ngắt trang cho mỗi ngày',
|
||||
'pdf.transportNotes': 'Ghi chú phương tiện',
|
||||
'pdf.mapTitle': 'Tổng quan lộ trình',
|
||||
|
||||
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '費用',
|
||||
'pdf.preview': 'PDF 預覽',
|
||||
'pdf.saveAsPdf': '儲存為 PDF',
|
||||
'pdf.openInSafari': '在 Safari 中開啟',
|
||||
'pdf.safariHint': 'iOS 無法從主畫面上的 App 列印。Safari 會開啟此行程並準備好匯出,你可以在那裡儲存 PDF。',
|
||||
'pdf.pageBreakPerDay': '每天分頁',
|
||||
'pdf.transportNotes': '交通備註',
|
||||
'pdf.mapTitle': '路線總覽',
|
||||
|
||||
@@ -6,6 +6,8 @@ const pdf: TranslationStrings = {
|
||||
'pdf.costLabel': '费用',
|
||||
'pdf.preview': 'PDF 预览',
|
||||
'pdf.saveAsPdf': '保存为 PDF',
|
||||
'pdf.openInSafari': '在 Safari 中打开',
|
||||
'pdf.safariHint': 'iOS 无法从主屏幕上的应用打印。Safari 会打开此行程并准备好导出,你可以在那里保存 PDF。',
|
||||
'pdf.pageBreakPerDay': '每天分页',
|
||||
'pdf.transportNotes': '交通备注',
|
||||
'pdf.mapTitle': '路线总览',
|
||||
|
||||
Reference in New Issue
Block a user