From 83a3222cf61c8ca08fe8d61030231268ca60389b Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Thu, 11 Sep 2025 19:08:44 +0100 Subject: [PATCH] Set up --- frontend/index.html | 1 + frontend/package-lock.json | 297 +++++++- frontend/package.json | 8 + frontend/src/components/shared/RightRail.tsx | 100 +++ .../src/components/viewer/EmbedPdfViewer.tsx | 134 ++++ .../src/components/viewer/LocalEmbedPDF.tsx | 158 ++++ frontend/src/components/viewer/OldViewer.tsx | 687 ++++++++++++++++++ .../components/viewer/PdfViewerToolbar.tsx | 264 +++++++ .../viewer/ScrollControlsExporter.tsx | 27 + frontend/src/components/viewer/Viewer.tsx | 685 +---------------- .../viewer/ZoomControlsExporter.tsx | 26 + frontend/src/global.d.ts | 6 +- frontend/tsconfig.json | 8 +- 13 files changed, 1679 insertions(+), 722 deletions(-) create mode 100644 frontend/src/components/viewer/EmbedPdfViewer.tsx create mode 100644 frontend/src/components/viewer/LocalEmbedPDF.tsx create mode 100644 frontend/src/components/viewer/OldViewer.tsx create mode 100644 frontend/src/components/viewer/PdfViewerToolbar.tsx create mode 100644 frontend/src/components/viewer/ScrollControlsExporter.tsx create mode 100644 frontend/src/components/viewer/ZoomControlsExporter.tsx diff --git a/frontend/index.html b/frontend/index.html index 31f1b3008..6773fd7dc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,7 @@ Stirling PDF + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 342f0512f..6bd83b76c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,14 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", + "@embedpdf/core": "^1.1.1", + "@embedpdf/engines": "^1.1.1", + "@embedpdf/plugin-interaction-manager": "^1.1.1", + "@embedpdf/plugin-loader": "^1.1.1", + "@embedpdf/plugin-render": "^1.1.1", + "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-viewport": "^1.1.1", + "@embedpdf/plugin-zoom": "^1.1.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", @@ -596,6 +604,144 @@ "node": ">=18" } }, + "node_modules/@embedpdf/core": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.1.1.tgz", + "integrity": "sha512-SNUwvfeo6VVixV3Q9zJ+gbN1KAwX5FSlxOWxeOnvC42dkTf736SRjBS14SamtwJ/qRNNZZT4UnpIUKrKHWvEfw==", + "dependencies": { + "@embedpdf/engines": "1.1.1", + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/engines": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.1.1.tgz", + "integrity": "sha512-WofvMPuSDrQNF+gdd28HwfmsMrAYlIg0rDQhI8qdrt/DPxctyfsgbM89rzDz0kdmz6EfL5yZ4cMd8DrTdk33Ig==", + "license": "MIT", + "dependencies": { + "@embedpdf/models": "1.1.1", + "@embedpdf/pdfium": "1.1.1" + }, + "peerDependencies": { + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/models": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.1.1.tgz", + "integrity": "sha512-Vyxj3OEZkf6xUPwrMQoEHREKzSTd3adGcY74F4ksNqJroOpS+qZ3/f2M4ZRDP9mdXjt+VBYbMQgW1jcjvY13/Q==", + "license": "MIT" + }, + "node_modules/@embedpdf/pdfium": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.1.1.tgz", + "integrity": "sha512-FktYEcJ9IMSw3feeFiFmIZLwmJKNSBHPZQAQEj3iNFOxc+5fdJgb+nc4kQ+ofDl9HybnaWVol7Rr9wI7IqXyhg==", + "license": "MIT" + }, + "node_modules/@embedpdf/plugin-interaction-manager": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.1.1.tgz", + "integrity": "sha512-uK65zhokTB66Ahvwt2gwa5TAfpYevIzhfozQebazfqQ5Pjp1Z4BatSyruxibvm9fKPi0BsqxECO9Fs+JnwIdNA==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-loader": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.1.1.tgz", + "integrity": "sha512-lFp9znB1H4NiMEK/+/OYP2u9k9ICXckp+CkUxHUwKdni+7b94BiwgNGmRzwk1BoVgrnZ8vWns+THhXtOqDkvQQ==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-render": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.1.1.tgz", + "integrity": "sha512-GKtHIOPdikOQS0nA2/XwH2AyVVUYV56Ps+sieB5ykz+dEWv0MUo0B4Wc0r6PWUK7i2JDu5ZALMidlXq7QUEirg==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-scroll": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.1.1.tgz", + "integrity": "sha512-+W0PwoYH0CECcEvMmYK63I5fb00/6kl0Pmin8h4MRE2T1S6vwJOoTac6vWJ5aF5q5sKItdgCuQLtJ9piVwiXYw==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-viewport": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-viewport": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.1.1.tgz", + "integrity": "sha512-tG6NCt9JoNtbrWFv1IN38Pkb8jaAHoxHD3suFMYjB8D5ar/5+heNcKeVVTCdqHk1do30EttB2jQsTZUsMIPv+Q==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-zoom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.1.1.tgz", + "integrity": "sha512-JL1tyQCi25J/jmqsAT7DRRX7kCstDqk7e0HNh/Ah80bLOupMuJ4Qyjhx0Y5pkgwsNiDIN0ijY3RCwf7udwglug==", + "dependencies": { + "@embedpdf/models": "1.1.1", + "hammerjs": "^2.0.8" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-interaction-manager": "1.1.1", + "@embedpdf/plugin-scroll": "1.1.1", + "@embedpdf/plugin-viewport": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1619,9 +1765,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3404,14 +3550,13 @@ "license": "MIT" }, "node_modules/@vue/compiler-core": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.19.tgz", - "integrity": "sha512-/afpyvlkrSNYbPo94Qu8GtIOWS+g5TRdOvs6XZNw6pWQQmj5pBgSZvEPOIZlqWq0YvoUhDDQaQ2TnzuJdOV4hA==", - "dev": true, + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", - "@vue/shared": "3.5.19", + "@vue/shared": "3.5.21", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" @@ -3421,7 +3566,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3434,34 +3578,31 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-dom": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.19.tgz", - "integrity": "sha512-Drs6rPHQZx/pN9S6ml3Z3K/TWCIRPvzG2B/o5kFK9X0MNHt8/E+38tiRfojufrYBfA6FQUFB2qBBRXlcSXWtOA==", - "dev": true, + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.19", - "@vue/shared": "3.5.19" + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.19.tgz", - "integrity": "sha512-YWCm1CYaJ+2RvNmhCwI7t3I3nU+hOrWGWMsn+Z/kmm1jy5iinnVtlmkiZwbLlbV1SRizX7vHsc0/bG5dj0zRTg==", - "dev": true, + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", "license": "MIT", "dependencies": { "@babel/parser": "^7.28.3", - "@vue/compiler-core": "3.5.19", - "@vue/compiler-dom": "3.5.19", - "@vue/compiler-ssr": "3.5.19", - "@vue/shared": "3.5.19", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", "estree-walker": "^2.0.2", - "magic-string": "^0.30.17", + "magic-string": "^0.30.18", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } @@ -3470,25 +3611,70 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.19.tgz", - "integrity": "sha512-/wx0VZtkWOPdiQLWPeQeqpHWR/LuNC7bHfSX7OayBTtUy8wur6vT6EQIX6Et86aED6J+y8tTw43qo2uoqGg5sw==", - "dev": true, + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.19", - "@vue/shared": "3.5.19" + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", + "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", + "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", + "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/runtime-core": "3.5.21", + "@vue/shared": "3.5.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", + "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "vue": "3.5.21" } }, "node_modules/@vue/shared": { - "version": "3.5.19", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.19.tgz", - "integrity": "sha512-IhXCOn08wgKrLQxRFKKlSacWg4Goi1BolrdEeLYn6tgHjJNXVrWJ5nzoxZqNwl5p88aLlQ8LOaoMa3AYvaKJ/Q==", - "dev": true, + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", "license": "MIT" }, "node_modules/abbrev": { @@ -5644,6 +5830,15 @@ "dev": true, "license": "MIT" }, + "node_modules/hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6940,12 +7135,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magicast": { @@ -11280,6 +11475,28 @@ "node": ">=0.10.0" } }, + "node_modules/vue": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", + "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-sfc": "3.5.21", + "@vue/runtime-dom": "3.5.21", + "@vue/server-renderer": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d73e9ad97..5a10c0387 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,14 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", + "@embedpdf/core": "^1.1.1", + "@embedpdf/engines": "^1.1.1", + "@embedpdf/plugin-interaction-manager": "^1.1.1", + "@embedpdf/plugin-loader": "^1.1.1", + "@embedpdf/plugin-render": "^1.1.1", + "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-viewport": "^1.1.1", + "@embedpdf/plugin-zoom": "^1.1.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@iconify/react": "^6.0.0", diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index ee0f9b911..1a5526627 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -7,6 +7,7 @@ import { useRightRail } from '../../contexts/RightRailContext'; import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext'; import { useNavigationState } from '../../contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; + import LanguageSelector from '../shared/LanguageSelector'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { Tooltip } from '../shared/Tooltip'; @@ -204,6 +205,105 @@ export default function RightRail() { )} + {/* Group: PDF Viewer Controls - visible only in viewer mode */} +
+
+ {/* Search */} + + (window as any).embedPdfControls?.search()} + disabled={currentView !== 'viewer'} + > + + + + + {/* Zoom Out */} + + (window as any).embedPdfZoom?.zoomOut()} + disabled={currentView !== 'viewer'} + > + + + + + {/* Zoom In */} + + (window as any).embedPdfZoom?.zoomIn()} + disabled={currentView !== 'viewer'} + > + + + + {/* Area Zoom */} + + (window as any).embedPdfZoom?.toggleMarqueeZoom()} + disabled={currentView !== 'viewer'} + > + + + + + {/* Pan Mode */} + + (window as any).embedPdfControls?.pan()} + disabled={currentView !== 'viewer'} + > + + + + + {/* Select Mode */} + + (window as any).embedPdfControls?.pointer()} + disabled={currentView !== 'viewer'} + > + + + + + {/* Sidebar Toggle */} + + (window as any).embedPdfControls?.sidebar()} + disabled={currentView !== 'viewer'} + > + + + +
+ +
+ {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
void; + onClose?: () => void; + previewFile?: File | null; +} + +const EmbedPdfViewer = ({ + sidebarsVisible, + setSidebarsVisible, + onClose, + previewFile, +}: EmbedPdfViewerProps) => { + const { t } = useTranslation(); + const theme = useMantineTheme(); + const { colorScheme } = useMantineColorScheme(); + + // Get current file from FileContext + const { selectors } = useFileState(); + const activeFiles = selectors.getFiles(); + + // Determine which file to display + const currentFile = React.useMemo(() => { + if (previewFile) { + return previewFile; + } else if (activeFiles.length > 0) { + return activeFiles[0]; // Use first file for simplicity + } + return null; + }, [previewFile, activeFiles]); + + // Get file with URL for rendering + const fileWithUrl = useFileWithUrl(currentFile); + + // Determine the effective file to display + const effectiveFile = React.useMemo(() => { + if (previewFile) { + // In preview mode, show the preview file + if (previewFile.size === 0) { + return null; + } + return { file: previewFile, url: null }; + } else { + return fileWithUrl; + } + }, [previewFile, fileWithUrl]); + + return ( + + {/* Close Button - Only show in preview mode */} + {onClose && previewFile && ( + + + + )} + + {!effectiveFile ? ( +
+ Error: No file provided to viewer +
+ ) : ( + <> + {/* Tabs for multiple files */} + {activeFiles.length > 1 && !previewFile && ( + + + Multiple files loaded - showing first file for now + + + )} + + {/* EmbedPDF Viewer with Toolbar Overlay */} + + + + {/* Bottom Toolbar Overlay */} +
+
+ { + // Placeholder - will implement page navigation later + console.log('Navigate to page:', page); + }} + dualPage={false} + onDualPageToggle={() => { + // Placeholder - will implement dual page toggle later + console.log('Toggle dual page view'); + }} + currentZoom={100} + /> +
+
+
+ + )} +
+ ); +}; + +export default EmbedPdfViewer; \ No newline at end of file diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx new file mode 100644 index 000000000..d354fadcf --- /dev/null +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { createPluginRegistration } from '@embedpdf/core'; +import { EmbedPDF } from '@embedpdf/core/react'; +import { usePdfiumEngine } from '@embedpdf/engines/react'; + +// Import the essential plugins +import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react'; +import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react'; +import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react'; +import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react'; +import { InteractionManagerPluginPackage } from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomControlsExporter } from './ZoomControlsExporter'; +import { ScrollControlsExporter } from './ScrollControlsExporter'; + +interface LocalEmbedPDFProps { + file?: File | Blob; + url?: string | null; + colorScheme: 'light' | 'dark' | 'auto'; +} + +export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) { + const [pdfUrl, setPdfUrl] = useState(null); + + // Convert color scheme (handle 'auto' mode by defaulting to 'light') + const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme; + + // Convert File to URL if needed + useEffect(() => { + if (file) { + const objectUrl = URL.createObjectURL(file); + setPdfUrl(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + } else if (url) { + setPdfUrl(url); + } + }, [file, url]); + + // Create plugins configuration + const plugins = useMemo(() => { + if (!pdfUrl) return []; + + return [ + createPluginRegistration(LoaderPluginPackage, { + loadingOptions: { + type: 'url', + pdfFile: { + id: 'stirling-pdf-viewer', + url: pdfUrl, + }, + }, + }), + createPluginRegistration(ViewportPluginPackage, { + viewportGap: 10, + }), + createPluginRegistration(ScrollPluginPackage, { + strategy: ScrollStrategy.Vertical, + initialPage: 0, + }), + createPluginRegistration(RenderPluginPackage), + + // Register interaction manager (required for zoom features) + createPluginRegistration(InteractionManagerPluginPackage), + + // Register zoom plugin with configuration + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: ZoomMode.FitPage, + minZoom: 0.2, + maxZoom: 5.0, + }), + ]; + }, [pdfUrl]); + + // Initialize the engine with the React hook + const { engine, isLoading, error } = usePdfiumEngine(); + + + // Early return if no file or URL provided + if (!file && !url) { + return ( +
+
+
📄
+
No PDF provided
+
+
+ ); + } + + if (isLoading || !engine || !pdfUrl) { + return ( +
+
+
+
Loading PDF Engine...
+
+
+ ); + } + + if (error) { + return ( +
+
+
+
Error loading PDF engine: {error.message}
+
+
+ ); + } + + // Wrap your UI with the provider + return ( +
+ + + + + ( +
+ +
+ )} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/OldViewer.tsx b/frontend/src/components/viewer/OldViewer.tsx new file mode 100644 index 000000000..dfcd5dc7d --- /dev/null +++ b/frontend/src/components/viewer/OldViewer.tsx @@ -0,0 +1,687 @@ +import React, { useEffect, useState, useRef, useCallback } from "react"; +import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { pdfWorkerManager } from "../../services/pdfWorkerManager"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; +import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; +import FirstPageIcon from "@mui/icons-material/FirstPage"; +import LastPageIcon from "@mui/icons-material/LastPage"; +import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book) +import DescriptionIcon from "@mui/icons-material/Description"; // for single page +import CloseIcon from "@mui/icons-material/Close"; +import { fileStorage } from "../../services/fileStorage"; +import SkeletonLoader from '../shared/SkeletonLoader'; +import { useFileState } from "../../contexts/FileContext"; +import { useFileWithUrl } from "../../hooks/useFileWithUrl"; +import { isFileObject } from "../../types/fileContext"; +import { FileId } from "../../types/file"; + + +// Lazy loading page image component +interface LazyPageImageProps { + pageIndex: number; + zoom: number; + theme: any; + isFirst: boolean; + renderPage: (pageIndex: number) => Promise; + pageImages: (string | null)[]; + setPageRef: (index: number, ref: HTMLImageElement | null) => void; +} + +const LazyPageImage = ({ + pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef +}: LazyPageImageProps) => { + const [isVisible, setIsVisible] = useState(false); + const [imageUrl, setImageUrl] = useState(null); + const imgRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !imageUrl) { + setIsVisible(true); + } + }); + }, + { + rootMargin: '200px', // Start loading 200px before visible + threshold: 0.1 + } + ); + + if (imgRef.current) { + observer.observe(imgRef.current); + } + + return () => observer.disconnect(); + }, [imageUrl]); + + // Update local state when pageImages changes (from preloading) + useEffect(() => { + if (pageImages[pageIndex]) { + setImageUrl(pageImages[pageIndex]); + } + }, [pageImages, pageIndex]); + + useEffect(() => { + if (isVisible && !imageUrl) { + renderPage(pageIndex).then((url) => { + if (url) setImageUrl(url); + }); + } + }, [isVisible, imageUrl, pageIndex, renderPage]); + + useEffect(() => { + if (imgRef.current) { + setPageRef(pageIndex, imgRef.current); + } + }, [pageIndex, setPageRef]); + + if (imageUrl) { + return ( + {`Page + ); + } + + // Placeholder while loading + return ( +
+ {isVisible ? ( +
+
+ Loading page {pageIndex + 1}... +
+ ) : ( + Page {pageIndex + 1} + )} +
+ ); +}; + +export interface ViewerProps { + sidebarsVisible: boolean; + setSidebarsVisible: (v: boolean) => void; + onClose?: () => void; + previewFile: File | null; // For preview mode - bypasses context +} + +const Viewer = ({ + onClose, + previewFile, +}: ViewerProps) => { + const { t } = useTranslation(); + const theme = useMantineTheme(); + + // Get current file from FileContext + const { selectors } = useFileState(); + + const activeFiles = selectors.getFiles(); + + // Tab management for multiple files + const [activeTab, setActiveTab] = useState("0"); + + // Reset PDF state when switching tabs + const handleTabChange = (newTab: string) => { + setActiveTab(newTab); + setNumPages(0); + setPageImages([]); + setCurrentPage(null); + setLoading(true); + }; + const [numPages, setNumPages] = useState(0); + const [pageImages, setPageImages] = useState([]); + const [loading, setLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(null); + const [dualPage, setDualPage] = useState(false); + const [zoom, setZoom] = useState(1); // 1 = 100% + const pageRefs = useRef<(HTMLImageElement | null)[]>([]); + + // Memoize setPageRef to prevent infinite re-renders + const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => { + pageRefs.current[index] = ref; + }, []); + + // Get files with URLs for tabs - we'll need to create these individually + const file0WithUrl = useFileWithUrl(activeFiles[0]); + const file1WithUrl = useFileWithUrl(activeFiles[1]); + const file2WithUrl = useFileWithUrl(activeFiles[2]); + const file3WithUrl = useFileWithUrl(activeFiles[3]); + const file4WithUrl = useFileWithUrl(activeFiles[4]); + + const filesWithUrls = React.useMemo(() => { + return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl] + .slice(0, activeFiles.length) + .filter(Boolean); + }, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]); + + // Use preview file if available, otherwise use active tab file + const effectiveFile = React.useMemo(() => { + if (previewFile) { + // Validate the preview file + if (!isFileObject(previewFile)) { + return null; + } + + if (previewFile.size === 0) { + return null; + } + + return { file: previewFile, url: null }; + } else { + // Use the file from the active tab + const tabIndex = parseInt(activeTab); + return filesWithUrls[tabIndex] || null; + } + }, [previewFile, filesWithUrls, activeTab]); + + const scrollAreaRef = useRef(null); + const pdfDocRef = useRef(null); + const renderingPagesRef = useRef>(new Set()); + const currentArrayBufferRef = useRef(null); + const preloadingRef = useRef(false); + + // Function to render a specific page on-demand + const renderPage = async (pageIndex: number): Promise => { + if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) { + return null; + } + + const pageNum = pageIndex + 1; + if (pageImages[pageIndex]) { + return pageImages[pageIndex]; // Already rendered + } + + renderingPagesRef.current.add(pageIndex); + + try { + const page = await pdfDocRef.current.getPage(pageNum); + const viewport = page.getViewport({ scale: 1.2 }); + const canvas = document.createElement("canvas"); + canvas.width = viewport.width; + canvas.height = viewport.height; + const ctx = canvas.getContext("2d"); + + if (ctx) { + await page.render({ canvasContext: ctx, viewport }).promise; + const dataUrl = canvas.toDataURL(); + + // Update the pageImages array + setPageImages(prev => { + const newImages = [...prev]; + newImages[pageIndex] = dataUrl; + return newImages; + }); + + renderingPagesRef.current.delete(pageIndex); + return dataUrl; + } + } catch (error) { + console.error(`Failed to render page ${pageNum}:`, error); + } + + renderingPagesRef.current.delete(pageIndex); + return null; + }; + + // Progressive preloading function + const startProgressivePreload = async () => { + if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return; + + preloadingRef.current = true; + + // Start with first few pages for immediate viewing + const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages + + // Render priority pages first + for (const pageIndex of priorityPages) { + if (pageIndex < numPages && !pageImages[pageIndex]) { + await renderPage(pageIndex); + // Small delay to allow UI to update + await new Promise(resolve => setTimeout(resolve, 50)); + } + } + + // Then render remaining pages in background + for (let pageIndex = 5; pageIndex < numPages; pageIndex++) { + if (!pageImages[pageIndex]) { + await renderPage(pageIndex); + // Longer delay for background loading to not block UI + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + preloadingRef.current = false; + }; + + // Initialize current page when PDF loads + useEffect(() => { + if (numPages > 0 && !currentPage) { + setCurrentPage(1); + } + }, [numPages, currentPage]); + + // Function to scroll to a specific page + const scrollToPage = (pageNumber: number) => { + const el = pageRefs.current[pageNumber - 1]; + const scrollArea = scrollAreaRef.current; + + if (el && scrollArea) { + const scrollAreaRect = scrollArea.getBoundingClientRect(); + const elRect = el.getBoundingClientRect(); + const currentScrollTop = scrollArea.scrollTop; + + // Position page near top of viewport with some padding + const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20; + + scrollArea.scrollTo({ + top: targetScrollTop, + behavior: "smooth" + }); + } + }; + + // Throttled scroll handler to prevent jerky updates + const handleScrollThrottled = useCallback(() => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea || !pageRefs.current.length) return; + + const areaRect = scrollArea.getBoundingClientRect(); + const viewportCenter = areaRect.top + areaRect.height / 2; + let closestIdx = 0; + let minDist = Infinity; + + pageRefs.current.forEach((img, idx) => { + if (img) { + const imgRect = img.getBoundingClientRect(); + const imgCenter = imgRect.top + imgRect.height / 2; + const dist = Math.abs(imgCenter - viewportCenter); + if (dist < minDist) { + minDist = dist; + closestIdx = idx; + } + } + }); + + // Update page number display only if changed + if (currentPage !== closestIdx + 1) { + setCurrentPage(closestIdx + 1); + } + }, [currentPage]); + + // Throttle scroll events to reduce jerkiness + const handleScroll = useCallback(() => { + if (window.requestAnimationFrame) { + window.requestAnimationFrame(handleScrollThrottled); + } else { + handleScrollThrottled(); + } + }, [handleScrollThrottled]); + + useEffect(() => { + let cancelled = false; + async function loadPdfInfo() { + if (!effectiveFile) { + setNumPages(0); + setPageImages([]); + return; + } + setLoading(true); + try { + let pdfData; + + // For preview files, use ArrayBuffer directly to avoid blob URL issues + if (previewFile && effectiveFile.file === previewFile) { + const arrayBuffer = await previewFile.arrayBuffer(); + pdfData = { data: arrayBuffer }; + } + // Handle special IndexedDB URLs for large files + else if (effectiveFile.url?.startsWith('indexeddb:')) { + const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */; + + // Get data directly from IndexedDB + const arrayBuffer = await fileStorage.getFileData(fileId); + if (!arrayBuffer) { + throw new Error('File not found in IndexedDB - may have been purged by browser'); + } + + // Store reference for cleanup + currentArrayBufferRef.current = arrayBuffer; + pdfData = { data: arrayBuffer }; + } else if (effectiveFile.url) { + // Standard blob URL or regular URL + pdfData = effectiveFile.url; + } else { + throw new Error('No valid PDF source available'); + } + + const pdf = await pdfWorkerManager.createDocument(pdfData); + pdfDocRef.current = pdf; + setNumPages(pdf.numPages); + if (!cancelled) { + setPageImages(new Array(pdf.numPages).fill(null)); + // Start progressive preloading after a short delay + setTimeout(() => startProgressivePreload(), 100); + } + } catch { + if (!cancelled) { + setPageImages([]); + setNumPages(0); + } + } + if (!cancelled) setLoading(false); + } + loadPdfInfo(); + return () => { + cancelled = true; + // Stop any ongoing preloading + preloadingRef.current = false; + // Cleanup PDF document using worker manager + if (pdfDocRef.current) { + pdfWorkerManager.destroyDocument(pdfDocRef.current); + pdfDocRef.current = null; + } + // Cleanup ArrayBuffer reference to help garbage collection + currentArrayBufferRef.current = null; + }; + }, [effectiveFile, previewFile]); + + useEffect(() => { + const viewport = scrollAreaRef.current; + if (!viewport) return; + const handler = () => { + handleScroll(); + }; + viewport.addEventListener("scroll", handler); + return () => viewport.removeEventListener("scroll", handler); + }, [pageImages]); + + return ( + + {/* Close Button - Only show in preview mode */} + {onClose && previewFile && ( + + + + )} + + {!effectiveFile ? ( +
+ Error: No file provided to viewer +
+ ) : ( + <> + {/* Tabs for multiple files */} + {activeFiles.length > 1 && !previewFile && ( + + handleTabChange(value || "0")}> + + {activeFiles.map((file: any, index: number) => ( + + {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name} + + ))} + + + + )} + + {loading ? ( +
+ +
+ ) : ( + + + {numPages === 0 && ( + {t("viewer.noPagesToDisplay", "No pages to display.")} + )} + {dualPage + ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => ( + + + {i * 2 + 1 < numPages && ( + + )} + + )) + : Array.from({ length: numPages }).map((_, idx) => ( + + ))} + + {/* Navigation bar overlays the scroll area */} +
+ + + + { + const page = Number(value); + if (!isNaN(page) && page >= 1 && page <= numPages) { + scrollToPage(page); + } + }} + min={1} + max={numPages} + hideControls + styles={{ + input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16}, + }} + /> + + / {numPages} + + + + + + + {Math.round(zoom * 100)}% + + + +
+
+ )} + + )} + +
+ ); +}; + +export default Viewer; diff --git a/frontend/src/components/viewer/PdfViewerToolbar.tsx b/frontend/src/components/viewer/PdfViewerToolbar.tsx new file mode 100644 index 000000000..1c24d752b --- /dev/null +++ b/frontend/src/components/viewer/PdfViewerToolbar.tsx @@ -0,0 +1,264 @@ +import React, { useState, useEffect } from 'react'; +import { Button, Paper, Group, NumberInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import FirstPageIcon from '@mui/icons-material/FirstPage'; +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; +import LastPageIcon from '@mui/icons-material/LastPage'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ViewWeekIcon from '@mui/icons-material/ViewWeek'; + +interface PdfViewerToolbarProps { + // Page navigation props (placeholders for now) + currentPage?: number; + totalPages?: number; + onPageChange?: (page: number) => void; + + // Dual page toggle (placeholder for now) + dualPage?: boolean; + onDualPageToggle?: () => void; + + // Zoom controls (will connect to window.embedPdfZoom) + currentZoom?: number; +} + +export function PdfViewerToolbar({ + currentPage = 1, + totalPages = 1, + onPageChange, + dualPage = false, + onDualPageToggle, + currentZoom = 100, +}: PdfViewerToolbarProps) { + const { t } = useTranslation(); + const [pageInput, setPageInput] = useState(currentPage); + const [dynamicZoom, setDynamicZoom] = useState(currentZoom); + const [dynamicPage, setDynamicPage] = useState(currentPage); + const [dynamicTotalPages, setDynamicTotalPages] = useState(totalPages); + + // Update zoom and scroll state from EmbedPDF APIs + useEffect(() => { + const updateState = () => { + // Update zoom + if ((window as any).embedPdfZoom) { + const zoomPercent = (window as any).embedPdfZoom.zoomPercent || currentZoom; + setDynamicZoom(zoomPercent); + } + + // Update scroll/page state + if ((window as any).embedPdfScroll) { + const currentPageNum = (window as any).embedPdfScroll.currentPage || currentPage; + const totalPagesNum = (window as any).embedPdfScroll.totalPages || totalPages; + setDynamicPage(currentPageNum); + setDynamicTotalPages(totalPagesNum); + setPageInput(currentPageNum); + } + }; + + // Update state immediately + updateState(); + + // Set up periodic updates to keep state in sync + const interval = setInterval(updateState, 200); + + return () => clearInterval(interval); + }, [currentZoom, currentPage, totalPages]); + + const handleZoomOut = () => { + if ((window as any).embedPdfZoom) { + (window as any).embedPdfZoom.zoomOut(); + } + }; + + const handleZoomIn = () => { + if ((window as any).embedPdfZoom) { + (window as any).embedPdfZoom.zoomIn(); + } + }; + + const handlePageNavigation = (page: number) => { + if ((window as any).embedPdfScroll) { + (window as any).embedPdfScroll.scrollToPage(page); + } else if (onPageChange) { + onPageChange(page); + } + setPageInput(page); + }; + + const handleFirstPage = () => { + if ((window as any).embedPdfScroll) { + (window as any).embedPdfScroll.scrollToFirstPage(); + } else { + handlePageNavigation(1); + } + }; + + const handlePreviousPage = () => { + if ((window as any).embedPdfScroll) { + (window as any).embedPdfScroll.scrollToPreviousPage(); + } else { + handlePageNavigation(Math.max(1, dynamicPage - 1)); + } + }; + + const handleNextPage = () => { + if ((window as any).embedPdfScroll) { + (window as any).embedPdfScroll.scrollToNextPage(); + } else { + handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1)); + } + }; + + const handleLastPage = () => { + if ((window as any).embedPdfScroll) { + (window as any).embedPdfScroll.scrollToLastPage(); + } else { + handlePageNavigation(dynamicTotalPages); + } + }; + + return ( + + {/* First Page Button */} + + + {/* Previous Page Button */} + + + {/* Page Input */} + { + const page = Number(value); + setPageInput(page); + if (!isNaN(page) && page >= 1 && page <= dynamicTotalPages) { + handlePageNavigation(page); + } + }} + min={1} + max={dynamicTotalPages} + hideControls + styles={{ + input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 }, + }} + /> + + + / {dynamicTotalPages} + + + {/* Next Page Button */} + + + {/* Last Page Button */} + + + {/* Dual Page Toggle */} + + + {/* Zoom Controls */} + + + + {dynamicZoom}% + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/ScrollControlsExporter.tsx b/frontend/src/components/viewer/ScrollControlsExporter.tsx new file mode 100644 index 000000000..e2aa8d214 --- /dev/null +++ b/frontend/src/components/viewer/ScrollControlsExporter.tsx @@ -0,0 +1,27 @@ +import { useEffect } from 'react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; + +/** + * Component that runs inside EmbedPDF context and exports scroll controls globally + */ +export function ScrollControlsExporter() { + const { provides: scroll, state: scrollState } = useScroll(); + + useEffect(() => { + if (scroll && scrollState) { + // Export scroll controls to global window for toolbar access + (window as any).embedPdfScroll = { + scrollToPage: (page: number) => scroll.scrollToPage({ pageNumber: page }), + scrollToNextPage: () => scroll.scrollToNextPage(), + scrollToPreviousPage: () => scroll.scrollToPreviousPage(), + scrollToFirstPage: () => scroll.scrollToPage({ pageNumber: 1 }), + scrollToLastPage: () => scroll.scrollToPage({ pageNumber: scrollState.totalPages }), + currentPage: scrollState.currentPage, + totalPages: scrollState.totalPages, + }; + + } + }, [scroll, scrollState]); + + return null; // This component doesn't render anything +} \ No newline at end of file diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index dfcd5dc7d..49d3a5854 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -1,687 +1,16 @@ -import React, { useEffect, useState, useRef, useCallback } from "react"; -import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { pdfWorkerManager } from "../../services/pdfWorkerManager"; -import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; -import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos"; -import FirstPageIcon from "@mui/icons-material/FirstPage"; -import LastPageIcon from "@mui/icons-material/LastPage"; -import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book) -import DescriptionIcon from "@mui/icons-material/Description"; // for single page -import CloseIcon from "@mui/icons-material/Close"; -import { fileStorage } from "../../services/fileStorage"; -import SkeletonLoader from '../shared/SkeletonLoader'; -import { useFileState } from "../../contexts/FileContext"; -import { useFileWithUrl } from "../../hooks/useFileWithUrl"; -import { isFileObject } from "../../types/fileContext"; -import { FileId } from "../../types/file"; - - -// Lazy loading page image component -interface LazyPageImageProps { - pageIndex: number; - zoom: number; - theme: any; - isFirst: boolean; - renderPage: (pageIndex: number) => Promise; - pageImages: (string | null)[]; - setPageRef: (index: number, ref: HTMLImageElement | null) => void; -} - -const LazyPageImage = ({ - pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef -}: LazyPageImageProps) => { - const [isVisible, setIsVisible] = useState(false); - const [imageUrl, setImageUrl] = useState(null); - const imgRef = useRef(null); - - useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting && !imageUrl) { - setIsVisible(true); - } - }); - }, - { - rootMargin: '200px', // Start loading 200px before visible - threshold: 0.1 - } - ); - - if (imgRef.current) { - observer.observe(imgRef.current); - } - - return () => observer.disconnect(); - }, [imageUrl]); - - // Update local state when pageImages changes (from preloading) - useEffect(() => { - if (pageImages[pageIndex]) { - setImageUrl(pageImages[pageIndex]); - } - }, [pageImages, pageIndex]); - - useEffect(() => { - if (isVisible && !imageUrl) { - renderPage(pageIndex).then((url) => { - if (url) setImageUrl(url); - }); - } - }, [isVisible, imageUrl, pageIndex, renderPage]); - - useEffect(() => { - if (imgRef.current) { - setPageRef(pageIndex, imgRef.current); - } - }, [pageIndex, setPageRef]); - - if (imageUrl) { - return ( - {`Page - ); - } - - // Placeholder while loading - return ( -
- {isVisible ? ( -
-
- Loading page {pageIndex + 1}... -
- ) : ( - Page {pageIndex + 1} - )} -
- ); -}; +import React from 'react'; +import EmbedPdfViewer from './EmbedPdfViewer'; export interface ViewerProps { sidebarsVisible: boolean; setSidebarsVisible: (v: boolean) => void; onClose?: () => void; - previewFile: File | null; // For preview mode - bypasses context + previewFile?: File | null; } -const Viewer = ({ - onClose, - previewFile, -}: ViewerProps) => { - const { t } = useTranslation(); - const theme = useMantineTheme(); - - // Get current file from FileContext - const { selectors } = useFileState(); - - const activeFiles = selectors.getFiles(); - - // Tab management for multiple files - const [activeTab, setActiveTab] = useState("0"); - - // Reset PDF state when switching tabs - const handleTabChange = (newTab: string) => { - setActiveTab(newTab); - setNumPages(0); - setPageImages([]); - setCurrentPage(null); - setLoading(true); - }; - const [numPages, setNumPages] = useState(0); - const [pageImages, setPageImages] = useState([]); - const [loading, setLoading] = useState(false); - const [currentPage, setCurrentPage] = useState(null); - const [dualPage, setDualPage] = useState(false); - const [zoom, setZoom] = useState(1); // 1 = 100% - const pageRefs = useRef<(HTMLImageElement | null)[]>([]); - - // Memoize setPageRef to prevent infinite re-renders - const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => { - pageRefs.current[index] = ref; - }, []); - - // Get files with URLs for tabs - we'll need to create these individually - const file0WithUrl = useFileWithUrl(activeFiles[0]); - const file1WithUrl = useFileWithUrl(activeFiles[1]); - const file2WithUrl = useFileWithUrl(activeFiles[2]); - const file3WithUrl = useFileWithUrl(activeFiles[3]); - const file4WithUrl = useFileWithUrl(activeFiles[4]); - - const filesWithUrls = React.useMemo(() => { - return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl] - .slice(0, activeFiles.length) - .filter(Boolean); - }, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]); - - // Use preview file if available, otherwise use active tab file - const effectiveFile = React.useMemo(() => { - if (previewFile) { - // Validate the preview file - if (!isFileObject(previewFile)) { - return null; - } - - if (previewFile.size === 0) { - return null; - } - - return { file: previewFile, url: null }; - } else { - // Use the file from the active tab - const tabIndex = parseInt(activeTab); - return filesWithUrls[tabIndex] || null; - } - }, [previewFile, filesWithUrls, activeTab]); - - const scrollAreaRef = useRef(null); - const pdfDocRef = useRef(null); - const renderingPagesRef = useRef>(new Set()); - const currentArrayBufferRef = useRef(null); - const preloadingRef = useRef(false); - - // Function to render a specific page on-demand - const renderPage = async (pageIndex: number): Promise => { - if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) { - return null; - } - - const pageNum = pageIndex + 1; - if (pageImages[pageIndex]) { - return pageImages[pageIndex]; // Already rendered - } - - renderingPagesRef.current.add(pageIndex); - - try { - const page = await pdfDocRef.current.getPage(pageNum); - const viewport = page.getViewport({ scale: 1.2 }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const ctx = canvas.getContext("2d"); - - if (ctx) { - await page.render({ canvasContext: ctx, viewport }).promise; - const dataUrl = canvas.toDataURL(); - - // Update the pageImages array - setPageImages(prev => { - const newImages = [...prev]; - newImages[pageIndex] = dataUrl; - return newImages; - }); - - renderingPagesRef.current.delete(pageIndex); - return dataUrl; - } - } catch (error) { - console.error(`Failed to render page ${pageNum}:`, error); - } - - renderingPagesRef.current.delete(pageIndex); - return null; - }; - - // Progressive preloading function - const startProgressivePreload = async () => { - if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return; - - preloadingRef.current = true; - - // Start with first few pages for immediate viewing - const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages - - // Render priority pages first - for (const pageIndex of priorityPages) { - if (pageIndex < numPages && !pageImages[pageIndex]) { - await renderPage(pageIndex); - // Small delay to allow UI to update - await new Promise(resolve => setTimeout(resolve, 50)); - } - } - - // Then render remaining pages in background - for (let pageIndex = 5; pageIndex < numPages; pageIndex++) { - if (!pageImages[pageIndex]) { - await renderPage(pageIndex); - // Longer delay for background loading to not block UI - await new Promise(resolve => setTimeout(resolve, 100)); - } - } - - preloadingRef.current = false; - }; - - // Initialize current page when PDF loads - useEffect(() => { - if (numPages > 0 && !currentPage) { - setCurrentPage(1); - } - }, [numPages, currentPage]); - - // Function to scroll to a specific page - const scrollToPage = (pageNumber: number) => { - const el = pageRefs.current[pageNumber - 1]; - const scrollArea = scrollAreaRef.current; - - if (el && scrollArea) { - const scrollAreaRect = scrollArea.getBoundingClientRect(); - const elRect = el.getBoundingClientRect(); - const currentScrollTop = scrollArea.scrollTop; - - // Position page near top of viewport with some padding - const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20; - - scrollArea.scrollTo({ - top: targetScrollTop, - behavior: "smooth" - }); - } - }; - - // Throttled scroll handler to prevent jerky updates - const handleScrollThrottled = useCallback(() => { - const scrollArea = scrollAreaRef.current; - if (!scrollArea || !pageRefs.current.length) return; - - const areaRect = scrollArea.getBoundingClientRect(); - const viewportCenter = areaRect.top + areaRect.height / 2; - let closestIdx = 0; - let minDist = Infinity; - - pageRefs.current.forEach((img, idx) => { - if (img) { - const imgRect = img.getBoundingClientRect(); - const imgCenter = imgRect.top + imgRect.height / 2; - const dist = Math.abs(imgCenter - viewportCenter); - if (dist < minDist) { - minDist = dist; - closestIdx = idx; - } - } - }); - - // Update page number display only if changed - if (currentPage !== closestIdx + 1) { - setCurrentPage(closestIdx + 1); - } - }, [currentPage]); - - // Throttle scroll events to reduce jerkiness - const handleScroll = useCallback(() => { - if (window.requestAnimationFrame) { - window.requestAnimationFrame(handleScrollThrottled); - } else { - handleScrollThrottled(); - } - }, [handleScrollThrottled]); - - useEffect(() => { - let cancelled = false; - async function loadPdfInfo() { - if (!effectiveFile) { - setNumPages(0); - setPageImages([]); - return; - } - setLoading(true); - try { - let pdfData; - - // For preview files, use ArrayBuffer directly to avoid blob URL issues - if (previewFile && effectiveFile.file === previewFile) { - const arrayBuffer = await previewFile.arrayBuffer(); - pdfData = { data: arrayBuffer }; - } - // Handle special IndexedDB URLs for large files - else if (effectiveFile.url?.startsWith('indexeddb:')) { - const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */; - - // Get data directly from IndexedDB - const arrayBuffer = await fileStorage.getFileData(fileId); - if (!arrayBuffer) { - throw new Error('File not found in IndexedDB - may have been purged by browser'); - } - - // Store reference for cleanup - currentArrayBufferRef.current = arrayBuffer; - pdfData = { data: arrayBuffer }; - } else if (effectiveFile.url) { - // Standard blob URL or regular URL - pdfData = effectiveFile.url; - } else { - throw new Error('No valid PDF source available'); - } - - const pdf = await pdfWorkerManager.createDocument(pdfData); - pdfDocRef.current = pdf; - setNumPages(pdf.numPages); - if (!cancelled) { - setPageImages(new Array(pdf.numPages).fill(null)); - // Start progressive preloading after a short delay - setTimeout(() => startProgressivePreload(), 100); - } - } catch { - if (!cancelled) { - setPageImages([]); - setNumPages(0); - } - } - if (!cancelled) setLoading(false); - } - loadPdfInfo(); - return () => { - cancelled = true; - // Stop any ongoing preloading - preloadingRef.current = false; - // Cleanup PDF document using worker manager - if (pdfDocRef.current) { - pdfWorkerManager.destroyDocument(pdfDocRef.current); - pdfDocRef.current = null; - } - // Cleanup ArrayBuffer reference to help garbage collection - currentArrayBufferRef.current = null; - }; - }, [effectiveFile, previewFile]); - - useEffect(() => { - const viewport = scrollAreaRef.current; - if (!viewport) return; - const handler = () => { - handleScroll(); - }; - viewport.addEventListener("scroll", handler); - return () => viewport.removeEventListener("scroll", handler); - }, [pageImages]); - - return ( - - {/* Close Button - Only show in preview mode */} - {onClose && previewFile && ( - - - - )} - - {!effectiveFile ? ( -
- Error: No file provided to viewer -
- ) : ( - <> - {/* Tabs for multiple files */} - {activeFiles.length > 1 && !previewFile && ( - - handleTabChange(value || "0")}> - - {activeFiles.map((file: any, index: number) => ( - - {file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name} - - ))} - - - - )} - - {loading ? ( -
- -
- ) : ( - - - {numPages === 0 && ( - {t("viewer.noPagesToDisplay", "No pages to display.")} - )} - {dualPage - ? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => ( - - - {i * 2 + 1 < numPages && ( - - )} - - )) - : Array.from({ length: numPages }).map((_, idx) => ( - - ))} - - {/* Navigation bar overlays the scroll area */} -
- - - - { - const page = Number(value); - if (!isNaN(page) && page >= 1 && page <= numPages) { - scrollToPage(page); - } - }} - min={1} - max={numPages} - hideControls - styles={{ - input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16}, - }} - /> - - / {numPages} - - - - - - - {Math.round(zoom * 100)}% - - - -
-
- )} - - )} - -
- ); +const Viewer = (props: ViewerProps) => { + // Default to EmbedPDF viewer + return ; }; -export default Viewer; +export default Viewer; \ No newline at end of file diff --git a/frontend/src/components/viewer/ZoomControlsExporter.tsx b/frontend/src/components/viewer/ZoomControlsExporter.tsx new file mode 100644 index 000000000..8ca61e7c2 --- /dev/null +++ b/frontend/src/components/viewer/ZoomControlsExporter.tsx @@ -0,0 +1,26 @@ +import { useEffect } from 'react'; +import { useZoom } from '@embedpdf/plugin-zoom/react'; + +/** + * Component that runs inside EmbedPDF context and exports zoom controls globally + */ +export function ZoomControlsExporter() { + const { provides: zoom, state: zoomState } = useZoom(); + + useEffect(() => { + if (zoom) { + // Export zoom controls to global window for right rail access + (window as any).embedPdfZoom = { + zoomIn: () => zoom.zoomIn(), + zoomOut: () => zoom.zoomOut(), + toggleMarqueeZoom: () => zoom.toggleMarqueeZoom(), + requestZoom: (level: any) => zoom.requestZoom(level), + currentZoom: zoomState?.currentZoomLevel || 1, + zoomPercent: Math.round((zoomState?.currentZoomLevel || 1) * 100), + }; + + } + }, [zoom, zoomState]); + + return null; // This component doesn't render anything +} \ No newline at end of file diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts index 5511059a8..87f0613ab 100644 --- a/frontend/src/global.d.ts +++ b/frontend/src/global.d.ts @@ -15,4 +15,8 @@ declare module '../assets/material-symbols-icons.json' { height?: number; }; export default value; -} \ No newline at end of file +} + +// TODO: Add proper EmbedPDF types for local submodule integration + +export {}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6886183a1..38151b5b3 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,9 +28,11 @@ /* Modules */ "module": "esnext", /* Specify what module code is generated. */ // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "moduleResolution": "bundler", /* Specify how TypeScript looks up a file from a given module specifier. */ + "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "@framework": ["vendor/embed-pdf-viewer/packages/core/src/react/adapter.ts"] + }, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */