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 2d52409ef..bf82fa8f4 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -2607,7 +2607,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...",
@@ -2973,5 +2978,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' ? (
+
+ ) : 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 (
-
- );
- }
-
- // 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: "./",
});