Compare commits

...
Author SHA1 Message Date
James Brunton 81c5d8ff46 Potential fix for mime type issues 2025-09-16 16:06:40 +01:00
James Brunton a67f5199d3 Improvements for scroll gestures 2025-09-16 16:06:27 +01:00
Reece Browne 3755bfde34 Set zoom to 140% 2025-09-15 18:20:11 +01:00
Reece Browne 2834eec3be Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-15 17:31:06 +01:00
Reece Browne d89e1b5b1e Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 17:27:51 +01:00
Reece Browne 19d7111cab Remove unused code 2025-09-15 17:27:22 +01:00
Reece Browne ca9d7ef465 Remove unused code 2025-09-15 17:03:52 +01:00
Reece Browne fad4f84c9c translations 2025-09-15 16:53:41 +01:00
Reece Browne 35863ac610 remove select mode 2025-09-15 16:53:32 +01:00
Reece Browne c17dd25069 Rotate 2025-09-15 16:05:19 +01:00
Reece Browne 5d7fb638af Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 15:31:45 +01:00
Reece Browne 2fb4710dd7 Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 13:34:00 +01:00
Reece Browne 85a74c1d46 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-15 13:33:45 +01:00
Reece Browne 21a93d6cac Context based right rail controls for viewer 2025-09-15 13:33:39 +01:00
Reece BrowneandCopilot 9599bca8a9 Update frontend/src/components/viewer/ThumbnailSidebar.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-15 12:37:07 +01:00
Reece Browne 1709ca9049 Rems 2025-09-12 16:38:29 +01:00
Reece Browne 18e4e03220 rename APIBridge 2025-09-12 16:26:05 +01:00
Reece Browne 9901771572 improve search 2025-09-12 16:19:07 +01:00
Reece Browne 514956570c pan state improvements 2025-09-12 15:06:06 +01:00
Reece Browne 423617db52 thumbnail sidebar 2025-09-12 14:21:31 +01:00
Reece Browne 143f0c5031 search pdf 2025-09-12 01:56:51 +01:00
Reece Browne 368e9801a1 Zoom with wheel and +/- 2025-09-12 00:35:27 +01:00
Reece Browne afc9ca5858 spread/multipage 2025-09-11 23:52:38 +01:00
Reece Browne 8815575124 pan 2025-09-11 22:51:10 +01:00
Reece Browne fb9b01f53b improved scaling and fix grey void 2025-09-11 20:07:43 +01:00
Reece Browne 93607937f6 selection also 2025-09-11 19:38:04 +01:00
Reece Browne 687ab39286 Text selection 2025-09-11 19:36:44 +01:00
Reece Browne 83a3222cf6 Set up 2025-09-11 19:08:44 +01:00
26 changed files with 2332 additions and 724 deletions
+1
View File
@@ -13,6 +13,7 @@
<link rel="manifest" href="/manifest.json" />
<title>Stirling PDF</title>
<!-- EmbedPDF is imported dynamically in Viewer component -->
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+378 -40
View File
@@ -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",
@@ -596,6 +611,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",
@@ -1619,9 +1886,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -3404,14 +3671,13 @@
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.19.tgz",
"integrity": "sha512-/afpyvlkrSNYbPo94Qu8GtIOWS+g5TRdOvs6XZNw6pWQQmj5pBgSZvEPOIZlqWq0YvoUhDDQaQ2TnzuJdOV4hA==",
"dev": true,
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz",
"integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/shared": "3.5.19",
"@vue/shared": "3.5.21",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
@@ -3421,7 +3687,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -3434,34 +3699,31 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.19.tgz",
"integrity": "sha512-Drs6rPHQZx/pN9S6ml3Z3K/TWCIRPvzG2B/o5kFK9X0MNHt8/E+38tiRfojufrYBfA6FQUFB2qBBRXlcSXWtOA==",
"dev": true,
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz",
"integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.19",
"@vue/shared": "3.5.19"
"@vue/compiler-core": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.19.tgz",
"integrity": "sha512-YWCm1CYaJ+2RvNmhCwI7t3I3nU+hOrWGWMsn+Z/kmm1jy5iinnVtlmkiZwbLlbV1SRizX7vHsc0/bG5dj0zRTg==",
"dev": true,
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz",
"integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.3",
"@vue/compiler-core": "3.5.19",
"@vue/compiler-dom": "3.5.19",
"@vue/compiler-ssr": "3.5.19",
"@vue/shared": "3.5.19",
"@vue/compiler-core": "3.5.21",
"@vue/compiler-dom": "3.5.21",
"@vue/compiler-ssr": "3.5.21",
"@vue/shared": "3.5.21",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.17",
"magic-string": "^0.30.18",
"postcss": "^8.5.6",
"source-map-js": "^1.2.1"
}
@@ -3470,25 +3732,70 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.19.tgz",
"integrity": "sha512-/wx0VZtkWOPdiQLWPeQeqpHWR/LuNC7bHfSX7OayBTtUy8wur6vT6EQIX6Et86aED6J+y8tTw43qo2uoqGg5sw==",
"dev": true,
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz",
"integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.19",
"@vue/shared": "3.5.19"
"@vue/compiler-dom": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz",
"integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz",
"integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.21",
"@vue/shared": "3.5.21"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz",
"integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.21",
"@vue/runtime-core": "3.5.21",
"@vue/shared": "3.5.21",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz",
"integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.21",
"@vue/shared": "3.5.21"
},
"peerDependencies": {
"vue": "3.5.21"
}
},
"node_modules/@vue/shared": {
"version": "3.5.19",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.19.tgz",
"integrity": "sha512-IhXCOn08wgKrLQxRFKKlSacWg4Goi1BolrdEeLYn6tgHjJNXVrWJ5nzoxZqNwl5p88aLlQ8LOaoMa3AYvaKJ/Q==",
"dev": true,
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz",
"integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==",
"license": "MIT"
},
"node_modules/abbrev": {
@@ -5644,6 +5951,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/hammerjs": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz",
"integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==",
"license": "MIT",
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6940,12 +7256,12 @@
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"version": "0.30.19",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0"
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
@@ -11280,6 +11596,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",
+15
View File
@@ -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",
@@ -2645,5 +2645,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"
}
}
+1 -1
View File
@@ -157,7 +157,7 @@ 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',
marginTop: 0,
}}
>
{renderMainContent()}
+121 -1
View File
@@ -7,13 +7,18 @@ import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import '../../types/embedPdf';
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';
export default function RightRail() {
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
const [currentRotation, setCurrentRotation] = useState(0);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
@@ -27,6 +32,24 @@ export default function RightRail() {
// Navigation view
const { workbench: currentView } = useNavigationState();
// Sync rotation state with EmbedPDF API
useEffect(() => {
if (currentView === 'viewer' && window.embedPdfRotate) {
const updateRotation = () => {
const rotation = window.embedPdfRotate?.getRotation() || 0;
setCurrentRotation(rotation * 90); // Convert enum to degrees
};
// Update rotation immediately
updateRotation();
// Set up periodic updates to keep state in sync
const interval = setInterval(updateRotation, 1000);
return () => clearInterval(interval);
}
}, [currentView]);
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
@@ -204,6 +227,103 @@ export default function RightRail() {
</>
)}
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
<div
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView !== 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Search */}
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface
visible={true}
onClose={() => {}}
/>
</div>
</Popover.Dropdown>
</Popover>
</Tooltip>
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfPan?.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Left */}
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateBackward();
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Right */}
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
window.embedPdfRotate?.rotateForward();
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Sidebar Toggle */}
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => window.toggleThumbnailSidebar?.()}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
@@ -260,7 +380,7 @@ export default function RightRail() {
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: 280 }}>
<div style={{ minWidth: '17.5rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
@@ -0,0 +1,131 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
interface SearchLayerProps {
pageIndex: number;
scale: number;
highlightColor?: string;
activeHighlightColor?: string;
opacity?: number;
padding?: number;
borderRadius?: number;
}
interface SearchResultState {
results: Array<{
pageIndex: number;
rects: Array<{
origin: { x: number; y: number };
size: { width: number; height: number };
}>;
}>;
activeResultIndex?: number;
}
export function CustomSearchLayer({
pageIndex,
scale,
highlightColor = '#FFFF00',
activeHighlightColor = '#FFBF00',
opacity = 0.6,
padding = 2,
borderRadius = 4
}: SearchLayerProps) {
const { provides: searchProvides } = useSearch();
const [searchResultState, setSearchResultState] = useState<SearchResultState | null>(null);
// Subscribe to search result state changes
useEffect(() => {
if (!searchProvides) {
return;
}
const unsubscribe = searchProvides.onSearchResultStateChange?.((state: SearchResultState) => {
// Expose search results globally for SearchInterface
if (state?.results) {
(window as any).currentSearchResults = state.results;
(window as any).currentActiveIndex = (state.activeResultIndex || 0) + 1; // Convert to 1-based index
// Auto-scroll to active result if we have one
if (state.activeResultIndex !== undefined && state.activeResultIndex >= 0) {
const activeResult = state.results[state.activeResultIndex];
if (activeResult) {
// Use the scroll API to navigate to the page containing the active result
const scrollAPI = (window as any).embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
// Convert 0-based page index to 1-based page number
const pageNumber = activeResult.pageIndex + 1;
scrollAPI.scrollToPage(pageNumber);
}
}
}
} else {
(window as any).currentSearchResults = null;
(window as any).currentActiveIndex = 0;
}
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 (
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 10
}}>
{pageResults.map(({ result, originalIndex }, idx) => (
<div key={`result-${idx}`}>
{result.rects.map((rect, rectIdx) => (
<div
key={`rect-${idx}-${rectIdx}`}
style={{
position: 'absolute',
top: `${rect.origin.y * scale - padding}px`,
left: `${rect.origin.x * scale - padding}px`,
width: `${rect.size.width * scale + (padding * 2)}px`,
height: `${rect.size.height * scale + (padding * 2)}px`,
backgroundColor: originalIndex === searchResultState?.activeResultIndex
? activeHighlightColor
: highlightColor,
opacity: opacity,
borderRadius: `${borderRadius}px`,
transform: 'scale(1.02)',
transformOrigin: 'center',
transition: 'opacity 0.3s ease-in-out, background-color 0.2s ease-in-out',
pointerEvents: 'none',
boxShadow: originalIndex === searchResultState?.activeResultIndex
? '0 0 0 1px rgba(255, 191, 0, 0.8)'
: 'none'
}}
/>
))}
</div>
))}
</div>
);
}
@@ -0,0 +1,246 @@
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 { ViewerProvider, useViewer } from "../../contexts/ViewerContext";
import { LocalEmbedPDF } from './LocalEmbedPDF';
import { PdfViewerToolbar } from './PdfViewerToolbar';
import { ThumbnailSidebar } from './ThumbnailSidebar';
import '../../types/embedPdf';
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 } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar } = useViewer();
// 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
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();
// Convert smooth scrolling gestures into discrete notches
accumulator += event.deltaY;
const threshold = 10;
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (accumulator <= -threshold) {
zoomAPI.zoomIn();
accumulator = 0;
} else if (accumulator >= threshold) {
zoomAPI.zoomOut();
accumulator = 0;
}
}
}
};
const viewerElement = viewerRef.current;
if (viewerElement) {
viewerElement.addEventListener('wheel', handleWheel, { passive: false });
return () => {
viewerElement.removeEventListener('wheel', handleWheel);
};
}
}, []);
// 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) {
const zoomAPI = window.embedPdfZoom;
if (zoomAPI) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomAPI.zoomIn();
} else if (event.key === '-' || event.key === '_') {
// Ctrl+- for zoom out
event.preventDefault();
zoomAPI.zoomOut();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isViewerHovered]);
// Expose toggle functions globally for right rail buttons
React.useEffect(() => {
window.toggleThumbnailSidebar = toggleThumbnailSidebar;
return () => {
delete window.toggleThumbnailSidebar;
};
}, [toggleThumbnailSidebar]);
return (
<Box
ref={viewerRef}
onMouseEnter={() => 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 && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 1000, borderRadius: '50%' }}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box p="md" style={{ borderBottom: `1px solid ${theme.colors.gray[3]}` }}>
<Text size="sm" c="dimmed">
Multiple files loaded - showing first file for now
</Text>
</Box>
)}
{/* EmbedPDF Viewer */}
<Box style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0
}}>
<LocalEmbedPDF
file={effectiveFile.file}
url={effectiveFile.url}
colorScheme={colorScheme}
/>
</Box>
</>
)}
{/* Bottom Toolbar Overlay */}
{effectiveFile && (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<div style={{ pointerEvents: "auto" }}>
<PdfViewerToolbar
currentPage={1}
totalPages={1}
onPageChange={(page) => {
// Placeholder - will implement page navigation later
console.log('Navigate to page:', page);
}}
dualPage={false}
onDualPageToggle={() => {
window.embedPdfSpread?.toggleSpreadMode();
}}
currentZoom={100}
/>
</div>
</div>
)}
{/* Thumbnail Sidebar */}
<ThumbnailSidebar
visible={isThumbnailSidebarVisible}
onToggle={toggleThumbnailSidebar}
colorScheme={colorScheme}
/>
</Box>
);
};
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
return (
<ViewerProvider>
<EmbedPdfViewerContent {...props} />
</ViewerProvider>
);
};
export default EmbedPdfViewer;
@@ -0,0 +1,255 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
// Import the essential plugins
import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react';
import { Scroller, ScrollPluginPackage, ScrollStrategy } from '@embedpdf/plugin-scroll/react';
import { LoaderPluginPackage } from '@embedpdf/plugin-loader/react';
import { RenderLayer, RenderPluginPackage } from '@embedpdf/plugin-render/react';
import { ZoomPluginPackage } 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 { 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;
colorScheme: 'light' | 'dark' | 'auto';
}
export function LocalEmbedPDF({ file, url, colorScheme }: LocalEmbedPDFProps) {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
// Convert color scheme (handle 'auto' mode by defaulting to 'light')
const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme;
// Convert File to URL if needed
useEffect(() => {
if (file) {
const objectUrl = URL.createObjectURL(file);
setPdfUrl(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else if (url) {
setPdfUrl(url);
}
}, [file, url]);
// Create plugins configuration
const plugins = useMemo(() => {
if (!pdfUrl) return [];
return [
createPluginRegistration(LoaderPluginPackage, {
loadingOptions: {
type: 'url',
pdfFile: {
id: 'stirling-pdf-viewer',
url: pdfUrl,
},
},
}),
createPluginRegistration(ViewportPluginPackage, {
viewportGap: 10,
}),
createPluginRegistration(ScrollPluginPackage, {
strategy: ScrollStrategy.Vertical,
initialPage: 0,
}),
createPluginRegistration(RenderPluginPackage),
// Register interaction manager (required for zoom 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 (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f8f9fa',
color: actualColorScheme === 'dark' ? '#ffffff' : '#666666',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}>📄</div>
<div>No PDF provided</div>
</div>
</div>
);
}
if (isLoading || !engine || !pdfUrl) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
color: actualColorScheme === 'dark' ? '#ffffff' : '#666666',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
<div>Loading PDF Engine...</div>
</div>
</div>
);
}
if (error) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
background: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
color: '#ff6b6b',
}}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', marginBottom: '16px' }}></div>
<div>Error loading PDF engine: {error.message}</div>
</div>
</div>
);
}
// Wrap your UI with the <EmbedPDF> provider
return (
<div style={{
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0
}}>
<EmbedPDF engine={engine} plugins={plugins}>
<ZoomAPIBridge />
<ScrollAPIBridge />
<SelectionAPIBridge />
<PanAPIBridge />
<SpreadAPIBridge />
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
backgroundColor: actualColorScheme === 'dark' ? '#1a1b1e' : '#f1f3f5',
height: '100%',
width: '100%',
maxHeight: '100%',
maxWidth: '100%',
overflow: 'auto',
position: 'relative',
flex: 1,
minHeight: 0,
minWidth: 0,
contain: 'strict',
}}
>
<Scroller
renderPage={({ width, height, pageIndex, scale, rotation }: { width: number; height: number; pageIndex: number; scale: number; rotation?: number }) => (
<Rotate pageSize={{ width, height }}>
<PagePointerProvider {...{ pageWidth: width, pageHeight: height, pageIndex, scale, rotation: rotation || 0 }}>
<div
style={{
width,
height,
position: 'relative',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
>
{/* 1. Low-resolution base layer for immediate feedback */}
<RenderLayer pageIndex={pageIndex} scale={0.5} />
{/* 2. High-resolution tile layer on top */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
{/* 3. Search highlight layer */}
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* 4. Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
</div>
</PagePointerProvider>
</Rotate>
)}
/>
</Viewport>
</GlobalPointerProvider>
</EmbedPDF>
</div>
);
}
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react';
/**
* Component that runs inside EmbedPDF context and bridges pan controls to global window
*/
export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan();
const [panStateListeners, setPanStateListeners] = useState<Array<(isPanning: boolean) => void>>([]);
useEffect(() => {
if (pan) {
// Export pan controls to global window for right rail access
(window as any).embedPdfPan = {
enablePan: () => {
console.log('EmbedPDF: Enabling pan mode');
pan.enablePan();
},
disablePan: () => {
console.log('EmbedPDF: Disabling pan mode');
pan.disablePan();
},
togglePan: () => {
pan.togglePan();
},
makePanDefault: () => pan.makePanDefault(),
get isPanning() { return isPanning; }, // Use getter to always return current value
// Subscribe to pan state changes for reactive UI
onPanStateChange: (callback: (isPanning: boolean) => void) => {
setPanStateListeners(prev => [...prev, callback]);
// Return unsubscribe function
return () => {
setPanStateListeners(prev => prev.filter(cb => cb !== callback));
};
},
};
}
}, [pan, isPanning]);
// Notify all listeners when isPanning state changes
useEffect(() => {
panStateListeners.forEach(callback => callback(isPanning));
}, [isPanning, panStateListeners]);
return null;
}
@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Button, Paper, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import FirstPageIcon from '@mui/icons-material/FirstPage';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import LastPageIcon from '@mui/icons-material/LastPage';
import DescriptionIcon from '@mui/icons-material/Description';
import ViewWeekIcon from '@mui/icons-material/ViewWeek';
import '../../types/embedPdf';
interface PdfViewerToolbarProps {
// Page navigation props (placeholders for now)
currentPage?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
// Dual page toggle (placeholder for now)
dualPage?: boolean;
onDualPageToggle?: () => void;
// Zoom controls (will connect to window.embedPdfZoom)
currentZoom?: number;
}
export function PdfViewerToolbar({
currentPage = 1,
totalPages = 1,
onPageChange,
dualPage = false,
onDualPageToggle,
currentZoom = 100,
}: PdfViewerToolbarProps) {
const { t } = useTranslation();
const [pageInput, setPageInput] = useState(currentPage);
const [dynamicZoom, setDynamicZoom] = useState(currentZoom);
const [dynamicPage, setDynamicPage] = useState(currentPage);
const [dynamicTotalPages, setDynamicTotalPages] = useState(totalPages);
// Update zoom and scroll state from EmbedPDF APIs
useEffect(() => {
const updateState = () => {
// Update zoom
if (window.embedPdfZoom) {
const zoomPercent = window.embedPdfZoom.zoomPercent || currentZoom;
setDynamicZoom(zoomPercent);
}
// Update scroll/page state
if (window.embedPdfScroll) {
const currentPageNum = window.embedPdfScroll.currentPage || currentPage;
const totalPagesNum = window.embedPdfScroll.totalPages || totalPages;
setDynamicPage(currentPageNum);
setDynamicTotalPages(totalPagesNum);
setPageInput(currentPageNum);
}
};
// Update state immediately
updateState();
// Set up periodic updates to keep state in sync
const interval = setInterval(updateState, 200);
return () => clearInterval(interval);
}, [currentZoom, currentPage, totalPages]);
const handleZoomOut = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomOut();
}
};
const handleZoomIn = () => {
if (window.embedPdfZoom) {
window.embedPdfZoom.zoomIn();
}
};
const handlePageNavigation = (page: number) => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPage(page);
} else if (onPageChange) {
onPageChange(page);
}
setPageInput(page);
};
const handleFirstPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToFirstPage();
} else {
handlePageNavigation(1);
}
};
const handlePreviousPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToPreviousPage();
} else {
handlePageNavigation(Math.max(1, dynamicPage - 1));
}
};
const handleNextPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToNextPage();
} else {
handlePageNavigation(Math.min(dynamicTotalPages, dynamicPage + 1));
}
};
const handleLastPage = () => {
if (window.embedPdfScroll) {
window.embedPdfScroll.scrollToLastPage();
} else {
handlePageNavigation(dynamicTotalPages);
}
};
return (
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
pb={12}
style={{
display: "flex",
alignItems: "center",
gap: 10,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: '26.5rem',
}}
>
{/* First Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handleFirstPage}
disabled={dynamicPage === 1}
style={{ minWidth: '2.5rem' }}
title={t("viewer.firstPage", "First Page")}
>
<FirstPageIcon fontSize="small" />
</Button>
{/* Previous Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handlePreviousPage}
disabled={dynamicPage === 1}
style={{ minWidth: '2.5rem' }}
title={t("viewer.previousPage", "Previous Page")}
>
<ArrowBackIosIcon fontSize="small" />
</Button>
{/* Page Input */}
<NumberInput
value={pageInput}
onChange={(value) => {
const page = Number(value);
setPageInput(page);
if (!isNaN(page) && page >= 1 && page <= dynamicTotalPages) {
handlePageNavigation(page);
}
}}
min={1}
max={dynamicTotalPages}
hideControls
styles={{
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
}}
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {dynamicTotalPages}
</span>
{/* Next Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handleNextPage}
disabled={dynamicPage === dynamicTotalPages}
style={{ minWidth: '2.5rem' }}
title={t("viewer.nextPage", "Next Page")}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
{/* Last Page Button */}
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={handleLastPage}
disabled={dynamicPage === dynamicTotalPages}
style={{ minWidth: '2.5rem' }}
title={t("viewer.lastPage", "Last Page")}
>
<LastPageIcon fontSize="small" />
</Button>
{/* Dual Page Toggle */}
<Button
variant={dualPage ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={onDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={handleZoomOut}
style={{ minWidth: '2rem', padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
>
</Button>
<span style={{ minWidth: '2.5rem', textAlign: "center" }}>
{dynamicZoom}%
</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={handleZoomIn}
style={{ minWidth: '2rem', padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>
+
</Button>
</Group>
</Paper>
);
}
@@ -0,0 +1,23 @@
import { useEffect } from 'react';
import { useRotate } from '@embedpdf/plugin-rotate/react';
/**
* Component that runs inside EmbedPDF context and exports rotate controls globally
*/
export function RotateAPIBridge() {
const { provides: rotate, rotation } = useRotate();
useEffect(() => {
if (rotate) {
// Export rotate controls to global window for right rail access
window.embedPdfRotate = {
rotateForward: () => rotate.rotateForward(),
rotateBackward: () => rotate.rotateBackward(),
setRotation: (rotationValue: number) => rotate.setRotation(rotationValue),
getRotation: () => rotation,
};
}
}, [rotate, rotation]);
return null;
}
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
/**
* Component that runs inside EmbedPDF context and exports scroll controls globally
*/
export function ScrollAPIBridge() {
const { provides: scroll, state: scrollState } = useScroll();
useEffect(() => {
if (scroll && scrollState) {
// Export scroll controls to global window for toolbar access
(window as any).embedPdfScroll = {
scrollToPage: (page: number) => scroll.scrollToPage({ pageNumber: page }),
scrollToNextPage: () => scroll.scrollToNextPage(),
scrollToPreviousPage: () => scroll.scrollToPreviousPage(),
scrollToFirstPage: () => scroll.scrollToPage({ pageNumber: 1 }),
scrollToLastPage: () => scroll.scrollToPage({ pageNumber: scrollState.totalPages }),
currentPage: scrollState.currentPage,
totalPages: scrollState.totalPages,
};
}
}, [scroll, scrollState]);
return null;
}
@@ -0,0 +1,52 @@
import { useEffect } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
/**
* Component that runs inside EmbedPDF context and bridges search controls to global window
*/
export function SearchAPIBridge() {
const { provides: search, state } = useSearch();
useEffect(() => {
if (search && state) {
// Export search controls to global window for toolbar access
(window as any).embedPdfSearch = {
search: async (query: string) => {
try {
search.startSearch();
const results = await search.searchAllPages(query);
return results;
} catch (error) {
console.error('Search error:', error);
throw error;
}
},
clearSearch: () => {
search.stopSearch();
},
nextResult: () => {
return search.nextResult();
},
previousResult: () => {
return search.previousResult();
},
goToResult: (index: number) => {
return search.goToResult(index);
},
// State getters
getSearchQuery: () => state.query,
isActive: () => state.active,
isLoading: () => state.loading,
// Current state for UI updates
state: state,
// Debug info
searchAPI: search,
availableMethods: search ? Object.keys(search) : [],
};
}
}, [search, state]);
return null;
}
@@ -0,0 +1,256 @@
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';
interface SearchInterfaceProps {
visible: boolean;
onClose: () => void;
}
export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
const { t } = useTranslation();
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 = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
const state = searchAPI.state;
if (state && state.query && state.active) {
// Try to get result info from the global search data
// The CustomSearchLayer stores results, let's try to access them
const searchResults = (window as any).currentSearchResults;
const activeIndex = (window as any).currentActiveIndex || 1;
setResultInfo({
currentIndex: activeIndex,
totalResults: searchResults ? searchResults.length : 0,
query: state.query
});
} else if (state && !state.active) {
setResultInfo(null);
}
setIsSearching(state ? state.loading : false);
}
};
// Check immediately and then poll for updates
checkSearchState();
const interval = setInterval(checkSearchState, 200);
return () => clearInterval(interval);
}, [visible]);
const handleSearch = async (query: string) => {
if (!query.trim()) {
// If query is empty, clear the search
handleClearSearch();
return;
}
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
setIsSearching(true);
try {
await searchAPI.search(query.trim());
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
}
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSearch(searchQuery);
} else if (event.key === 'Escape') {
onClose();
}
};
const handleNext = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.nextResult();
}
};
const handlePrevious = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.previousResult();
}
};
const handleClearSearch = () => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI) {
searchAPI.clearSearch();
// Also try to explicitly clear highlights if available
if (searchAPI.searchAPI && searchAPI.searchAPI.clearHighlights) {
searchAPI.searchAPI.clearHighlights();
}
}
setSearchQuery('');
setResultInfo(null);
};
// Sync search query with API state on mount
useEffect(() => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI && searchAPI.state && searchAPI.state.query) {
setSearchQuery(searchAPI.state.query);
}
}, []);
const handleJumpToResult = (index: number) => {
const searchAPI = (window as any).embedPdfSearch;
if (searchAPI && resultInfo && index >= 1 && index <= resultInfo.totalResults) {
// Convert 1-based user input to 0-based API index
searchAPI.goToResult(index - 1);
}
};
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 (
<Box
style={{
padding: '0px'
}}
>
{/* Header */}
<Group mb="md">
<Text size="sm" fw={600}>
{t('search.title', 'Search PDF')}
</Text>
</Group>
{/* Search input */}
<Group mb="md">
<TextInput
placeholder={t('search.placeholder', 'Enter search term...')}
value={searchQuery}
onChange={(e) => {
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={
<ActionIcon
variant="subtle"
onClick={() => handleSearch(searchQuery)}
disabled={!searchQuery.trim() || isSearching}
loading={isSearching}
>
<LocalIcon icon="search" width="1rem" height="1rem" />
</ActionIcon>
}
/>
</Group>
{/* Results info and navigation */}
{resultInfo && (
<Group justify="space-between" align="center">
{resultInfo.totalResults === 0 ? (
<Text size="sm" c="dimmed">
{t('search.noResults', 'No results found')}
</Text>
) : (
<Group gap="xs" align="center">
<TextInput
size="xs"
value={jumpToValue}
onChange={(e) => setJumpToValue(e.currentTarget.value)}
onKeyDown={handleJumpToKeyDown}
onBlur={handleJumpToSubmit}
placeholder={resultInfo.currentIndex.toString()}
style={{ width: '3rem' }}
type="number"
min="1"
max={resultInfo.totalResults}
/>
<Text size="sm" c="dimmed">
of {resultInfo.totalResults}
</Text>
</Group>
)}
{resultInfo.totalResults > 0 && (
<Group gap="xs">
<ActionIcon
variant="subtle"
size="sm"
onClick={handlePrevious}
disabled={resultInfo.currentIndex <= 1}
aria-label="Previous result"
>
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleNext}
disabled={resultInfo.currentIndex >= resultInfo.totalResults}
aria-label="Next result"
>
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleClearSearch}
aria-label="Clear search"
>
<LocalIcon icon="close" width="1rem" height="1rem" />
</ActionIcon>
</Group>
)}
</Group>
)}
{/* Loading state */}
{isSearching && (
<Text size="xs" c="dimmed" ta="center" mt="sm">
{t('search.searching', 'Searching...')}
</Text>
)}
</Box>
);
}
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useSelectionCapability, SelectionRangeX } from '@embedpdf/plugin-selection/react';
/**
* Component that runs inside EmbedPDF context and exports selection controls globally
*/
export function SelectionAPIBridge() {
const { provides: selection } = useSelectionCapability();
const [hasSelection, setHasSelection] = useState(false);
useEffect(() => {
if (selection) {
// Export selection controls to global window
(window as any).embedPdfSelection = {
copyToClipboard: () => selection.copyToClipboard(),
getSelectedText: () => selection.getSelectedText(),
getFormattedSelection: () => selection.getFormattedSelection(),
hasSelection: hasSelection,
};
// Listen for selection changes to track when text is selected
const unsubscribe = selection.onSelectionChange((sel: SelectionRangeX | null) => {
const hasText = !!sel;
setHasSelection(hasText);
// Update global state
if ((window as any).embedPdfSelection) {
(window as any).embedPdfSelection.hasSelection = hasText;
}
});
// 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;
}
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
/**
* Component that runs inside EmbedPDF context and exports spread controls globally
*/
export function SpreadAPIBridge() {
const { provides: spread, spreadMode } = useSpread();
useEffect(() => {
if (spread) {
// Export spread controls to global window for toolbar access
(window as any).embedPdfSpread = {
setSpreadMode: (mode: SpreadMode) => {
console.log('EmbedPDF: Setting spread mode to:', mode);
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;
console.log('EmbedPDF: Toggling spread mode from', spreadMode, 'to', newMode);
spread.setSpreadMode(newMode);
},
currentSpreadMode: spreadMode,
isDualPage: spreadMode !== SpreadMode.None,
SpreadMode: SpreadMode, // Export enum for reference
};
console.log('EmbedPDF spread controls exported to window.embedPdfSpread', {
currentSpreadMode: spreadMode,
isDualPage: spreadMode !== SpreadMode.None,
spreadAPI: spread,
availableMethods: Object.keys(spread)
});
}
}, [spread, spreadMode]);
return null;
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
/**
* Component that runs inside EmbedPDF context and exports thumbnail controls globally
*/
export function ThumbnailAPIBridge() {
const { provides: thumbnail } = useThumbnailCapability();
useEffect(() => {
console.log('📄 ThumbnailAPIBridge useEffect:', { thumbnail: !!thumbnail });
if (thumbnail) {
console.log('📄 Exporting thumbnail controls to window:', {
availableMethods: Object.keys(thumbnail),
renderThumb: typeof thumbnail.renderThumb
});
// Export thumbnail controls to global window for debugging
(window as any).embedPdfThumbnail = {
thumbnailAPI: thumbnail,
availableMethods: Object.keys(thumbnail),
};
}
}, [thumbnail]);
return null;
}
@@ -0,0 +1,227 @@
import React, { useState, useEffect } from 'react';
import { Box, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon';
import '../../types/embedPdf';
interface ThumbnailSidebarProps {
visible: boolean;
onToggle: () => void;
colorScheme: 'light' | 'dark' | 'auto';
}
export function ThumbnailSidebar({ visible, onToggle, colorScheme }: ThumbnailSidebarProps) {
const [selectedPage, setSelectedPage] = useState<number>(1);
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
const [totalPages, setTotalPages] = useState<number>(0);
// Convert color scheme
const actualColorScheme = colorScheme === 'auto' ? 'light' : colorScheme;
// Get total pages from scroll API
useEffect(() => {
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.totalPages) {
setTotalPages(scrollAPI.totalPages);
}
}, [visible]);
// Generate thumbnails when sidebar becomes visible
useEffect(() => {
if (!visible || totalPages === 0) return;
const thumbnailAPI = window.embedPdfThumbnail?.thumbnailAPI;
console.log('📄 ThumbnailSidebar useEffect triggered:', {
visible,
thumbnailAPI: !!thumbnailAPI,
totalPages,
existingThumbnails: Object.keys(thumbnails).length
});
if (!thumbnailAPI) return;
const generateThumbnails = async () => {
console.log('📄 Starting thumbnail generation for', totalPages, 'pages');
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
if (thumbnails[pageIndex]) continue; // Skip if already generated
try {
console.log('📄 Attempting to generate thumbnail for page', pageIndex + 1);
const thumbTask = thumbnailAPI.renderThumb(pageIndex, 1.0);
console.log('📄 Received thumbTask:', thumbTask);
// Convert Task to Promise and handle properly
thumbTask.toPromise().then((thumbBlob: Blob) => {
console.log('📄 Thumbnail generated successfully for page', pageIndex + 1, 'blob:', thumbBlob);
const thumbUrl = URL.createObjectURL(thumbBlob);
console.log('📄 Created blob URL:', thumbUrl);
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);
// Set a placeholder or error state
setThumbnails(prev => ({
...prev,
[pageIndex]: 'error'
}));
}
}
};
generateThumbnails();
// Cleanup blob URLs when component unmounts
return () => {
Object.values(thumbnails).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
};
}, [visible, totalPages]);
const handlePageClick = (pageIndex: number) => {
const pageNumber = pageIndex + 1; // Convert to 1-based
setSelectedPage(pageNumber);
// Use scroll API to navigate to page
const scrollAPI = window.embedPdfScroll;
if (scrollAPI && scrollAPI.scrollToPage) {
scrollAPI.scrollToPage(pageNumber);
}
};
return (
<>
{/* Thumbnail Sidebar */}
{visible && (
<Box
style={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#1a1b1e' : '#f8f9fa',
borderLeft: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`,
zIndex: 998,
display: 'flex',
flexDirection: 'column',
boxShadow: '-2px 0 8px rgba(0, 0, 0, 0.1)'
}}
>
{/* Thumbnails Container */}
<ScrollArea style={{ flex: 1 }}>
<Box p="sm">
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
{Array.from({ length: totalPages }, (_, pageIndex) => (
<Box
key={pageIndex}
onClick={() => handlePageClick(pageIndex)}
style={{
cursor: 'pointer',
borderRadius: '8px',
padding: '8px',
backgroundColor: selectedPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#364FC7' : '#e7f5ff')
: 'transparent',
border: selectedPage === pageIndex + 1
? '2px solid #1c7ed6'
: '2px solid transparent',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px'
}}
onMouseEnter={(e) => {
if (selectedPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = actualColorScheme === 'dark' ? '#25262b' : '#f1f3f5';
}
}}
onMouseLeave={(e) => {
if (selectedPage !== pageIndex + 1) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
{/* Thumbnail Image */}
{thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? (
<img
src={thumbnails[pageIndex]}
alt={`Page ${pageIndex + 1} thumbnail`}
style={{
maxWidth: '100%',
height: 'auto',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
border: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`
}}
/>
) : thumbnails[pageIndex] === 'error' ? (
<div style={{
width: '11.5rem',
height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#2d1b1b' : '#ffebee',
border: `1px solid ${actualColorScheme === 'dark' ? '#5d3737' : '#ffcdd2'}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#d32f2f',
fontSize: '12px'
}}>
Failed
</div>
) : (
<div style={{
width: '11.5rem',
height: '15rem',
backgroundColor: actualColorScheme === 'dark' ? '#25262b' : '#f8f9fa',
border: `1px solid ${actualColorScheme === 'dark' ? '#373A40' : '#e9ecef'}`,
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d',
fontSize: '12px'
}}>
Loading...
</div>
)}
{/* Page Number */}
<div style={{
fontSize: '12px',
fontWeight: 500,
color: selectedPage === pageIndex + 1
? (actualColorScheme === 'dark' ? '#ffffff' : '#1c7ed6')
: (actualColorScheme === 'dark' ? '#adb5bd' : '#6c757d')
}}>
Page {pageIndex + 1}
</div>
</Box>
))}
</div>
</Box>
</ScrollArea>
</Box>
)}
</>
);
}
+7 -678
View File
@@ -1,687 +1,16 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import LastPageIcon from "@mui/icons-material/LastPage";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileState } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
import { isFileObject } from "../../types/fileContext";
import { FileId } from "../../types/file";
// Lazy loading page image component
interface LazyPageImageProps {
pageIndex: number;
zoom: number;
theme: any;
isFirst: boolean;
renderPage: (pageIndex: number) => Promise<string | null>;
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<string | null>(null);
const imgRef = useRef<HTMLImageElement>(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 (
<img
ref={imgRef}
src={imageUrl}
alt={`Page ${pageIndex + 1}`}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
}}
/>
);
}
// Placeholder while loading
return (
<div
ref={imgRef}
style={{
width: `${100 * zoom}%`,
maxWidth: 700 * zoom,
height: 800 * zoom, // Estimated height
backgroundColor: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
marginTop: isFirst ? theme.spacing.xl : 0,
border: '1px dashed #ccc'
}}
>
{isVisible ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
margin: '0 auto 8px'
}} />
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
</div>
) : (
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
)}
</div>
);
};
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<string>("0");
// Reset PDF state when switching tabs
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
setNumPages(0);
setPageImages([]);
setCurrentPage(null);
setLoading(true);
};
const [numPages, setNumPages] = useState<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number | null>(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<HTMLDivElement>(null);
const pdfDocRef = useRef<any>(null);
const renderingPagesRef = useRef<Set<number>>(new Set());
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
const preloadingRef = useRef<boolean>(false);
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
const pageNum = pageIndex + 1;
if (pageImages[pageIndex]) {
return pageImages[pageIndex]; // Already rendered
}
renderingPagesRef.current.add(pageIndex);
try {
const page = await pdfDocRef.current.getPage(pageNum);
const viewport = page.getViewport({ scale: 1.2 });
const canvas = document.createElement("canvas");
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d");
if (ctx) {
await page.render({ canvasContext: ctx, viewport }).promise;
const dataUrl = canvas.toDataURL();
// Update the pageImages array
setPageImages(prev => {
const newImages = [...prev];
newImages[pageIndex] = dataUrl;
return newImages;
});
renderingPagesRef.current.delete(pageIndex);
return dataUrl;
}
} catch (error) {
console.error(`Failed to render page ${pageNum}:`, error);
}
renderingPagesRef.current.delete(pageIndex);
return null;
};
// Progressive preloading function
const startProgressivePreload = async () => {
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
preloadingRef.current = true;
// Start with first few pages for immediate viewing
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
// Render priority pages first
for (const pageIndex of priorityPages) {
if (pageIndex < numPages && !pageImages[pageIndex]) {
await renderPage(pageIndex);
// Small delay to allow UI to update
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Then render remaining pages in background
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
if (!pageImages[pageIndex]) {
await renderPage(pageIndex);
// Longer delay for background loading to not block UI
await new Promise(resolve => setTimeout(resolve, 100));
}
}
preloadingRef.current = false;
};
// Initialize current page when PDF loads
useEffect(() => {
if (numPages > 0 && !currentPage) {
setCurrentPage(1);
}
}, [numPages, currentPage]);
// Function to scroll to a specific page
const scrollToPage = (pageNumber: number) => {
const el = pageRefs.current[pageNumber - 1];
const scrollArea = scrollAreaRef.current;
if (el && scrollArea) {
const scrollAreaRect = scrollArea.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentScrollTop = scrollArea.scrollTop;
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
scrollArea.scrollTo({
top: targetScrollTop,
behavior: "smooth"
});
}
};
// Throttled scroll handler to prevent jerky updates
const handleScrollThrottled = useCallback(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea || !pageRefs.current.length) return;
const areaRect = scrollArea.getBoundingClientRect();
const viewportCenter = areaRect.top + areaRect.height / 2;
let closestIdx = 0;
let minDist = Infinity;
pageRefs.current.forEach((img, idx) => {
if (img) {
const imgRect = img.getBoundingClientRect();
const imgCenter = imgRect.top + imgRect.height / 2;
const dist = Math.abs(imgCenter - viewportCenter);
if (dist < minDist) {
minDist = dist;
closestIdx = idx;
}
}
});
// Update page number display only if changed
if (currentPage !== closestIdx + 1) {
setCurrentPage(closestIdx + 1);
}
}, [currentPage]);
// Throttle scroll events to reduce jerkiness
const handleScroll = useCallback(() => {
if (window.requestAnimationFrame) {
window.requestAnimationFrame(handleScrollThrottled);
} else {
handleScrollThrottled();
}
}, [handleScrollThrottled]);
useEffect(() => {
let cancelled = false;
async function loadPdfInfo() {
if (!effectiveFile) {
setNumPages(0);
setPageImages([]);
return;
}
setLoading(true);
try {
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
const arrayBuffer = await previewFile.arrayBuffer();
pdfData = { data: arrayBuffer };
}
// Handle special IndexedDB URLs for large files
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
pdfData = { data: arrayBuffer };
} else if (effectiveFile.url) {
// Standard blob URL or regular URL
pdfData = effectiveFile.url;
} else {
throw new Error('No valid PDF source available');
}
const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
setPageImages(new Array(pdf.numPages).fill(null));
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} catch {
if (!cancelled) {
setPageImages([]);
setNumPages(0);
}
}
if (!cancelled) setLoading(false);
}
loadPdfInfo();
return () => {
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup PDF document using worker manager
if (pdfDocRef.current) {
pdfWorkerManager.destroyDocument(pdfDocRef.current);
pdfDocRef.current = null;
}
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [effectiveFile, previewFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
if (!viewport) return;
const handler = () => {
handleScroll();
};
viewport.addEventListener("scroll", handler);
return () => viewport.removeEventListener("scroll", handler);
}, [pageImages]);
return (
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
borderRadius: '50%',
}}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
</Center>
) : (
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
position: 'relative',
zIndex: 100,
marginTop: '60px' // Push tabs below TopControls
}}
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
</Box>
)}
{loading ? (
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />
</div>
) : (
<ScrollArea
style={{ flex: 1, position: "relative"}}
viewportRef={scrollAreaRef}
>
<Stack gap="xl" align="center" >
{numPages === 0 && (
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
<LazyPageImage
pageIndex={i * 2}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
pageIndex={i * 2 + 1}
zoom={zoom}
theme={theme}
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
)}
</Group>
))
: Array.from({ length: numPages }).map((_, idx) => (
<LazyPageImage
key={idx}
pageIndex={idx}
zoom={zoom}
theme={theme}
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={setPageRef}
/>
))}
</Stack>
{/* Navigation bar overlays the scroll area */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<Paper
radius="xl xl 0 0"
shadow="sm"
p={12}
pb={12}
style={{
display: "flex",
alignItems: "center",
gap: 10,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
pointerEvents: "auto",
minWidth: 420,
maxWidth: 700,
flexWrap: "wrap",
}}
>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(1);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<FirstPageIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const prevPage = Math.max(1, (currentPage || 1) - 1);
scrollToPage(prevPage);
}}
disabled={currentPage === 1}
style={{ minWidth: 36 }}
>
<ArrowBackIosNewIcon fontSize="small" />
</Button>
<NumberInput
value={currentPage || 1}
onChange={value => {
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},
}}
/>
<span style={{ fontWeight: 500, fontSize: 16 }}>
/ {numPages}
</span>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
scrollToPage(nextPage);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<ArrowForwardIosIcon fontSize="small" />
</Button>
<Button
variant="subtle"
color="blue"
size="md"
px={8}
radius="xl"
onClick={() => {
scrollToPage(numPages);
}}
disabled={currentPage === numPages}
style={{ minWidth: 36 }}
>
<LastPageIcon fontSize="small" />
</Button>
<Button
variant={dualPage ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={() => setDualPage(v => !v)}
style={{ minWidth: 36 }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomOut", "Zoom out")}
></Button>
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
style={{ minWidth: 32, padding: 0 }}
title={t("viewer.zoomIn", "Zoom in")}
>+</Button>
</Group>
</Paper>
</div>
</ScrollArea>
)}
</>
)}
</Box>
);
const Viewer = (props: ViewerProps) => {
// Default to EmbedPDF viewer
return <EmbedPdfViewer {...props} />;
};
export default Viewer;
export default Viewer;
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
import { useZoom } from '@embedpdf/plugin-zoom/react';
/**
* Component that runs inside EmbedPDF context and exports zoom controls globally
*/
export function ZoomAPIBridge() {
const { provides: zoom, state: zoomState } = useZoom();
const hasSetInitialZoom = useRef(false);
// Set initial zoom once when plugin is ready
useEffect(() => {
if (zoom && !hasSetInitialZoom.current) {
hasSetInitialZoom.current = true;
setTimeout(() => {
console.log('Setting initial zoom to 140%');
zoom.requestZoom(1.4);
}, 50);
}
}, [zoom]);
useEffect(() => {
if (zoom) {
// Export zoom controls to global window for right rail access
(window as any).embedPdfZoom = {
zoomIn: () => zoom.zoomIn(),
zoomOut: () => zoom.zoomOut(),
toggleMarqueeZoom: () => zoom.toggleMarqueeZoom(),
requestZoom: (level: any) => zoom.requestZoom(level),
currentZoom: zoomState?.currentZoomLevel || 1.4,
zoomPercent: Math.round((zoomState?.currentZoomLevel || 1.4) * 100),
};
}
}, [zoom, zoomState]);
return null;
}
+46
View File
@@ -0,0 +1,46 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ViewerContextType {
// Thumbnail sidebar state
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
setThumbnailSidebarVisible: (visible: boolean) => void;
}
const ViewerContext = createContext<ViewerContextType | null>(null);
interface ViewerProviderProps {
children: ReactNode;
}
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const setThumbnailSidebarVisible = (visible: boolean) => {
setIsThumbnailSidebarVisible(visible);
};
const value: ViewerContextType = {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
setThumbnailSidebarVisible,
};
return (
<ViewerContext.Provider value={value}>
{children}
</ViewerContext.Provider>
);
};
export const useViewer = (): ViewerContextType => {
const context = useContext(ViewerContext);
if (!context) {
throw new Error('useViewer must be used within a ViewerProvider');
}
return context;
};
+5 -1
View File
@@ -15,4 +15,8 @@ declare module '../assets/material-symbols-icons.json' {
height?: number;
};
export default value;
}
}
// TODO: Add proper EmbedPDF types for local submodule integration
export {};
+57
View File
@@ -0,0 +1,57 @@
// Types for EmbedPDF global APIs
export interface EmbedPdfZoomAPI {
zoomPercent: number;
zoomIn: () => void;
zoomOut: () => void;
}
export interface EmbedPdfScrollAPI {
currentPage: number;
totalPages: number;
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
}
export interface EmbedPdfPanAPI {
isPanning: boolean;
togglePan: () => void;
}
export interface EmbedPdfSpreadAPI {
toggleSpreadMode: () => void;
}
export interface EmbedPdfRotateAPI {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
export interface EmbedPdfControlsAPI {
pointer: () => void;
}
export interface EmbedPdfThumbnailAPI {
thumbnailAPI: {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
};
};
}
declare global {
interface Window {
embedPdfZoom?: EmbedPdfZoomAPI;
embedPdfScroll?: EmbedPdfScrollAPI;
embedPdfPan?: EmbedPdfPanAPI;
embedPdfSpread?: EmbedPdfSpreadAPI;
embedPdfRotate?: EmbedPdfRotateAPI;
embedPdfControls?: EmbedPdfControlsAPI;
embedPdfThumbnail?: EmbedPdfThumbnailAPI;
toggleThumbnailSidebar?: () => void;
}
}
+4 -3
View File
@@ -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. */
+1
View File
@@ -12,4 +12,5 @@ export default defineConfig({
},
},
},
base: "http://localhost:8080",
});