mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-22 19:46:39 +00:00
Merge branch 'V2' into V2-crop-pdf
This commit is contained in:
commit
afa726be14
418
frontend/package-lock.json
generated
418
frontend/package-lock.json
generated
@ -10,6 +10,21 @@
|
|||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
"@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/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
@ -597,6 +612,258 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.13.5",
|
"version": "11.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||||
@ -1620,9 +1887,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
@ -3421,14 +3688,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.19",
|
"version": "3.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz",
|
||||||
"integrity": "sha512-/afpyvlkrSNYbPo94Qu8GtIOWS+g5TRdOvs6XZNw6pWQQmj5pBgSZvEPOIZlqWq0YvoUhDDQaQ2TnzuJdOV4hA==",
|
"integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.3",
|
"@babel/parser": "^7.28.3",
|
||||||
"@vue/shared": "3.5.19",
|
"@vue/shared": "3.5.21",
|
||||||
"entities": "^4.5.0",
|
"entities": "^4.5.0",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
@ -3438,7 +3704,6 @@
|
|||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
@ -3451,34 +3716,31 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.19",
|
"version": "3.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz",
|
||||||
"integrity": "sha512-Drs6rPHQZx/pN9S6ml3Z3K/TWCIRPvzG2B/o5kFK9X0MNHt8/E+38tiRfojufrYBfA6FQUFB2qBBRXlcSXWtOA==",
|
"integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.19",
|
"@vue/compiler-core": "3.5.21",
|
||||||
"@vue/shared": "3.5.19"
|
"@vue/shared": "3.5.21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.19",
|
"version": "3.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz",
|
||||||
"integrity": "sha512-YWCm1CYaJ+2RvNmhCwI7t3I3nU+hOrWGWMsn+Z/kmm1jy5iinnVtlmkiZwbLlbV1SRizX7vHsc0/bG5dj0zRTg==",
|
"integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.3",
|
"@babel/parser": "^7.28.3",
|
||||||
"@vue/compiler-core": "3.5.19",
|
"@vue/compiler-core": "3.5.21",
|
||||||
"@vue/compiler-dom": "3.5.19",
|
"@vue/compiler-dom": "3.5.21",
|
||||||
"@vue/compiler-ssr": "3.5.19",
|
"@vue/compiler-ssr": "3.5.21",
|
||||||
"@vue/shared": "3.5.19",
|
"@vue/shared": "3.5.21",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.17",
|
"magic-string": "^0.30.18",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
}
|
}
|
||||||
@ -3487,25 +3749,70 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.19",
|
"version": "3.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz",
|
||||||
"integrity": "sha512-/wx0VZtkWOPdiQLWPeQeqpHWR/LuNC7bHfSX7OayBTtUy8wur6vT6EQIX6Et86aED6J+y8tTw43qo2uoqGg5sw==",
|
"integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.19",
|
"@vue/compiler-dom": "3.5.21",
|
||||||
"@vue/shared": "3.5.19"
|
"@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": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.19",
|
"version": "3.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.19.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz",
|
||||||
"integrity": "sha512-IhXCOn08wgKrLQxRFKKlSacWg4Goi1BolrdEeLYn6tgHjJNXVrWJ5nzoxZqNwl5p88aLlQ8LOaoMa3AYvaKJ/Q==",
|
"integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/abbrev": {
|
"node_modules/abbrev": {
|
||||||
@ -5668,6 +5975,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@ -6964,12 +7280,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.19",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz",
|
||||||
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
|
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/magicast": {
|
"node_modules/magicast": {
|
||||||
@ -11304,6 +11620,28 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/w3c-xmlserializer": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
@ -6,6 +6,21 @@
|
|||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
"@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/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@iconify/react": "^6.0.0",
|
"@iconify/react": "^6.0.0",
|
||||||
|
@ -2607,7 +2607,12 @@
|
|||||||
"downloadSelected": "Download Selected Files",
|
"downloadSelected": "Download Selected Files",
|
||||||
"downloadAll": "Download All",
|
"downloadAll": "Download All",
|
||||||
"toggleTheme": "Toggle Theme",
|
"toggleTheme": "Toggle Theme",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"search": "Search PDF",
|
||||||
|
"panMode": "Pan Mode",
|
||||||
|
"rotateLeft": "Rotate Left",
|
||||||
|
"rotateRight": "Rotate Right",
|
||||||
|
"toggleSidebar": "Toggle Sidebar"
|
||||||
},
|
},
|
||||||
"toolPicker": {
|
"toolPicker": {
|
||||||
"searchPlaceholder": "Search tools...",
|
"searchPlaceholder": "Search tools...",
|
||||||
@ -2973,5 +2978,15 @@
|
|||||||
"processImages": "Process Images",
|
"processImages": "Process Images",
|
||||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import "./styles/tailwind.css";
|
|||||||
import "./styles/cookieconsent.css";
|
import "./styles/cookieconsent.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { RightRailProvider } from "./contexts/RightRailContext";
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
import { ViewerProvider } from "./contexts/ViewerContext";
|
||||||
|
|
||||||
// Import file ID debugging helpers (development only)
|
// Import file ID debugging helpers (development only)
|
||||||
import "./utils/fileIdSafety";
|
import "./utils/fileIdSafety";
|
||||||
@ -43,9 +44,11 @@ export default function App() {
|
|||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
<ToolWorkflowProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<RightRailProvider>
|
<ViewerProvider>
|
||||||
<HomePage />
|
<RightRailProvider>
|
||||||
</RightRailProvider>
|
<HomePage />
|
||||||
|
</RightRailProvider>
|
||||||
|
</ViewerProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
|
@ -157,7 +157,6 @@ export default function Workbench() {
|
|||||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
marginTop: '1rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
@ -7,14 +7,23 @@ import { useRightRail } from '../../contexts/RightRailContext';
|
|||||||
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
||||||
import { useNavigationState } from '../../contexts/NavigationContext';
|
import { useNavigationState } from '../../contexts/NavigationContext';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import LanguageSelector from '../shared/LanguageSelector';
|
import LanguageSelector from '../shared/LanguageSelector';
|
||||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
import { Tooltip } from '../shared/Tooltip';
|
import { Tooltip } from '../shared/Tooltip';
|
||||||
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||||
|
import { SearchInterface } from '../viewer/SearchInterface';
|
||||||
|
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||||
|
|
||||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||||
|
|
||||||
|
|
||||||
export default function RightRail() {
|
export default function RightRail() {
|
||||||
const { t } = useTranslation();
|
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 { toggleTheme } = useRainbowThemeContext();
|
||||||
const { buttons, actions } = useRightRail();
|
const { buttons, actions } = useRightRail();
|
||||||
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
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 */}
|
||||||
|
<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={() => {
|
||||||
|
viewerContext?.panActions.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={() => {
|
||||||
|
viewerContext?.rotationActions.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={() => {
|
||||||
|
viewerContext?.rotationActions.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={() => {
|
||||||
|
viewerContext?.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 */}
|
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||||
<div
|
<div
|
||||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||||
@ -224,6 +332,7 @@ export default function RightRail() {
|
|||||||
</div>
|
</div>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
|
|
||||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||||
<BulkSelectionPanel
|
<BulkSelectionPanel
|
||||||
csvInput={csvInput}
|
csvInput={csvInput}
|
||||||
|
120
frontend/src/components/viewer/CustomSearchLayer.tsx
Normal file
120
frontend/src/components/viewer/CustomSearchLayer.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useSearch } from '@embedpdf/plugin-search/react';
|
||||||
|
import { useViewer } from '../../contexts/ViewerContext';
|
||||||
|
import { SEARCH_CONSTANTS } from './constants/search';
|
||||||
|
|
||||||
|
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 = 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<SearchResultState | null>(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 (
|
||||||
|
<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.2s ease-in-out, background-color 0.2s ease-in-out',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
boxShadow: originalIndex === searchResultState?.activeResultIndex
|
||||||
|
? `0 0 0 1px ${SEARCH_CONSTANTS.HIGHLIGHT_COLORS.ACTIVE_BACKGROUND}80`
|
||||||
|
: 'none'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
233
frontend/src/components/viewer/EmbedPdfViewer.tsx
Normal file
233
frontend/src/components/viewer/EmbedPdfViewer.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||||
|
<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,
|
||||||
|
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||||
|
transition: 'margin-right 0.3s ease'
|
||||||
|
}}>
|
||||||
|
<LocalEmbedPDF
|
||||||
|
file={effectiveFile.file}
|
||||||
|
url={effectiveFile.url}
|
||||||
|
/>
|
||||||
|
</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={scrollState.currentPage}
|
||||||
|
totalPages={scrollState.totalPages}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
// Page navigation handled by scrollActions
|
||||||
|
console.log('Navigate to page:', page);
|
||||||
|
}}
|
||||||
|
dualPage={spreadState.isDualPage}
|
||||||
|
onDualPageToggle={() => {
|
||||||
|
spreadActions.toggleSpreadMode();
|
||||||
|
}}
|
||||||
|
currentZoom={zoomState.zoomPercent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Thumbnail Sidebar */}
|
||||||
|
<ThumbnailSidebar
|
||||||
|
visible={isThumbnailSidebarVisible}
|
||||||
|
onToggle={toggleThumbnailSidebar}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedPdfViewer = (props: EmbedPdfViewerProps) => {
|
||||||
|
return <EmbedPdfViewerContent {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EmbedPdfViewer;
|
226
frontend/src/components/viewer/LocalEmbedPDF.tsx
Normal file
226
frontend/src/components/viewer/LocalEmbedPDF.tsx
Normal file
@ -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<string | null>(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 (
|
||||||
|
<Center h="100%" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<div style={{ fontSize: '24px' }}>📄</div>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
No PDF provided
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !engine || !pdfUrl) {
|
||||||
|
return <ToolLoadingFallback toolName="PDF Engine" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Center h="100%" w="100%">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<div style={{ fontSize: '24px' }}>❌</div>
|
||||||
|
<Text c="red" size="sm" style={{ textAlign: 'center' }}>
|
||||||
|
Error loading PDF engine: {error.message}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: 'var(--bg-surface)',
|
||||||
|
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()}
|
||||||
|
>
|
||||||
|
{/* High-resolution tile layer */}
|
||||||
|
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
|
||||||
|
{/* Search highlight layer */}
|
||||||
|
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
|
||||||
|
{/* Selection layer for text interaction */}
|
||||||
|
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||||
|
</div>
|
||||||
|
</PagePointerProvider>
|
||||||
|
</Rotate>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Viewport>
|
||||||
|
</GlobalPointerProvider>
|
||||||
|
</EmbedPDF>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
45
frontend/src/components/viewer/PanAPIBridge.tsx
Normal file
45
frontend/src/components/viewer/PanAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
232
frontend/src/components/viewer/PdfViewerToolbar.tsx
Normal file
232
frontend/src/components/viewer/PdfViewerToolbar.tsx
Normal file
@ -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 (
|
||||||
|
<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={scrollState.currentPage === 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={scrollState.currentPage === 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 <= scrollState.totalPages) {
|
||||||
|
handlePageNavigation(page);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={scrollState.totalPages}
|
||||||
|
hideControls
|
||||||
|
styles={{
|
||||||
|
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
/ {scrollState.totalPages}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Next Page Button */}
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
px={8}
|
||||||
|
radius="xl"
|
||||||
|
onClick={handleNextPage}
|
||||||
|
disabled={scrollState.currentPage === scrollState.totalPages}
|
||||||
|
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={scrollState.currentPage === scrollState.totalPages}
|
||||||
|
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" }}>
|
||||||
|
{displayZoomPercent}%
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
39
frontend/src/components/viewer/RotateAPIBridge.tsx
Normal file
39
frontend/src/components/viewer/RotateAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
31
frontend/src/components/viewer/ScrollAPIBridge.tsx
Normal file
31
frontend/src/components/viewer/ScrollAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
71
frontend/src/components/viewer/SearchAPIBridge.tsx
Normal file
71
frontend/src/components/viewer/SearchAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
241
frontend/src/components/viewer/SearchInterface.tsx
Normal file
241
frontend/src/components/viewer/SearchInterface.tsx
Normal file
@ -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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
65
frontend/src/components/viewer/SelectionAPIBridge.tsx
Normal file
65
frontend/src/components/viewer/SelectionAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
39
frontend/src/components/viewer/SpreadAPIBridge.tsx
Normal file
39
frontend/src/components/viewer/SpreadAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
23
frontend/src/components/viewer/ThumbnailAPIBridge.tsx
Normal file
23
frontend/src/components/viewer/ThumbnailAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
198
frontend/src/components/viewer/ThumbnailSidebar.tsx
Normal file
198
frontend/src/components/viewer/ThumbnailSidebar.tsx
Normal file
@ -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 && (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: '15rem',
|
||||||
|
backgroundColor: 'var(--bg-surface)',
|
||||||
|
borderLeft: '1px solid var(--border-subtle)',
|
||||||
|
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: scrollState.totalPages }, (_, pageIndex) => (
|
||||||
|
<Box
|
||||||
|
key={pageIndex}
|
||||||
|
onClick={() => 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' ? (
|
||||||
|
<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 var(--border-subtle)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : thumbnails[pageIndex] === 'error' ? (
|
||||||
|
<div style={{
|
||||||
|
width: '11.5rem',
|
||||||
|
height: '15rem',
|
||||||
|
backgroundColor: 'var(--color-red-50)',
|
||||||
|
border: '1px solid var(--color-red-200)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--color-red-500)',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
Failed
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
width: '11.5rem',
|
||||||
|
height: '15rem',
|
||||||
|
backgroundColor: 'var(--bg-muted)',
|
||||||
|
border: '1px solid var(--border-subtle)',
|
||||||
|
borderRadius: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page Number */}
|
||||||
|
<div style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: scrollState.currentPage === pageIndex + 1
|
||||||
|
? 'var(--color-primary-500)'
|
||||||
|
: 'var(--text-muted)'
|
||||||
|
}}>
|
||||||
|
Page {pageIndex + 1}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</ScrollArea>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,688 +1,16 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
import React from 'react';
|
||||||
import { Paper, Stack, Text, ScrollArea, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
import EmbedPdfViewer from './EmbedPdfViewer';
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ViewerProps {
|
export interface ViewerProps {
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
previewFile: File | null; // For preview mode - bypasses context
|
previewFile?: File | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer = ({
|
const Viewer = (props: ViewerProps) => {
|
||||||
onClose,
|
// Default to EmbedPDF viewer
|
||||||
previewFile,
|
return <EmbedPdfViewer {...props} />;
|
||||||
}: 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 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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Viewer;
|
export default Viewer;
|
44
frontend/src/components/viewer/ZoomAPIBridge.tsx
Normal file
44
frontend/src/components/viewer/ZoomAPIBridge.tsx
Normal file
@ -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;
|
||||||
|
}
|
15
frontend/src/components/viewer/constants/search.ts
Normal file
15
frontend/src/components/viewer/constants/search.ts
Normal file
@ -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;
|
543
frontend/src/contexts/ViewerContext.tsx
Normal file
543
frontend/src/contexts/ViewerContext.tsx
Normal file
@ -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<any>;
|
||||||
|
clear: () => void;
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThumbnailAPIWrapper {
|
||||||
|
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 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<TState = unknown, TApi = unknown> {
|
||||||
|
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<void>;
|
||||||
|
next: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bridge registration - internal use by bridges
|
||||||
|
registerBridge: (type: string, ref: BridgeRef) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewerContext = createContext<ViewerContextType | null>(null);
|
||||||
|
|
||||||
|
interface ViewerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ 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<ScrollState, ScrollAPIWrapper> | null,
|
||||||
|
zoom: null as BridgeRef<ZoomState, ZoomAPIWrapper> | null,
|
||||||
|
pan: null as BridgeRef<PanState, PanAPIWrapper> | null,
|
||||||
|
selection: null as BridgeRef<SelectionState, SelectionAPIWrapper> | null,
|
||||||
|
search: null as BridgeRef<SearchState, SearchAPIWrapper> | null,
|
||||||
|
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
|
||||||
|
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
|
||||||
|
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | 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<ScrollState, ScrollAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'zoom':
|
||||||
|
bridgeRefs.current.zoom = ref as BridgeRef<ZoomState, ZoomAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'pan':
|
||||||
|
bridgeRefs.current.pan = ref as BridgeRef<PanState, PanAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'selection':
|
||||||
|
bridgeRefs.current.selection = ref as BridgeRef<SelectionState, SelectionAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'search':
|
||||||
|
bridgeRefs.current.search = ref as BridgeRef<SearchState, SearchAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'spread':
|
||||||
|
bridgeRefs.current.spread = ref as BridgeRef<SpreadState, SpreadAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'rotation':
|
||||||
|
bridgeRefs.current.rotation = ref as BridgeRef<RotationState, RotationAPIWrapper>;
|
||||||
|
break;
|
||||||
|
case 'thumbnail':
|
||||||
|
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
|
||||||
|
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 (
|
||||||
|
<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;
|
||||||
|
};
|
6
frontend/src/global.d.ts
vendored
6
frontend/src/global.d.ts
vendored
@ -15,4 +15,8 @@ declare module '../assets/material-symbols-icons.json' {
|
|||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
export default value;
|
export default value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Add proper EmbedPDF types for local submodule integration
|
||||||
|
|
||||||
|
export {};
|
||||||
|
@ -30,6 +30,17 @@
|
|||||||
--color-primary-800: #1e40af;
|
--color-primary-800: #1e40af;
|
||||||
--color-primary-900: #1e3a8a;
|
--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-50: #f9fafb;
|
||||||
--color-gray-100: #f3f4f6;
|
--color-gray-100: #f3f4f6;
|
||||||
--color-gray-200: #e5e7eb;
|
--color-gray-200: #e5e7eb;
|
||||||
@ -141,6 +152,7 @@
|
|||||||
--text-brand: var(--color-gray-700);
|
--text-brand: var(--color-gray-700);
|
||||||
--text-brand-accent: #DC2626;
|
--text-brand-accent: #DC2626;
|
||||||
|
|
||||||
|
|
||||||
/* Placeholder text colors */
|
/* Placeholder text colors */
|
||||||
--search-text-and-icon-color: #6B7382;
|
--search-text-and-icon-color: #6B7382;
|
||||||
|
|
||||||
@ -205,6 +217,17 @@
|
|||||||
--border: 55 65 81;
|
--border: 55 65 81;
|
||||||
|
|
||||||
/* Dark theme Mantine colors */
|
/* 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-50: #111827;
|
||||||
--color-gray-100: #1F2329;
|
--color-gray-100: #1F2329;
|
||||||
--color-gray-200: #2A2F36;
|
--color-gray-200: #2A2F36;
|
||||||
@ -321,6 +344,7 @@
|
|||||||
--tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */
|
--tool-subcategory-text-color: #9CA3AF; /* lighter text in dark mode as well */
|
||||||
--tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */
|
--tool-subcategory-rule-color: #3A4047; /* doubly lighter (relative) line in dark */
|
||||||
|
|
||||||
|
|
||||||
/* Placeholder text colors (dark mode) */
|
/* Placeholder text colors (dark mode) */
|
||||||
--search-text-and-icon-color: #FFFFFF !important;
|
--search-text-and-icon-color: #FFFFFF !important;
|
||||||
|
|
||||||
|
@ -28,9 +28,10 @@
|
|||||||
/* Modules */
|
/* Modules */
|
||||||
"module": "esnext", /* Specify what module code is generated. */
|
"module": "esnext", /* Specify what module code is generated. */
|
||||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
"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. */
|
"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. */
|
"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. */
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
// "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. */
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
@ -12,4 +12,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
base: "./",
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user