diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 43ee35e16..deb9b1a05 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,24 +10,24 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", @@ -441,7 +441,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -488,7 +487,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -512,7 +510,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.4.1", "@embedpdf/models": "1.4.1" @@ -596,7 +593,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -613,7 +609,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -631,7 +626,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -668,7 +662,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -703,7 +696,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -740,7 +732,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -816,7 +807,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.4.1" }, @@ -972,7 +962,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1016,7 +1005,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2047,7 +2035,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz", "integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2098,7 +2085,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz", "integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2166,7 +2152,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.4", @@ -3850,7 +3835,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4174,7 +4158,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4185,7 +4168,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4246,7 +4228,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -4960,6 +4941,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz", "integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.22" } @@ -4969,6 +4951,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz", "integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/shared": "3.5.22" @@ -4979,6 +4962,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz", "integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.22", "@vue/runtime-core": "3.5.22", @@ -4991,6 +4975,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz", "integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.22", "@vue/shared": "3.5.22" @@ -5017,7 +5002,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5702,7 +5686,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6748,8 +6731,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7144,7 +7126,6 @@ "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7315,7 +7296,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -8638,7 +8618,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9446,7 +9425,6 @@ "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/dom-selector": "^6.7.2", "cssstyle": "^5.3.1", @@ -11223,7 +11201,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11503,7 +11480,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11876,7 +11852,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11886,7 +11861,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13321,9 +13295,9 @@ } }, "node_modules/svelte": { - "version": "5.42.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz", - "integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==", + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz", + "integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==", "license": "MIT", "peer": true, "dependencies": { @@ -13557,7 +13531,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13859,7 +13832,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13942,7 +13914,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14147,7 +14118,6 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14299,7 +14269,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14313,7 +14282,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/frontend/package.json b/frontend/package.json index 825749f3e..7b9480ece 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,24 +6,24 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", - "@embedpdf/core": "^1.3.14", - "@embedpdf/engines": "^1.3.14", - "@embedpdf/plugin-annotation": "^1.3.14", - "@embedpdf/plugin-export": "^1.3.14", - "@embedpdf/plugin-history": "^1.3.14", - "@embedpdf/plugin-interaction-manager": "^1.3.14", - "@embedpdf/plugin-loader": "^1.3.14", - "@embedpdf/plugin-pan": "^1.3.14", - "@embedpdf/plugin-render": "^1.3.14", - "@embedpdf/plugin-rotate": "^1.3.14", - "@embedpdf/plugin-scroll": "^1.3.14", - "@embedpdf/plugin-search": "^1.3.14", - "@embedpdf/plugin-selection": "^1.3.14", - "@embedpdf/plugin-spread": "^1.3.14", - "@embedpdf/plugin-thumbnail": "^1.3.14", - "@embedpdf/plugin-tiling": "^1.3.14", - "@embedpdf/plugin-viewport": "^1.3.14", - "@embedpdf/plugin-zoom": "^1.3.14", + "@embedpdf/core": "^1.4.1", + "@embedpdf/engines": "^1.4.1", + "@embedpdf/plugin-annotation": "^1.4.1", + "@embedpdf/plugin-export": "^1.4.1", + "@embedpdf/plugin-history": "^1.4.1", + "@embedpdf/plugin-interaction-manager": "^1.4.1", + "@embedpdf/plugin-loader": "^1.4.1", + "@embedpdf/plugin-pan": "^1.4.1", + "@embedpdf/plugin-render": "^1.4.1", + "@embedpdf/plugin-rotate": "^1.4.1", + "@embedpdf/plugin-scroll": "^1.4.1", + "@embedpdf/plugin-search": "^1.4.1", + "@embedpdf/plugin-selection": "^1.4.1", + "@embedpdf/plugin-spread": "^1.4.1", + "@embedpdf/plugin-thumbnail": "^1.4.1", + "@embedpdf/plugin-tiling": "^1.4.1", + "@embedpdf/plugin-viewport": "^1.4.1", + "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index f85058a73..5ec3e6eb7 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -35,14 +35,12 @@ const EmbedPdfViewerContent = ({ const viewerRef = React.useRef(null); const [isViewerHovered, setIsViewerHovered] = React.useState(false); - const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); // Register viewer right-rail buttons useViewerRightRailButtons(); const scrollState = getScrollState(); - const zoomState = getZoomState(); - const spreadState = getSpreadState(); const rotationState = getRotationState(); // Track initial rotation to detect changes @@ -320,15 +318,6 @@ const EmbedPdfViewerContent = ({ { - // Page navigation handled by scrollActions - console.log('Navigate to page:', page); - }} - dualPage={spreadState.isDualPage} - onDualPageToggle={() => { - spreadActions.toggleSpreadMode(); - }} - currentZoom={zoomState.zoomPercent} /> diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 0d963eeca..62d60e14f 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -9,7 +9,7 @@ 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 { ZoomPluginPackage, ZoomMode } 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'; @@ -115,9 +115,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur // Register zoom plugin with configuration createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: 1.4, // Start at 140% zoom for better readability + defaultZoomLevel: ZoomMode.FitWidth, // Start with FitWidth, will be adjusted in ZoomAPIBridge minZoom: 0.2, - maxZoom: 3.0, + maxZoom: 5.0, }), // Register tiling plugin (depends on Render, Scroll, Viewport) @@ -287,6 +287,8 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur minHeight: 0, minWidth: 0, contain: 'strict', + display: 'flex', + justifyContent: 'center', }} >
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 { + getScrollState, + getZoomState, + getSpreadState, + scrollActions, + zoomActions, + spreadActions, + registerImmediateZoomUpdate, + registerImmediateScrollUpdate, + registerImmediateSpreadUpdate, + } = useViewer(); const scrollState = getScrollState(); const zoomState = getZoomState(); + const spreadState = getSpreadState(); const [pageInput, setPageInput] = useState(scrollState.currentPage || currentPage); const [displayZoomPercent, setDisplayZoomPercent] = useState(zoomState.zoomPercent || 140); + const [isDualPageActive, setIsDualPageActive] = useState(spreadState.isDualPage); // Register for immediate scroll updates and sync with actual scroll state useEffect(() => { @@ -53,6 +55,13 @@ export function PdfViewerToolbar({ setDisplayZoomPercent(zoomState.zoomPercent || 140); }, [zoomState.zoomPercent, registerImmediateZoomUpdate]); + useEffect(() => { + registerImmediateSpreadUpdate((_mode, isDual) => { + setIsDualPageActive(isDual); + }); + setIsDualPageActive(spreadState.isDualPage); + }, [registerImmediateSpreadUpdate, spreadState.isDualPage]); + const handleZoomOut = () => { zoomActions.zoomOut(); }; @@ -69,6 +78,10 @@ export function PdfViewerToolbar({ setPageInput(page); }; + const handleDualPageToggle = () => { + spreadActions.toggleSpreadMode(); + }; + const handleFirstPage = () => { scrollActions.scrollToFirstPage(); }; @@ -188,15 +201,19 @@ export function PdfViewerToolbar({ {/* Dual Page Toggle */} {/* Zoom Controls */} diff --git a/frontend/src/core/components/viewer/SpreadAPIBridge.tsx b/frontend/src/core/components/viewer/SpreadAPIBridge.tsx index e256ecc8d..1163e7c7c 100644 --- a/frontend/src/core/components/viewer/SpreadAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SpreadAPIBridge.tsx @@ -7,33 +7,36 @@ import { useViewer } from '@app/contexts/ViewerContext'; */ export function SpreadAPIBridge() { const { provides: spread, spreadMode } = useSpread(); - const { registerBridge } = useViewer(); + const { registerBridge, triggerImmediateSpreadUpdate } = 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 - } - }); + if (!spread) { + return; } - }, [spread, spreadMode]); + + const newState = { + spreadMode, + isDualPage: spreadMode !== SpreadMode.None, + }; + + registerBridge('spread', { + state: newState, + api: { + setSpreadMode: (mode: SpreadMode) => { + spread.setSpreadMode(mode); + }, + getSpreadMode: () => spread.getSpreadMode(), + toggleSpreadMode: () => { + const current = spread.getSpreadMode(); + const nextMode = current === SpreadMode.None ? SpreadMode.Odd : SpreadMode.None; + spread.setSpreadMode(nextMode); + }, + SpreadMode, + }, + }); + + triggerImmediateSpreadUpdate(spreadMode); + }, [spread, spreadMode, registerBridge, triggerImmediateSpreadUpdate]); return null; } diff --git a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx index 000bf47d9..44a4c403e 100644 --- a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx @@ -1,68 +1,246 @@ -import { useEffect, useRef } from 'react'; -import { useZoom } from '@embedpdf/plugin-zoom/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useZoom, ZoomMode } from '@embedpdf/plugin-zoom/react'; +import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react'; import { useViewer } from '@app/contexts/ViewerContext'; +import { useFileState } from '@app/contexts/FileContext'; +import { + determineAutoZoom, + DEFAULT_FALLBACK_ZOOM, + DEFAULT_VISIBILITY_THRESHOLD, + measureRenderedPageRect, + useFitWidthResize, + ZoomViewport, +} from '@app/utils/viewerZoom'; +import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata'; -/** - * Component that runs inside EmbedPDF context and manages zoom state locally - */ export function ZoomAPIBridge() { const { provides: zoom, state: zoomState } = useZoom(); + const { spreadMode } = useSpread(); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const hasSetInitialZoom = useRef(false); + const { selectors } = useFileState(); + + const hasSetInitialZoom = useRef(false); + const lastSpreadMode = useRef(spreadMode ?? SpreadMode.None); + const lastFileId = useRef(undefined); + const lastAppliedZoom = useRef(null); + const [autoZoomTick, setAutoZoomTick] = useState(0); + + const scheduleAutoZoom = useCallback(() => { + hasSetInitialZoom.current = false; + lastAppliedZoom.current = null; + setAutoZoomTick((tick) => tick + 1); + }, []); + + const requestFitWidth = useCallback(() => { + if (zoom) { + zoom.requestZoom(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }); + } + }, [zoom]); + + const stubs = selectors.getStirlingFileStubs(); + const firstFileStub = stubs[0]; + const firstFileId = firstFileStub?.id; - // Set initial zoom once when plugin is ready useEffect(() => { - if (!zoom || hasSetInitialZoom.current) { + if (!firstFileId) { + hasSetInitialZoom.current = false; + lastFileId.current = undefined; + lastAppliedZoom.current = null; return; } - let retryTimer: ReturnType | undefined; - const attemptInitialZoom = () => { - try { - zoom.requestZoom(1.4); - hasSetInitialZoom.current = true; - } catch (error) { - console.log('Zoom initialization delayed, viewport not ready:', error); - retryTimer = setTimeout(() => { - try { - zoom.requestZoom(1.4); - hasSetInitialZoom.current = true; - } catch (retryError) { - console.log('Zoom initialization failed:', retryError); - } - }, 200); - } - }; - - const timer = setTimeout(attemptInitialZoom, 50); - - return () => { - clearTimeout(timer); - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [zoom, zoomState]); + if (firstFileId !== lastFileId.current) { + lastFileId.current = firstFileId; + scheduleAutoZoom(); + } + }, [firstFileId, scheduleAutoZoom]); useEffect(() => { - if (zoom && zoomState) { - // Update local state - const currentZoomLevel = zoomState.currentZoomLevel ?? 1.4; - const newState = { - currentZoom: currentZoomLevel, - zoomPercent: Math.round(currentZoomLevel * 100), - }; + const currentSpreadMode = spreadMode ?? SpreadMode.None; + if (currentSpreadMode !== lastSpreadMode.current) { + lastSpreadMode.current = currentSpreadMode; - // Trigger immediate update for responsive UI - triggerImmediateZoomUpdate(newState.zoomPercent); - - // Register this bridge with ViewerContext - registerBridge('zoom', { - state: newState, - api: zoom - }); + const hadTrackedAutoZoom = lastAppliedZoom.current !== null; + const zoomLevel = zoomState?.zoomLevel; + if ( + zoomLevel === ZoomMode.FitWidth || + zoomLevel === ZoomMode.Automatic || + hadTrackedAutoZoom + ) { + requestFitWidth(); + scheduleAutoZoom(); + } } - }, [zoom, zoomState]); + }, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]); + + const getViewportSnapshot = useCallback((): ZoomViewport | null => { + if (!zoomState || typeof zoomState !== 'object') { + return null; + } + + if ('viewport' in zoomState) { + const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport; + return candidate ?? null; + } + + return null; + }, [zoomState]); + + const isManagedZoom = + !!zoom && + (zoomState?.zoomLevel === ZoomMode.FitWidth || + zoomState?.zoomLevel === ZoomMode.Automatic || + lastAppliedZoom.current !== null); + + useFitWidthResize({ + isManaged: isManagedZoom, + requestFitWidth, + onDebouncedResize: scheduleAutoZoom, + }); + + useEffect(() => { + if (!zoom || !zoomState) { + return; + } + + if (!firstFileId) { + return; + } + + if (hasSetInitialZoom.current) { + return; + } + + if (zoomState.zoomLevel !== ZoomMode.FitWidth) { + if (zoomState.zoomLevel === ZoomMode.Automatic) { + requestFitWidth(); + } + return; + } + + const fitWidthZoom = zoomState.currentZoomLevel; + if (!fitWidthZoom || fitWidthZoom <= 0) { + return; + } + + const applyTrackedZoom = (level: number | ZoomMode, effectiveZoom: number) => { + zoom.requestZoom(level, { vx: 0.5, vy: 0 }); + lastAppliedZoom.current = effectiveZoom; + triggerImmediateZoomUpdate(Math.round(effectiveZoom * 100)); + hasSetInitialZoom.current = true; + }; + + let cancelled = false; + + const applyAutoZoom = async () => { + const currentSpreadMode = spreadMode ?? SpreadMode.None; + const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1; + const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub); + + const viewport = getViewportSnapshot(); + + if (cancelled) { + return; + } + + const metrics = viewport ?? {}; + const viewportWidth = + metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0; + const viewportHeight = + metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0; + + if (viewportWidth <= 0 || viewportHeight <= 0) { + return; + } + + const pageRect = await measureRenderedPageRect({ + shouldCancel: () => cancelled, + }); + if (cancelled) { + return; + } + + const decision = determineAutoZoom({ + viewportWidth, + viewportHeight, + fitWidthZoom, + pagesPerSpread, + pageRect: pageRect + ? { width: pageRect.width, height: pageRect.height } + : undefined, + metadataAspectRatio: metadataAspectRatio ?? null, + visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD, + fallbackZoom: DEFAULT_FALLBACK_ZOOM, + }); + + if (decision.type === 'fallback') { + applyTrackedZoom(decision.zoom, decision.zoom); + return; + } + + if (decision.type === 'fitWidth') { + applyTrackedZoom(ZoomMode.FitWidth, fitWidthZoom); + return; + } + + applyTrackedZoom(decision.zoom, decision.zoom); + }; + + applyAutoZoom(); + + return () => { + cancelled = true; + }; + }, [ + zoom, + zoomState, + firstFileId, + firstFileStub, + requestFitWidth, + getViewportSnapshot, + autoZoomTick, + spreadMode, + triggerImmediateZoomUpdate, + ]); + + useEffect(() => { + if (!zoom) { + return; + } + + const unsubscribe = zoom.onZoomChange((event: { newZoom?: number }) => { + if (typeof event?.newZoom !== 'number') { + return; + } + lastAppliedZoom.current = event.newZoom; + triggerImmediateZoomUpdate(Math.round(event.newZoom * 100)); + }); + + return () => { + unsubscribe(); + }; + }, [zoom, triggerImmediateZoomUpdate]); + + useEffect(() => { + if (!zoom || !zoomState) { + return; + } + + const currentZoomLevel = + lastAppliedZoom.current ?? zoomState.currentZoomLevel ?? 1; + + const newState = { + currentZoom: currentZoomLevel, + zoomPercent: Math.round(currentZoomLevel * 100), + }; + + triggerImmediateZoomUpdate(newState.zoomPercent); + + registerBridge('zoom', { + state: newState, + api: zoom, + }); + }, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]); return null; } diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 8e0bea44a..2ffe2210d 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -1,112 +1,55 @@ -import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; -import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import React, { + createContext, + useContext, + useState, + ReactNode, + useRef, + useCallback, +} from 'react'; import { useNavigation } from '@app/contexts/NavigationContext'; +import { + createViewerActions, + ScrollActions, + ZoomActions, + PanActions, + SelectionActions, + SpreadActions, + RotationActions, + SearchActions, + ExportActions, +} from '@app/contexts/viewer/viewerActions'; +import { + BridgeRef, + BridgeApiMap, + BridgeStateMap, + BridgeKey, + ViewerBridgeRegistry, + createBridgeRegistry, + registerBridge as setBridgeRef, + ScrollState, + ZoomState, + PanState, + SelectionState, + SpreadState, + RotationState, + SearchState, + ExportState, + ThumbnailAPIWrapper, +} from '@app/contexts/viewer/viewerBridges'; +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; -} +function useImmediateNotifier() { + const callbackRef = useRef<((...args: Args) => void) | null>(null); -interface ZoomAPIWrapper { - zoomIn: () => void; - zoomOut: () => void; - toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; -} + const register = useCallback((callback: (...args: Args) => void) => { + callbackRef.current = callback; + }, []); -interface PanAPIWrapper { - enable: () => void; - disable: () => void; - toggle: () => void; -} + const trigger = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); -interface SelectionAPIWrapper { - copyToClipboard: () => void; - getSelectedText: () => string | any; - getFormattedSelection: () => any; -} - -interface SpreadAPIWrapper { - setSpreadMode: (mode: SpreadMode) => void; - getSpreadMode: () => SpreadMode | null; - toggleSpreadMode: () => void; -} - -interface RotationAPIWrapper { - rotateForward: () => void; - rotateBackward: () => void; - setRotation: (rotation: number) => void; - getRotation: () => number; -} - -interface SearchAPIWrapper { - search: (query: string) => Promise; - clear: () => void; - next: () => void; - previous: () => void; -} - -interface ThumbnailAPIWrapper { - renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise }; -} - -interface ExportAPIWrapper { - download: () => void; - saveAsCopy: () => { toPromise: () => Promise }; -} - - -// State interfaces - represent the shape of data from each bridge -interface ScrollState { - currentPage: number; - totalPages: number; -} - -interface ZoomState { - currentZoom: number; - zoomPercent: number; -} - -interface PanState { - isPanning: boolean; -} - -interface SelectionState { - hasSelection: boolean; -} - -interface SpreadState { - spreadMode: SpreadMode; - isDualPage: boolean; -} - -interface RotationState { - rotation: number; -} - -interface SearchResult { - pageIndex: number; - rects: Array<{ - origin: { x: number; y: number }; - size: { width: number; height: number }; - }>; -} - -interface SearchState { - results: SearchResult[] | null; - activeIndex: number; -} - -interface ExportState { - canExport: boolean; -} - -// Bridge registration interface - bridges register with state and API -interface BridgeRef { - state: TState; - api: TApi; + return { register, trigger }; } /** @@ -150,66 +93,28 @@ interface ViewerContextType { // Immediate update callbacks registerImmediateZoomUpdate: (callback: (percent: number) => void) => void; registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void; + registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void; // Internal - for bridges to trigger immediate updates triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void; triggerImmediateZoomUpdate: (zoomPercent: number) => void; + triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void; // Action handlers - call EmbedPDF APIs directly - scrollActions: { - scrollToPage: (page: number) => void; - scrollToFirstPage: () => void; - scrollToPreviousPage: () => void; - scrollToNextPage: () => void; - scrollToLastPage: () => void; - }; - - zoomActions: { - zoomIn: () => void; - zoomOut: () => void; - toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; - }; - - panActions: { - enablePan: () => void; - disablePan: () => void; - togglePan: () => void; - }; - - selectionActions: { - copyToClipboard: () => void; - getSelectedText: () => string; - getFormattedSelection: () => unknown; - }; - - spreadActions: { - setSpreadMode: (mode: SpreadMode) => void; - getSpreadMode: () => SpreadMode | null; - toggleSpreadMode: () => void; - }; - - rotationActions: { - rotateForward: () => void; - rotateBackward: () => void; - setRotation: (rotation: number) => void; - getRotation: () => number; - }; - - searchActions: { - search: (query: string) => Promise; - next: () => void; - previous: () => void; - clear: () => void; - }; - - exportActions: { - download: () => void; - saveAsCopy: () => Promise; - }; + scrollActions: ScrollActions; + zoomActions: ZoomActions; + panActions: PanActions; + selectionActions: SelectionActions; + spreadActions: SpreadActions; + rotationActions: RotationActions; + searchActions: SearchActions; + exportActions: ExportActions; // Bridge registration - internal use by bridges - registerBridge: (type: string, ref: BridgeRef) => void; + registerBridge: ( + type: K, + ref: BridgeRef + ) => void; } export const ViewerContext = createContext(null); @@ -229,56 +134,51 @@ export const ViewerProvider: React.FC = ({ children }) => { useNavigation(); // Bridge registry - bridges register their state and APIs here - const bridgeRefs = useRef({ - scroll: null as BridgeRef | null, - zoom: null as BridgeRef | null, - pan: null as BridgeRef | null, - selection: null as BridgeRef | null, - search: null as BridgeRef | null, - spread: null as BridgeRef | null, - rotation: null as BridgeRef | null, - thumbnail: null as BridgeRef | null, - export: null as BridgeRef | null, - }); + const bridgeRefs = useRef(createBridgeRegistry()); - // Immediate zoom callback for responsive display updates - const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null); + const { + register: registerImmediateZoomUpdate, + trigger: triggerImmediateZoomInternal, + } = useImmediateNotifier<[number]>(); + const { + register: registerImmediateScrollUpdate, + trigger: triggerImmediateScrollInternal, + } = useImmediateNotifier<[number, number]>(); + const { + register: registerImmediateSpreadUpdate, + trigger: triggerImmediateSpreadInternal, + } = useImmediateNotifier<[SpreadMode, boolean]>(); - // Immediate scroll callback for responsive display updates - const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null); + const triggerImmediateZoomUpdate = useCallback( + (percent: number) => { + triggerImmediateZoomInternal(percent); + }, + [triggerImmediateZoomInternal] + ); - const registerBridge = (type: string, ref: BridgeRef) => { - // Type-safe assignment - we know the bridges will provide correct types - switch (type) { - case 'scroll': - bridgeRefs.current.scroll = ref as BridgeRef; - break; - case 'zoom': - bridgeRefs.current.zoom = ref as BridgeRef; - break; - case 'pan': - bridgeRefs.current.pan = ref as BridgeRef; - break; - case 'selection': - bridgeRefs.current.selection = ref as BridgeRef; - break; - case 'search': - bridgeRefs.current.search = ref as BridgeRef; - break; - case 'spread': - bridgeRefs.current.spread = ref as BridgeRef; - break; - case 'rotation': - bridgeRefs.current.rotation = ref as BridgeRef; - break; - case 'thumbnail': - bridgeRefs.current.thumbnail = ref as BridgeRef; - break; - case 'export': - bridgeRefs.current.export = ref as BridgeRef; - break; - } - }; + const triggerImmediateScrollUpdate = useCallback( + (currentPage: number, totalPages: number) => { + triggerImmediateScrollInternal(currentPage, totalPages); + }, + [triggerImmediateScrollInternal] + ); + + const triggerImmediateSpreadUpdate = useCallback( + (mode: SpreadMode, isDualPage: boolean = mode !== SpreadMode.None) => { + triggerImmediateSpreadInternal(mode, isDualPage); + }, + [triggerImmediateSpreadInternal] + ); + + const registerBridge = useCallback( + ( + type: K, + ref: BridgeRef + ) => { + setBridgeRef(bridgeRefs.current, type, ref); + }, + [] + ); const toggleThumbnailSidebar = () => { setIsThumbnailSidebarVisible(prev => !prev); @@ -334,241 +234,21 @@ export const ViewerProvider: React.FC = ({ children }) => { }; // 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 exportActions = { - download: () => { - const api = bridgeRefs.current.export?.api; - if (api?.download) { - api.download(); - } - }, - saveAsCopy: async () => { - const api = bridgeRefs.current.export?.api; - if (api?.saveAsCopy) { - try { - const result = api.saveAsCopy(); - return await result.toPromise(); - } catch (error) { - console.error('Failed to save PDF copy:', error); - return null; - } - } - return null; - } - }; - - 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 { + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + } = createViewerActions({ + registry: bridgeRefs, + getScrollState, + getZoomState, + triggerImmediateZoomUpdate, + }); const value: ViewerContextType = { // UI state @@ -600,8 +280,10 @@ export const ViewerProvider: React.FC = ({ children }) => { // Immediate updates registerImmediateZoomUpdate, registerImmediateScrollUpdate, + registerImmediateSpreadUpdate, triggerImmediateScrollUpdate, triggerImmediateZoomUpdate, + triggerImmediateSpreadUpdate, // Actions scrollActions, diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index 5b4d1d2d9..0a37134f1 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -58,14 +58,21 @@ const addFilesMutex = new SimpleMutex(); /** * Helper to create ProcessedFile metadata structure */ -export function createProcessedFile(pageCount: number, thumbnail?: string, pageRotations?: number[]) { +export function createProcessedFile( + pageCount: number, + thumbnail?: string, + pageRotations?: number[], + pageDimensions?: Array<{ width: number; height: number }> +) { return { totalPages: pageCount, pages: Array.from({ length: pageCount }, (_, index) => ({ pageNumber: index + 1, thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially rotation: pageRotations?.[index] ?? 0, - splitBefore: false + splitBefore: false, + width: pageDimensions?.[index]?.width, + height: pageDimensions?.[index]?.height })), thumbnailUrl: thumbnail, lastProcessed: Date.now() @@ -92,7 +99,8 @@ export async function generateProcessedFileMetadata(file: File): Promise void; + scrollToFirstPage: () => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; + scrollToLastPage: () => void; +} + +export interface ZoomActions { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; +} + +export interface PanActions { + enablePan: () => void; + disablePan: () => void; + togglePan: () => void; +} + +export interface SelectionActions { + copyToClipboard: () => void; + getSelectedText: () => string; + getFormattedSelection: () => any; +} + +export interface SpreadActions { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; +} + +export interface RotationActions { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; +} + +export interface SearchActions { + search: (query: string) => Promise | undefined; + next: () => void; + previous: () => void; + clear: () => void; +} + +export interface ExportActions { + download: () => void; + saveAsCopy: () => Promise; +} + +export interface ViewerActionsBundle { + scrollActions: ScrollActions; + zoomActions: ZoomActions; + panActions: PanActions; + selectionActions: SelectionActions; + spreadActions: SpreadActions; + rotationActions: RotationActions; + searchActions: SearchActions; + exportActions: ExportActions; +} + +interface ViewerActionDependencies { + registry: MutableRefObject; + getScrollState: () => ScrollState; + getZoomState: () => ZoomState; + triggerImmediateZoomUpdate: (percent: number) => void; +} + +export function createViewerActions({ + registry, + getScrollState, + getZoomState, + triggerImmediateZoomUpdate, +}: ViewerActionDependencies): ViewerActionsBundle { + const scrollActions: ScrollActions = { + scrollToPage: (page: number) => { + const api = registry.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: page }); + } + }, + scrollToFirstPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToPage) { + api.scrollToPage({ pageNumber: 1 }); + } + }, + scrollToPreviousPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToPreviousPage) { + api.scrollToPreviousPage(); + } + }, + scrollToNextPage: () => { + const api = registry.current.scroll?.api; + if (api?.scrollToNextPage) { + api.scrollToNextPage(); + } + }, + scrollToLastPage: () => { + const api = registry.current.scroll?.api; + const state = getScrollState(); + if (api?.scrollToPage && state.totalPages > 0) { + api.scrollToPage({ pageNumber: state.totalPages }); + } + }, + }; + + const zoomActions: ZoomActions = { + zoomIn: () => { + const api = registry.current.zoom?.api; + if (api?.zoomIn) { + const currentState = getZoomState(); + const newPercent = Math.min( + Math.round(currentState.zoomPercent * 1.2), + 300 + ); + triggerImmediateZoomUpdate(newPercent); + api.zoomIn(); + } + }, + zoomOut: () => { + const api = registry.current.zoom?.api; + if (api?.zoomOut) { + const currentState = getZoomState(); + const newPercent = Math.max( + Math.round(currentState.zoomPercent / 1.2), + 20 + ); + triggerImmediateZoomUpdate(newPercent); + api.zoomOut(); + } + }, + toggleMarqueeZoom: () => { + const api = registry.current.zoom?.api; + if (api?.toggleMarqueeZoom) { + api.toggleMarqueeZoom(); + } + }, + requestZoom: (level: number) => { + const api = registry.current.zoom?.api; + if (api?.requestZoom) { + api.requestZoom(level); + } + }, + }; + + const panActions: PanActions = { + enablePan: () => { + const api = registry.current.pan?.api; + if (api?.enable) { + api.enable(); + } + }, + disablePan: () => { + const api = registry.current.pan?.api; + if (api?.disable) { + api.disable(); + } + }, + togglePan: () => { + const api = registry.current.pan?.api; + if (api?.toggle) { + api.toggle(); + } + }, + }; + + const selectionActions: SelectionActions = { + copyToClipboard: () => { + const api = registry.current.selection?.api; + if (api?.copyToClipboard) { + api.copyToClipboard(); + } + }, + getSelectedText: () => { + const api = registry.current.selection?.api; + if (api?.getSelectedText) { + return api.getSelectedText() ?? ''; + } + return ''; + }, + getFormattedSelection: () => { + const api = registry.current.selection?.api; + if (api?.getFormattedSelection) { + return api.getFormattedSelection(); + } + return null; + }, + }; + + const spreadActions: SpreadActions = { + setSpreadMode: (mode: SpreadMode) => { + const api = registry.current.spread?.api; + if (api?.setSpreadMode) { + api.setSpreadMode(mode); + } + }, + getSpreadMode: () => { + const api = registry.current.spread?.api; + if (api?.getSpreadMode) { + return api.getSpreadMode(); + } + return null; + }, + toggleSpreadMode: () => { + const api = registry.current.spread?.api; + if (api?.toggleSpreadMode) { + api.toggleSpreadMode(); + } + }, + }; + + const rotationActions: RotationActions = { + rotateForward: () => { + const api = registry.current.rotation?.api; + if (api?.rotateForward) { + api.rotateForward(); + } + }, + rotateBackward: () => { + const api = registry.current.rotation?.api; + if (api?.rotateBackward) { + api.rotateBackward(); + } + }, + setRotation: (rotation: number) => { + const api = registry.current.rotation?.api; + if (api?.setRotation) { + api.setRotation(rotation); + } + }, + getRotation: () => { + const api = registry.current.rotation?.api; + if (api?.getRotation) { + return api.getRotation(); + } + return 0; + }, + }; + + const searchActions: SearchActions = { + search: (query: string) => { + const api = registry.current.search?.api; + if (api?.search) { + return api.search(query); + } + }, + next: () => { + const api = registry.current.search?.api; + if (api?.next) { + api.next(); + } + }, + previous: () => { + const api = registry.current.search?.api; + if (api?.previous) { + api.previous(); + } + }, + clear: () => { + const api = registry.current.search?.api; + if (api?.clear) { + api.clear(); + } + }, + }; + + const exportActions: ExportActions = { + download: () => { + const api = registry.current.export?.api; + if (api?.download) { + api.download(); + } + }, + saveAsCopy: async () => { + const api = registry.current.export?.api; + if (api?.saveAsCopy) { + try { + const result = api.saveAsCopy(); + return await result.toPromise(); + } catch (error) { + console.error('Failed to save PDF copy:', error); + return null; + } + } + return null; + }, + }; + + return { + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + }; +} diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts new file mode 100644 index 000000000..032a05e18 --- /dev/null +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -0,0 +1,174 @@ +import { SpreadMode } from '@embedpdf/plugin-spread/react'; + +export interface ScrollAPIWrapper { + scrollToPage: (params: { pageNumber: number }) => void; + scrollToPreviousPage: () => void; + scrollToNextPage: () => void; +} + +export interface ZoomAPIWrapper { + zoomIn: () => void; + zoomOut: () => void; + toggleMarqueeZoom: () => void; + requestZoom: (level: number) => void; +} + +export interface PanAPIWrapper { + enable: () => void; + disable: () => void; + toggle: () => void; + makePanDefault: () => void; +} + +export interface SelectionAPIWrapper { + copyToClipboard: () => void; + getSelectedText: () => string | any; + getFormattedSelection: () => any; +} + +export interface SpreadAPIWrapper { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; + SpreadMode: typeof SpreadMode; +} + +export interface RotationAPIWrapper { + rotateForward: () => void; + rotateBackward: () => void; + setRotation: (rotation: number) => void; + getRotation: () => number; +} + +export interface SearchAPIWrapper { + search: (query: string) => Promise; + clear: () => void; + next: () => void; + previous: () => void; + goToResult: (index: number) => void; +} + +export interface ThumbnailAPIWrapper { + renderThumb: (pageIndex: number, scale: number) => { + toPromise: () => Promise; + }; +} + +export interface ExportAPIWrapper { + download: () => void; + saveAsCopy: () => { toPromise: () => Promise }; +} + +export interface ScrollState { + currentPage: number; + totalPages: number; +} + +export interface ZoomState { + currentZoom: number; + zoomPercent: number; +} + +export interface PanState { + isPanning: boolean; +} + +export interface SelectionState { + hasSelection: boolean; +} + +export interface SpreadState { + spreadMode: SpreadMode; + isDualPage: boolean; +} + +export interface RotationState { + rotation: number; +} + +export interface SearchResult { + pageIndex: number; + rects: Array<{ + origin: { x: number; y: number }; + size: { width: number; height: number }; + }>; +} + +export interface SearchState { + results: SearchResult[] | null; + activeIndex: number; +} + +export interface ExportState { + canExport: boolean; +} + +export interface BridgeRef { + state: TState; + api: TApi; +} + +export interface BridgeStateMap { + scroll: ScrollState; + zoom: ZoomState; + pan: PanState; + selection: SelectionState; + spread: SpreadState; + rotation: RotationState; + search: SearchState; + thumbnail: unknown; + export: ExportState; +} + +export interface BridgeApiMap { + scroll: ScrollAPIWrapper; + zoom: ZoomAPIWrapper; + pan: PanAPIWrapper; + selection: SelectionAPIWrapper; + spread: SpreadAPIWrapper; + rotation: RotationAPIWrapper; + search: SearchAPIWrapper; + thumbnail: ThumbnailAPIWrapper; + export: ExportAPIWrapper; +} + +export type BridgeKey = keyof BridgeStateMap; + +export type ViewerBridgeRegistry = { + [K in BridgeKey]: BridgeRef | null; +}; + +export const createBridgeRegistry = (): ViewerBridgeRegistry => ({ + scroll: null, + zoom: null, + pan: null, + selection: null, + spread: null, + rotation: null, + search: null, + thumbnail: null, + export: null, +}); + +export function registerBridge( + registry: ViewerBridgeRegistry, + type: K, + ref: BridgeRef +): void { + registry[type] = ref as ViewerBridgeRegistry[K]; +} + +export function getBridgeState( + registry: ViewerBridgeRegistry, + type: K, + fallback: BridgeStateMap[K] +): BridgeStateMap[K] { + return registry[type]?.state ?? fallback; +} + +export function getBridgeApi( + registry: ViewerBridgeRegistry, + type: K +): BridgeApiMap[K] | null { + return registry[type]?.api ?? null; +} diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index fa8a5da45..3ec4380cc 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -14,6 +14,8 @@ export interface ProcessedFilePage { pageNumber?: number; rotation?: number; splitBefore?: boolean; + width?: number; + height?: number; [key: string]: any; } diff --git a/frontend/src/core/utils/pageMetadata.ts b/frontend/src/core/utils/pageMetadata.ts new file mode 100644 index 000000000..fa9b4d7cc --- /dev/null +++ b/frontend/src/core/utils/pageMetadata.ts @@ -0,0 +1,53 @@ +import { + ProcessedFileMetadata, + ProcessedFilePage, + StirlingFileStub, +} from '@app/types/fileContext'; + +export interface PageDimensions { + width: number | null; + height: number | null; +} + +export function getPageDimensions( + page?: ProcessedFilePage | null +): PageDimensions { + const width = + typeof page?.width === 'number' && page.width > 0 ? page.width : null; + const height = + typeof page?.height === 'number' && page.height > 0 ? page.height : null; + + return { width, height }; +} + +export function getFirstPageDimensionsFromMetadata( + metadata?: ProcessedFileMetadata | null +): PageDimensions { + if (!metadata?.pages?.length) { + return { width: null, height: null }; + } + + return getPageDimensions(metadata.pages[0]); +} + +export function getFirstPageDimensionsFromStub( + file?: StirlingFileStub +): PageDimensions { + return getFirstPageDimensionsFromMetadata(file?.processedFile); +} + +export function getFirstPageAspectRatioFromMetadata( + metadata?: ProcessedFileMetadata | null +): number | null { + const { width, height } = getFirstPageDimensionsFromMetadata(metadata); + if (width && height) { + return height / width; + } + return null; +} + +export function getFirstPageAspectRatioFromStub( + file?: StirlingFileStub +): number | null { + return getFirstPageAspectRatioFromMetadata(file?.processedFile); +} diff --git a/frontend/src/core/utils/thumbnailUtils.ts b/frontend/src/core/utils/thumbnailUtils.ts index 8faec2644..88c4aeaef 100644 --- a/frontend/src/core/utils/thumbnailUtils.ts +++ b/frontend/src/core/utils/thumbnailUtils.ts @@ -4,6 +4,7 @@ export interface ThumbnailWithMetadata { thumbnail: string; // Always returns a thumbnail (placeholder if needed) pageCount: number; pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270) + pageDimensions?: Array<{ width: number; height: number }>; } interface ColorScheme { @@ -402,12 +403,18 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b const pageCount = pdf.numPages; const page = await pdf.getPage(1); + const pageDimensions: Array<{ width: number; height: number }> = []; // If applyRotation is false, render without rotation (for CSS-based rotation) // If applyRotation is true, let PDF.js apply rotation (for static display) const viewport = applyRotation ? page.getViewport({ scale }) : page.getViewport({ scale, rotation: 0 }); + const baseViewport = page.getViewport({ scale: 1, rotation: 0 }); + pageDimensions[0] = { + width: baseViewport.width, + height: baseViewport.height + }; const canvas = document.createElement("canvas"); canvas.width = viewport.width; @@ -428,10 +435,17 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b const p = await pdf.getPage(i); const rotation = p.rotate || 0; pageRotations.push(rotation); + if (!pageDimensions[i - 1]) { + const pageViewport = p.getViewport({ scale: 1, rotation: 0 }); + pageDimensions[i - 1] = { + width: pageViewport.width, + height: pageViewport.height + }; + } } pdfWorkerManager.destroyDocument(pdf); - return { thumbnail, pageCount, pageRotations }; + return { thumbnail, pageCount, pageRotations, pageDimensions }; } catch (error) { if (error instanceof Error && error.name === "PasswordException") { diff --git a/frontend/src/core/utils/viewerZoom.ts b/frontend/src/core/utils/viewerZoom.ts new file mode 100644 index 000000000..1fa1fb492 --- /dev/null +++ b/frontend/src/core/utils/viewerZoom.ts @@ -0,0 +1,188 @@ +import { useEffect, useRef } from 'react'; + +export const DEFAULT_VISIBILITY_THRESHOLD = 80; // Require at least 80% of the page height to be visible +export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present + +export interface ZoomViewport { + clientWidth?: number; + clientHeight?: number; + width?: number; + height?: number; +} + +export type AutoZoomDecision = + | { type: 'fallback'; zoom: number } + | { type: 'fitWidth' } + | { type: 'adjust'; zoom: number }; + +export interface AutoZoomParams { + viewportWidth: number; + viewportHeight: number; + fitWidthZoom: number; + pagesPerSpread: number; + pageRect?: { width: number; height: number } | null; + metadataAspectRatio?: number | null; + visibilityThreshold?: number; + fallbackZoom?: number; +} + +export function determineAutoZoom({ + viewportWidth, + viewportHeight, + fitWidthZoom, + pagesPerSpread, + pageRect, + metadataAspectRatio, + visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD, + fallbackZoom = DEFAULT_FALLBACK_ZOOM, +}: AutoZoomParams): AutoZoomDecision { + const rectWidth = pageRect?.width ?? 0; + const rectHeight = pageRect?.height ?? 0; + + const aspectRatio: number | null = + rectWidth > 0 ? rectHeight / rectWidth : metadataAspectRatio ?? null; + + let renderedHeight: number | null = rectHeight > 0 ? rectHeight : null; + + if (!renderedHeight || renderedHeight <= 0) { + if (aspectRatio == null || aspectRatio <= 0) { + return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) }; + } + + const pageWidth = viewportWidth / (fitWidthZoom * pagesPerSpread); + const pageHeight = pageWidth * aspectRatio; + renderedHeight = pageHeight * fitWidthZoom; + } + + if (!renderedHeight || renderedHeight <= 0) { + return { type: 'fitWidth' }; + } + + const isLandscape = aspectRatio !== null && aspectRatio < 1; + const targetVisibility = isLandscape ? 100 : visibilityThreshold; + + const visiblePercent = (viewportHeight / renderedHeight) * 100; + + if (visiblePercent >= targetVisibility) { + return { type: 'fitWidth' }; + } + + const allowableHeightRatio = targetVisibility / 100; + const zoomScale = + viewportHeight / (allowableHeightRatio * renderedHeight); + const targetZoom = Math.min(fitWidthZoom, fitWidthZoom * zoomScale); + + if (Math.abs(targetZoom - fitWidthZoom) < 0.001) { + return { type: 'fitWidth' }; + } + + return { type: 'adjust', zoom: targetZoom }; +} + +export interface MeasurePageRectOptions { + selector?: string; + maxAttempts?: number; + shouldCancel?: () => boolean; +} + +export async function measureRenderedPageRect({ + selector = '[data-page-index="0"]', + maxAttempts = 12, + shouldCancel, +}: MeasurePageRectOptions = {}): Promise { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return null; + } + + let rafId: number | null = null; + + const waitForNextFrame = () => + new Promise((resolve) => { + rafId = window.requestAnimationFrame(() => { + rafId = null; + resolve(); + }); + }); + + try { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (shouldCancel?.()) { + return null; + } + + const element = document.querySelector(selector) as HTMLElement | null; + + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + return rect; + } + } + + await waitForNextFrame(); + } + } finally { + if (rafId !== null) { + window.cancelAnimationFrame(rafId); + } + } + + return null; +} + +export interface FitWidthResizeOptions { + isManaged: boolean; + requestFitWidth: () => void; + onDebouncedResize: () => void; + debounceMs?: number; +} + +export function useFitWidthResize({ + isManaged, + requestFitWidth, + onDebouncedResize, + debounceMs = 150, +}: FitWidthResizeOptions): void { + const managedRef = useRef(isManaged); + const requestFitWidthRef = useRef(requestFitWidth); + const onDebouncedResizeRef = useRef(onDebouncedResize); + + useEffect(() => { + managedRef.current = isManaged; + }, [isManaged]); + + useEffect(() => { + requestFitWidthRef.current = requestFitWidth; + }, [requestFitWidth]); + + useEffect(() => { + onDebouncedResizeRef.current = onDebouncedResize; + }, [onDebouncedResize]); + + useEffect(() => { + let timeoutId: number | undefined; + + const handleResize = () => { + if (!managedRef.current) { + return; + } + + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + requestFitWidthRef.current?.(); + onDebouncedResizeRef.current?.(); + }, debounceMs); + }; + + window.addEventListener('resize', handleResize); + return () => { + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + } + window.removeEventListener('resize', handleResize); + }; + }, [debounceMs]); +}