From 30c57dd32ee8ec209d77c4ea581dc9580aa53617 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sun, 19 Jul 2026 19:47:13 +0530 Subject: [PATCH 01/10] Add html-to-image, pptxgenjs, jspdf for deck export --- package-lock.json | 400 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 3 + 2 files changed, 403 insertions(+) diff --git a/package-lock.json b/package-lock.json index a216d6a..29559e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "license": "MIT", "dependencies": { "framer-motion": "^11.3.0", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", + "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -253,6 +256,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 +1163,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "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 +1185,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 +1213,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 +1241,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 +1319,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 +1346,34 @@ "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/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "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 +1399,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 +1465,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 +1534,71 @@ "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/https": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "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/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "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 +1631,50 @@ "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/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1513,6 +1748,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 +1807,43 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "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 +1879,38 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "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", @@ -1629,6 +1956,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1648,6 +1981,12 @@ "semver": "bin/semver.js" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1658,6 +1997,45 @@ "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/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "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", @@ -1678,6 +2056,12 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1709,6 +2093,22 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "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..3d2673b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ }, "dependencies": { "framer-motion": "^11.3.0", + "html-to-image": "^1.11.13", + "jspdf": "^4.2.1", + "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, From ff7db85f098a8ed0c1dbb59e044dacdcb722d0ce Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sun, 19 Jul 2026 19:53:58 +0530 Subject: [PATCH 02/10] Add PDF/PPTX export (E) to the deck dock Rasterizes each slide off-screen (isStatic context, same as thumbnails) via html-to-image, then assembles a .pdf (jsPDF) or .pptx (pptxgenjs) from the captured images. Both builder libs are dynamically imported so they only load when export is actually triggered. --- .bolt/skills/slides/SKILL.md | 4 +- README.md | 1 + src/deck/Deck.tsx | 80 ++++++++++++++++++++++++++++ src/deck/export.tsx | 100 +++++++++++++++++++++++++++++++++++ src/deck/icons.tsx | 6 +++ src/styles/base.css | 60 +++++++++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 src/deck/export.tsx diff --git a/.bolt/skills/slides/SKILL.md b/.bolt/skills/slides/SKILL.md index 99b6e5b..affa3e0 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 PDF/PPTX downloads, 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..9a21b12 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ 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 or PPTX | | `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`. diff --git a/src/deck/Deck.tsx b/src/deck/Deck.tsx index 9a58447..6fa8136 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 { exportDeck, type ExportKind } 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). @@ -113,6 +115,13 @@ export default function Deck({ children }: { children: ReactNode }) { } } ); + const [exportOpen, setExportOpen] = useState(false); + const [exportBusy, setExportBusy] = useState<{ + kind: ExportKind; + done: number; + total: number; + } | null>(null); + const exportMenuRef = useRef(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 +201,36 @@ 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 (kind: ExportKind) => { + setExportOpen(false); + setExportBusy({ kind, done: 0, total }); + try { + await exportDeck(kind, slides, (done, tot) => + setExportBusy({ kind, done, total: tot }) + ); + } catch (err) { + alert(`Export failed: ${err instanceof Error ? err.message : err}`); + } finally { + setExportBusy(null); + } + }, + [slides, total] + ); + + // close the export popover on outside click + useEffect(() => { + if (!exportOpen) return; + const onClick = (e: MouseEvent) => { + if ( + exportMenuRef.current && + !exportMenuRef.current.contains(e.target as Node) + ) + setExportOpen(false); + }; + document.addEventListener('mousedown', onClick); + return () => document.removeEventListener('mousedown', onClick); + }, [exportOpen]); // keyboard useEffect(() => { @@ -251,11 +290,16 @@ export default function Deck({ children }: { children: ReactNode }) { case 'H': setUiHidden((v) => !v); break; + case 'e': + case 'E': + setExportOpen((v) => !v); + break; case 'Escape': setRailOpen(false); setGridOpen(false); setDrawing(false); setUiHidden(false); + setExportOpen(false); break; } }; @@ -526,6 +570,42 @@ export default function Deck({ children }: { children: ReactNode }) { > + +
+ + {exportOpen && !exportBusy && ( +
+ + +
+ )} + {exportBusy && ( +
+
+ + Exporting {exportBusy.kind.toUpperCase()} —{' '} + {exportBusy.done}/{exportBusy.total} +
+
+ )} +
diff --git a/src/deck/export.tsx b/src/deck/export.tsx new file mode 100644 index 0000000..0dfa3c4 --- /dev/null +++ b/src/deck/export.tsx @@ -0,0 +1,100 @@ +import { createRoot } from 'react-dom/client'; +import type { ReactElement } from 'react'; +import { toPng } from 'html-to-image'; +import { DeckCtx } from './DeckContext'; +// jspdf/pptxgenjs are dynamically imported below — they're only needed once +// the user actually exports, and jsPDF alone drags in a ~250kB html2canvas +// dependency we never call, so keep it out of the main bundle. + +/* Export the deck as PDF / PPTX. Slides are arbitrary live web content + (canvas, SVG, animation) — there's no way to turn that into editable + PDF/PPTX shapes, so each slide is rasterized to a PNG (reusing the same + isStatic:true / clicks:9999 context Thumb uses for thumbnails, so builds + and entrance animations render in their final state with no waiting) and + the PNGs are laid one-per-page/slide into the output file. */ + +const W = 1920; +const H = 1080; // capture at a fixed 16:9 canvas regardless of window size + +async function captureSlide(slide: ReactElement): Promise { + const host = document.createElement('div'); + host.style.cssText = `position:fixed; left:-10000px; top:0; width:${W}px; height:${H}px; overflow:hidden;`; + document.body.appendChild(host); + 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())); + }); + return await toPng(host, { pixelRatio: 1 }); + } finally { + root.unmount(); + host.remove(); + } +} + +export async function captureSlides( + slides: ReactElement[], + onProgress?: (done: number, total: number) => void +): Promise { + await document.fonts?.ready; + const images: string[] = []; + for (const slide of slides) { + images.push(await captureSlide(slide)); + onProgress?.(images.length, slides.length); + } + return images; +} + +function deckFilename(): string { + const name = document.title + .trim() + .replace(/[\\/:*?"<>|]+/g, '-') + .slice(0, 80); + return name || 'deck'; +} + +async function exportToPdf(images: string[], filename: string) { + 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, 'PNG', 0, 0, W, H); + }); + pdf.save(filename + '.pdf'); +} + +async function exportToPptx(images: string[], filename: string) { + const { default: PptxGenJS } = await import('pptxgenjs'); + const pptx = new PptxGenJS(); + pptx.defineLayout({ name: 'DECK', width: W / 96, height: H / 96 }); // px → inches @96dpi + pptx.layout = 'DECK'; + for (const img of images) { + const slide = pptx.addSlide(); + slide.addImage({ data: img, x: 0, y: 0, w: W / 96, h: H / 96 }); + } + pptx.writeFile({ fileName: filename + '.pptx' }); +} + +export type ExportKind = 'pdf' | 'pptx'; + +export async function exportDeck( + kind: ExportKind, + slides: ReactElement[], + onProgress?: (done: number, total: number) => void +): Promise { + const images = await captureSlides(slides, onProgress); + const filename = deckFilename(); + if (kind === 'pdf') await exportToPdf(images, filename); + else await exportToPptx(images, filename); +} 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..6fd98d4 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -2113,6 +2113,66 @@ strong { gap: 4px; } +/* Export button popover — same dark-glass look as .noir-bar */ +.noir-export { + position: relative; +} +.noir-export-menu { + position: absolute; + bottom: calc(100% + 12px); + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 168px; + padding: 6px; + border-radius: 14px; + background: rgba(28, 28, 30, 0.9); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(22px) saturate(180%); + -webkit-backdrop-filter: blur(22px) saturate(180%); + box-shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.8); +} +.noir-export-item { + border: none; + background: transparent; + color: #e8e8ea; + font: inherit; + font-size: 13px; + text-align: left; + padding: 9px 10px; + border-radius: 9px; + cursor: pointer; + white-space: nowrap; +} +.noir-export-item:hover { + background: rgba(255, 255, 255, 0.12); +} +.noir-export-status { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 10px; + font-size: 13px; + color: #e8e8ea; + white-space: nowrap; +} +.noir-spinner { + width: 13px; + height: 13px; + flex: none; + 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; From da84e1a1c1e01bd93d32d39c4ac6d5b9925b1a53 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sun, 19 Jul 2026 20:42:37 +0530 Subject: [PATCH 03/10] Fix blank capture: off-screen -10000px offset broke html-to-image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit html-to-image clones inline styles into an SVG foreignObject, so a node positioned with left:-10000px rendered 10000px outside the captured canvas — every exported slide came back blank. Swap to a zero-size clipped wrapper instead, which hides the capture host from the viewer without moving the captured node itself off-screen. Also drop the film-grain overlay during capture: foreignObject rasterization doesn't honor mix-blend-mode, so the 3.5%-opacity grain was rendering as full-strength noise over every exported slide. Verified all 26 demo slides capture non-blank content and both the PDF and PPTX build paths complete without error. --- src/deck/export.tsx | 20 ++++++++++++++++---- src/styles/base.css | 6 ++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/deck/export.tsx b/src/deck/export.tsx index 0dfa3c4..1423604 100644 --- a/src/deck/export.tsx +++ b/src/deck/export.tsx @@ -17,14 +17,26 @@ const W = 1920; const H = 1080; // capture at a fixed 16:9 canvas regardless of window size async function captureSlide(slide: ReactElement): 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 = `position:fixed; left:-10000px; top:0; width:${W}px; height:${H}px; overflow:hidden;`; - document.body.appendChild(host); + 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} @@ -39,7 +51,7 @@ async function captureSlide(slide: ReactElement): Promise { return await toPng(host, { pixelRatio: 1 }); } finally { root.unmount(); - host.remove(); + clip.remove(); } } diff --git a/src/styles/base.css b/src/styles/base.css index 6fd98d4..9bc2fea 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -116,6 +116,12 @@ 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/PPTX 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; +} /* ── Fluid type atoms ───────────────────────────────────────────── */ h1, From 0260b96d9040b54e544e491fc5b4f4661e1dbb80 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Fri, 24 Jul 2026 21:15:54 +0530 Subject: [PATCH 04/10] Fix missing background color in exported slides The offscreen capture host has no ancestor supplying the dark theme background (that comes from body's CSS in the live app), so captured slides were ~24% transparent. jsPDF/pptxgenjs then composited that onto their default white page, turning the theme white and washing out every semi-transparent gradient/overlay color designed against the dark background. Resolve --bg to a literal color and pass it as html-to-image's backgroundColor option (a CSS var wouldn't resolve inside the isolated SVG it rasterizes through). Verified via alpha-channel inspection (now 100% opaque, was ~24% alpha=0) and visual check of Cover, Bento, Globe, and Pricing slides. --- src/deck/export.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/deck/export.tsx b/src/deck/export.tsx index 1423604..ffee252 100644 --- a/src/deck/export.tsx +++ b/src/deck/export.tsx @@ -16,7 +16,7 @@ import { DeckCtx } from './DeckContext'; const W = 1920; const H = 1080; // capture at a fixed 16:9 canvas regardless of window size -async function captureSlide(slide: ReactElement): Promise { +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 @@ -48,7 +48,12 @@ async function captureSlide(slide: ReactElement): Promise { // the browser a paint before we snapshot. requestAnimationFrame(() => requestAnimationFrame(() => resolve())); }); - return await toPng(host, { pixelRatio: 1 }); + // 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. + return await toPng(host, { pixelRatio: 1, backgroundColor: bg }); } finally { root.unmount(); clip.remove(); @@ -60,9 +65,12 @@ export async function captureSlides( onProgress?: (done: number, total: number) => void ): Promise { await document.fonts?.ready; + const bg = getComputedStyle(document.documentElement) + .getPropertyValue('--bg') + .trim(); const images: string[] = []; for (const slide of slides) { - images.push(await captureSlide(slide)); + images.push(await captureSlide(slide, bg)); onProgress?.(images.length, slides.length); } return images; From 7141a760275950dbafdd767cc1f5c4737dd1f78f Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Fri, 24 Jul 2026 21:29:55 +0530 Subject: [PATCH 05/10] Switch capture to JPEG: PNG output was bloating exports to 200MB+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once the capture background is opaque, the PNG's alpha channel is just dead weight — and jsPDF embeds PNG's RGBA data essentially uncompressed, so the 26-slide demo deck came out at 215MB. JPEG at quality 0.92 has no alpha channel to carry, compresses properly, and brings the same deck down to ~1.2MB (PDF) / ~1.35MB (PPTX), verified by building both outputs in-browser and checking byte size. --- src/deck/export.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/deck/export.tsx b/src/deck/export.tsx index ffee252..24f0770 100644 --- a/src/deck/export.tsx +++ b/src/deck/export.tsx @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client'; import type { ReactElement } from 'react'; -import { toPng } from 'html-to-image'; +import { toJpeg } from 'html-to-image'; import { DeckCtx } from './DeckContext'; // jspdf/pptxgenjs are dynamically imported below — they're only needed once // the user actually exports, and jsPDF alone drags in a ~250kB html2canvas @@ -8,10 +8,10 @@ import { DeckCtx } from './DeckContext'; /* Export the deck as PDF / PPTX. Slides are arbitrary live web content (canvas, SVG, animation) — there's no way to turn that into editable - PDF/PPTX shapes, so each slide is rasterized to a PNG (reusing the same + PDF/PPTX shapes, 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 in their final state with no waiting) and - the PNGs are laid one-per-page/slide into the output file. */ + the images are laid one-per-page/slide into the output file. */ const W = 1920; const H = 1080; // capture at a fixed 16:9 canvas regardless of window size @@ -53,7 +53,11 @@ async function captureSlide(slide: ReactElement, bg: string): Promise { // 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. - return await toPng(host, { pixelRatio: 1, backgroundColor: bg }); + // JPEG (not PNG): the canvas is fully opaque now, 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(); @@ -89,7 +93,7 @@ async function exportToPdf(images: string[], filename: string) { 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, 'PNG', 0, 0, W, H); + pdf.addImage(img, 'JPEG', 0, 0, W, H); }); pdf.save(filename + '.pdf'); } From 1abc3312c27f1af6a1c1210304fb55a60cd48006 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sat, 25 Jul 2026 10:33:41 +0530 Subject: [PATCH 06/10] Bring click-build animations into PPTX export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slides with reveals are now captured as one layered image per click step instead of a single flattened image. Build count comes from walking the slide's element tree (no mounting needed, so it can't race with useEffect timing during offscreen capture), and Build.tsx now respects the real clicks count under isStatic so each step can be rendered. pptxgenjs has no animation API at all, so click-to-reveal is wired up by hand-authoring PowerPoint's raw timing XML (p:timing/p:bldLst) into the generated .pptx after the fact, via jszip (already a transitive dep of pptxgenjs, now explicit). Each layer is tagged with altText so its shape id — assigned internally by pptxgenjs — can be looked up reliably instead of assumed. The base (unrevealed) layer is never added to the build list, so even a broken timing block degrades to 'that one slide shows its pre-click state' rather than losing content. PDF has no animation concept and keeps using only the final, fully-built layer per slide. Verified: single-build and 3-build synthetic slides produce well-formed, schema-correct XML with correctly sequenced shape ids and click targets; the real 26-slide demo deck exports cleanly with exactly the one slide that has a getting timing XML. Real PowerPoint validation wasn't possible in this environment (no PowerPoint install, and LibreOffice's CLI didn't produce output here) — structural/schema correctness is confirmed, but worth a real open in PowerPoint before relying on it. --- package-lock.json | 1 + package.json | 1 + src/deck/Build.tsx | 7 +- src/deck/export.tsx | 193 +++++++++++++++++++++++++++++++++++++------- 4 files changed, 173 insertions(+), 29 deletions(-) diff --git a/package-lock.json b/package-lock.json index 29559e2..9914bb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "framer-motion": "^11.3.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", + "jszip": "^3.10.1", "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/package.json b/package.json index 3d2673b..d426551 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "framer-motion": "^11.3.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", + "jszip": "^3.10.1", "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/src/deck/Build.tsx b/src/deck/Build.tsx index 3c2ea3b..479f8d3 100644 --- a/src/deck/Build.tsx +++ b/src/deck/Build.tsx @@ -25,14 +25,17 @@ export default function Build({ registerMax?.(at); }, [at, registerMax]); + const shown = clicks >= at; + + // Static previews (thumbnails) pass clicks:9999 so this is always true — + // export capture passes real click counts to render each build step. if (isStatic) return ( -
+
{children}
); - const shown = clicks >= at; return ( click-reveals are + the one animation PPTX can represent natively (its own click-advance + animation model), so a slide with N builds is captured as N+1 layered + images — one per click step — stacked on a single pptx slide and wired + up with "appear on click" timing. PDF has no animation concept, so it + only uses the final, fully-built layer. */ 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 { +// How many click-builds a slide has, found by walking its element tree — +// no need to mount anything, and it sidesteps any uncertainty about +// useEffect timing during the offscreen capture below. +function maxBuildAt(node: ReactNode): number { + if (node == null || typeof node !== 'object') return 0; + if (Array.isArray(node)) { + return node.reduce((m, c) => Math.max(m, maxBuildAt(c)), 0); + } + const el = node as ReactElement; + if (!('type' in el) || !('props' in el)) return 0; + let m = el.type === Build ? (el.props as { at?: number }).at ?? 1 : 0; + const children = (el.props as { children?: ReactNode })?.children; + if (children != null) m = Math.max(m, maxBuildAt(children)); + return m; +} + +async function captureAt( + slide: ReactElement, + bg: string, + clicks: number +): 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 @@ -37,7 +62,7 @@ async function captureSlide(slide: ReactElement, bg: string): Promise { className="deck export-capture" style={{ width: W, height: H, position: 'relative' }} > - +
{slide}
@@ -53,7 +78,7 @@ async function captureSlide(slide: ReactElement, bg: string): Promise { // 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 now, so there's no alpha + // 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. @@ -64,20 +89,29 @@ async function captureSlide(slide: ReactElement, bg: string): Promise { } } -export async function captureSlides( +interface SlideCapture { + layers: string[]; // layers[i] = slide state after i clicks; length is buildCount+1 +} + +async function captureSlides( slides: ReactElement[], onProgress?: (done: number, total: number) => void -): Promise { +): Promise { await document.fonts?.ready; const bg = getComputedStyle(document.documentElement) .getPropertyValue('--bg') .trim(); - const images: string[] = []; + const results: SlideCapture[] = []; for (const slide of slides) { - images.push(await captureSlide(slide, bg)); - onProgress?.(images.length, slides.length); + const buildCount = maxBuildAt(slide); + const layers: string[] = []; + for (let clicks = 0; clicks <= buildCount; clicks++) { + layers.push(await captureAt(slide, bg, clicks)); + } + results.push({ layers }); + onProgress?.(results.length, slides.length); } - return images; + return results; } function deckFilename(): string { @@ -88,26 +122,131 @@ function deckFilename(): string { return name || 'deck'; } -async function exportToPdf(images: string[], filename: string) { +async function exportToPdf(captures: SlideCapture[], filename: string) { const { jsPDF } = await import('jspdf'); const pdf = new jsPDF({ orientation: 'landscape', unit: 'px', format: [W, H] }); - images.forEach((img, i) => { + captures.forEach((cap, i) => { if (i > 0) pdf.addPage([W, H], 'landscape'); - pdf.addImage(img, 'JPEG', 0, 0, W, H); + pdf.addImage(cap.layers[cap.layers.length - 1], 'JPEG', 0, 0, W, H); }); pdf.save(filename + '.pdf'); } -async function exportToPptx(images: string[], filename: string) { +// PowerPoint's click-animation timing (p:timing/p:bldLst) has no library +// support — this hand-builds the minimal "Appear on click" OOXML for a list +// of shape ids, in click order. Each shape gets its own top-level +// under mainSeq with an indefinite start condition, which is how PowerPoint +// represents "advance on click" steps. +function buildTimingXml(spids: number[]): string { + let id = 3; // 1 and 2 are used by the root/mainSeq nodes below + const pars = spids + .map((spid) => { + const outerId = id++; + const effectId = id++; + const setId = id++; + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `style.visibility` + + `` + + `` + ); + }) + .join(''); + const bldPs = spids + .map((spid, i) => ``) + .join(''); + return ( + `` + + `` + + `${pars}` + + `` + + `` + + `${bldPs}` + ); +} + +// Post-process the generated .pptx (itself a zip) to inject click-animation +// timing on slides with build layers. altText tags each layered image so +// the shape id assigned by pptxgenjs — not something we control — can be +// looked up reliably instead of assumed. +async function addClickAnimations( + blob: Blob, + slideNums: number[] +): Promise { + const { default: JSZip } = await import('jszip'); + const zip = await JSZip.loadAsync(blob); + for (const n of slideNums) { + const path = `ppt/slides/slide${n}.xml`; + const file = zip.file(path); + if (!file) continue; + const xml = await file.async('string'); + const doc = new DOMParser().parseFromString(xml, 'application/xml'); + const spidByLayer = new Map(); + const nodes = doc.getElementsByTagName('p:cNvPr'); + for (let i = 0; i < nodes.length; i++) { + const descr = nodes[i].getAttribute('descr') || ''; + const m = /^bslide-layer-(\d+)$/.exec(descr); + const idAttr = nodes[i].getAttribute('id'); + if (m && idAttr) spidByLayer.set(Number(m[1]), Number(idAttr)); + } + // layer 0 is the always-visible base; layers 1..N-1 reveal on click + const spids: number[] = []; + for (let l = 1; l < spidByLayer.size; l++) { + const spid = spidByLayer.get(l); + if (spid != null) spids.push(spid); + } + if (spids.length === 0) continue; + zip.file(path, xml.replace('', buildTimingXml(spids) + '')); + } + return zip.generateAsync({ + type: 'blob', + mimeType: + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + }); +} + +function downloadBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +async function exportToPptx(captures: SlideCapture[], filename: string) { const { default: PptxGenJS } = await import('pptxgenjs'); const pptx = new PptxGenJS(); pptx.defineLayout({ name: 'DECK', width: W / 96, height: H / 96 }); // px → inches @96dpi pptx.layout = 'DECK'; - for (const img of images) { + + const animatedSlideNums: number[] = []; + captures.forEach((cap, i) => { const slide = pptx.addSlide(); - slide.addImage({ data: img, x: 0, y: 0, w: W / 96, h: H / 96 }); - } - pptx.writeFile({ fileName: filename + '.pptx' }); + cap.layers.forEach((img, layerIdx) => { + slide.addImage({ + data: img, + x: 0, + y: 0, + w: W / 96, + h: H / 96, + altText: `bslide-layer-${layerIdx}`, + }); + }); + if (cap.layers.length > 1) animatedSlideNums.push(i + 1); + }); + + const blob = (await pptx.write({ outputType: 'blob' })) as Blob; + const finalBlob = animatedSlideNums.length + ? await addClickAnimations(blob, animatedSlideNums) + : blob; + downloadBlob(finalBlob, filename + '.pptx'); } export type ExportKind = 'pdf' | 'pptx'; @@ -117,8 +256,8 @@ export async function exportDeck( slides: ReactElement[], onProgress?: (done: number, total: number) => void ): Promise { - const images = await captureSlides(slides, onProgress); + const captures = await captureSlides(slides, onProgress); const filename = deckFilename(); - if (kind === 'pdf') await exportToPdf(images, filename); - else await exportToPptx(images, filename); + if (kind === 'pdf') await exportToPdf(captures, filename); + else await exportToPptx(captures, filename); } From 3181ababab819ba1fcb7ff65d190dfecb4e49990 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sat, 25 Jul 2026 11:35:15 +0530 Subject: [PATCH 07/10] Drop PPTX export, focus on PDF only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PPTX added real complexity (hand-authored PowerPoint animation XML, unvalidatable in this environment) for a format that isn't the priority. Removes pptxgenjs/jszip, the click-build layering in export.tsx (back to one flattened, fully-built image per slide via exportPdf), the export popover/menu in Deck.tsx (now a single button, E triggers it directly), and the Build.tsx clicks-under-isStatic change that only existed to support per-layer PPTX capture. PDF was never actually losing build content — it always rendered the final, fully-revealed state — but the simplification removes any doubt: back to a single capture per slide, no layer indexing at all. --- .bolt/skills/slides/SKILL.md | 2 +- README.md | 2 +- package-lock.json | 158 -------------------------- package.json | 2 - src/deck/Build.tsx | 7 +- src/deck/Deck.tsx | 109 ++++++------------ src/deck/export.tsx | 210 +++++------------------------------ src/styles/base.css | 53 +-------- 8 files changed, 69 insertions(+), 474 deletions(-) diff --git a/.bolt/skills/slides/SKILL.md b/.bolt/skills/slides/SKILL.md index affa3e0..41c55ba 100644 --- a/.bolt/skills/slides/SKILL.md +++ b/.bolt/skills/slides/SKILL.md @@ -295,7 +295,7 @@ trigger or that it exists — just deliver the demo. 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, - export (E) offers PDF/PPTX downloads, and the URL hash tracks the slide. + 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 9a21b12..5e4f2c7 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ 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 or PPTX | +| `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`. diff --git a/package-lock.json b/package-lock.json index 9914bb6..795c438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,6 @@ "framer-motion": "^11.3.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", - "jszip": "^3.10.1", - "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1164,15 +1162,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -1359,12 +1348,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -1555,51 +1538,12 @@ "node": ">=8.0.0" } }, - "node_modules/https": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz", - "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", - "license": "ISC" - }, - "node_modules/image-size": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", - "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", - "license": "MIT", - "dependencies": { - "queue": "6.0.2" - }, - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "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/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1649,33 +1593,6 @@ "html2canvas": "^1.0.0-rc.5" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/jszip/node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1808,33 +1725,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pptxgenjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-4.0.1.tgz", - "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", - "license": "MIT", - "dependencies": { - "@types/node": "^22.8.1", - "https": "^1.0.0", - "image-size": "^1.2.1", - "jszip": "^3.10.1" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/queue": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", - "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", - "license": "MIT", - "dependencies": { - "inherits": "~2.0.3" - } - }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -1880,21 +1770,6 @@ "node": ">=0.10.0" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -1957,12 +1832,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1982,12 +1851,6 @@ "semver": "bin/semver.js" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2008,15 +1871,6 @@ "node": ">=0.1.14" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/svg-pathdata": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", @@ -2057,12 +1911,6 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2094,12 +1942,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", diff --git a/package.json b/package.json index d426551..e5728aa 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,6 @@ "framer-motion": "^11.3.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", - "jszip": "^3.10.1", - "pptxgenjs": "^4.0.1", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/src/deck/Build.tsx b/src/deck/Build.tsx index 479f8d3..3c2ea3b 100644 --- a/src/deck/Build.tsx +++ b/src/deck/Build.tsx @@ -25,17 +25,14 @@ export default function Build({ registerMax?.(at); }, [at, registerMax]); - const shown = clicks >= at; - - // Static previews (thumbnails) pass clicks:9999 so this is always true — - // export capture passes real click counts to render each build step. if (isStatic) return ( -
+
{children}
); + const shown = clicks >= at; return ( , 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) => @@ -115,13 +115,10 @@ export default function Deck({ children }: { children: ReactNode }) { } } ); - const [exportOpen, setExportOpen] = useState(false); const [exportBusy, setExportBusy] = useState<{ - kind: ExportKind; done: number; total: number; } | null>(null); - const exportMenuRef = useRef(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). @@ -201,36 +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 (kind: ExportKind) => { - setExportOpen(false); - setExportBusy({ kind, done: 0, total }); - try { - await exportDeck(kind, slides, (done, tot) => - setExportBusy({ kind, done, total: tot }) - ); - } catch (err) { - alert(`Export failed: ${err instanceof Error ? err.message : err}`); - } finally { - setExportBusy(null); - } - }, - [slides, total] - ); - - // close the export popover on outside click - useEffect(() => { - if (!exportOpen) return; - const onClick = (e: MouseEvent) => { - if ( - exportMenuRef.current && - !exportMenuRef.current.contains(e.target as Node) - ) - setExportOpen(false); - }; - document.addEventListener('mousedown', onClick); - return () => document.removeEventListener('mousedown', onClick); - }, [exportOpen]); + 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(() => { @@ -292,20 +269,29 @@ export default function Deck({ children }: { children: ReactNode }) { break; case 'e': case 'E': - setExportOpen((v) => !v); + runExport(); break; case 'Escape': setRailOpen(false); setGridOpen(false); setDrawing(false); setUiHidden(false); - setExportOpen(false); break; } }; 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. @@ -571,41 +557,18 @@ export default function Deck({ children }: { children: ReactNode }) { -
- - {exportOpen && !exportBusy && ( -
- - -
- )} - {exportBusy && ( -
-
- - Exporting {exportBusy.kind.toUpperCase()} —{' '} - {exportBusy.done}/{exportBusy.total} -
-
- )} -
+
diff --git a/src/deck/export.tsx b/src/deck/export.tsx index 0d585d4..2d0cc36 100644 --- a/src/deck/export.tsx +++ b/src/deck/export.tsx @@ -1,47 +1,22 @@ import { createRoot } from 'react-dom/client'; -import type { ReactElement, ReactNode } from 'react'; +import type { ReactElement } from 'react'; import { toJpeg } from 'html-to-image'; import { DeckCtx } from './DeckContext'; -import Build from './Build'; -// jspdf/pptxgenjs/jszip are dynamically imported below — they're only needed -// once the user actually exports, and jsPDF alone drags in a ~250kB -// html2canvas dependency we never call, so keep it out of the main bundle. +// 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 PDF / PPTX. Slides are arbitrary live web content - (canvas, SVG, animation) — there's no way to turn that into editable - PDF/PPTX shapes, so each slide is rasterized to a JPEG (reusing the same - isStatic:true context Thumb uses for thumbnails, so entrance animations - render in their final state with no waiting). click-reveals are - the one animation PPTX can represent natively (its own click-advance - animation model), so a slide with N builds is captured as N+1 layered - images — one per click step — stacked on a single pptx slide and wired - up with "appear on click" timing. PDF has no animation concept, so it - only uses the final, fully-built layer. */ +/* 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 -// How many click-builds a slide has, found by walking its element tree — -// no need to mount anything, and it sidesteps any uncertainty about -// useEffect timing during the offscreen capture below. -function maxBuildAt(node: ReactNode): number { - if (node == null || typeof node !== 'object') return 0; - if (Array.isArray(node)) { - return node.reduce((m, c) => Math.max(m, maxBuildAt(c)), 0); - } - const el = node as ReactElement; - if (!('type' in el) || !('props' in el)) return 0; - let m = el.type === Build ? (el.props as { at?: number }).at ?? 1 : 0; - const children = (el.props as { children?: ReactNode })?.children; - if (children != null) m = Math.max(m, maxBuildAt(children)); - return m; -} - -async function captureAt( - slide: ReactElement, - bg: string, - clicks: number -): Promise { +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 @@ -62,7 +37,7 @@ async function captureAt( className="deck export-capture" style={{ width: W, height: H, position: 'relative' }} > - +
{slide}
@@ -89,29 +64,20 @@ async function captureAt( } } -interface SlideCapture { - layers: string[]; // layers[i] = slide state after i clicks; length is buildCount+1 -} - async function captureSlides( slides: ReactElement[], onProgress?: (done: number, total: number) => void -): Promise { +): Promise { await document.fonts?.ready; const bg = getComputedStyle(document.documentElement) .getPropertyValue('--bg') .trim(); - const results: SlideCapture[] = []; + const images: string[] = []; for (const slide of slides) { - const buildCount = maxBuildAt(slide); - const layers: string[] = []; - for (let clicks = 0; clicks <= buildCount; clicks++) { - layers.push(await captureAt(slide, bg, clicks)); - } - results.push({ layers }); - onProgress?.(results.length, slides.length); + images.push(await captureSlide(slide, bg)); + onProgress?.(images.length, slides.length); } - return results; + return images; } function deckFilename(): string { @@ -122,142 +88,16 @@ function deckFilename(): string { return name || 'deck'; } -async function exportToPdf(captures: SlideCapture[], filename: string) { +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] }); - captures.forEach((cap, i) => { + images.forEach((img, i) => { if (i > 0) pdf.addPage([W, H], 'landscape'); - pdf.addImage(cap.layers[cap.layers.length - 1], 'JPEG', 0, 0, W, H); - }); - pdf.save(filename + '.pdf'); -} - -// PowerPoint's click-animation timing (p:timing/p:bldLst) has no library -// support — this hand-builds the minimal "Appear on click" OOXML for a list -// of shape ids, in click order. Each shape gets its own top-level -// under mainSeq with an indefinite start condition, which is how PowerPoint -// represents "advance on click" steps. -function buildTimingXml(spids: number[]): string { - let id = 3; // 1 and 2 are used by the root/mainSeq nodes below - const pars = spids - .map((spid) => { - const outerId = id++; - const effectId = id++; - const setId = id++; - return ( - `` + - `` + - `` + - `` + - `` + - `` + - `` + - `style.visibility` + - `` + - `` - ); - }) - .join(''); - const bldPs = spids - .map((spid, i) => ``) - .join(''); - return ( - `` + - `` + - `${pars}` + - `` + - `` + - `${bldPs}` - ); -} - -// Post-process the generated .pptx (itself a zip) to inject click-animation -// timing on slides with build layers. altText tags each layered image so -// the shape id assigned by pptxgenjs — not something we control — can be -// looked up reliably instead of assumed. -async function addClickAnimations( - blob: Blob, - slideNums: number[] -): Promise { - const { default: JSZip } = await import('jszip'); - const zip = await JSZip.loadAsync(blob); - for (const n of slideNums) { - const path = `ppt/slides/slide${n}.xml`; - const file = zip.file(path); - if (!file) continue; - const xml = await file.async('string'); - const doc = new DOMParser().parseFromString(xml, 'application/xml'); - const spidByLayer = new Map(); - const nodes = doc.getElementsByTagName('p:cNvPr'); - for (let i = 0; i < nodes.length; i++) { - const descr = nodes[i].getAttribute('descr') || ''; - const m = /^bslide-layer-(\d+)$/.exec(descr); - const idAttr = nodes[i].getAttribute('id'); - if (m && idAttr) spidByLayer.set(Number(m[1]), Number(idAttr)); - } - // layer 0 is the always-visible base; layers 1..N-1 reveal on click - const spids: number[] = []; - for (let l = 1; l < spidByLayer.size; l++) { - const spid = spidByLayer.get(l); - if (spid != null) spids.push(spid); - } - if (spids.length === 0) continue; - zip.file(path, xml.replace('', buildTimingXml(spids) + '')); - } - return zip.generateAsync({ - type: 'blob', - mimeType: - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + pdf.addImage(img, 'JPEG', 0, 0, W, H); }); -} - -function downloadBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); -} - -async function exportToPptx(captures: SlideCapture[], filename: string) { - const { default: PptxGenJS } = await import('pptxgenjs'); - const pptx = new PptxGenJS(); - pptx.defineLayout({ name: 'DECK', width: W / 96, height: H / 96 }); // px → inches @96dpi - pptx.layout = 'DECK'; - - const animatedSlideNums: number[] = []; - captures.forEach((cap, i) => { - const slide = pptx.addSlide(); - cap.layers.forEach((img, layerIdx) => { - slide.addImage({ - data: img, - x: 0, - y: 0, - w: W / 96, - h: H / 96, - altText: `bslide-layer-${layerIdx}`, - }); - }); - if (cap.layers.length > 1) animatedSlideNums.push(i + 1); - }); - - const blob = (await pptx.write({ outputType: 'blob' })) as Blob; - const finalBlob = animatedSlideNums.length - ? await addClickAnimations(blob, animatedSlideNums) - : blob; - downloadBlob(finalBlob, filename + '.pptx'); -} - -export type ExportKind = 'pdf' | 'pptx'; - -export async function exportDeck( - kind: ExportKind, - slides: ReactElement[], - onProgress?: (done: number, total: number) => void -): Promise { - const captures = await captureSlides(slides, onProgress); - const filename = deckFilename(); - if (kind === 'pdf') await exportToPdf(captures, filename); - else await exportToPptx(captures, filename); + pdf.save(deckFilename() + '.pdf'); } diff --git a/src/styles/base.css b/src/styles/base.css index 9bc2fea..21d935d 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -116,7 +116,7 @@ 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/PPTX export rasterizes slides via html-to-image, which doesn't honor +/* 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 { @@ -2119,55 +2119,10 @@ strong { gap: 4px; } -/* Export button popover — same dark-glass look as .noir-bar */ -.noir-export { - position: relative; -} -.noir-export-menu { - position: absolute; - bottom: calc(100% + 12px); - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 2px; - min-width: 168px; - padding: 6px; - border-radius: 14px; - background: rgba(28, 28, 30, 0.9); - border: 1px solid rgba(255, 255, 255, 0.1); - backdrop-filter: blur(22px) saturate(180%); - -webkit-backdrop-filter: blur(22px) saturate(180%); - box-shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.8); -} -.noir-export-item { - border: none; - background: transparent; - color: #e8e8ea; - font: inherit; - font-size: 13px; - text-align: left; - padding: 9px 10px; - border-radius: 9px; - cursor: pointer; - white-space: nowrap; -} -.noir-export-item:hover { - background: rgba(255, 255, 255, 0.12); -} -.noir-export-status { - display: flex; - align-items: center; - gap: 8px; - padding: 9px 10px; - font-size: 13px; - color: #e8e8ea; - white-space: nowrap; -} +/* Export-in-progress spinner, swapped in for the download icon */ .noir-spinner { - width: 13px; - height: 13px; - flex: none; + width: 17px; + height: 17px; border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.25); border-top-color: #e8e8ea; From 150b5d3ec5c0ef73eee76ef54659866f5bb875f4 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sat, 25 Jul 2026 12:00:22 +0530 Subject: [PATCH 08/10] Fix components that skipped isStatic, causing invisible content in exports Found the real cause of the "missing data" report: Tabs, Comparison, Table, and Timeline all play real Framer Motion entrance transitions (0.25-0.9s, some with per-row/per-item stagger delays up to ~1s) that were gated only on prefers-reduced-motion, never on the app's own isStatic flag -- unlike every other animated component (Reveal, Build, Charts, CountUp, StatGrid, Pricing, Steps, Bento, Team, Agenda, Contrast), which all correctly derive animate = !isStatic && !reduce. Export's offscreen capture renders with isStatic:true and only waits two animation frames (~33ms) before snapshotting -- nowhere near enough for these components' transitions to finish, so any slide using Tabs/Comparison/Table/Timeline was captured mid-fade or fully transparent, i.e. missing content in the PDF. Fixed each to match the established isStatic-aware pattern used everywhere else in the codebase (Tabs skips its AnimatePresence panel transition entirely under isStatic, matching Reveal/Build; Comparison/Table/Timeline gate initial/duration on isStatic || reduce, matching Charts' DonutChart). Verified by rendering each of the four previously-broken demo slides through the real export pipeline and inspecting the output -- all content is now fully visible with no fade-in artifacts. --- src/components/Comparison.tsx | 4 +++- src/components/Table.tsx | 4 +++- src/components/Tabs.tsx | 36 ++++++++++++++++++++--------------- src/components/Timeline.tsx | 13 +++++++++---- 4 files changed, 36 insertions(+), 21 deletions(-) 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[] }) { From 354cd8864b9352091cbcd6aaf589529f8be6a611 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sat, 25 Jul 2026 14:38:11 +0530 Subject: [PATCH 09/10] Fix VisualDashboard content loss in PDF export, plus font-race and error-containment fixes VisualDashboard's sparklines, area-chart line, and weekly bars use CSS @keyframes (not Framer Motion or a transition), so they always played their full draw-in timeline on mount regardless of isStatic and were captured blank/flat. Neutralize them under .export-capture, mirroring the existing prefers-reduced-motion overrides. Also force-load Inter/JetBrains Mono weights before waiting on document.fonts.ready, since fonts.ready only waits for faces already requested by on-screen content, not ones a later slide in the export queue is first to use. And wrap per-slide capture in try/catch so a failure names the slide instead of aborting the whole export with a generic error. --- src/deck/export.tsx | 21 +++++++++++++++++++-- src/styles/base.css | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/deck/export.tsx b/src/deck/export.tsx index 2d0cc36..b01e31f 100644 --- a/src/deck/export.tsx +++ b/src/deck/export.tsx @@ -68,13 +68,30 @@ 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 slide of slides) { - images.push(await captureSlide(slide, bg)); + 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; diff --git a/src/styles/base.css b/src/styles/base.css index 21d935d..e18b10e 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -122,6 +122,22 @@ body { .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, From cb341dc7a0151fbe0d57743d2587c71af6e093c7 Mon Sep 17 00:00:00 2001 From: eyeofshreyas Date: Sat, 25 Jul 2026 19:55:56 +0530 Subject: [PATCH 10/10] README: document the PDF export shortcut --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5e4f2c7..2b07b8f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Each top-level child of `` is one slide. Compose them from the component l - **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