diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f16d1f9cb..9c0c807a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,21 @@ "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-pan": "^1.1.1", + "@embedpdf/plugin-render": "^1.1.1", + "@embedpdf/plugin-rotate": "^1.1.1", + "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-search": "^1.1.1", + "@embedpdf/plugin-selection": "^1.1.1", + "@embedpdf/plugin-spread": "^1.1.1", + "@embedpdf/plugin-thumbnail": "^1.1.1", + "@embedpdf/plugin-tiling": "^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", @@ -597,6 +612,258 @@ "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-pan": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.1.1.tgz", + "integrity": "sha512-38ZUJ+AYGXaL1sMVRghbJWdqqcfu7uO216yU/kTcCYs4hKgReW6Nw4XGGKo2beWIWfSqcSiD/3G4Y3RSBNsaYg==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-interaction-manager": "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-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-rotate": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.1.1.tgz", + "integrity": "sha512-xxM62dv4TAoTEfCNxyC0UbGryT3ucAH4txQAoab7tfvnfqbAIxTonH1PzQMsBmzN0WETCGjUBm1Ympb95cDx2Q==", + "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-search": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.1.1.tgz", + "integrity": "sha512-h3gc9HVK8HCOD2MGCikhbkpwWjAYnu/KQ1ZxT2mb5AvbcIog+LQhVElSyV80deeFStqqLn/epz27b2TqQSz0EA==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-loader": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-selection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.1.1.tgz", + "integrity": "sha512-VHiLcXvM0a/bkWF2yAc7ltEIrwp1HHahtd5pMO0egODTqGKFzX6QhQ4PkL35KlVyDxMpowHj4pjgYYKPTqla0A==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-interaction-manager": "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-spread": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.1.1.tgz", + "integrity": "sha512-UyzPKx96f96qKqtGKLv0wT26NbCUi2D+pBdk+/CUQFbe7PJfNIIpECPp0bZJAwQ6MwBWnIwCVT1OwK+egqji5A==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-loader": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0", + "vue": ">=3.2.0" + } + }, + "node_modules/@embedpdf/plugin-thumbnail": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.1.1.tgz", + "integrity": "sha512-xH5p2XgxkDgAbZKJSGAYcDNPlnKEsBHm0EH+YCxOI0T5zX/a9j4uFj7mbbs9IcQ9vEFuIi6lGvY3DUWN4TeDpQ==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-render": "1.1.1", + "preact": "^10.26.4", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@embedpdf/plugin-tiling": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.1.1.tgz", + "integrity": "sha512-KxpouPRsDZ1rnxsxnusywlI92L0l9+WilMAa+RzLDDuesOrtDWL2ry/MAhT0A/lhZP6NdPZNjlqqZGBWcFZhHA==", + "dependencies": { + "@embedpdf/models": "1.1.1" + }, + "peerDependencies": { + "@embedpdf/core": "1.1.1", + "@embedpdf/plugin-render": "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/@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", @@ -1620,9 +1887,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": { @@ -3421,14 +3688,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" @@ -3438,7 +3704,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" @@ -3451,34 +3716,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" } @@ -3487,25 +3749,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": { @@ -5668,6 +5975,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", @@ -6964,12 +7280,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": { @@ -11304,6 +11620,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 214830cd2..f40b8946f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,21 @@ "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-pan": "^1.1.1", + "@embedpdf/plugin-render": "^1.1.1", + "@embedpdf/plugin-rotate": "^1.1.1", + "@embedpdf/plugin-scroll": "^1.1.1", + "@embedpdf/plugin-search": "^1.1.1", + "@embedpdf/plugin-selection": "^1.1.1", + "@embedpdf/plugin-spread": "^1.1.1", + "@embedpdf/plugin-thumbnail": "^1.1.1", + "@embedpdf/plugin-tiling": "^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/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 9d53d472a..cc5353e4d 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2578,7 +2578,12 @@ "downloadSelected": "Download Selected Files", "downloadAll": "Download All", "toggleTheme": "Toggle Theme", - "language": "Language" + "language": "Language", + "search": "Search PDF", + "panMode": "Pan Mode", + "rotateLeft": "Rotate Left", + "rotateRight": "Rotate Right", + "toggleSidebar": "Toggle Sidebar" }, "toolPicker": { "searchPlaceholder": "Search tools...", @@ -2944,5 +2949,15 @@ "processImages": "Process Images", "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } + }, + "viewer": { + "firstPage": "First Page", + "lastPage": "Last Page", + "previousPage": "Previous Page", + "nextPage": "Next Page", + "zoomIn": "Zoom In", + "zoomOut": "Zoom Out", + "singlePageView": "Single Page View", + "dualPageView": "Dual Page View" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c3cbf3e89..767fa918a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,7 @@ import "./styles/tailwind.css"; import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; +import { ViewerProvider } from "./contexts/ViewerContext"; // Import file ID debugging helpers (development only) import "./utils/fileIdSafety"; @@ -43,9 +44,11 @@ export default function App() { - - - + + + + + diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index bfa33bc03..f75072b42 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -157,7 +157,6 @@ export default function Workbench() { className="flex-1 min-h-0 relative z-10 workbench-scrollable " style={{ transition: 'opacity 0.15s ease-in-out', - marginTop: '1rem', }} > {renderMainContent()} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 462b29d7e..e5223d42b 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -7,14 +7,23 @@ 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'; import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; +import { SearchInterface } from '../viewer/SearchInterface'; +import { ViewerContext } from '../../contexts/ViewerContext'; + import { parseSelection } from '../../utils/bulkselection/parseSelection'; + export default function RightRail() { const { t } = useTranslation(); + const [isPanning, setIsPanning] = useState(false); + + // Viewer context for PDF controls - safely handle when not available + const viewerContext = React.useContext(ViewerContext); const { toggleTheme } = useRainbowThemeContext(); const { buttons, actions } = useRightRail(); const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]); @@ -168,6 +177,105 @@ export default function RightRail() { )} + {/* Group: PDF Viewer Controls - visible only in viewer mode */} +
+
+ {/* Search */} + + + +
+ + + +
+
+ +
+ {}} + /> +
+
+
+
+ + + {/* Pan Mode */} + + { + viewerContext?.panActions.togglePan(); + setIsPanning(!isPanning); + }} + disabled={currentView !== 'viewer'} + > + + + + + {/* Rotate Left */} + + { + viewerContext?.rotationActions.rotateBackward(); + }} + disabled={currentView !== 'viewer'} + > + + + + + {/* Rotate Right */} + + { + viewerContext?.rotationActions.rotateForward(); + }} + disabled={currentView !== 'viewer'} + > + + + + + {/* Sidebar Toggle */} + + { + viewerContext?.toggleThumbnailSidebar(); + }} + disabled={currentView !== 'viewer'} + > + + + +
+ +
+ {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
+
; + }>; + activeResultIndex?: number; +} + +export function CustomSearchLayer({ + pageIndex, + scale, + highlightColor = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.BACKGROUND, + activeHighlightColor = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.ACTIVE_BACKGROUND, + opacity = SEARCH_CONSTANTS.HIGHLIGHT_COLORS.OPACITY, + padding = SEARCH_CONSTANTS.UI.HIGHLIGHT_PADDING, + borderRadius = 4 +}: SearchLayerProps) { + const { provides: searchProvides } = useSearch(); + const { scrollActions } = useViewer(); + const [searchResultState, setSearchResultState] = useState(null); + + // Subscribe to search result state changes + useEffect(() => { + if (!searchProvides) { + return; + } + + const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => { + // Auto-scroll to active search result + if (state?.results && state.activeResultIndex !== undefined && state.activeResultIndex >= 0) { + const activeResult = state.results[state.activeResultIndex]; + if (activeResult) { + const pageNumber = activeResult.pageIndex + 1; // Convert to 1-based page number + scrollActions.scrollToPage(pageNumber); + } + } + + setSearchResultState(state); + }); + + + return unsubscribe; + }, [searchProvides, pageIndex]); + + // Filter results for current page while preserving original indices + const pageResults = useMemo(() => { + if (!searchResultState?.results) { + return []; + } + + const filtered = searchResultState.results + .map((result, originalIndex) => ({ result, originalIndex })) + .filter(({ result }) => result.pageIndex === pageIndex); + + return filtered; + }, [searchResultState, pageIndex]); + + if (!pageResults.length) { + return null; + } + + return ( +
+ {pageResults.map(({ result, originalIndex }, idx) => ( +
+ {result.rects.map((rect, rectIdx) => ( +
+ ))} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx new file mode 100644 index 000000000..ac61bca5b --- /dev/null +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { Box, Center, Text, ActionIcon } from '@mantine/core'; +import { useMantineTheme, useMantineColorScheme } from '@mantine/core'; +import CloseIcon from '@mui/icons-material/Close'; + +import { useFileState } from "../../contexts/FileContext"; +import { useFileWithUrl } from "../../hooks/useFileWithUrl"; +import { useViewer } from "../../contexts/ViewerContext"; +import { LocalEmbedPDF } from './LocalEmbedPDF'; +import { PdfViewerToolbar } from './PdfViewerToolbar'; +import { ThumbnailSidebar } from './ThumbnailSidebar'; + +export interface EmbedPdfViewerProps { + sidebarsVisible: boolean; + setSidebarsVisible: (v: boolean) => void; + onClose?: () => void; + previewFile?: File | null; +} + +const EmbedPdfViewerContent = ({ + sidebarsVisible: _sidebarsVisible, + setSidebarsVisible: _setSidebarsVisible, + onClose, + previewFile, +}: EmbedPdfViewerProps) => { + const theme = useMantineTheme(); + const { colorScheme: _colorScheme } = useMantineColorScheme(); + const viewerRef = React.useRef(null); + const [isViewerHovered, setIsViewerHovered] = React.useState(false); + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer(); + + const scrollState = getScrollState(); + const zoomState = getZoomState(); + const spreadState = getSpreadState(); + + + // 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]); + + // Handle scroll wheel zoom with accumulator for smooth trackpad pinch + React.useEffect(() => { + let accumulator = 0; + + const handleWheel = (event: WheelEvent) => { + // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + event.stopPropagation(); + + accumulator += event.deltaY; + const threshold = 10; + + if (accumulator <= -threshold) { + // Accumulated scroll up - zoom in + zoomActions.zoomIn(); + accumulator = 0; + } else if (accumulator >= threshold) { + // Accumulated scroll down - zoom out + zoomActions.zoomOut(); + accumulator = 0; + } + } + }; + + const viewerElement = viewerRef.current; + if (viewerElement) { + viewerElement.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + viewerElement.removeEventListener('wheel', handleWheel); + }; + } + }, [zoomActions]); + + // Handle keyboard zoom shortcuts + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!isViewerHovered) return; + + // Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed + if (event.ctrlKey || event.metaKey) { + if (event.key === '=' || event.key === '+') { + // Ctrl+= or Ctrl++ for zoom in + event.preventDefault(); + zoomActions.zoomIn(); + } else if (event.key === '-' || event.key === '_') { + // Ctrl+- for zoom out + event.preventDefault(); + zoomActions.zoomOut(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isViewerHovered]); + + + return ( + setIsViewerHovered(true)} + onMouseLeave={() => setIsViewerHovered(false)} + style={{ + position: 'relative', + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + contain: 'layout style paint' + }}> + {/* 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 */} + + + + + )} + + {/* Bottom Toolbar Overlay */} + {effectiveFile && ( +
+
+ { + // Page navigation handled by scrollActions + console.log('Navigate to page:', page); + }} + dualPage={spreadState.isDualPage} + onDualPageToggle={() => { + spreadActions.toggleSpreadMode(); + }} + currentZoom={zoomState.zoomPercent} + /> +
+
+ )} + + + {/* Thumbnail Sidebar */} + +
+ ); +}; + +const EmbedPdfViewer = (props: EmbedPdfViewerProps) => { + return ; +}; + +export default EmbedPdfViewer; diff --git a/frontend/src/components/viewer/LocalEmbedPDF.tsx b/frontend/src/components/viewer/LocalEmbedPDF.tsx new file mode 100644 index 000000000..52c2ff2fd --- /dev/null +++ b/frontend/src/components/viewer/LocalEmbedPDF.tsx @@ -0,0 +1,226 @@ +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 { RenderPluginPackage } from '@embedpdf/plugin-render/react'; +import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react'; +import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react'; +import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; +import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; +import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; +import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react'; +import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; +import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; +import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; +import { Rotation } from '@embedpdf/models'; +import { CustomSearchLayer } from './CustomSearchLayer'; +import { ZoomAPIBridge } from './ZoomAPIBridge'; +import ToolLoadingFallback from '../tools/ToolLoadingFallback'; +import { Center, Stack, Text } from '@mantine/core'; +import { ScrollAPIBridge } from './ScrollAPIBridge'; +import { SelectionAPIBridge } from './SelectionAPIBridge'; +import { PanAPIBridge } from './PanAPIBridge'; +import { SpreadAPIBridge } from './SpreadAPIBridge'; +import { SearchAPIBridge } from './SearchAPIBridge'; +import { ThumbnailAPIBridge } from './ThumbnailAPIBridge'; +import { RotateAPIBridge } from './RotateAPIBridge'; + +interface LocalEmbedPDFProps { + file?: File | Blob; + url?: string | null; +} + +export function LocalEmbedPDF({ file, url }: LocalEmbedPDFProps) { + const [pdfUrl, setPdfUrl] = useState(null); + + // 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 and selection features) + createPluginRegistration(InteractionManagerPluginPackage), + + // Register selection plugin (depends on InteractionManager) + createPluginRegistration(SelectionPluginPackage), + + // Register pan plugin (depends on Viewport, InteractionManager) + createPluginRegistration(PanPluginPackage, { + defaultMode: 'mobile', // Try mobile mode which might be more permissive + }), + + // Register zoom plugin with configuration + createPluginRegistration(ZoomPluginPackage, { + defaultZoomLevel: 1.4, // Start at 140% zoom for better readability + minZoom: 0.2, + maxZoom: 3.0, + }), + + // Register tiling plugin (depends on Render, Scroll, Viewport) + createPluginRegistration(TilingPluginPackage, { + tileSize: 768, + overlapPx: 5, + extraRings: 1, + }), + + // Register spread plugin for dual page layout + createPluginRegistration(SpreadPluginPackage, { + defaultSpreadMode: SpreadMode.None, // Start with single page view + }), + + // Register search plugin for text search + createPluginRegistration(SearchPluginPackage), + + // Register thumbnail plugin for page thumbnails + createPluginRegistration(ThumbnailPluginPackage), + + // Register rotate plugin + createPluginRegistration(RotatePluginPackage, { + defaultRotation: Rotation.Degree0, // Start with no rotation + }), + ]; + }, [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 ; + } + + if (error) { + return ( +
+ +
+ + Error loading PDF engine: {error.message} + +
+
+ ); + } + + // Wrap your UI with the provider + return ( +
+ + + + + + + + + + + + ( + + +
e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onDragOver={(e) => e.preventDefault()} + > + {/* High-resolution tile layer */} + + + {/* Search highlight layer */} + + + {/* Selection layer for text interaction */} + +
+
+
+ )} + /> +
+
+
+
+ ); +} diff --git a/frontend/src/components/viewer/PanAPIBridge.tsx b/frontend/src/components/viewer/PanAPIBridge.tsx new file mode 100644 index 000000000..26e8c73ee --- /dev/null +++ b/frontend/src/components/viewer/PanAPIBridge.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { usePan } from '@embedpdf/plugin-pan/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and updates pan state in ViewerContext + */ +export function PanAPIBridge() { + const { provides: pan, isPanning } = usePan(); + const { registerBridge } = useViewer(); + + // Store state locally + const [_localState, setLocalState] = useState({ + isPanning: false + }); + + useEffect(() => { + if (pan) { + // Update local state + const newState = { + isPanning + }; + setLocalState(newState); + + // Register this bridge with ViewerContext + registerBridge('pan', { + state: newState, + api: { + enable: () => { + pan.enablePan(); + }, + disable: () => { + pan.disablePan(); + }, + toggle: () => { + pan.togglePan(); + }, + makePanDefault: () => pan.makePanDefault(), + } + }); + } + }, [pan, isPanning]); + + return null; +} diff --git a/frontend/src/components/viewer/PdfViewerToolbar.tsx b/frontend/src/components/viewer/PdfViewerToolbar.tsx new file mode 100644 index 000000000..f4e64d5a5 --- /dev/null +++ b/frontend/src/components/viewer/PdfViewerToolbar.tsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect } from 'react'; +import { Button, Paper, Group, NumberInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useViewer } from '../../contexts/ViewerContext'; +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 (connected via ViewerContext) + currentZoom?: number; +} + +export function PdfViewerToolbar({ + currentPage = 1, + totalPages: _totalPages = 1, + onPageChange, + dualPage = false, + onDualPageToggle, + currentZoom: _currentZoom = 100, +}: PdfViewerToolbarProps) { + const { t } = useTranslation(); + const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer(); + + const scrollState = getScrollState(); + const zoomState = getZoomState(); + const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage); + const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140); + + // Register for immediate scroll updates and sync with actual scroll state + useEffect(() => { + registerImmediateScrollUpdate((currentPage, _totalPages) => { + setPageInput(currentPage); + }); + setPageInput(scrollState.currentPage); + }, [registerImmediateScrollUpdate]); + + // Register for immediate zoom updates and sync with actual zoom state + useEffect(() => { + registerImmediateZoomUpdate(setDisplayZoomPercent); + setDisplayZoomPercent(zoomState.zoomPercent || 140); + }, [zoomState.zoomPercent, registerImmediateZoomUpdate]); + + const handleZoomOut = () => { + zoomActions.zoomOut(); + }; + + const handleZoomIn = () => { + zoomActions.zoomIn(); + }; + + const handlePageNavigation = (page: number) => { + scrollActions.scrollToPage(page); + if (onPageChange) { + onPageChange(page); + } + setPageInput(page); + }; + + const handleFirstPage = () => { + scrollActions.scrollToFirstPage(); + }; + + const handlePreviousPage = () => { + scrollActions.scrollToPreviousPage(); + }; + + const handleNextPage = () => { + scrollActions.scrollToNextPage(); + }; + + const handleLastPage = () => { + scrollActions.scrollToLastPage(); + }; + + return ( + + {/* First Page Button */} + + + {/* Previous Page Button */} + + + {/* Page Input */} + { + const page = Number(value); + setPageInput(page); + if (!isNaN(page) && page >= 1 && page <= scrollState.totalPages) { + handlePageNavigation(page); + } + }} + min={1} + max={scrollState.totalPages} + hideControls + styles={{ + input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 }, + }} + /> + + + / {scrollState.totalPages} + + + {/* Next Page Button */} + + + {/* Last Page Button */} + + + {/* Dual Page Toggle */} + + + {/* Zoom Controls */} + + + + {displayZoomPercent}% + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/RotateAPIBridge.tsx b/frontend/src/components/viewer/RotateAPIBridge.tsx new file mode 100644 index 000000000..30aa9b304 --- /dev/null +++ b/frontend/src/components/viewer/RotateAPIBridge.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; +import { useRotate } from '@embedpdf/plugin-rotate/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and updates rotation state in ViewerContext + */ +export function RotateAPIBridge() { + const { provides: rotate, rotation } = useRotate(); + const { registerBridge } = useViewer(); + + // Store state locally + const [_localState, setLocalState] = useState({ + rotation: 0 + }); + + useEffect(() => { + if (rotate) { + // Update local state + const newState = { + rotation + }; + setLocalState(newState); + + // Register this bridge with ViewerContext + registerBridge('rotation', { + state: newState, + api: { + rotateForward: () => rotate.rotateForward(), + rotateBackward: () => rotate.rotateBackward(), + setRotation: (rotationValue: number) => rotate.setRotation(rotationValue), + getRotation: () => rotation, + } + }); + } + }, [rotate, rotation]); + + return null; +} \ No newline at end of file diff --git a/frontend/src/components/viewer/ScrollAPIBridge.tsx b/frontend/src/components/viewer/ScrollAPIBridge.tsx new file mode 100644 index 000000000..45522f7f8 --- /dev/null +++ b/frontend/src/components/viewer/ScrollAPIBridge.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; +import { useScroll } from '@embedpdf/plugin-scroll/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * ScrollAPIBridge manages scroll state and exposes scroll actions. + * Registers with ViewerContext to provide scroll functionality to UI components. + */ +export function ScrollAPIBridge() { + const { provides: scroll, state: scrollState } = useScroll(); + const { registerBridge, triggerImmediateScrollUpdate } = useViewer(); + + useEffect(() => { + if (scroll && scrollState) { + const newState = { + currentPage: scrollState.currentPage, + totalPages: scrollState.totalPages, + }; + + // Trigger immediate update for responsive UI + triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages); + + registerBridge('scroll', { + state: newState, + api: scroll + }); + } + }, [scroll, scrollState]); + + return null; +} diff --git a/frontend/src/components/viewer/SearchAPIBridge.tsx b/frontend/src/components/viewer/SearchAPIBridge.tsx new file mode 100644 index 000000000..67bb4c446 --- /dev/null +++ b/frontend/src/components/viewer/SearchAPIBridge.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { useSearch } from '@embedpdf/plugin-search/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +interface SearchResult { + pageIndex: number; + rects: Array<{ + origin: { x: number; y: number }; + size: { width: number; height: number }; + }>; +} + +/** + * SearchAPIBridge manages search state and provides search functionality. + * Listens for search result changes from EmbedPDF and maintains local state. + */ +export function SearchAPIBridge() { + const { provides: search } = useSearch(); + const { registerBridge } = useViewer(); + + const [localState, setLocalState] = useState({ + results: null as SearchResult[] | null, + activeIndex: 0 + }); + + // Subscribe to search result changes from EmbedPDF + useEffect(() => { + if (!search) return; + + const unsubscribe = search.onSearchResultStateChange?.((state: any) => { + const newState = { + results: state?.results || null, + activeIndex: (state?.activeResultIndex || 0) + 1 // Convert to 1-based index + }; + + setLocalState(prevState => { + // Only update if state actually changed + if (prevState.results !== newState.results || prevState.activeIndex !== newState.activeIndex) { + return newState; + } + return prevState; + }); + }); + + return unsubscribe; + }, [search]); + + // Register bridge whenever search API or state changes + useEffect(() => { + if (search) { + registerBridge('search', { + state: localState, + api: { + search: async (query: string) => { + search.startSearch(); + return search.searchAllPages(query); + }, + clear: () => { + search.stopSearch(); + setLocalState({ results: null, activeIndex: 0 }); + }, + next: () => search.nextResult(), + previous: () => search.previousResult(), + goToResult: (index: number) => search.goToResult(index), + } + }); + } + }, [search, localState]); + + return null; +} diff --git a/frontend/src/components/viewer/SearchInterface.tsx b/frontend/src/components/viewer/SearchInterface.tsx new file mode 100644 index 000000000..ff91153ad --- /dev/null +++ b/frontend/src/components/viewer/SearchInterface.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from 'react'; +import { Box, TextInput, ActionIcon, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { LocalIcon } from '../shared/LocalIcon'; +import { ViewerContext } from '../../contexts/ViewerContext'; + +interface SearchInterfaceProps { + visible: boolean; + onClose: () => void; +} + +export function SearchInterface({ visible, onClose }: SearchInterfaceProps) { + const { t } = useTranslation(); + const viewerContext = React.useContext(ViewerContext); + + const searchState = viewerContext?.getSearchState(); + const searchResults = searchState?.results; + const searchActiveIndex = searchState?.activeIndex; + const searchActions = viewerContext?.searchActions; + const [searchQuery, setSearchQuery] = useState(''); + const [jumpToValue, setJumpToValue] = useState(''); + const [resultInfo, setResultInfo] = useState<{ + currentIndex: number; + totalResults: number; + query: string; + } | null>(null); + const [isSearching, setIsSearching] = useState(false); + + // Monitor search state changes + useEffect(() => { + if (!visible) return; + + const checkSearchState = () => { + // Use ViewerContext state instead of window APIs + if (searchResults && searchResults.length > 0) { + const activeIndex = searchActiveIndex || 1; + + setResultInfo({ + currentIndex: activeIndex, + totalResults: searchResults.length, + query: searchQuery // Use local search query + }); + } else if (searchQuery && searchResults?.length === 0) { + // Show "no results" state + setResultInfo({ + currentIndex: 0, + totalResults: 0, + query: searchQuery + }); + } else { + setResultInfo(null); + } + }; + + // Check immediately and then poll for updates + checkSearchState(); + const interval = setInterval(checkSearchState, 200); + + return () => clearInterval(interval); + }, [visible, searchResults, searchActiveIndex, searchQuery]); + + const handleSearch = async (query: string) => { + if (!query.trim()) { + // If query is empty, clear the search + handleClearSearch(); + return; + } + + if (query.trim() && searchActions) { + setIsSearching(true); + try { + await searchActions.search(query.trim()); + } catch (error) { + console.error('Search failed:', error); + } finally { + setIsSearching(false); + } + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch(searchQuery); + } else if (event.key === 'Escape') { + onClose(); + } + }; + + const handleNext = () => { + searchActions?.next(); + }; + + const handlePrevious = () => { + searchActions?.previous(); + }; + + const handleClearSearch = () => { + searchActions?.clear(); + setSearchQuery(''); + setResultInfo(null); + }; + + // No longer need to sync with external API on mount - removed + + const handleJumpToResult = (index: number) => { + // Use context actions instead of window API - functionality simplified for now + if (resultInfo && index >= 1 && index <= resultInfo.totalResults) { + // Note: goToResult functionality would need to be implemented in SearchAPIBridge + console.log('Jump to result:', index); + } + }; + + const handleJumpToSubmit = () => { + const index = parseInt(jumpToValue); + if (index && resultInfo && index >= 1 && index <= resultInfo.totalResults) { + handleJumpToResult(index); + } + }; + + const handleJumpToKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleJumpToSubmit(); + } + }; + + const _handleClose = () => { + handleClearSearch(); + onClose(); + }; + + + return ( + + {/* Header */} + + + {t('search.title', 'Search PDF')} + + + + {/* Search input */} + + { + const newValue = e.currentTarget.value; + setSearchQuery(newValue); + // If user clears the input, clear the search highlights + if (!newValue.trim()) { + handleClearSearch(); + } + }} + onKeyDown={handleKeyDown} + style={{ flex: 1 }} + rightSection={ + handleSearch(searchQuery)} + disabled={!searchQuery.trim() || isSearching} + loading={isSearching} + > + + + } + /> + + + {/* Results info and navigation */} + {resultInfo && ( + + {resultInfo.totalResults === 0 ? ( + + {t('search.noResults', 'No results found')} + + ) : ( + + setJumpToValue(e.currentTarget.value)} + onKeyDown={handleJumpToKeyDown} + onBlur={handleJumpToSubmit} + placeholder={resultInfo.currentIndex.toString()} + style={{ width: '3rem' }} + type="number" + min="1" + max={resultInfo.totalResults} + /> + + of {resultInfo.totalResults} + + + )} + + {resultInfo.totalResults > 0 && ( + + + + + = resultInfo.totalResults} + aria-label="Next result" + > + + + + + + + )} + + )} + + {/* Loading state */} + {isSearching && ( + + {t('search.searching', 'Searching...')} + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/viewer/SelectionAPIBridge.tsx b/frontend/src/components/viewer/SelectionAPIBridge.tsx new file mode 100644 index 000000000..f57c8dd78 --- /dev/null +++ b/frontend/src/components/viewer/SelectionAPIBridge.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { useSelectionCapability, SelectionRangeX } from '@embedpdf/plugin-selection/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and updates selection state in ViewerContext + */ +export function SelectionAPIBridge() { + const { provides: selection } = useSelectionCapability(); + const { registerBridge } = useViewer(); + const [hasSelection, setHasSelection] = useState(false); + + useEffect(() => { + if (selection) { + const newState = { + hasSelection + }; + + // Register this bridge with ViewerContext + registerBridge('selection', { + state: newState, + api: { + copyToClipboard: () => selection.copyToClipboard(), + getSelectedText: () => selection.getSelectedText(), + getFormattedSelection: () => selection.getFormattedSelection(), + } + }); + + // Listen for selection changes to track when text is selected + const unsubscribe = selection.onSelectionChange((sel: SelectionRangeX | null) => { + const hasText = !!sel; + setHasSelection(hasText); + const updatedState = { hasSelection: hasText }; + // Re-register with updated state + registerBridge('selection', { + state: updatedState, + api: { + copyToClipboard: () => selection.copyToClipboard(), + getSelectedText: () => selection.getSelectedText(), + getFormattedSelection: () => selection.getFormattedSelection(), + } + }); + }); + + // Intercept Ctrl+C only when we have PDF text selected + const handleKeyDown = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === 'c' && hasSelection) { + // Call EmbedPDF's copyToClipboard API + selection.copyToClipboard(); + // Don't prevent default - let EmbedPDF handle the clipboard + } + }; + + // Add keyboard listener + document.addEventListener('keydown', handleKeyDown); + + return () => { + unsubscribe?.(); + document.removeEventListener('keydown', handleKeyDown); + }; + } + }, [selection, hasSelection]); + + return null; +} diff --git a/frontend/src/components/viewer/SpreadAPIBridge.tsx b/frontend/src/components/viewer/SpreadAPIBridge.tsx new file mode 100644 index 000000000..81a2eaa99 --- /dev/null +++ b/frontend/src/components/viewer/SpreadAPIBridge.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and updates spread state in ViewerContext + */ +export function SpreadAPIBridge() { + const { provides: spread, spreadMode } = useSpread(); + const { registerBridge } = useViewer(); + + useEffect(() => { + if (spread) { + const newState = { + spreadMode, + isDualPage: spreadMode !== SpreadMode.None + }; + + // Register this bridge with ViewerContext + registerBridge('spread', { + state: newState, + api: { + setSpreadMode: (mode: SpreadMode) => { + spread.setSpreadMode(mode); + }, + getSpreadMode: () => spread.getSpreadMode(), + toggleSpreadMode: () => { + // Toggle between None and Odd (most common dual-page mode) + const newMode = spreadMode === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None; + spread.setSpreadMode(newMode); + }, + SpreadMode: SpreadMode, // Export enum for reference + } + }); + } + }, [spread, spreadMode]); + + return null; +} diff --git a/frontend/src/components/viewer/ThumbnailAPIBridge.tsx b/frontend/src/components/viewer/ThumbnailAPIBridge.tsx new file mode 100644 index 000000000..fc3a583bc --- /dev/null +++ b/frontend/src/components/viewer/ThumbnailAPIBridge.tsx @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * ThumbnailAPIBridge provides thumbnail generation functionality. + * Exposes thumbnail API to UI components without managing state. + */ +export function ThumbnailAPIBridge() { + const { provides: thumbnail } = useThumbnailCapability(); + const { registerBridge } = useViewer(); + + useEffect(() => { + if (thumbnail) { + registerBridge('thumbnail', { + state: null, // No state - just provides API + api: thumbnail + }); + } + }, [thumbnail]); + + return null; +} diff --git a/frontend/src/components/viewer/ThumbnailSidebar.tsx b/frontend/src/components/viewer/ThumbnailSidebar.tsx new file mode 100644 index 000000000..3accbe042 --- /dev/null +++ b/frontend/src/components/viewer/ThumbnailSidebar.tsx @@ -0,0 +1,198 @@ +import React, { useState, useEffect } from 'react'; +import { Box, ScrollArea } from '@mantine/core'; +import { useViewer } from '../../contexts/ViewerContext'; + +interface ThumbnailSidebarProps { + visible: boolean; + onToggle: () => void; +} + +export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) { + const { getScrollState, scrollActions, getThumbnailAPI } = useViewer(); + const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({}); + + const scrollState = getScrollState(); + const thumbnailAPI = getThumbnailAPI(); + + // Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks + useEffect(() => { + if (!visible) { + Object.values(thumbnails).forEach((thumbUrl) => { + // Only revoke if it's a blob URL (not 'error') + if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) { + URL.revokeObjectURL(thumbUrl); + } + }); + setThumbnails({}); + } + }, [visible, thumbnails]); + + // Generate thumbnails when sidebar becomes visible + useEffect(() => { + if (!visible || scrollState.totalPages === 0) return; + if (!thumbnailAPI) return; + + const generateThumbnails = async () => { + for (let pageIndex = 0; pageIndex < scrollState.totalPages; pageIndex++) { + if (thumbnails[pageIndex]) continue; // Skip if already generated + + try { + const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0); + + // Convert Task to Promise and handle properly + thumbTask.toPromise().then((thumbBlob: Blob) => { + const thumbUrl = URL.createObjectURL(thumbBlob); + + setThumbnails(prev => ({ + ...prev, + [pageIndex]: thumbUrl + })); + }).catch((error: any) => { + console.error('Failed to generate thumbnail for page', pageIndex + 1, error); + setThumbnails(prev => ({ + ...prev, + [pageIndex]: 'error' + })); + }); + + } catch (error) { + console.error('Failed to generate thumbnail for page', pageIndex + 1, error); + setThumbnails(prev => ({ + ...prev, + [pageIndex]: 'error' + })); + } + } + }; + + generateThumbnails(); + }, [visible, scrollState.totalPages, thumbnailAPI]); + + const handlePageClick = (pageIndex: number) => { + const pageNumber = pageIndex + 1; // Convert to 1-based + scrollActions.scrollToPage(pageNumber); + }; + + return ( + <> + {/* Thumbnail Sidebar */} + {visible && ( + + {/* Thumbnails Container */} + + +
+ {Array.from({ length: scrollState.totalPages }, (_, pageIndex) => ( + handlePageClick(pageIndex)} + style={{ + cursor: 'pointer', + borderRadius: '8px', + padding: '8px', + backgroundColor: scrollState.currentPage === pageIndex + 1 + ? 'var(--color-primary-100)' + : 'transparent', + border: scrollState.currentPage === pageIndex + 1 + ? '2px solid var(--color-primary-500)' + : '2px solid transparent', + transition: 'all 0.2s ease', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '8px' + }} + onMouseEnter={(e) => { + if (scrollState.currentPage !== pageIndex + 1) { + e.currentTarget.style.backgroundColor = 'var(--hover-bg)'; + } + }} + onMouseLeave={(e) => { + if (scrollState.currentPage !== pageIndex + 1) { + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} + > + {/* Thumbnail Image */} + {thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? ( + {`Page + ) : thumbnails[pageIndex] === 'error' ? ( +
+ Failed +
+ ) : ( +
+ Loading... +
+ )} + + {/* Page Number */} +
+ Page {pageIndex + 1} +
+
+ ))} +
+
+
+
+ )} + + ); +} diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index e40b1f076..49d3a5854 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -1,688 +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 file directly from IndexedDB - const file = await fileStorage.getStirlingFile(fileId); - if (!file) { - throw new Error('File not found in IndexedDB - may have been purged by browser'); - } - const arrayBuffer = await file.arrayBuffer(); - - // 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/ZoomAPIBridge.tsx b/frontend/src/components/viewer/ZoomAPIBridge.tsx new file mode 100644 index 000000000..8cb0f4fcc --- /dev/null +++ b/frontend/src/components/viewer/ZoomAPIBridge.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef } from 'react'; +import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { useViewer } from '../../contexts/ViewerContext'; + +/** + * Component that runs inside EmbedPDF context and manages zoom state locally + */ +export function ZoomAPIBridge() { + const { provides: zoom, state: zoomState } = useZoom(); + const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); + const hasSetInitialZoom = useRef(false); + + // Set initial zoom once when plugin is ready + useEffect(() => { + if (zoom && !hasSetInitialZoom.current) { + hasSetInitialZoom.current = true; + setTimeout(() => { + zoom.requestZoom(1.4); + }, 50); + } + }, [zoom]); + + useEffect(() => { + if (zoom && zoomState) { + // Update local state + const currentZoomLevel = zoomState.currentZoomLevel || 1.4; + const newState = { + currentZoom: currentZoomLevel, + zoomPercent: Math.round(currentZoomLevel * 100), + }; + + // Trigger immediate update for responsive UI + triggerImmediateZoomUpdate(newState.zoomPercent); + + // Register this bridge with ViewerContext + registerBridge('zoom', { + state: newState, + api: zoom + }); + } + }, [zoom, zoomState]); + + return null; +} diff --git a/frontend/src/components/viewer/constants/search.ts b/frontend/src/components/viewer/constants/search.ts new file mode 100644 index 000000000..0da10a7cb --- /dev/null +++ b/frontend/src/components/viewer/constants/search.ts @@ -0,0 +1,15 @@ +export const SEARCH_CONSTANTS = { + HIGHLIGHT_COLORS: { + BACKGROUND: '#ffff00', + ACTIVE_BACKGROUND: '#ff6b35', + OPACITY: 0.4, + }, + TIMING: { + DEBOUNCE_DELAY: 300, + }, + UI: { + HIGHLIGHT_PADDING: 2, + MIN_SEARCH_LENGTH: 1, + MAX_RESULTS_DISPLAY: 100, + } +} as const; \ No newline at end of file diff --git a/frontend/src/contexts/ViewerContext.tsx b/frontend/src/contexts/ViewerContext.tsx new file mode 100644 index 000000000..34a49de42 --- /dev/null +++ b/frontend/src/contexts/ViewerContext.tsx @@ -0,0 +1,543 @@ +import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; +import { SpreadMode } from '@embedpdf/plugin-spread/react'; + +// Bridge API interfaces - these match what the bridges provide +interface ScrollAPIWrapper { + scrollToPage: (params: { pageNumber: number }) => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; +} + +interface ZoomAPIWrapper { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; +} + +interface PanAPIWrapper { + enable: () => void; + disable: () => void; + toggle: () => void; +} + +interface SelectionAPIWrapper { + copyToClipboard: () => void; + getSelectedText: () => string | any; + getFormattedSelection: () => any; +} + +interface SpreadAPIWrapper { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; +} + +interface RotationAPIWrapper { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; +} + +interface SearchAPIWrapper { + search: (query: string) => Promise; + clear: () => void; + next: () => void; + previous: () => void; +} + +interface ThumbnailAPIWrapper { + renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise }; +} + + +// State interfaces - represent the shape of data from each bridge +interface ScrollState { + currentPage: number; + totalPages: number; +} + +interface ZoomState { + currentZoom: number; + zoomPercent: number; +} + +interface PanState { + isPanning: boolean; +} + +interface SelectionState { + hasSelection: boolean; +} + +interface SpreadState { + spreadMode: SpreadMode; + isDualPage: boolean; +} + +interface RotationState { + rotation: number; +} + +interface SearchResult { + pageIndex: number; + rects: Array<{ + origin: { x: number; y: number }; + size: { width: number; height: number }; + }>; +} + +interface SearchState { + results: SearchResult[] | null; + activeIndex: number; +} + +// Bridge registration interface - bridges register with state and API +interface BridgeRef { + state: TState; + api: TApi; +} + +/** + * ViewerContext provides a unified interface to EmbedPDF functionality. + * + * Architecture: + * - Bridges store their own state locally and register with this context + * - Context provides read-only access to bridge state via getter functions + * - Actions call EmbedPDF APIs directly through bridge references + * - No circular dependencies - bridges don't call back into this context + */ +interface ViewerContextType { + // UI state managed by this context + isThumbnailSidebarVisible: boolean; + toggleThumbnailSidebar: () => void; + + // State getters - read current state from bridges + getScrollState: () => ScrollState; + getZoomState: () => ZoomState; + getPanState: () => PanState; + getSelectionState: () => SelectionState; + getSpreadState: () => SpreadState; + getRotationState: () => RotationState; + getSearchState: () => SearchState; + getThumbnailAPI: () => ThumbnailAPIWrapper | null; + + // Immediate update callbacks + registerImmediateZoomUpdate: (callback: (percent: number) => void) => void; + registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void; + + // Internal - for bridges to trigger immediate updates + triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void; + triggerImmediateZoomUpdate: (zoomPercent: number) => void; + + // Action handlers - call EmbedPDF APIs directly + scrollActions: { + scrollToPage: (page: number) => void; + scrollToFirstPage: () => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; + scrollToLastPage: () => void; + }; + + zoomActions: { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; + }; + + panActions: { + enablePan: () => void; + disablePan: () => void; + togglePan: () => void; + }; + + selectionActions: { + copyToClipboard: () => void; + getSelectedText: () => string; + getFormattedSelection: () => unknown; + }; + + spreadActions: { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; + }; + + rotationActions: { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; + }; + + searchActions: { + search: (query: string) => Promise; + next: () => void; + previous: () => void; + clear: () => void; + }; + + // Bridge registration - internal use by bridges + registerBridge: (type: string, ref: BridgeRef) => void; +} + +export const ViewerContext = createContext(null); + +interface ViewerProviderProps { + children: ReactNode; +} + +export const ViewerProvider: React.FC = ({ children }) => { + // UI state - only state directly managed by this context + const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false); + + // Bridge registry - bridges register their state and APIs here + const bridgeRefs = useRef({ + scroll: null as BridgeRef | null, + zoom: null as BridgeRef | null, + pan: null as BridgeRef | null, + selection: null as BridgeRef | null, + search: null as BridgeRef | null, + spread: null as BridgeRef | null, + rotation: null as BridgeRef | null, + thumbnail: null as BridgeRef | null, + }); + + // Immediate zoom callback for responsive display updates + const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null); + + // Immediate scroll callback for responsive display updates + const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null); + + const registerBridge = (type: string, ref: BridgeRef) => { + // Type-safe assignment - we know the bridges will provide correct types + switch (type) { + case 'scroll': + bridgeRefs.current.scroll = ref as BridgeRef; + break; + case 'zoom': + bridgeRefs.current.zoom = ref as BridgeRef; + break; + case 'pan': + bridgeRefs.current.pan = ref as BridgeRef; + break; + case 'selection': + bridgeRefs.current.selection = ref as BridgeRef; + break; + case 'search': + bridgeRefs.current.search = ref as BridgeRef; + break; + case 'spread': + bridgeRefs.current.spread = ref as BridgeRef; + break; + case 'rotation': + bridgeRefs.current.rotation = ref as BridgeRef; + break; + case 'thumbnail': + bridgeRefs.current.thumbnail = ref as BridgeRef; + break; + } + }; + + const toggleThumbnailSidebar = () => { + setIsThumbnailSidebarVisible(prev => !prev); + }; + + // State getters - read from bridge refs + const getScrollState = (): ScrollState => { + return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 }; + }; + + const getZoomState = (): ZoomState => { + return bridgeRefs.current.zoom?.state || { currentZoom: 1.4, zoomPercent: 140 }; + }; + + const getPanState = (): PanState => { + return bridgeRefs.current.pan?.state || { isPanning: false }; + }; + + const getSelectionState = (): SelectionState => { + return bridgeRefs.current.selection?.state || { hasSelection: false }; + }; + + const getSpreadState = (): SpreadState => { + return bridgeRefs.current.spread?.state || { spreadMode: SpreadMode.None, isDualPage: false }; + }; + + const getRotationState = (): RotationState => { + return bridgeRefs.current.rotation?.state || { rotation: 0 }; + }; + + const getSearchState = (): SearchState => { + return bridgeRefs.current.search?.state || { results: null, activeIndex: 0 }; + }; + + const getThumbnailAPI = () => { + return bridgeRefs.current.thumbnail?.api || null; + }; + + // Action handlers - call APIs directly + const scrollActions = { + scrollToPage: (page: number) => { + const api = bridgeRefs.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: page }); + } + }, + scrollToFirstPage: () => { + const api = bridgeRefs.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: 1 }); + } + }, + scrollToPreviousPage: () => { + const api = bridgeRefs.current.scroll?.api; + if (api?.scrollToPreviousPage) { + api.scrollToPreviousPage(); + } + }, + scrollToNextPage: () => { + const api = bridgeRefs.current.scroll?.api; + if (api?.scrollToNextPage) { + api.scrollToNextPage(); + } + }, + scrollToLastPage: () => { + const scrollState = getScrollState(); + const api = bridgeRefs.current.scroll?.api; + if (api?.scrollToPage && scrollState.totalPages > 0) { + api.scrollToPage({ pageNumber: scrollState.totalPages }); + } + } + }; + + const zoomActions = { + zoomIn: () => { + const api = bridgeRefs.current.zoom?.api; + if (api?.zoomIn) { + // Update display immediately if callback is registered + if (immediateZoomUpdateCallback.current) { + const currentState = getZoomState(); + const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300); + immediateZoomUpdateCallback.current(newPercent); + } + api.zoomIn(); + } + }, + zoomOut: () => { + const api = bridgeRefs.current.zoom?.api; + if (api?.zoomOut) { + // Update display immediately if callback is registered + if (immediateZoomUpdateCallback.current) { + const currentState = getZoomState(); + const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20); + immediateZoomUpdateCallback.current(newPercent); + } + api.zoomOut(); + } + }, + toggleMarqueeZoom: () => { + const api = bridgeRefs.current.zoom?.api; + if (api?.toggleMarqueeZoom) { + api.toggleMarqueeZoom(); + } + }, + requestZoom: (level: number) => { + const api = bridgeRefs.current.zoom?.api; + if (api?.requestZoom) { + api.requestZoom(level); + } + } + }; + + const panActions = { + enablePan: () => { + const api = bridgeRefs.current.pan?.api; + if (api?.enable) { + api.enable(); + } + }, + disablePan: () => { + const api = bridgeRefs.current.pan?.api; + if (api?.disable) { + api.disable(); + } + }, + togglePan: () => { + const api = bridgeRefs.current.pan?.api; + if (api?.toggle) { + api.toggle(); + } + } + }; + + const selectionActions = { + copyToClipboard: () => { + const api = bridgeRefs.current.selection?.api; + if (api?.copyToClipboard) { + api.copyToClipboard(); + } + }, + getSelectedText: () => { + const api = bridgeRefs.current.selection?.api; + if (api?.getSelectedText) { + return api.getSelectedText(); + } + return ''; + }, + getFormattedSelection: () => { + const api = bridgeRefs.current.selection?.api; + if (api?.getFormattedSelection) { + return api.getFormattedSelection(); + } + return null; + } + }; + + const spreadActions = { + setSpreadMode: (mode: SpreadMode) => { + const api = bridgeRefs.current.spread?.api; + if (api?.setSpreadMode) { + api.setSpreadMode(mode); + } + }, + getSpreadMode: () => { + const api = bridgeRefs.current.spread?.api; + if (api?.getSpreadMode) { + return api.getSpreadMode(); + } + return null; + }, + toggleSpreadMode: () => { + const api = bridgeRefs.current.spread?.api; + if (api?.toggleSpreadMode) { + api.toggleSpreadMode(); + } + } + }; + + const rotationActions = { + rotateForward: () => { + const api = bridgeRefs.current.rotation?.api; + if (api?.rotateForward) { + api.rotateForward(); + } + }, + rotateBackward: () => { + const api = bridgeRefs.current.rotation?.api; + if (api?.rotateBackward) { + api.rotateBackward(); + } + }, + setRotation: (rotation: number) => { + const api = bridgeRefs.current.rotation?.api; + if (api?.setRotation) { + api.setRotation(rotation); + } + }, + getRotation: () => { + const api = bridgeRefs.current.rotation?.api; + if (api?.getRotation) { + return api.getRotation(); + } + return 0; + } + }; + + const searchActions = { + search: async (query: string) => { + const api = bridgeRefs.current.search?.api; + if (api?.search) { + return api.search(query); + } + }, + next: () => { + const api = bridgeRefs.current.search?.api; + if (api?.next) { + api.next(); + } + }, + previous: () => { + const api = bridgeRefs.current.search?.api; + if (api?.previous) { + api.previous(); + } + }, + clear: () => { + const api = bridgeRefs.current.search?.api; + if (api?.clear) { + api.clear(); + } + } + }; + + const registerImmediateZoomUpdate = (callback: (percent: number) => void) => { + immediateZoomUpdateCallback.current = callback; + }; + + const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => { + immediateScrollUpdateCallback.current = callback; + }; + + const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => { + if (immediateScrollUpdateCallback.current) { + immediateScrollUpdateCallback.current(currentPage, totalPages); + } + }; + + const triggerImmediateZoomUpdate = (zoomPercent: number) => { + if (immediateZoomUpdateCallback.current) { + immediateZoomUpdateCallback.current(zoomPercent); + } + }; + + const value: ViewerContextType = { + // UI state + isThumbnailSidebarVisible, + toggleThumbnailSidebar, + + // State getters + getScrollState, + getZoomState, + getPanState, + getSelectionState, + getSpreadState, + getRotationState, + getSearchState, + getThumbnailAPI, + + // Immediate updates + registerImmediateZoomUpdate, + registerImmediateScrollUpdate, + triggerImmediateScrollUpdate, + triggerImmediateZoomUpdate, + + // Actions + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + + // Bridge registration + registerBridge, + }; + + return ( + + {children} + + ); +}; + +export const useViewer = (): ViewerContextType => { + const context = useContext(ViewerContext); + if (!context) { + throw new Error('useViewer must be used within a ViewerProvider'); + } + return context; +}; 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/src/styles/theme.css b/frontend/src/styles/theme.css index 4b91e7ed4..8095a165c 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -30,6 +30,17 @@ --color-primary-800: #1e40af; --color-primary-900: #1e3a8a; + --color-red-50: #fef2f2; + --color-red-100: #fee2e2; + --color-red-200: #fecaca; + --color-red-300: #fca5a5; + --color-red-400: #f87171; + --color-red-500: #ef4444; + --color-red-600: #dc2626; + --color-red-700: #b91c1c; + --color-red-800: #991b1b; + --color-red-900: #7f1d1d; + --color-gray-50: #f9fafb; --color-gray-100: #f3f4f6; --color-gray-200: #e5e7eb; @@ -141,6 +152,7 @@ --text-brand: var(--color-gray-700); --text-brand-accent: #DC2626; + /* Placeholder text colors */ --search-text-and-icon-color: #6B7382; @@ -205,6 +217,17 @@ --border: 55 65 81; /* Dark theme Mantine colors */ + --color-red-50: #2d1b1b; + --color-red-100: #3a2323; + --color-red-200: #4a2d2d; + --color-red-300: #5c3535; + --color-red-400: #7c4a4a; + --color-red-500: #ef4444; + --color-red-600: #dc2626; + --color-red-700: #b91c1c; + --color-red-800: #991b1b; + --color-red-900: #7f1d1d; + --color-gray-50: #111827; --color-gray-100: #1F2329; --color-gray-200: #2A2F36; @@ -321,6 +344,7 @@ --tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */ --tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */ + /* Placeholder text colors (dark mode) */ --search-text-and-icon-color: #FFFFFF !important; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6886183a1..3e4c6105d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,9 +28,10 @@ /* 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. */ + }, // "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. */ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index d957fa3f0..e84b42cd6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,4 +12,5 @@ export default defineConfig({ }, }, }, + base: "./", });