import { useEffect, useId, useRef, useState } from 'react' import { BookOpen, FileText, Printer, Scissors } from 'lucide-react' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import type { BookDocument } from '@trek/shared' import { BookSheetsView } from './BookSheetsView' import { sheetBox, sheetsFor, type SheetMode } from './bookSheets' import { printSheets } from './printSheets' /** * Getting the book out. * * ── Two questions, not a settings panel ────────────────────────────────── * * Leaves or spreads, and marks or no marks. Everything else a print dialog * usually asks — resolution, colour profile, embedding — either has one right * answer here or is not ours to give: the images go out at full size because * anything else is a worse book, and the profile belongs to whoever is doing * the printing. * * ── Why the sheets are rendered here rather than built as a string ─────── * * They are the editor's own components, and those read context — the active * locale, most visibly, which is what a stats element and a date line are * written in. Rendering them to markup outside the tree would mean standing up * that context again and getting a book in English for someone who wrote it in * German. So they render inside the app, off screen, and the markup is taken * from the DOM afterwards. */ export function StudioExport({ doc, title, t, onClose, }: { doc: BookDocument title: string t: (key: string, params?: Record) => string onClose: () => void }) { const [mode, setMode] = useState('pages') const [marks, setMarks] = useState(true) /** Set once the user has asked for it — this is what triggers the render. */ const [building, setBuilding] = useState(false) const stage = useRef(null) const labelId = useId() const sheets = sheetsFor(doc, mode) /* * Two sizes, because spread mode mixes them: covers are one page and * everything between them is two. The wider one is the document's page box * and the narrower gets a named rule — see printSheets. */ const widest = Math.max(...sheets.map(s => s.width), doc.page.pageWidth) const box = sheetBox(widest, doc.page.pageHeight, doc.page.bleed, marks) const single = sheetBox(doc.page.pageWidth, doc.page.pageHeight, doc.page.bleed, marks) useEffect(() => { if (!building) return const html = stage.current?.innerHTML if (!html) return printSheets({ html, sheetWidth: box.width, sheetHeight: box.height, singleWidth: single.width, singleHeight: single.height, title, labels: { save: t('journey.studio.exportSave'), close: t('common.close'), count: t('journey.studio.exportSheetCount', { count: sheets.length }), preparing: t('journey.studio.exportPreparing'), }, }) setBuilding(false) onClose() // Runs once per build. Re-running on every render of the options would // open a second print view behind the first. // eslint-disable-next-line react-hooks/exhaustive-deps }, [building]) // Studio closes itself on Escape. While this dialog is open the key is its own, // so it is taken in the capture phase before the editor's handler sees it. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return e.stopPropagation() onClose() } document.addEventListener('keydown', onKey, true) return () => document.removeEventListener('keydown', onKey, true) }, [onClose]) return ( <> } tint={NEUTRAL_TINT} labelId={labelId} onClose={onClose} title={t('journey.studio.export')} /> )} footer={( {t('common.cancel')} setBuilding(true)} disabled={building} icon={}> {t('journey.studio.exportOpen')} )} >

{t('journey.studio.exportNote', { count: sheets.length, width: round1(box.width), height: round1(box.height), })}

{/* The sheets, rendered where nobody can see them. Off screen rather than `display: none`: a hidden subtree lays nothing out, and these are measured in millimetres by the same CSS that will print them, a book built from an unlaid-out tree is a book of empty boxes. They stay inside Studio's tree, where that CSS applies, while the dialog itself is portalled over it. `aria-hidden` keeps them out of the reading order. */} {building && ( )} ) } const round1 = (n: number) => Math.round(n * 10) / 10 /** One choice of the dialog: an icon, its name and what it means, pressed while it holds. */ function Option({ icon: Icon, name, hint, on, onClick }: { icon: typeof FileText name: string hint: string on: boolean onClick: () => void }) { return ( ) }