From 1bc8e7613fbbee77610b5003f9aa1a6263dc0c59 Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 31 Oct 2025 18:34:33 +0000 Subject: [PATCH 01/17] Viewer update and autozoom --- frontend/package-lock.json | 385 +++++++++++------- frontend/package.json | 36 +- .../core/components/viewer/EmbedPdfViewer.tsx | 13 +- .../core/components/viewer/LocalEmbedPDF.tsx | 11 +- .../components/viewer/PdfViewerToolbar.tsx | 47 ++- .../components/viewer/SpreadAPIBridge.tsx | 51 +-- .../core/components/viewer/ZoomAPIBridge.tsx | 290 ++++++++++--- frontend/src/core/contexts/ViewerContext.tsx | 101 +++-- .../src/core/contexts/file/fileActions.ts | 14 +- frontend/src/core/types/fileContext.ts | 2 + frontend/src/core/utils/pageMetadata.ts | 53 +++ frontend/src/core/utils/thumbnailUtils.ts | 16 +- frontend/src/core/utils/viewerZoom.ts | 188 +++++++++ 13 files changed, 906 insertions(+), 301 deletions(-) create mode 100644 frontend/src/core/utils/pageMetadata.ts create mode 100644 frontend/src/core/utils/viewerZoom.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c884272c4..c4f4caded 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", @@ -502,63 +502,65 @@ } }, "node_modules/@embedpdf/core": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz", - "integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz", + "integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==", "license": "MIT", "dependencies": { - "@embedpdf/engines": "1.3.14", - "@embedpdf/models": "1.3.14" + "@embedpdf/engines": "1.4.1", + "@embedpdf/models": "1.4.1" }, "peerDependencies": { "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/engines": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz", - "integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.4.1.tgz", + "integrity": "sha512-yugIb5OwTI/1VnAaEvSYxAd2DvYBPkV/D7wytagyaOq98o3sqzcY2Q9zHt+LhnawA5KKG1e/FDPjCd4qm8gsvg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14", - "@embedpdf/pdfium": "1.3.14" + "@embedpdf/models": "1.4.1", + "@embedpdf/pdfium": "1.4.1" }, "peerDependencies": { "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/models": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz", - "integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.4.1.tgz", + "integrity": "sha512-2nTg8Q1qpplBvspZJXMCZOA+/OILpfdNRPddlplxZXY/Upx0rzKXx/e6pXWW7AuOgtfGneT4h9tMs3A595/PdQ==", "license": "MIT" }, "node_modules/@embedpdf/pdfium": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz", - "integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.4.1.tgz", + "integrity": "sha512-BekKEK4UNCwzj7xOffKn6WpL0FQHxq+mTj2iGI3N7OwAX2J/BO2G+rDOB+lvojQG+Dkpg8uqm427ZKJDRyLgVQ==", "license": "MIT" }, "node_modules/@embedpdf/plugin-annotation": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz", - "integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.4.1.tgz", + "integrity": "sha512-d4HibNy6ecyDqx2Y2R8VjaqppSdjNofAJmU6VenOd88wn080sAUqvnkeVJ6ehJH5BoND4ymQrcAkcbVeYK0myA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14", - "@embedpdf/utils": "1.3.14" + "@embedpdf/models": "1.4.1", + "@embedpdf/utils": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-history": "1.3.14", - "@embedpdf/plugin-interaction-manager": "1.3.14", - "@embedpdf/plugin-selection": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-history": "1.4.1", + "@embedpdf/plugin-interaction-manager": "1.4.1", + "@embedpdf/plugin-selection": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -566,31 +568,32 @@ } }, "node_modules/@embedpdf/plugin-export": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz", - "integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz", + "integrity": "sha512-g89fREFM/zkt2Ai2Q5dWwDkhXgC/JmVyUniaMgm1fTG/MZ0Z05E7f34DUzX/CKcJyVjxEgl6tojBTMeUbm15bA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-history": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz", - "integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz", + "integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", @@ -598,232 +601,245 @@ } }, "node_modules/@embedpdf/plugin-interaction-manager": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz", - "integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz", + "integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-loader": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz", - "integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz", + "integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-pan": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz", - "integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.4.1.tgz", + "integrity": "sha512-zmOZJ9dUqXiaV0F5GPf/5WTWf3jAEkiv153Tl3x8HT9Rfff+WQhV48NruCIBAy/T4jVt4aH7D1zt/B/ftvcdkA==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-interaction-manager": "1.3.14", - "@embedpdf/plugin-viewport": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-interaction-manager": "1.4.1", + "@embedpdf/plugin-viewport": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-render": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz", - "integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz", + "integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-rotate": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz", - "integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.4.1.tgz", + "integrity": "sha512-hVzHkKwMNH3tUhxqJGsj5qTLpYZXbj6E74AEcG0w/fz5FrK7EnofPqt0gRfYmIzxnQGIh+39BRtcp8gmx8UNnw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-scroll": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz", - "integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz", + "integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-viewport": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-viewport": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-search": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz", - "integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.4.1.tgz", + "integrity": "sha512-8JG4CbOcUsLuT0vHJJ4cECmu+Yn53EokWFUVXi2Mo/XvHjhrQuWmD7+y6s/qQPEpctFYWmUCXTDAX9ynPud+2Q==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-loader": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-loader": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-selection": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz", - "integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz", + "integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-interaction-manager": "1.3.14", - "@embedpdf/plugin-viewport": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-interaction-manager": "1.4.1", + "@embedpdf/plugin-viewport": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-spread": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz", - "integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.4.1.tgz", + "integrity": "sha512-l+SrDVGTiiItkt2cEtzv7V/X5HhmLbYHcQ8CFobGeIKdJtzKS1Nu/JSKqg7Ki7eCNgyPL1yMNfNE92bNKYVN4w==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-loader": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-loader": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-thumbnail": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz", - "integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.4.1.tgz", + "integrity": "sha512-bN3msjI0PovazgbPK3LyugYVTwIDo0RyBUhBaG42FgJxeY3hmFOWTPgfUH1QF7twHlySnksIvHRFYR3nViryVw==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-render": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-render": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-tiling": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz", - "integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.4.1.tgz", + "integrity": "sha512-wgTfj5T8HV6KP61iiR63DVNrbVp8sPxTqa1Sm+2/D0jY+EPSSCmpt1/qYWiAXd1X+t78foOjCnfbo7fEMn5/pg==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-render": "1.3.14", - "@embedpdf/plugin-scroll": "1.3.14", - "@embedpdf/plugin-viewport": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-render": "1.4.1", + "@embedpdf/plugin-scroll": "1.4.1", + "@embedpdf/plugin-viewport": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-viewport": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz", - "integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz", + "integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14" + "@embedpdf/models": "1.4.1" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", + "@embedpdf/core": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/plugin-zoom": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz", - "integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.4.1.tgz", + "integrity": "sha512-9HocmXnPZxqN06q7kyNAmLjgDHOEW8/8QfgNE3nMpRyNHIgnAjxvsWc9lApgp5ErDPG0cSDt0Cduil6nB3wSBQ==", "license": "MIT", "dependencies": { - "@embedpdf/models": "1.3.14", + "@embedpdf/models": "1.4.1", "hammerjs": "^2.0.8" }, "peerDependencies": { - "@embedpdf/core": "1.3.14", - "@embedpdf/plugin-interaction-manager": "1.3.14", - "@embedpdf/plugin-scroll": "1.3.14", - "@embedpdf/plugin-viewport": "1.3.14", + "@embedpdf/core": "1.4.1", + "@embedpdf/plugin-interaction-manager": "1.4.1", + "@embedpdf/plugin-scroll": "1.4.1", + "@embedpdf/plugin-viewport": "1.4.1", "preact": "^10.26.4", "react": ">=16.8.0", "react-dom": ">=16.8.0", + "svelte": ">=5 <6", "vue": ">=3.2.0" } }, "node_modules/@embedpdf/utils": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz", - "integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-vvJ51Qsz3PyJWR2YvDMMpJXg4+YqdV7Vn2cusmW9sx+4EnAiBiw0HevEE+FepgFV8k+A0WbwXzmsujDIQJ7R4A==", "license": "MIT", "peerDependencies": { "preact": "^10.26.4", @@ -3045,6 +3061,16 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", + "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -3802,7 +3828,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/gapi": { @@ -4743,7 +4768,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -5147,6 +5171,16 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/b4a": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", @@ -7176,6 +7210,13 @@ "node": "*" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT", + "peer": true + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -7234,6 +7275,16 @@ "node": ">=0.10" } }, + "node_modules/esrap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.1.tgz", + "integrity": "sha512-ebTT9B6lOtZGMgJ3o5r12wBacHctG7oEWazIda8UlPfA3HD/Wrv8FdXoVo73vzdpwCxNyXjPauyN2bbJzMkB9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -8747,6 +8798,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -9623,6 +9684,13 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT", + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12959,6 +13027,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "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": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -14447,6 +14551,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "license": "MIT", + "peer": true + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/frontend/package.json b/frontend/package.json index 892e48569..bebf9a3e6 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", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", 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 bfe75df2f..1fe44b3d4 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -8,7 +8,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'; @@ -114,9 +114,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..9cec9e9da 100644 --- a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx @@ -1,68 +1,256 @@ -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 '@core/utils/viewerZoom'; +import { getFirstPageAspectRatioFromStub } from '@core/utils/pageMetadata'; -/** - * Component that runs inside EmbedPDF context and manages zoom state locally - */ export function ZoomAPIBridge() { const { provides: zoom, state: zoomState } = useZoom(); + const { state: spreadState } = useSpread(); const { registerBridge, triggerImmediateZoomUpdate } = useViewer(); - const hasSetInitialZoom = useRef(false); + const { selectors } = useFileState(); + + const hasSetInitialZoom = useRef(false); + const lastSpreadMode = useRef(spreadState?.spreadMode); + const lastFileId = useRef(); + 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 = spreadState?.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]); + }, [ + spreadState?.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 spreadMode = spreadState?.spreadMode ?? SpreadMode.None; + const pagesPerSpread = spreadMode !== 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, + spreadState?.spreadMode, + triggerImmediateZoomUpdate, + ]); + + useEffect(() => { + if (!zoom || typeof zoom.onZoomChange !== 'function') { + 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 () => { + if (typeof unsubscribe === 'function') { + 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..076cf86f7 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -1,4 +1,11 @@ -import React, { createContext, useContext, useState, ReactNode, useRef } from 'react'; +import React, { + createContext, + useContext, + useState, + ReactNode, + useRef, + useCallback, +} from 'react'; import { SpreadMode } from '@embedpdf/plugin-spread/react'; import { useNavigation } from '@app/contexts/NavigationContext'; @@ -109,6 +116,20 @@ interface BridgeRef { api: TApi; } +function useImmediateNotifier() { + const callbackRef = useRef<((...args: Args) => void) | null>(null); + + const register = useCallback((callback: (...args: Args) => void) => { + callbackRef.current = callback; + }, []); + + const trigger = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); + + return { register, trigger }; +} + /** * ViewerContext provides a unified interface to EmbedPDF functionality. * @@ -150,10 +171,12 @@ 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: { @@ -241,11 +264,39 @@ export const ViewerProvider: React.FC = ({ children }) => { export: null as BridgeRef | null, }); - // 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 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 = (type: string, ref: BridgeRef) => { // Type-safe assignment - we know the bridges will provide correct types @@ -372,24 +423,18 @@ export const ViewerProvider: React.FC = ({ children }) => { 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); - } + const currentState = getZoomState(); + const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300); + triggerImmediateZoomUpdate(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); - } + const currentState = getZoomState(); + const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20); + triggerImmediateZoomUpdate(newPercent); api.zoomOut(); } }, @@ -550,26 +595,6 @@ export const ViewerProvider: React.FC = ({ children }) => { } }; - 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, @@ -600,8 +625,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 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..d185398c0 --- /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 (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + + timeoutId = window.setTimeout(() => { + requestFitWidthRef.current?.(); + onDebouncedResizeRef.current?.(); + }, debounceMs); + }; + + window.addEventListener('resize', handleResize); + return () => { + if (typeof timeoutId === 'number') { + window.clearTimeout(timeoutId); + } + window.removeEventListener('resize', handleResize); + }; + }, [debounceMs]); +} From fe23952828f43160300c4196763c5f58b46c915a Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 31 Oct 2025 20:06:11 +0000 Subject: [PATCH 02/17] Viewer bridge handler. Reduce overengineering as flagged by copilot --- .../core/components/viewer/ZoomAPIBridge.tsx | 6 +- frontend/src/core/contexts/ViewerContext.tsx | 191 ++++-------------- .../src/core/contexts/viewer/viewerBridges.ts | 171 ++++++++++++++++ frontend/src/core/utils/viewerZoom.ts | 4 +- 4 files changed, 214 insertions(+), 158 deletions(-) create mode 100644 frontend/src/core/contexts/viewer/viewerBridges.ts diff --git a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx index 9cec9e9da..ddc362e2b 100644 --- a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx @@ -209,7 +209,7 @@ export function ZoomAPIBridge() { ]); useEffect(() => { - if (!zoom || typeof zoom.onZoomChange !== 'function') { + if (!zoom) { return; } @@ -222,9 +222,7 @@ export function ZoomAPIBridge() { }); return () => { - if (typeof unsubscribe === 'function') { - unsubscribe(); - } + unsubscribe(); }; }, [zoom, triggerImmediateZoomUpdate]); diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 076cf86f7..44c3b741e 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -6,115 +6,35 @@ import React, { useRef, useCallback, } from 'react'; -import { SpreadMode } from '@embedpdf/plugin-spread/react'; import { useNavigation } from '@app/contexts/NavigationContext'; - -// Bridge API interfaces - these match what the bridges provide -interface ScrollAPIWrapper { - scrollToPage: (params: { pageNumber: number }) => void; - scrollToPreviousPage: () => void; - scrollToNextPage: () => void; -} - -interface ZoomAPIWrapper { - zoomIn: () => void; - zoomOut: () => void; - toggleMarqueeZoom: () => void; - requestZoom: (level: number) => void; -} - -interface PanAPIWrapper { - enable: () => void; - disable: () => void; - toggle: () => void; -} - -interface SelectionAPIWrapper { - copyToClipboard: () => void; - getSelectedText: () => string | any; - getFormattedSelection: () => any; -} - -interface SpreadAPIWrapper { - setSpreadMode: (mode: SpreadMode) => void; - getSpreadMode: () => SpreadMode | null; - toggleSpreadMode: () => void; -} - -interface RotationAPIWrapper { - rotateForward: () => void; - rotateBackward: () => void; - setRotation: (rotation: number) => void; - getRotation: () => number; -} - -interface SearchAPIWrapper { - search: (query: string) => Promise; - clear: () => void; - next: () => void; - previous: () => void; -} - -interface ThumbnailAPIWrapper { - renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise }; -} - -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; -} +import { + BridgeRef, + BridgeApiMap, + BridgeStateMap, + BridgeKey, + ViewerBridgeRegistry, + createBridgeRegistry, + registerBridge as setBridgeRef, + ScrollAPIWrapper, + ScrollState, + ZoomAPIWrapper, + ZoomState, + PanAPIWrapper, + PanState, + SelectionAPIWrapper, + SelectionState, + SpreadAPIWrapper, + SpreadState, + RotationAPIWrapper, + RotationState, + SearchAPIWrapper, + SearchState, + SearchResult, + ThumbnailAPIWrapper, + ExportAPIWrapper, + ExportState, +} from '@core/contexts/viewer/viewerBridges'; +import { SpreadMode } from '@embedpdf/plugin-spread/react'; function useImmediateNotifier() { const callbackRef = useRef<((...args: Args) => void) | null>(null); @@ -232,7 +152,7 @@ interface ViewerContextType { }; // Bridge registration - internal use by bridges - registerBridge: (type: string, ref: BridgeRef) => void; + registerBridge: (type: BridgeKey, ref: BridgeRef) => void; } export const ViewerContext = createContext(null); @@ -252,17 +172,7 @@ 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()); const { register: registerImmediateZoomUpdate, @@ -298,38 +208,15 @@ export const ViewerProvider: React.FC = ({ children }) => { [triggerImmediateSpreadInternal] ); - 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 registerBridge = useCallback( + ( + type: K, + ref: BridgeRef + ) => { + setBridgeRef(bridgeRefs.current, type, ref); + }, + [] + ); const toggleThumbnailSidebar = () => { setIsThumbnailSidebarVisible(prev => !prev); diff --git a/frontend/src/core/contexts/viewer/viewerBridges.ts b/frontend/src/core/contexts/viewer/viewerBridges.ts new file mode 100644 index 000000000..25b7fa1e4 --- /dev/null +++ b/frontend/src/core/contexts/viewer/viewerBridges.ts @@ -0,0 +1,171 @@ +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; +} + +export interface SelectionAPIWrapper { + copyToClipboard: () => void; + getSelectedText: () => string | any; + getFormattedSelection: () => any; +} + +export interface SpreadAPIWrapper { + setSpreadMode: (mode: SpreadMode) => void; + getSpreadMode: () => SpreadMode | null; + toggleSpreadMode: () => void; +} + +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; +} + +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; +} + +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/utils/viewerZoom.ts b/frontend/src/core/utils/viewerZoom.ts index d185398c0..1fa1fb492 100644 --- a/frontend/src/core/utils/viewerZoom.ts +++ b/frontend/src/core/utils/viewerZoom.ts @@ -167,7 +167,7 @@ export function useFitWidthResize({ return; } - if (typeof timeoutId === 'number') { + if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } @@ -179,7 +179,7 @@ export function useFitWidthResize({ window.addEventListener('resize', handleResize); return () => { - if (typeof timeoutId === 'number') { + if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } window.removeEventListener('resize', handleResize); From f6063c1e74f4a591e334c730f94fe20c2087677b Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 31 Oct 2025 20:32:16 +0000 Subject: [PATCH 03/17] Dedicated viewer actions --- frontend/src/core/contexts/ViewerContext.tsx | 284 ++-------------- .../src/core/contexts/viewer/viewerActions.ts | 311 ++++++++++++++++++ 2 files changed, 335 insertions(+), 260 deletions(-) create mode 100644 frontend/src/core/contexts/viewer/viewerActions.ts diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 44c3b741e..cbf30a451 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -7,6 +7,7 @@ import React, { useCallback, } from 'react'; import { useNavigation } from '@app/contexts/NavigationContext'; +import { createViewerActions } from '@core/contexts/viewer/viewerActions'; import { BridgeRef, BridgeApiMap, @@ -99,57 +100,14 @@ interface ViewerContextType { 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: BridgeKey, ref: BridgeRef) => void; @@ -272,215 +230,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) { - const currentState = getZoomState(); - const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300); - triggerImmediateZoomUpdate(newPercent); - api.zoomIn(); - } - }, - zoomOut: () => { - const api = bridgeRefs.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 = 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 { + scrollActions, + zoomActions, + panActions, + selectionActions, + spreadActions, + rotationActions, + searchActions, + exportActions, + } = createViewerActions({ + registry: bridgeRefs, + getScrollState, + getZoomState, + triggerImmediateZoomUpdate, + }); const value: ViewerContextType = { // UI state diff --git a/frontend/src/core/contexts/viewer/viewerActions.ts b/frontend/src/core/contexts/viewer/viewerActions.ts new file mode 100644 index 000000000..3eda0423f --- /dev/null +++ b/frontend/src/core/contexts/viewer/viewerActions.ts @@ -0,0 +1,311 @@ +import { MutableRefObject } from 'react'; +import { SpreadMode } from '@embedpdf/plugin-spread/react'; +import { + ViewerBridgeRegistry, + ScrollState, + ZoomState, +} from '@core/contexts/viewer/viewerBridges'; + +export interface ScrollActions { + scrollToPage: (page: number) => 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, + }; +} From a86ece0473e111925927f27bbfd49984b0bc582c Mon Sep 17 00:00:00 2001 From: Reece Date: Mon, 3 Nov 2025 17:54:37 +0000 Subject: [PATCH 04/17] Accidentally the whole thing --- .../annotation/shared/BaseAnnotationTool.tsx | 31 +- .../annotation/shared/ColorPicker.tsx | 12 +- .../annotation/shared/DrawingCanvas.tsx | 141 ++-- .../annotation/shared/DrawingControls.tsx | 66 +- .../annotation/shared/TextInputWithFont.tsx | 14 +- .../src/core/components/shared/Tooltip.tsx | 72 +- .../BookletImpositionSettings.tsx | 6 +- .../components/tools/sign/SignSettings.tsx | 637 +++++++++++++----- .../core/components/viewer/EmbedPdfViewer.tsx | 32 +- .../components/viewer/HistoryAPIBridge.tsx | 61 +- .../components/viewer/SignatureAPIBridge.tsx | 280 ++++++-- .../viewer/SignaturePlacementOverlay.tsx | 159 +++++ .../src/core/components/viewer/viewerTypes.ts | 1 + .../src/core/contexts/SignatureContext.tsx | 26 + .../core/hooks/tools/sign/useSignOperation.ts | 4 +- .../hooks/tools/sign/useSignParameters.ts | 4 +- frontend/src/core/hooks/useTooltipPosition.ts | 4 +- frontend/src/core/tools/Sign.tsx | 27 +- .../src/core/utils/signatureFlattening.ts | 51 +- frontend/src/core/utils/signaturePreview.ts | 83 +++ 20 files changed, 1333 insertions(+), 378 deletions(-) create mode 100644 frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx create mode 100644 frontend/src/core/utils/signaturePreview.ts diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index c61b61cfd..ea093b5be 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Stack, Alert, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { DrawingControls } from '@app/components/annotation/shared/DrawingControls'; import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider'; +import { useSignature } from '@app/contexts/SignatureContext'; export interface AnnotationToolConfig { enableDrawing?: boolean; @@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC = ({ undo, redo } = usePDFAnnotation(); + const { historyApiRef } = useSignature(); const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [signatureData, setSignatureData] = useState(null); + const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); + const historyApiInstance = historyApiRef.current; + + useEffect(() => { + if (!historyApiInstance) { + setHistoryAvailability({ canUndo: false, canRedo: false }); + return; + } + + const updateAvailability = () => { + setHistoryAvailability({ + canUndo: historyApiInstance.canUndo?.() ?? false, + canRedo: historyApiInstance.canRedo?.() ?? false, + }); + }; + + const unsubscribe = historyApiInstance.subscribe?.(updateAvailability); + updateAvailability(); + + return () => { + unsubscribe?.(); + }; + }, [historyApiInstance]); const handleSignatureDataChange = (data: string | null) => { setSignatureData(data); @@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC = ({ = ({ /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 40bb363b4..04ae501bb 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; interface ColorPickerProps { isOpen: boolean; @@ -14,13 +15,16 @@ export const ColorPicker: React.FC = ({ onClose, selectedColor, onColorChange, - title = "Choose Color" + title }) => { + const { t } = useTranslation(); + const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); + return ( @@ -36,7 +40,7 @@ export const ColorPicker: React.FC = ({ /> @@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC = ({ onClick={onClick} /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index 4b9de1cbb..3c75f9e6f 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -1,5 +1,6 @@ -import React, { useRef, useState } from 'react'; -import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core'; +import React, { useEffect, useRef, useState } from 'react'; +import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker'; import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector'; import SignaturePad from 'signature_pad'; @@ -19,6 +20,7 @@ interface DrawingCanvasProps { modalWidth?: number; modalHeight?: number; additionalButtons?: React.ReactNode; + initialSignatureData?: string; } export const DrawingCanvas: React.FC = ({ @@ -33,12 +35,13 @@ export const DrawingCanvas: React.FC = ({ disabled = false, width = 400, height = 150, + initialSignatureData, }) => { + const { t } = useTranslation(); const previewCanvasRef = useRef(null); const modalCanvasRef = useRef(null); const padRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); - const [colorPickerOpen, setColorPickerOpen] = useState(false); const initPad = (canvas: HTMLCanvasElement) => { if (!padRef.current) { @@ -103,36 +106,33 @@ export const DrawingCanvas: React.FC = ({ return trimmedCanvas.toDataURL('image/png'); }; + const renderPreview = (dataUrl: string) => { + const canvas = previewCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / img.width, canvas.height / img.height); + const scaledWidth = img.width * scale; + const scaledHeight = img.height * scale; + const x = (canvas.width - scaledWidth) / 2; + const y = (canvas.height - scaledHeight) / 2; + + ctx.drawImage(img, x, y, scaledWidth, scaledHeight); + }; + img.src = dataUrl; + }; + const closeModal = () => { if (padRef.current && !padRef.current.isEmpty()) { const canvas = modalCanvasRef.current; if (canvas) { const trimmedPng = trimCanvas(canvas); onSignatureDataChange(trimmedPng); - - // Update preview canvas with proper aspect ratio - const img = new Image(); - img.onload = () => { - if (previewCanvasRef.current) { - const ctx = previewCanvasRef.current.getContext('2d'); - if (ctx) { - ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); - - // Calculate scaling to fit within preview canvas while maintaining aspect ratio - const scale = Math.min( - previewCanvasRef.current.width / img.width, - previewCanvasRef.current.height / img.height - ); - const scaledWidth = img.width * scale; - const scaledHeight = img.height * scale; - const x = (previewCanvasRef.current.width - scaledWidth) / 2; - const y = (previewCanvasRef.current.height - scaledHeight) / 2; - - ctx.drawImage(img, x, y, scaledWidth, scaledHeight); - } - } - }; - img.src = trimmedPng; + renderPreview(trimmedPng); if (onDrawingComplete) { onDrawingComplete(); @@ -172,11 +172,33 @@ export const DrawingCanvas: React.FC = ({ } }; + useEffect(() => { + updatePenColor(selectedColor); + }, [selectedColor]); + + useEffect(() => { + updatePenSize(penSize); + }, [penSize]); + + useEffect(() => { + const canvas = previewCanvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + if (!initialSignatureData) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + return; + } + + renderPreview(initialSignatureData); + }, [initialSignatureData]); + return ( <> - Draw your signature + {t('sign.canvas.heading', 'Draw your signature')} = ({ onClick={disabled ? undefined : openModal} /> - Click to open drawing canvas + {t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')} - + -
-
- Color - - -
- setColorPickerOpen(!colorPickerOpen)} - /> -
-
- - { - onColorSwatchClick(); - updatePenColor(color); - }} - swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']} - /> - -
-
-
- Pen Size + + + + {t('sign.canvas.colorLabel', 'Colour')} + + + + + + {t('sign.canvas.penSizeLabel', 'Pen size')} + = ({ updatePenSize(size); }} onInputChange={onPenSizeInputChange} - placeholder="Size" + placeholder={t('sign.canvas.penSizePlaceholder', 'Size')} size="compact-sm" - style={{ width: '60px' }} + style={{ width: '80px' }} /> -
-
+
+ { @@ -266,10 +269,10 @@ export const DrawingCanvas: React.FC = ({
diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 62c7c615f..3c28a594e 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Group, Button } from '@mantine/core'; +import { Group, Button, ActionIcon, Tooltip } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { LocalIcon } from '@app/components/shared/LocalIcon'; interface DrawingControlsProps { onUndo?: () => void; @@ -8,8 +9,11 @@ interface DrawingControlsProps { onPlaceSignature?: () => void; hasSignatureData?: boolean; disabled?: boolean; + canUndo?: boolean; + canRedo?: boolean; showPlaceButton?: boolean; placeButtonText?: string; + additionalControls?: React.ReactNode; } export const DrawingControls: React.FC = ({ @@ -18,30 +22,48 @@ export const DrawingControls: React.FC = ({ onPlaceSignature, hasSignatureData = false, disabled = false, + canUndo = true, + canRedo = true, showPlaceButton = true, - placeButtonText = "Update and Place" + placeButtonText = "Update and Place", + additionalControls, }) => { const { t } = useTranslation(); + const undoDisabled = disabled || !canUndo; + const redoDisabled = disabled || !canRedo; return ( - - {/* Undo/Redo Controls */} - - + + {onUndo && ( + + + + + + )} + {onRedo && ( + + + + + + )} + + {additionalControls} {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( @@ -50,11 +72,11 @@ export const DrawingControls: React.FC = ({ color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} - flex={1} + ml="auto" > {placeButtonText} )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index aca7430ce..c6d9d03a9 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -53,7 +53,7 @@ export const TextInputWithFont: React.FC = ({ return ( onTextChange(e.target.value)} @@ -63,7 +63,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */} { - const isDoubleSided = e.target.checked; - onParameterChange('doubleSided', isDoubleSided); - // Reset to BOTH when turning double-sided back on - if (isDoubleSided) { - onParameterChange('duplexPass', 'BOTH'); - } else { - // Default to FIRST pass when going to manual duplex - onParameterChange('duplexPass', 'FIRST'); - } - }} - disabled={disabled} - /> - {t('bookletImposition.doubleSided.label', 'Double-sided printing')} - + { + const isDoubleSided = event.currentTarget.checked; + onParameterChange('doubleSided', isDoubleSided); + // Reset to BOTH when turning double-sided back on + if (isDoubleSided) { + onParameterChange('duplexPass', 'BOTH'); + } else { + // Default to FIRST pass when going to manual duplex + onParameterChange('duplexPass', 'FIRST'); + } + }} + disabled={disabled} + label={ +
+ {t('bookletImposition.doubleSided.label', 'Double-sided printing')} + {t('bookletImposition.doubleSided.tooltip', 'Creates both front and back sides for proper booklet printing')} +
+ } + /> {/* Manual Duplex Pass Selection - only show when double-sided is OFF */} {!parameters.doubleSided && ( @@ -67,8 +66,8 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f {parameters.duplexPass === 'FIRST' - ? t('bookletImposition.duplexPass.firstInstructions', 'Prints front sides -> stack face-down -> run again with 2nd Pass') - : t('bookletImposition.duplexPass.secondInstructions', 'Load printed stack face-down -> prints back sides') + ? t('bookletImposition.duplexPass.firstInstructions', 'Prints front sides → stack face-down → run again with 2nd Pass') + : t('bookletImposition.duplexPass.secondInstructions', 'Load printed stack face-down → prints back sides') }
@@ -84,53 +83,50 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f onClick={() => setAdvancedOpen(!advancedOpen)} disabled={disabled} > - {t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '^' : 'v'} + {t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '▲' : '▼'} {/* Right-to-Left Binding */} - + onParameterChange('spineLocation', event.currentTarget.checked ? 'RIGHT' : 'LEFT')} + disabled={disabled} + label={ +
+ {t('bookletImposition.rtlBinding.label', 'Right-to-left binding')} + {t('bookletImposition.rtlBinding.tooltip', 'For Arabic, Hebrew, or other right-to-left languages')} +
+ } + /> {/* Add Border Option */} - + onParameterChange('addBorder', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addBorder.label', 'Add borders around pages')} + {t('bookletImposition.addBorder.tooltip', 'Adds borders around each page section to help with cutting and alignment')} +
+ } + /> {/* Gutter Margin */} - + onParameterChange('addGutter', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('bookletImposition.addGutter.label', 'Add gutter margin')} + {t('bookletImposition.addGutter.tooltip', 'Adds inner margin space for binding')} +
+ } + /> {parameters.addGutter && ( {/* Flip on Short Edge */} - + /> {/* Paper Size Note */} From 365c0a7829fc850b50fb0029ba7bf11d17640beb Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 16:14:00 +0000 Subject: [PATCH 07/17] undo flattening --- .../src/core/utils/signatureFlattening.ts | 53 +++++-------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index df122e4dc..2baf7586b 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -4,27 +4,6 @@ import { createProcessedFile, createChildStub } from '@app/contexts/file/fileAct import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext'; import type { SignatureAPI } from '@app/components/viewer/viewerTypes'; -const extractDataUrl = (value: unknown, depth = 0): string | undefined => { - if (!value || depth > 6) return undefined; - if (typeof value === 'string') { - return value.startsWith('data:image') ? value : undefined; - } - if (Array.isArray(value)) { - for (const entry of value) { - const result = extractDataUrl(entry, depth + 1); - if (result) return result; - } - return undefined; - } - if (typeof value === 'object') { - for (const key of Object.keys(value as Record)) { - const result = extractDataUrl((value as Record)[key], depth + 1); - if (result) return result; - } - } - return undefined; -}; - interface MinimalFileContextSelectors { getAllFileIds: () => FileId[]; getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined; @@ -69,19 +48,18 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr if (pageAnnotations && pageAnnotations.length > 0) { // Filter to only include annotations added in this session const sessionAnnotations = pageAnnotations.filter(annotation => { + // Check if this annotation has stored image data (indicates it was added this session) const hasStoredImageData = annotation.id && getImageData(annotation.id); - const directImageData = - extractDataUrl(annotation.imageData) || - extractDataUrl(annotation.appearance) || - extractDataUrl(annotation.stampData) || - extractDataUrl(annotation.imageSrc) || - extractDataUrl(annotation.contents) || - extractDataUrl(annotation.data) || - extractDataUrl(annotation.customData) || - extractDataUrl(annotation.asset); + // Also check if it has image data directly in the annotation (new signatures) + const hasDirectImageData = annotation.imageData || annotation.appearance || + annotation.stampData || annotation.imageSrc || + annotation.contents || annotation.data; - return Boolean(hasStoredImageData || directImageData); + const isSessionAnnotation = hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image')); + + + return isSessionAnnotation; }); if (sessionAnnotations.length > 0) { @@ -108,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr } } - // Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations) + // Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations) if (!exportActions) { console.error('No export actions available'); return null; @@ -202,15 +180,8 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr // Try to get annotation image data - let imageDataUrl = - extractDataUrl(annotation.imageData) || - extractDataUrl(annotation.appearance) || - extractDataUrl(annotation.stampData) || - extractDataUrl(annotation.imageSrc) || - extractDataUrl(annotation.contents) || - extractDataUrl(annotation.data) || - extractDataUrl(annotation.customData) || - extractDataUrl(annotation.asset); + let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData || + annotation.imageSrc || annotation.contents || annotation.data; // If no image data found directly, try to get it from storage if (!imageDataUrl && annotation.id) { From 0692fe97d0d766e2ba580f34efa74d11a01456e9 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 16:47:21 +0000 Subject: [PATCH 08/17] remove whitespace, retain signature, px to rem --- .../annotation/shared/DrawingCanvas.tsx | 18 +++++++++++++++++- .../core/components/viewer/ZoomAPIBridge.tsx | 5 +---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index c310b0ec7..52908edbc 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -43,6 +43,7 @@ export const DrawingCanvas: React.FC = ({ const modalCanvasRef = useRef(null); const padRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); + const [savedSignatureData, setSavedSignatureData] = useState(null); const initPad = (canvas: HTMLCanvasElement) => { if (!padRef.current) { @@ -58,6 +59,18 @@ export const DrawingCanvas: React.FC = ({ minDistance: 5, velocityFilterWeight: 0.7, }); + + // Restore saved signature data if it exists + if (savedSignatureData) { + const img = new Image(); + img.onload = () => { + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + } + }; + img.src = savedSignatureData; + } } }; @@ -132,6 +145,8 @@ export const DrawingCanvas: React.FC = ({ const canvas = modalCanvasRef.current; if (canvas) { const trimmedPng = trimCanvas(canvas); + const untrimmedPng = canvas.toDataURL('image/png'); + setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration onSignatureDataChange(trimmedPng); renderPreview(trimmedPng); @@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC = ({ ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); } } + setSavedSignatureData(null); // Clear saved signature onSignatureDataChange(null); }; @@ -266,7 +282,7 @@ export const DrawingCanvas: React.FC = ({ backgroundColor: 'white', width: '100%', maxWidth: '800px', - height: '400px', + height: '25rem', cursor: 'crosshair', }} /> diff --git a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx index 7d2f31960..76ade76cd 100644 --- a/frontend/src/core/components/viewer/ZoomAPIBridge.tsx +++ b/frontend/src/core/components/viewer/ZoomAPIBridge.tsx @@ -243,7 +243,4 @@ export function ZoomAPIBridge() { }, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]); return null; -} - - - +} \ No newline at end of file From b978f1848a5196559eada1b5754acbdeaf2382d1 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 16:48:08 +0000 Subject: [PATCH 09/17] - --- frontend/src/core/utils/signatureFlattening.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 2baf7586b..8fcd80b98 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -168,7 +168,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position; if (rect) { - // Extract original annotation position and size + // Extract base annotation position and size const originalX = rect.origin?.x || rect.x || rect.left || 0; const originalY = rect.origin?.y || rect.y || rect.top || 0; const width = rect.size?.width || rect.width || 100; From 7fe93674a0f8bc8ba402261f1721f5ed281e6157 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 16:49:06 +0000 Subject: [PATCH 10/17] - --- frontend/src/core/utils/signatureFlattening.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/core/utils/signatureFlattening.ts b/frontend/src/core/utils/signatureFlattening.ts index 8fcd80b98..25c187020 100644 --- a/frontend/src/core/utils/signatureFlattening.ts +++ b/frontend/src/core/utils/signatureFlattening.ts @@ -86,7 +86,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr } } - // Step 3: Use EmbedPDF's saveAsCopy to get the base PDF (now without annotations) + // Step 3: Use EmbedPDF's saveAsCopy to get the original PDF (now without annotations) if (!exportActions) { console.error('No export actions available'); return null; @@ -168,7 +168,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position; if (rect) { - // Extract base annotation position and size + // Extract original annotation position and size const originalX = rect.origin?.x || rect.x || rect.left || 0; const originalY = rect.origin?.y || rect.y || rect.top || 0; const width = rect.size?.width || rect.width || 100; From 2525e70abc6937d74789e6a7e04c1de188c13c3b Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 16:54:58 +0000 Subject: [PATCH 11/17] tsc --- .../src/core/components/viewer/SignaturePlacementOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx index 83f847035..fd87ea51a 100644 --- a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx +++ b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx @@ -5,7 +5,7 @@ import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePre import { useSignature } from '@app/contexts/SignatureContext'; interface SignaturePlacementOverlayProps { - containerRef: React.RefObject; + containerRef: React.RefObject; isActive: boolean; signatureConfig: SignParameters | null; } From 3e198309f564f3ac71c31cdd556868ad2ebf1926 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 17:03:58 +0000 Subject: [PATCH 12/17] Remove tooltip stuff --- .../src/core/components/shared/Tooltip.tsx | 83 +++++++------------ 1 file changed, 28 insertions(+), 55 deletions(-) diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index cb406b414..77db24d46 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -65,10 +65,6 @@ export const Tooltip: React.FC = ({ const clickPendingRef = useRef(false); const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); - // Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target - const isDomNode = (value: unknown): value is Node => - typeof Node !== 'undefined' && value instanceof Node; - const clearTimers = useCallback(() => { if (openTimeoutRef.current) { clearTimeout(openTimeoutRef.current); @@ -81,11 +77,6 @@ export const Tooltip: React.FC = ({ const isControlled = controlledOpen !== undefined; const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; - const childPropsRef = useRef(children.props); - useEffect(() => { - childPropsRef.current = children.props; - }, [children]); - const setOpen = useCallback( (newOpen: boolean) => { if (newOpen === open) return; // avoid churn @@ -107,14 +98,14 @@ export const Tooltip: React.FC = ({ sidebarState: sidebarContext?.sidebarState, }); - // Close on outside click: pinned -> close; not pinned -> optionally close + // Close on outside click: pinned → close; not pinned → optionally close const handleDocumentClick = useCallback( (e: MouseEvent) => { const tEl = tooltipRef.current; const trg = triggerRef.current; - const target = e.target as unknown; - const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target)); - const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target)); + const target = e.target as Node | null; + const insideTooltip = tEl && target && tEl.contains(target); + const insideTrigger = trg && target && trg.contains(target); // If pinned: only close when clicking outside BOTH tooltip & trigger if (isPinned) { @@ -171,9 +162,9 @@ export const Tooltip: React.FC = ({ const handlePointerEnter = useCallback( (e: React.PointerEvent) => { if (!isPinned && !disabled) openWithDelay(); - (childPropsRef.current as any)?.onPointerEnter?.(e); + (children.props as any)?.onPointerEnter?.(e); }, - [isPinned, openWithDelay, disabled] + [isPinned, openWithDelay, children.props, disabled] ); const handlePointerLeave = useCallback( @@ -181,40 +172,39 @@ export const Tooltip: React.FC = ({ const related = e.relatedTarget as Node | null; // Moving into the tooltip → keep open - if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { - + if (related && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onPointerLeave?.(e); return; } // Ignore transient leave between mousedown and click if (clickPendingRef.current) { - (childPropsRef.current as any)?.onPointerLeave?.(e); + (children.props as any)?.onPointerLeave?.(e); return; } clearTimers(); if (!isPinned) setOpen(false); - (childPropsRef.current as any)?.onPointerLeave?.(e); + (children.props as any)?.onPointerLeave?.(e); }, - [clearTimers, isPinned, setOpen] + [clearTimers, isPinned, setOpen, children.props] ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { clickPendingRef.current = true; - (childPropsRef.current as any)?.onMouseDown?.(e); + (children.props as any)?.onMouseDown?.(e); }, - [] + [children.props] ); const handleMouseUp = useCallback( (e: React.MouseEvent) => { // allow microtask turn so click can see this false queueMicrotask(() => (clickPendingRef.current = false)); - (childPropsRef.current as any)?.onMouseUp?.(e); + (children.props as any)?.onMouseUp?.(e); }, - [] + [children.props] ); const handleClick = useCallback( @@ -229,31 +219,31 @@ export const Tooltip: React.FC = ({ return; } clickPendingRef.current = false; - (childPropsRef.current as any)?.onClick?.(e); + (children.props as any)?.onClick?.(e); }, - [clearTimers, pinOnClick, open, setOpen] + [clearTimers, pinOnClick, open, setOpen, children.props] ); // Keyboard / focus accessibility const handleFocus = useCallback( (e: React.FocusEvent) => { if (!isPinned && !disabled && openOnFocus) openWithDelay(); - (childPropsRef.current as any)?.onFocus?.(e); + (children.props as any)?.onFocus?.(e); }, - [isPinned, openWithDelay, disabled, openOnFocus] + [isPinned, openWithDelay, children.props, disabled, openOnFocus] ); const handleBlur = useCallback( (e: React.FocusEvent) => { const related = e.relatedTarget as Node | null; - if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { + if (related && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onBlur?.(e); return; } if (!isPinned) setOpen(false); - (childPropsRef.current as any)?.onBlur?.(e); + (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen] + [isPinned, setOpen, children.props] ); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -268,36 +258,20 @@ export const Tooltip: React.FC = ({ const handleTooltipPointerLeave = useCallback( (e: React.PointerEvent) => { const related = e.relatedTarget as Node | null; - if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; + if (related && triggerRef.current && triggerRef.current.contains(related)) return; if (!isPinned) setOpen(false); }, [isPinned, setOpen] ); - const originalChildRef = (children as any).ref; - const mergedRef = useMemo(() => { - if (typeof originalChildRef === 'function') { - return (node: HTMLElement | null) => { - triggerRef.current = node || null; - originalChildRef(node); - }; - } - - if (originalChildRef && typeof originalChildRef === 'object') { - return (node: HTMLElement | null) => { - triggerRef.current = node || null; - (originalChildRef as React.MutableRefObject).current = node; - }; - } - - return (node: HTMLElement | null) => { - triggerRef.current = node || null; - }; - }, [originalChildRef]); - // Enhance child with handlers and ref const childWithHandlers = React.cloneElement(children as any, { - ref: mergedRef, + ref: (node: HTMLElement | null) => { + triggerRef.current = node || null; + const originalRef = (children as any).ref; + if (typeof originalRef === 'function') originalRef(node); + else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node; + }, 'aria-describedby': open ? tooltipIdRef.current : undefined, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave, @@ -389,4 +363,3 @@ export const Tooltip: React.FC = ({ ); }; - From 3855a3ccc446358920b89da2747d1e3d3d78360d Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 17:45:02 +0000 Subject: [PATCH 13/17] Revert Tooltip.tsx to match V2 - remove unrelated changes --- frontend/src/core/components/shared/Tooltip.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 77db24d46..448b4d913 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -65,6 +65,10 @@ export const Tooltip: React.FC = ({ const clickPendingRef = useRef(false); const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); + // Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target + const isDomNode = (value: unknown): value is Node => + typeof Node !== 'undefined' && value instanceof Node; + const clearTimers = useCallback(() => { if (openTimeoutRef.current) { clearTimeout(openTimeoutRef.current); @@ -103,9 +107,9 @@ export const Tooltip: React.FC = ({ (e: MouseEvent) => { const tEl = tooltipRef.current; const trg = triggerRef.current; - const target = e.target as Node | null; - const insideTooltip = tEl && target && tEl.contains(target); - const insideTrigger = trg && target && trg.contains(target); + const target = e.target as unknown; + const insideTooltip = Boolean(tEl && isDomNode(target) && tEl.contains(target)); + const insideTrigger = Boolean(trg && isDomNode(target) && trg.contains(target)); // If pinned: only close when clicking outside BOTH tooltip & trigger if (isPinned) { @@ -172,7 +176,8 @@ export const Tooltip: React.FC = ({ const related = e.relatedTarget as Node | null; // Moving into the tooltip → keep open - if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { + (children.props as any)?.onPointerLeave?.(e); return; } @@ -236,7 +241,7 @@ export const Tooltip: React.FC = ({ const handleBlur = useCallback( (e: React.FocusEvent) => { const related = e.relatedTarget as Node | null; - if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onBlur?.(e); return; } @@ -258,7 +263,7 @@ export const Tooltip: React.FC = ({ const handleTooltipPointerLeave = useCallback( (e: React.PointerEvent) => { const related = e.relatedTarget as Node | null; - if (related && triggerRef.current && triggerRef.current.contains(related)) return; + if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; if (!isPinned) setOpen(false); }, [isPinned, setOpen] From 1ea4b137f198e26c827f4e2e3db15301e7ea6f13 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 17:54:19 +0000 Subject: [PATCH 14/17] translations --- .../public/locales/en-GB/translation.json | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 2665b3420..6f73aeeb2 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1978,9 +1978,23 @@ "title": "Draw your signature", "clear": "Clear" }, + "canvas": { + "heading": "Draw your signature", + "clickToOpen": "Click to open the drawing canvas", + "modalTitle": "Draw your signature", + "colorLabel": "Colour", + "penSizeLabel": "Pen size", + "penSizePlaceholder": "Size", + "clear": "Clear canvas", + "colorPickerTitle": "Choose stroke colour" + }, "text": { "name": "Signer Name", - "placeholder": "Enter your full name" + "placeholder": "Enter your full name", + "fontLabel": "Font", + "fontSizeLabel": "Font size", + "fontSizePlaceholder": "Type or select font size (8-200)", + "colorLabel": "Text colour" }, "clear": "Clear", "add": "Add", @@ -2003,6 +2017,11 @@ "steps": { "configure": "Configure Signature" }, + "step": { + "createDesc": "Choose how you want to create the signature", + "place": "Place & save", + "placeDesc": "Position the signature on your PDF" + }, "type": { "title": "Signature Type", "draw": "Draw", @@ -2019,11 +2038,16 @@ "title": "How to add signature", "canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.", "image": "After uploading your signature image above, click anywhere on the PDF to place it.", - "text": "After entering your name above, click anywhere on the PDF to place your signature." + "text": "After entering your name above, click anywhere on the PDF to place your signature.", + "paused": "Placement paused", + "resumeHint": "Resume placement to click and add your signature.", + "noSignature": "Create a signature above to enable placement tools." }, "mode": { "move": "Move Signature", - "place": "Place Signature" + "place": "Place Signature", + "pause": "Pause placement", + "resume": "Resume placement" }, "updateAndPlace": "Update and Place", "activate": "Activate Signature Placement", @@ -4607,6 +4631,9 @@ "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } }, + "colorPicker": { + "title": "Choose colour" + }, "common": { "previous": "Previous", "next": "Next", @@ -4622,7 +4649,8 @@ "used": "used", "available": "available", "cancel": "Cancel", - "preview": "Preview" + "preview": "Preview", + "done": "Done" }, "config": { "overview": { From 2c491c6bf4e41649339367da9b38b1ea03c4ed8d Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 20:00:30 +0000 Subject: [PATCH 15/17] More robust --- .../components/tools/sign/SignSettings.tsx | 21 +++++------ .../components/tools/sign/signConstants.ts | 15 ++++++++ .../components/viewer/SignatureAPIBridge.tsx | 36 +++++++++++++------ .../viewer/SignaturePlacementOverlay.tsx | 24 +++++++++---- frontend/src/core/utils/signaturePreview.ts | 5 +-- 5 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 frontend/src/core/components/tools/sign/signConstants.ts diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 910c2727e..7be7e62d4 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect, useMemo, useRef } from 'react'; +import { useState, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from "react-i18next"; import { Stack, Button, Text, Alert, SegmentedControl, Divider, ActionIcon, Tooltip, Group, Box } from '@mantine/core'; import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; +import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from './signConstants'; // Import the new reusable components import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; @@ -174,12 +175,12 @@ const SignSettings = ({ case 'image': return imageSignatureData ?? null; case 'text': - return [ - (parameters.signerName ?? '').trim(), - parameters.fontSize ?? 16, - parameters.fontFamily ?? 'Helvetica', - parameters.textColor ?? '#000000', - ].join('|'); + return JSON.stringify({ + signerName: (parameters.signerName ?? '').trim(), + fontSize: parameters.fontSize ?? 16, + fontFamily: parameters.fontFamily ?? 'Helvetica', + textColor: parameters.textColor ?? '#000000', + }); default: return null; } @@ -355,7 +356,7 @@ const SignSettings = ({ if (typeof window !== 'undefined') { const timer = window.setTimeout(() => { onActivateSignaturePlacement?.(); - }, 60); + }, PLACEMENT_ACTIVATION_DELAY); return () => window.clearTimeout(timer); } @@ -392,7 +393,7 @@ const SignSettings = ({ }; if (typeof window !== 'undefined') { - const timer = window.setTimeout(trigger, 60); + const timer = window.setTimeout(trigger, PLACEMENT_ACTIVATION_DELAY); return () => window.clearTimeout(timer); } @@ -415,7 +416,7 @@ const SignSettings = ({ if (typeof window !== 'undefined') { const timer = window.setTimeout(() => { onActivateSignaturePlacement?.(); - }, 80); + }, FILE_SWITCH_ACTIVATION_DELAY); return () => window.clearTimeout(timer); } diff --git a/frontend/src/core/components/tools/sign/signConstants.ts b/frontend/src/core/components/tools/sign/signConstants.ts new file mode 100644 index 000000000..ade726a6c --- /dev/null +++ b/frontend/src/core/components/tools/sign/signConstants.ts @@ -0,0 +1,15 @@ +// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode +export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes +export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files + +// Signature preview sizing +export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container +export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container +export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem +export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem +export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility +export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges + +// Text signature padding (relative to font size) +export const HORIZONTAL_PADDING_RATIO = 0.8; +export const VERTICAL_PADDING_RATIO = 0.6; diff --git a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx index 0077c3b18..c914c50a2 100644 --- a/frontend/src/core/components/viewer/SignatureAPIBridge.tsx +++ b/frontend/src/core/components/viewer/SignatureAPIBridge.tsx @@ -6,28 +6,42 @@ import type { SignatureAPI } from '@app/components/viewer/viewerTypes'; import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; import { useViewer } from '@app/contexts/ViewerContext'; +// Minimum allowed width/height (in pixels) for a signature image or text stamp. +// This prevents rendering issues and ensures signatures are always visible and usable. const MIN_SIGNATURE_DIMENSION = 12; +// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images. +// This provides a good balance between visual fidelity and performance/memory usage. const TEXT_OVERSAMPLE_FACTOR = 2; -const extractDataUrl = (value: unknown, depth = 0): string | undefined => { +const extractDataUrl = (value: unknown, depth = 0, visited: Set = new Set()): string | undefined => { if (!value || depth > 6) return undefined; + + // Prevent circular references + if (typeof value === 'object' && visited.has(value)) { + return undefined; + } + if (typeof value === 'string') { return value.startsWith('data:image') ? value : undefined; } - if (Array.isArray(value)) { - for (const entry of value) { - const result = extractDataUrl(entry, depth + 1); - if (result) return result; - } - return undefined; - } + if (typeof value === 'object') { - for (const key of Object.keys(value as Record)) { - const result = extractDataUrl((value as Record)[key], depth + 1); - if (result) return result; + visited.add(value); + + if (Array.isArray(value)) { + for (const entry of value) { + const result = extractDataUrl(entry, depth + 1, visited); + if (result) return result; + } + } else { + for (const key of Object.keys(value as Record)) { + const result = extractDataUrl((value as Record)[key], depth + 1, visited); + if (result) return result; + } } } + return undefined; }; diff --git a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx index fd87ea51a..c483311fa 100644 --- a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx +++ b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx @@ -3,6 +3,17 @@ import { Box } from '@mantine/core'; import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview'; import { useSignature } from '@app/contexts/SignatureContext'; +import { + MAX_PREVIEW_WIDTH_RATIO, + MAX_PREVIEW_HEIGHT_RATIO, + MAX_PREVIEW_WIDTH_REM, + MAX_PREVIEW_HEIGHT_REM, + MIN_SIGNATURE_DIMENSION_REM, + OVERLAY_EDGE_PADDING_REM, +} from '@app/components/tools/sign/signConstants'; + +// Convert rem to pixels using browser's base font size (typically 16px) +const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize); interface SignaturePlacementOverlayProps { containerRef: React.RefObject; @@ -78,8 +89,8 @@ export const SignaturePlacementOverlay: React.FC const containerWidth = container.clientWidth || 1; const containerHeight = container.clientHeight || 1; - const maxWidth = Math.min(containerWidth * 0.35, 240); - const maxHeight = Math.min(containerHeight * 0.35, 160); + const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM)); + const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM)); const scale = Math.min( 1, @@ -88,8 +99,8 @@ export const SignaturePlacementOverlay: React.FC ); return { - width: Math.max(12, preview.width * scale), - height: Math.max(12, preview.height * scale), + width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale), + height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale), }; }, [preview, containerRef]); @@ -118,9 +129,10 @@ export const SignaturePlacementOverlay: React.FC const width = scaledSize.width; const height = scaledSize.height; + const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM); - const clampedLeft = Math.max(4, Math.min(cursor.x - width / 2, containerWidth - width - 4)); - const clampedTop = Math.max(4, Math.min(cursor.y - height / 2, containerHeight - height - 4)); + const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding)); + const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding)); return { left: clampedLeft, diff --git a/frontend/src/core/utils/signaturePreview.ts b/frontend/src/core/utils/signaturePreview.ts index 15666e31b..c5be5bc2d 100644 --- a/frontend/src/core/utils/signaturePreview.ts +++ b/frontend/src/core/utils/signaturePreview.ts @@ -1,4 +1,5 @@ import { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; +import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/components/tools/sign/signConstants'; export interface SignaturePreview { dataUrl: string; @@ -33,8 +34,8 @@ export const buildSignaturePreview = async (config: SignParameters | null): Prom const fontFamily = config.fontFamily ?? 'Helvetica'; const textColor = config.textColor ?? '#000000'; - const paddingX = Math.round(fontSize * 0.8); - const paddingY = Math.round(fontSize * 0.6); + const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO); + const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO); const measureCanvas = document.createElement('canvas'); const measureCtx = measureCanvas.getContext('2d'); From de4724a291a125518b63109044e6a89785a1edb2 Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 20:03:16 +0000 Subject: [PATCH 16/17] Escape key pause toggle --- .../components/tools/sign/SignSettings.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 7be7e62d4..d2a52c942 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -503,6 +503,25 @@ const SignSettings = ({ onActivateSignaturePlacement?.(); }; + // Handle Escape key to toggle pause/resume + useEffect(() => { + if (!isCurrentTypeReady) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (isPlacementMode) { + handlePausePlacement(); + } else if (isPlacementManuallyPaused) { + handleResumePlacement(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isCurrentTypeReady, isPlacementMode, isPlacementManuallyPaused]); + const placementToggleControl = onActivateSignaturePlacement || onDeactivateSignature ? isPlacementMode From 4a5f7e9c49021c3b0cd49d6b87e299756596eb1d Mon Sep 17 00:00:00 2001 From: Reece Date: Thu, 13 Nov 2025 20:10:24 +0000 Subject: [PATCH 17/17] imports --- frontend/src/core/components/tools/sign/SignSettings.tsx | 2 +- .../src/core/components/viewer/SignaturePlacementOverlay.tsx | 2 +- .../core/{components/tools/sign => constants}/signConstants.ts | 0 frontend/src/core/utils/signaturePreview.ts | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename frontend/src/core/{components/tools/sign => constants}/signConstants.ts (100%) diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index d2a52c942..6c2599d0c 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -5,7 +5,7 @@ import { SignParameters } from "@app/hooks/tools/sign/useSignParameters"; import { SuggestedToolsSection } from "@app/components/tools/shared/SuggestedToolsSection"; import { useSignature } from "@app/contexts/SignatureContext"; import { useViewer } from "@app/contexts/ViewerContext"; -import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from './signConstants'; +import { PLACEMENT_ACTIVATION_DELAY, FILE_SWITCH_ACTIVATION_DELAY } from '@app/constants/signConstants'; // Import the new reusable components import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; diff --git a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx index c483311fa..be5257deb 100644 --- a/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx +++ b/frontend/src/core/components/viewer/SignaturePlacementOverlay.tsx @@ -10,7 +10,7 @@ import { MAX_PREVIEW_HEIGHT_REM, MIN_SIGNATURE_DIMENSION_REM, OVERLAY_EDGE_PADDING_REM, -} from '@app/components/tools/sign/signConstants'; +} from '@app/constants/signConstants'; // Convert rem to pixels using browser's base font size (typically 16px) const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize); diff --git a/frontend/src/core/components/tools/sign/signConstants.ts b/frontend/src/core/constants/signConstants.ts similarity index 100% rename from frontend/src/core/components/tools/sign/signConstants.ts rename to frontend/src/core/constants/signConstants.ts diff --git a/frontend/src/core/utils/signaturePreview.ts b/frontend/src/core/utils/signaturePreview.ts index c5be5bc2d..739edfb43 100644 --- a/frontend/src/core/utils/signaturePreview.ts +++ b/frontend/src/core/utils/signaturePreview.ts @@ -1,5 +1,5 @@ import { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; -import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/components/tools/sign/signConstants'; +import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants'; export interface SignaturePreview { dataUrl: string;