diff --git a/.bolt/skills/slides/SKILL.md b/.bolt/skills/slides/SKILL.md index 99b6e5b..41c55ba 100644 --- a/.bolt/skills/slides/SKILL.md +++ b/.bolt/skills/slides/SKILL.md @@ -294,8 +294,8 @@ trigger or that it exists — just deliver the demo. - [ ] The engine + chrome in `src/deck/` are **left untouched**; the dock + thumbnail rail appear, arrow keys advance AND step back through builds, fullscreen / sidebar (S) / grid view (G) work, annotation (A) has full tools and - persists per slide, presenter (P) opens a synced new tab, `H` hides the UI, and - the URL hash tracks the slide. + persists per slide, presenter (P) opens a synced new tab, `H` hides the UI, + export (E) offers a PDF download, and the URL hash tracks the slide. - [ ] The deck is **authored, not reskinned** — topic, structure, copy, names are the user's, with no starter leftovers (no "Title"/"Northwind"). - [ ] If a brand/URL was given, `--primary`, fonts, and logo come from that brand. diff --git a/README.md b/README.md index bbfba26..2b07b8f 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,13 @@ Each top-level child of `` is one slide. Compose them from the component l | `F` | Fullscreen | | `P` | Presenter mode — synced new tab | | `H` | Hide the UI | +| `E` | Export — download as PDF | | `Esc` | Close overlays | - **Presenter mode** opens in a second tab with a timer, next-slide preview, and editable notes — kept in sync with the audience tab via `BroadcastChannel`. - **Annotations are content-anchored**: a circle drawn around a stat on a laptop rings the same stat on a phone, wherever the layout moved it. Drawings persist per slide. - **Deep links**: the URL hash tracks the slide (`/#7`), so you can share a link straight to a slide. +- **Export**: `E` renders the deck to a PDF, one page per slide — click-builds fully revealed and animations settled to their final frame, so nothing exports mid-fade or blank. It's a flat snapshot for handoff or print, not an editable format (slides are live web pages, not vector shapes). ## Component library diff --git a/package-lock.json b/package-lock.json index a216d6a..795c438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "license": "MIT", "dependencies": { "framer-motion": "^11.3.0", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -253,6 +255,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1151,6 +1162,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1158,6 +1175,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", @@ -1179,6 +1203,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1200,6 +1231,16 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -1268,6 +1309,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1275,6 +1336,28 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1300,6 +1383,16 @@ } } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.382", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz", @@ -1356,6 +1449,23 @@ "node": ">=6" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/framer-motion": { "version": "11.18.2", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", @@ -1408,6 +1518,32 @@ "node": ">=6.9.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1440,6 +1576,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1513,6 +1666,29 @@ "node": ">=18" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1549,6 +1725,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1584,6 +1770,23 @@ "node": ">=0.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1658,6 +1861,36 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1709,6 +1942,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", diff --git a/package.json b/package.json index b9de5af..e5728aa 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "framer-motion": "^11.3.0", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src/components/Comparison.tsx b/src/components/Comparison.tsx index 8f606c3..279ad68 100644 --- a/src/components/Comparison.tsx +++ b/src/components/Comparison.tsx @@ -8,6 +8,7 @@ import type { CSSProperties } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; import { useInView } from '../deck/useInView'; +import { useDeck } from '../deck/DeckContext'; export type CompRow = { label: string; values: (boolean | string)[] }; @@ -47,6 +48,7 @@ export default function Comparison({ }) { const { ref, inView } = useInView(0.25); const reduce = useReducedMotion(); + const { isStatic } = useDeck(); const vcols = cols.length - 1; return (
(0.25); const reduce = useReducedMotion(); + const { isStatic } = useDeck(); const align = (c: TableColumn) => typeof c === 'string' ? undefined : c.align; const alignClass = (i: number) => { @@ -60,7 +62,7 @@ export default function Table({
- - - {tabs[active].content} - - + {isStatic ? ( +
{tabs[active].content}
+ ) : ( + + + {tabs[active].content} + + + )}
); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index 78ee2c0..138e318 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,6 +1,7 @@ import { motion, useReducedMotion } from 'framer-motion'; import type { ReactNode } from 'react'; import { useInView } from '../deck/useInView'; +import { useDeck } from '../deck/DeckContext'; /* A vertical timeline / roadmap. The connector draws in, milestones are glowing rings with accent cores, and time labels are mono chips. Reveals @@ -11,14 +12,18 @@ export type TimelineItem = { time: string; title: string; body?: ReactNode }; export default function Timeline({ items }: { items: TimelineItem[] }) { const { ref, inView } = useInView(0.2); const reduce = useReducedMotion(); + const { isStatic } = useDeck(); return (
@@ -26,11 +31,11 @@ export default function Timeline({ items }: { items: TimelineItem[] }) { diff --git a/src/deck/Deck.tsx b/src/deck/Deck.tsx index 9a58447..5fcd1f5 100644 --- a/src/deck/Deck.tsx +++ b/src/deck/Deck.tsx @@ -11,6 +11,7 @@ import type { ReactElement, ReactNode } from 'react'; import { MotionConfig } from 'framer-motion'; import { DeckCtx } from './DeckContext'; import Annotator, { type Stroke } from './Annotator'; +import { exportPdf } from './export'; import { IconSidebar, IconGrid, @@ -21,6 +22,7 @@ import { IconShrink, IconPresent, IconClose, + IconDownload, } from './icons'; /* ── The paged presentation engine + the Slidev-style chrome (dock + rail). @@ -28,7 +30,7 @@ import { → / ↓ / Space next (reveals the next , then the next slide) ← / ↑ previous S sidebar G grid view Home / End first / last A annotate P presenter (new tab) - F fullscreen H hide/show the UI + F fullscreen H hide/show the UI E export PDF Copy verbatim; theme only via the :root tokens. ───────────────────────── */ const fmt = (s: number) => @@ -113,6 +115,10 @@ export default function Deck({ children }: { children: ReactNode }) { } } ); + const [exportBusy, setExportBusy] = useState<{ + done: number; + total: number; + } | null>(null); // per-slide build maxima (so going back restores the right click state) and // per-slide annotations (so drawings persist on the slide they were made). @@ -192,6 +198,16 @@ export default function Deck({ children }: { children: ReactNode }) { window.location.pathname + '?presenter=1' + window.location.hash; window.open(url, 'deck-presenter'); }, [isPresenter]); + const runExport = useCallback(async () => { + setExportBusy({ done: 0, total }); + try { + await exportPdf(slides, (done, tot) => setExportBusy({ done, total: tot })); + } catch (err) { + alert(`Export failed: ${err instanceof Error ? err.message : err}`); + } finally { + setExportBusy(null); + } + }, [slides, total]); // keyboard useEffect(() => { @@ -251,6 +267,10 @@ export default function Deck({ children }: { children: ReactNode }) { case 'H': setUiHidden((v) => !v); break; + case 'e': + case 'E': + runExport(); + break; case 'Escape': setRailOpen(false); setGridOpen(false); @@ -261,7 +281,17 @@ export default function Deck({ children }: { children: ReactNode }) { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [next, prev, go, total, toggleRail, toggleGrid, toggleFs, openPresenter]); + }, [ + next, + prev, + go, + total, + toggleRail, + toggleGrid, + toggleFs, + openPresenter, + runExport, + ]); // safety net: if the authored deck kept the placeholder tab title, derive // one from the current slide's heading so shared links look right. @@ -526,6 +556,19 @@ export default function Deck({ children }: { children: ReactNode }) { > + +
diff --git a/src/deck/export.tsx b/src/deck/export.tsx new file mode 100644 index 0000000..b01e31f --- /dev/null +++ b/src/deck/export.tsx @@ -0,0 +1,120 @@ +import { createRoot } from 'react-dom/client'; +import type { ReactElement } from 'react'; +import { toJpeg } from 'html-to-image'; +import { DeckCtx } from './DeckContext'; +// jspdf is dynamically imported below — it's only needed once the user +// actually exports, and it alone drags in a ~250kB html2canvas dependency +// we never call, so keep it out of the main bundle. + +/* Export the deck as a PDF. Slides are arbitrary live web content (canvas, + SVG, animation) — there's no way to turn that into an editable PDF, so + each slide is rasterized to a JPEG (reusing the same isStatic:true / + clicks:9999 context Thumb uses for thumbnails, so builds and entrance + animations render fully revealed, in their final state, with no + waiting) and the images are laid one-per-page into the PDF. */ + +const W = 1920; +const H = 1080; // capture at a fixed 16:9 canvas regardless of window size + +async function captureSlide(slide: ReactElement, bg: string): Promise { + // A zero-size, clipped wrapper hides this from the viewer without moving + // the captured node off-screen — html-to-image clones inline styles into + // an SVG foreignObject, so a `left:-10000px` node renders 10000px outside + // the captured canvas and comes back blank. The host itself stays at a + // normal (unpositioned) layout so its clone paints at the origin. + const clip = document.createElement('div'); + clip.style.cssText = + 'position:fixed; top:0; left:0; width:0; height:0; overflow:hidden; pointer-events:none;'; + const host = document.createElement('div'); + host.style.cssText = `width:${W}px; height:${H}px; overflow:hidden;`; + clip.appendChild(host); + document.body.appendChild(clip); + const root = createRoot(host); + try { + await new Promise((resolve) => { + root.render( +
+ +
+ {slide} +
+
+
+ ); + // isStatic renders final state on first commit — two rAFs just give + // the browser a paint before we snapshot. + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + // The live app's dark theme comes from `body`'s background — an + // ancestor this offscreen host doesn't have. html-to-image also doesn't + // reliably inline the captured root's own background, and a CSS custom + // property wouldn't resolve inside the isolated SVG it rasterizes + // through, so pass the theme color pre-resolved to a literal value. + // JPEG (not PNG): the canvas is fully opaque, so there's no alpha + // channel worth keeping — jsPDF embeds PNG's RGBA essentially + // uncompressed (a 26-slide deck came out at 200MB+), while JPEG gives + // real compression and is a fraction of the size at this quality. + return await toJpeg(host, { pixelRatio: 1, backgroundColor: bg, quality: 0.92 }); + } finally { + root.unmount(); + clip.remove(); + } +} + +async function captureSlides( + slides: ReactElement[], + onProgress?: (done: number, total: number) => void +): Promise { + // fonts.ready only waits for faces something on-screen already requested + // — force-load every family/weight the deck uses so a slide that's first + // to use e.g. mono type doesn't get captured mid-fetch with a fallback font. + await Promise.all( + [ + '400 16px Inter', + '500 16px Inter', + '600 16px Inter', + '700 16px Inter', + '400 16px "JetBrains Mono"', + '500 16px "JetBrains Mono"', + ].map((f) => document.fonts?.load(f)) + ); + await document.fonts?.ready; + const bg = getComputedStyle(document.documentElement) + .getPropertyValue('--bg') + .trim(); + const images: string[] = []; + for (const [i, slide] of slides.entries()) { + try { + images.push(await captureSlide(slide, bg)); + } catch (err) { + throw new Error(`Slide ${i + 1}: ${err instanceof Error ? err.message : err}`); + } + onProgress?.(images.length, slides.length); + } + return images; +} + +function deckFilename(): string { + const name = document.title + .trim() + .replace(/[\\/:*?"<>|]+/g, '-') + .slice(0, 80); + return name || 'deck'; +} + +export async function exportPdf( + slides: ReactElement[], + onProgress?: (done: number, total: number) => void +): Promise { + const images = await captureSlides(slides, onProgress); + const { jsPDF } = await import('jspdf'); + const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: [W, H] }); + images.forEach((img, i) => { + if (i > 0) pdf.addPage([W, H], 'landscape'); + pdf.addImage(img, 'JPEG', 0, 0, W, H); + }); + pdf.save(deckFilename() + '.pdf'); +} diff --git a/src/deck/icons.tsx b/src/deck/icons.tsx index f58cc26..3735821 100644 --- a/src/deck/icons.tsx +++ b/src/deck/icons.tsx @@ -61,3 +61,9 @@ export const IconClose = () => ( ); +export const IconDownload = () => ( + + + + +); diff --git a/src/styles/base.css b/src/styles/base.css index 7d4c1f1..e18b10e 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -116,6 +116,28 @@ body { mix-blend-mode: overlay; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } +/* PDF export rasterizes slides via html-to-image, which doesn't honor + mix-blend-mode — the 3.5%-opacity grain above renders as full-strength + noise. It's decoration, not content, so drop it for the capture. */ +.export-capture .slide::after { + display: none; +} +/* VisualDashboard's draw-ins are CSS @keyframes, not transitions, so they + always play their full timeline on mount regardless of isStatic — snap + them to their end state for capture, same as the reduced-motion rules + below do for the live app. */ +.export-capture .dline, +.export-capture .dspark path { + stroke-dashoffset: 0 !important; + animation: none !important; +} +.export-capture .dfill-area { + opacity: 1; +} +.export-capture .dbar-track span { + transform: scaleY(1) !important; + animation: none !important; +} /* ── Fluid type atoms ───────────────────────────────────────────── */ h1, @@ -2113,6 +2135,21 @@ strong { gap: 4px; } +/* Export-in-progress spinner, swapped in for the download icon */ +.noir-spinner { + width: 17px; + height: 17px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: #e8e8ea; + animation: noir-spin 0.7s linear infinite; +} +@keyframes noir-spin { + to { + transform: rotate(360deg); + } +} + /* Left thumbnail rail */ .noir-rail { position: fixed;