Compare commits

...
34 changed files with 2801 additions and 1080 deletions
+248 -137
View File
@@ -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",
+18 -18
View File
@@ -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",
@@ -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<BaseAnnotationToolProps> = ({
undo,
redo
} = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(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<BaseAnnotationToolProps> = ({
<DrawingControls
onUndo={undo}
onRedo={redo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
@@ -86,4 +113,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
/>
</Stack>
);
};
};
@@ -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<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
title
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
title={resolvedTitle}
size="sm"
centered
>
@@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
{t('common.done', 'Done')}
</Button>
</Group>
</Stack>
@@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
onClick={onClick}
/>
);
};
};
@@ -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<DrawingCanvasProps> = ({
@@ -33,12 +35,13 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
disabled = false,
width = 400,
height = 150,
initialSignatureData,
}) => {
const { t } = useTranslation();
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(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<DrawingCanvasProps> = ({
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<DrawingCanvasProps> = ({
}
};
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 (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
<canvas
ref={previewCanvasRef}
width={width}
@@ -191,46 +213,27 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
onClick={disabled ? undefined : openModal}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.colorLabel', 'Colour')}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.penSizeLabel', 'Pen size')}
</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
@@ -239,12 +242,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
size="compact-sm"
style={{ width: '60px' }}
style={{ width: '80px' }}
/>
</div>
</div>
</Stack>
</Group>
<canvas
ref={(el) => {
@@ -266,10 +269,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
Done
{t('common.done', 'Done')}
</Button>
</div>
</Stack>
@@ -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<DrawingControlsProps> = ({
@@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
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 (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t('sign.undo', 'Undo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.undo', 'Undo')}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t('sign.redo', 'Redo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.redo', 'Redo')}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{additionalControls}
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
@@ -50,11 +72,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
ml="auto"
>
{placeButtonText}
</Button>
)}
</Group>
);
};
};
@@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInput, setColorInput] = useState(textColor);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
// Sync color input with prop changes
useEffect(() => {
setColorInput(textColor);
}, [textColor]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
@@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
return /^#[0-9A-Fa-f]{6}$/.test(color);
};
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
@@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label="Font"
label={t('sign.text.fontLabel', 'Font')}
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
@@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
label={t('sign.text.colorLabel', 'Text colour')}
value={colorInput}
placeholder="#000000"
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
onChange={(e) => {
const value = e.currentTarget.value;
setColorInput(value);
// Update color if valid hex
if (isValidHexColor(value)) {
onTextColorChange(value);
}
}}
onBlur={() => {
// Revert to valid color on blur if invalid
if (!isValidHexColor(colorInput)) {
setColorInput(textColor);
}
}}
style={{ width: '100%' }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{
width: 24,
height: 24,
@@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
)}
</Stack>
);
};
};
+47 -25
View File
@@ -77,6 +77,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
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
@@ -98,7 +103,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
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;
@@ -162,49 +167,49 @@ export const Tooltip: React.FC<TooltipProps> = ({
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned && !disabled) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
(childPropsRef.current as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props, disabled]
[isPinned, openWithDelay, disabled]
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
// Moving into the tooltip keep open
// Moving into the tooltip -> keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
(childPropsRef.current as any)?.onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
(childPropsRef.current as any)?.onPointerLeave?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
(childPropsRef.current as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
[clearTimers, isPinned, setOpen]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
(childPropsRef.current 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));
(children.props as any)?.onMouseUp?.(e);
(childPropsRef.current as any)?.onMouseUp?.(e);
},
[children.props]
[]
);
const handleClick = useCallback(
@@ -219,31 +224,31 @@ export const Tooltip: React.FC<TooltipProps> = ({
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
(childPropsRef.current as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
[clearTimers, pinOnClick, open, setOpen]
);
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned && !disabled && openOnFocus) openWithDelay();
(children.props as any)?.onFocus?.(e);
(childPropsRef.current as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props, disabled, openOnFocus]
[isPinned, openWithDelay, disabled, openOnFocus]
);
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
(childPropsRef.current as any)?.onBlur?.(e);
return;
}
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
(childPropsRef.current as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
[isPinned, setOpen]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
@@ -264,14 +269,30 @@ export const Tooltip: React.FC<TooltipProps> = ({
[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<HTMLElement | null>).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: (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;
},
ref: mergedRef,
'aria-describedby': open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
@@ -363,3 +384,4 @@ export const Tooltip: React.FC<TooltipProps> = ({
</>
);
};
@@ -67,8 +67,8 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
<Text size="xs" c="blue" fs="italic">
{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')
}
</Text>
</Stack>
@@ -84,7 +84,7 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
onClick={() => setAdvancedOpen(!advancedOpen)}
disabled={disabled}
>
{t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '' : ''}
{t('bookletImposition.advanced.toggle', 'Advanced Options')} {advancedOpen ? '^' : 'v'}
</Button>
<Collapse in={advancedOpen}>
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, Tabs, SegmentedControl } from '@mantine/core';
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 the new reusable components
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
@@ -11,6 +12,18 @@ import { DrawingControls } from "@app/components/annotation/shared/DrawingContro
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { LocalIcon } from "@app/components/shared/LocalIcon";
type SignatureDrafts = {
canvas?: string;
image?: string;
text?: {
signerName: string;
fontSize: number;
fontFamily: string;
textColor: string;
};
};
interface SignSettingsProps {
parameters: SignParameters;
@@ -31,23 +44,64 @@ const SignSettings = ({
disabled = false,
onActivateSignaturePlacement,
onDeactivateSignature,
onUpdateDrawSettings,
onUndo,
onRedo,
onSave
}: SignSettingsProps) => {
const { t } = useTranslation();
const { isPlacementMode } = useSignature();
const { isPlacementMode, signaturesApplied, historyApiRef } = useSignature();
const { activeFileIndex } = useViewer();
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current;
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [interactionMode, setInteractionMode] = useState<'move' | 'place'>('move');
const [isPlacementManuallyPaused, setPlacementManuallyPaused] = useState(false);
// State for different signature types
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
const [canvasSignatureData, setCanvasSignatureData] = useState<string | undefined>();
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
const [signatureDrafts, setSignatureDrafts] = useState<SignatureDrafts>({});
const lastSyncedTextDraft = useRef<SignatureDrafts['text'] | null>(null);
const lastAppliedPlacementKey = useRef<string | null>(null);
const previousFileIndexRef = useRef(activeFileIndex);
useEffect(() => {
if (!disabled) {
onUpdateDrawSettings?.(selectedColor, penSize);
}
}, [selectedColor, penSize, disabled, onUpdateDrawSettings]);
useEffect(() => {
if (signaturesApplied) {
setPlacementManuallyPaused(false);
}
}, [signaturesApplied]);
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]);
// Handle image upload
const handleImageChange = async (file: File | null) => {
@@ -66,64 +120,209 @@ const SignSettings = ({
reader.readAsDataURL(file);
});
// Clear any existing canvas signatures when uploading image
setCanvasSignatureData(null);
setImageSignatureData(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageSignatureData(null);
if (onDeactivateSignature) {
onDeactivateSignature();
}
setImageSignatureData(undefined);
onDeactivateSignature?.();
}
};
// Handle signature data changes
const handleCanvasSignatureChange = (data: string | null) => {
const nextValue = data ?? undefined;
setCanvasSignatureData(prev => {
if (prev === data) return prev; // Prevent unnecessary updates
return data;
if (prev === nextValue) {
return prev;
}
return nextValue;
});
if (data) {
// Clear image data when canvas is used
setImageSignatureData(null);
}
};
// Handle signature mode deactivation when switching types
useEffect(() => {
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
onDeactivateSignature();
const hasCanvasSignature = useMemo(() => Boolean(canvasSignatureData), [canvasSignatureData]);
const hasImageSignature = useMemo(() => Boolean(imageSignatureData), [imageSignatureData]);
const hasTextSignature = useMemo(
() => Boolean(parameters.signerName && parameters.signerName.trim() !== ''),
[parameters.signerName]
);
const hasAnySignature = hasCanvasSignature || hasImageSignature || hasTextSignature;
const isCurrentTypeReady = useMemo(() => {
switch (parameters.signatureType) {
case 'canvas':
return hasCanvasSignature;
case 'image':
return hasImageSignature;
case 'text':
return hasTextSignature;
default:
return false;
}
}, [parameters.signatureType]);
}, [parameters.signatureType, hasCanvasSignature, hasImageSignature, hasTextSignature]);
const placementSignatureKey = useMemo(() => {
if (!isCurrentTypeReady) {
return null;
}
switch (parameters.signatureType) {
case 'canvas':
return canvasSignatureData ?? null;
case 'image':
return imageSignatureData ?? null;
case 'text':
return [
(parameters.signerName ?? '').trim(),
parameters.fontSize ?? 16,
parameters.fontFamily ?? 'Helvetica',
parameters.textColor ?? '#000000',
].join('|');
default:
return null;
}
}, [
isCurrentTypeReady,
parameters.signatureType,
canvasSignatureData,
imageSignatureData,
parameters.signerName,
parameters.fontSize,
parameters.fontFamily,
parameters.textColor,
]);
const shouldEnablePlacement = useMemo(() => {
if (disabled) return false;
return isCurrentTypeReady;
}, [disabled, isCurrentTypeReady]);
const shouldAutoActivate = shouldEnablePlacement && !isPlacementManuallyPaused && !signaturesApplied;
// Handle text signature activation (including fontSize and fontFamily changes)
useEffect(() => {
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
if (onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
setSignatureDrafts(prev => {
if (canvasSignatureData) {
if (prev.canvas === canvasSignatureData) {
return prev;
}
return { ...prev, canvas: canvasSignatureData };
}
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
if (onDeactivateSignature) {
setInteractionMode('move');
onDeactivateSignature();
if (prev.canvas !== undefined) {
const next = { ...prev };
delete next.canvas;
return next;
}
}
}, [parameters.signatureType, parameters.signerName, parameters.fontSize, parameters.fontFamily, onActivateSignaturePlacement, onDeactivateSignature]);
// Reset to move mode when placement mode is deactivated
return prev;
});
}, [canvasSignatureData]);
useEffect(() => {
if (!isPlacementMode && interactionMode === 'place') {
setInteractionMode('move');
setSignatureDrafts(prev => {
if (imageSignatureData) {
if (prev.image === imageSignatureData) {
return prev;
}
return { ...prev, image: imageSignatureData };
}
if (prev.image !== undefined) {
const next = { ...prev };
delete next.image;
return next;
}
return prev;
});
}, [imageSignatureData]);
useEffect(() => {
const nextDraft = {
signerName: parameters.signerName || '',
fontSize: parameters.fontSize || 16,
fontFamily: parameters.fontFamily || 'Helvetica',
textColor: parameters.textColor || '#000000',
};
setSignatureDrafts(prev => {
const prevDraft = prev.text;
if (
prevDraft &&
prevDraft.signerName === nextDraft.signerName &&
prevDraft.fontSize === nextDraft.fontSize &&
prevDraft.fontFamily === nextDraft.fontFamily &&
prevDraft.textColor === nextDraft.textColor
) {
return prev;
}
return { ...prev, text: nextDraft };
});
}, [parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
useEffect(() => {
if (parameters.signatureType === 'text') {
const draft = signatureDrafts.text;
if (!draft) {
lastSyncedTextDraft.current = null;
return;
}
const currentSignerName = parameters.signerName ?? '';
const currentFontSize = parameters.fontSize ?? 16;
const currentFontFamily = parameters.fontFamily ?? 'Helvetica';
const currentTextColor = parameters.textColor ?? '#000000';
const isSynced =
draft.signerName === currentSignerName &&
draft.fontSize === currentFontSize &&
draft.fontFamily === currentFontFamily &&
draft.textColor === currentTextColor;
if (isSynced) {
lastSyncedTextDraft.current = draft;
return;
}
const lastSynced = lastSyncedTextDraft.current;
const alreadyAttempted =
lastSynced &&
lastSynced.signerName === draft.signerName &&
lastSynced.fontSize === draft.fontSize &&
lastSynced.fontFamily === draft.fontFamily &&
lastSynced.textColor === draft.textColor;
if (!alreadyAttempted) {
lastSyncedTextDraft.current = draft;
if (draft.signerName !== currentSignerName) {
onParameterChange('signerName', draft.signerName);
}
if (draft.fontSize !== currentFontSize) {
onParameterChange('fontSize', draft.fontSize);
}
if (draft.fontFamily !== currentFontFamily) {
onParameterChange('fontFamily', draft.fontFamily);
}
if (draft.textColor !== currentTextColor) {
onParameterChange('textColor', draft.textColor);
}
}
} else {
lastSyncedTextDraft.current = null;
}
}, [isPlacementMode, interactionMode]);
}, [
parameters.signatureType,
parameters.signerName,
parameters.fontSize,
parameters.fontFamily,
parameters.textColor,
signatureDrafts.text,
onParameterChange,
]);
// Handle signature data updates
useEffect(() => {
let newSignatureData: string | undefined = undefined;
@@ -133,71 +332,99 @@ const SignSettings = ({
newSignatureData = canvasSignatureData;
}
// Only update if the signature data has actually changed
if (parameters.signatureData !== newSignatureData) {
onParameterChange('signatureData', newSignatureData);
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData, onParameterChange]);
// Handle image signature activation - activate when image data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
if (!shouldEnablePlacement) {
if (isPlacementMode) {
onDeactivateSignature?.();
}
if (isPlacementManuallyPaused) {
setPlacementManuallyPaused(false);
}
return;
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
// Handle canvas signature activation - activate when canvas data syncs with parameters
if (!shouldAutoActivate || isPlacementMode) {
return;
}
if (typeof window !== 'undefined') {
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, 60);
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
}, [
shouldEnablePlacement,
shouldAutoActivate,
isPlacementMode,
isPlacementManuallyPaused,
onActivateSignaturePlacement,
onDeactivateSignature,
]);
useEffect(() => {
if (parameters.signatureType === 'canvas' && canvasSignatureData && parameters.signatureData === canvasSignatureData && onActivateSignaturePlacement) {
setInteractionMode('place');
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
if (!shouldAutoActivate || !placementSignatureKey) {
if (!shouldEnablePlacement || !shouldAutoActivate) {
lastAppliedPlacementKey.current = null;
}
return;
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData]);
// Draw settings are no longer needed since draw mode is removed
if (!isPlacementMode) {
lastAppliedPlacementKey.current = null;
return;
}
return (
<Stack gap="md">
{/* Signature Type Selection */}
<Tabs
value={parameters.signatureType}
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
>
<Tabs.List grow>
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
{t('sign.type.canvas', 'Canvas')}
</Tabs.Tab>
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
{t('sign.type.image', 'Image')}
</Tabs.Tab>
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
{t('sign.type.text', 'Text')}
</Tabs.Tab>
</Tabs.List>
</Tabs>
if (lastAppliedPlacementKey.current === placementSignatureKey) {
return;
}
{/* Drawing Controls */}
<DrawingControls
onUndo={onUndo}
onRedo={onRedo}
onPlaceSignature={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
disabled={disabled}
showPlaceButton={false}
placeButtonText={t('sign.updateAndPlace', 'Update and Place')}
/>
const trigger = () => {
onActivateSignaturePlacement?.();
lastAppliedPlacementKey.current = placementSignatureKey;
};
{/* Signature Creation based on type */}
{parameters.signatureType === 'canvas' && (
if (typeof window !== 'undefined') {
const timer = window.setTimeout(trigger, 60);
return () => window.clearTimeout(timer);
}
trigger();
}, [placementSignatureKey, shouldAutoActivate, shouldEnablePlacement, isPlacementMode, onActivateSignaturePlacement]);
useEffect(() => {
if (activeFileIndex === previousFileIndexRef.current) {
return;
}
previousFileIndexRef.current = activeFileIndex;
if (!shouldEnablePlacement || signaturesApplied) {
return;
}
setPlacementManuallyPaused(false);
lastAppliedPlacementKey.current = null;
if (typeof window !== 'undefined') {
const timer = window.setTimeout(() => {
onActivateSignaturePlacement?.();
}, 80);
return () => window.clearTimeout(timer);
}
onActivateSignaturePlacement?.();
}, [activeFileIndex, shouldEnablePlacement, signaturesApplied, onActivateSignaturePlacement]);
const renderSignatureBuilder = () => {
if (parameters.signatureType === 'canvas') {
return (
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
@@ -207,92 +434,185 @@ const SignSettings = ({
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
onActivateSignaturePlacement?.();
}}
disabled={disabled}
additionalButtons={
<Button
onClick={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
color="blue"
variant="filled"
disabled={disabled || !canvasSignatureData}
>
{t('sign.updateAndPlace', 'Update and Place')}
</Button>
}
initialSignatureData={canvasSignatureData}
/>
)}
);
}
{parameters.signatureType === 'image' && (
if (parameters.signatureType === 'image') {
return (
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
/>
)}
);
}
{parameters.signatureType === 'text' && (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
)}
return (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
textColor={parameters.textColor || '#000000'}
onTextColorChange={(color) => onParameterChange('textColor', color)}
disabled={disabled}
/>
);
};
const placementInstructions = () => {
if (parameters.signatureType === 'canvas') {
return t('sign.instructions.canvas', 'After drawing your signature and closing the canvas, click anywhere on the PDF to place it.');
}
if (parameters.signatureType === 'image') {
return t('sign.instructions.image', 'After uploading your signature image, click anywhere on the PDF to place it.');
}
return t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.');
};
{/* Interaction Mode Toggle */}
{(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== '')) && (
<SegmentedControl
value={interactionMode}
onChange={(value) => {
setInteractionMode(value as 'move' | 'place');
if (value === 'place') {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
} else {
if (onDeactivateSignature) {
onDeactivateSignature();
}
}
}}
data={[
{ label: t('sign.mode.move', 'Move Signature'), value: 'move' },
{ label: t('sign.mode.place', 'Place Signature'), value: 'place' }
]}
fullWidth
/>
)}
const placementAlert = isCurrentTypeReady
? {
color: isPlacementMode ? 'blue' : 'teal',
title: isPlacementMode
? t('sign.instructions.title', 'How to add your signature')
: t('sign.instructions.paused', 'Placement paused'),
message: isPlacementMode
? placementInstructions()
: t('sign.instructions.resumeHint', 'Resume placement to click and add your signature.'),
}
: {
color: 'yellow',
title: t('sign.instructions.title', 'How to add your signature'),
message: t('sign.instructions.noSignature', 'Create a signature above to enable placement tools.'),
};
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && t('sign.instructions.canvas', 'After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.')}
{parameters.signatureType === 'image' && t('sign.instructions.image', 'After uploading your signature image above, click anywhere on the PDF to place it.')}
{parameters.signatureType === 'text' && t('sign.instructions.text', 'After entering your name above, click anywhere on the PDF to place your signature.')}
const handlePausePlacement = () => {
setPlacementManuallyPaused(true);
onDeactivateSignature?.();
};
const handleResumePlacement = () => {
setPlacementManuallyPaused(false);
onActivateSignaturePlacement?.();
};
const placementToggleControl =
onActivateSignaturePlacement || onDeactivateSignature
? isPlacementMode
? (
<Tooltip label={t('sign.mode.pause', 'Pause placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.pause', 'Pause placement')}
onClick={handlePausePlacement}
disabled={disabled || !onDeactivateSignature}
style={{
width: 'auto',
paddingInline: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:pause-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.pause', 'Pause placement')}
</Text>
</ActionIcon>
</Tooltip>
)
: (
<Tooltip label={t('sign.mode.resume', 'Resume placement')}>
<ActionIcon
variant="default"
size="lg"
aria-label={t('sign.mode.resume', 'Resume placement')}
onClick={handleResumePlacement}
disabled={disabled || !isCurrentTypeReady || !onActivateSignaturePlacement}
style={{
width: 'auto',
paddingInline: '0.75rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.4rem',
}}
>
<LocalIcon icon="material-symbols:play-arrow-rounded" width={20} height={20} />
<Text component="span" size="sm" fw={500}>
{t('sign.mode.resume', 'Resume placement')}
</Text>
</ActionIcon>
</Tooltip>
)
: null;
return (
<Stack>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t('sign.step.createDesc', 'Choose how you want to create the signature')}
</Text>
</Alert>
<SegmentedControl
value={parameters.signatureType}
fullWidth
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
data={[
{ label: t('sign.type.canvas', 'Draw'), value: 'canvas' },
{ label: t('sign.type.image', 'Upload'), value: 'image' },
{ label: t('sign.type.text', 'Type'), value: 'text' },
]}
/>
{renderSignatureBuilder()}
</Stack>
<Divider />
<Stack gap="sm">
<Text fw={600} size="md">
{t('sign.step.place', 'Place & save')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.step.placeDesc', 'Position the signature on your PDF')}
</Text>
<Group gap="xs" wrap="nowrap" align="center">
<DrawingControls
onUndo={onUndo}
onRedo={onRedo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
hasSignatureData={hasAnySignature}
disabled={disabled}
showPlaceButton={false}
/>
<Box style={{ marginLeft: 'auto' }}>
{placementToggleControl}
</Box>
</Group>
<Alert color={placementAlert.color} title={placementAlert.title}>
<Text size="sm">
{placementAlert.message}
</Text>
</Alert>
</Stack>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
/>
{/* Apply Signatures Button */}
{onSave && (
<Button
onClick={onSave}
@@ -304,7 +624,6 @@ const SignSettings = ({
</Button>
)}
{/* Suggested Tools Section */}
<SuggestedToolsSection />
</Stack>
);
@@ -14,6 +14,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
@@ -33,16 +34,15 @@ const EmbedPdfViewerContent = ({
setActiveFileIndex: externalSetActiveFileIndex,
}: EmbedPdfViewerProps) => {
const viewerRef = React.useRef<HTMLDivElement>(null);
const pdfContainerRef = useRef<HTMLDivElement>(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
@@ -54,7 +54,7 @@ const EmbedPdfViewerContent = ({
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -72,6 +72,9 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
const isPlacementOverlayActive = Boolean(
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
);
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
@@ -276,15 +279,17 @@ const EmbedPdfViewerContent = ({
) : (
<>
{/* EmbedPDF Viewer */}
<Box style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<Box
ref={pdfContainerRef}
style={{
position: 'relative',
flex: 1,
overflow: 'hidden',
minHeight: 0,
minWidth: 0,
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
transition: 'margin-right 0.3s ease'
}}>
<LocalEmbedPDF
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
@@ -297,6 +302,11 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion
}}
/>
<SignaturePlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
</Box>
</>
)}
@@ -320,15 +330,6 @@ const EmbedPdfViewerContent = ({
<PdfViewerToolbar
currentPage={scrollState.currentPage}
totalPages={scrollState.totalPages}
onPageChange={(page) => {
// Page navigation handled by scrollActions
console.log('Navigate to page:', page);
}}
dualPage={spreadState.isDualPage}
onDualPageToggle={() => {
spreadActions.toggleSpreadMode();
}}
currentZoom={zoomState.zoomPercent}
/>
</div>
</div>
@@ -1,4 +1,4 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
@@ -9,6 +9,7 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
const { provides: historyApi } = useHistoryCapability();
const { provides: annotationApi } = useAnnotationCapability();
const { getImageData, storeImageData } = useSignature();
const restoringIds = useRef<Set<string>>(new Set());
// Monitor annotation events to detect when annotations are restored
useEffect(() => {
@@ -20,11 +21,52 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
// Store image data for all STAMP annotations immediately when created or modified
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
const storedImageData = getImageData(annotation.id);
if (!storedImageData || storedImageData !== annotation.imageSrc) {
if (!storedImageData) {
storeImageData(annotation.id, annotation.imageSrc);
}
}
if (annotation && annotation.type === 13 && annotation.id) {
// Prevent infinite loops when we recreate annotations
if (restoringIds.current.has(annotation.id)) {
restoringIds.current.delete(annotation.id);
return;
}
const storedImageData = getImageData(annotation.id);
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
const newId = uuidV4();
restoringIds.current.add(newId);
storeImageData(newId, storedImageData);
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
try {
annotationApi.deleteAnnotation(pageIndex, annotation.id);
setTimeout(() => {
annotationApi.createAnnotation(pageIndex, {
type: annotation.type,
rect,
author: annotation.author || 'Digital Signature',
subject: annotation.subject || 'Digital Signature',
pageIndex,
id: newId,
created: annotation.created || new Date(),
imageSrc: storedImageData,
contents: storedImageData,
data: storedImageData,
appearance: storedImageData,
});
}, 50);
} catch (restoreError) {
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
}
return;
}
}
// Handle annotation restoration after undo operations
if (event.type === 'create' && event.committed) {
// Check if this is a STAMP annotation (signature) that might need image data restoration
@@ -102,6 +144,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
canRedo: () => {
return historyApi ? historyApi.canRedo() : false;
},
subscribe: (listener: () => void) => {
if (!historyApi?.onHistoryChange) {
return () => {};
}
const wrapped = () => listener();
const unsubscribe = historyApi.onHistoryChange(wrapped);
listener();
if (typeof unsubscribe === 'function') {
return unsubscribe;
}
return () => {};
},
}), [historyApi]);
return null; // This is a bridge component with no UI
@@ -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',
}}
>
<Scroller
@@ -295,6 +297,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<Rotate key={document?.id} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
data-page-index={pageIndex}
data-page-width={width}
data-page-height={height}
style={{
width,
height,
@@ -14,30 +14,32 @@ interface PdfViewerToolbarProps {
currentPage?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
// Dual page toggle (placeholder for now)
dualPage?: boolean;
onDualPageToggle?: () => void;
// Zoom controls (connected via ViewerContext)
currentZoom?: number;
}
export function PdfViewerToolbar({
currentPage = 1,
totalPages: _totalPages = 1,
onPageChange,
dualPage = false,
onDualPageToggle,
currentZoom: _currentZoom = 100,
}: PdfViewerToolbarProps) {
const { t } = useTranslation();
const { getScrollState, getZoomState, scrollActions, zoomActions, registerImmediateZoomUpdate, registerImmediateScrollUpdate } = useViewer();
const {
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 */}
<Button
variant={dualPage ? "filled" : "light"}
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={onDualPageToggle}
onClick={handleDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
title={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
{/* Zoom Controls */}
@@ -1,12 +1,179 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '@app/contexts/SignatureContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useViewer } from '@app/contexts/ViewerContext';
const MIN_SIGNATURE_DIMENSION = 12;
const TEXT_OVERSAMPLE_FACTOR = 2;
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<string, unknown>)) {
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1);
if (result) return result;
}
}
return undefined;
};
const createTextStampImage = (
config: SignParameters,
displaySize?: { width: number; height: number } | null
): { dataUrl: string; pixelWidth: number; pixelHeight: number; displayWidth: number; displayHeight: number } | null => {
const text = (config.signerName ?? '').trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? 'Helvetica';
const textColor = config.textColor ?? '#000000';
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
const scale =
displaySize && naturalWidth > 0 && naturalHeight > 0
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
: 1;
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
const canvasWidth = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
);
const canvasHeight = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
);
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
ctx.scale(effectiveScale, effectiveScale);
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2;
ctx.fillText(text, horizontalPadding, verticalCenter);
return {
dataUrl: canvas.toDataURL('image/png'),
pixelWidth: canvasWidth,
pixelHeight: canvasHeight,
displayWidth,
displayHeight,
};
};
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
const { getZoomState } = useViewer();
const currentZoom = getZoomState()?.currentZoom ?? 1;
const lastStampImageRef = useRef<string | null>(null);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const applyStampDefaults = useCallback(
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
if (!annotationApi) return;
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc,
subject,
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
});
}
},
[annotationApi]
);
const configureStampDefaults = useCallback(async () => {
if (!annotationApi || !signatureConfig) {
return;
}
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
if (textStamp) {
const displaySize =
placementPreviewSize ?? {
width: textStamp.displayWidth,
height: textStamp.displayHeight,
};
const pdfSize = cssToPdfSize(displaySize);
lastStampImageRef.current = textStamp.dataUrl;
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
}
return;
}
if (signatureConfig.signatureData) {
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
lastStampImageRef.current = signatureConfig.signatureData;
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
return;
}
} catch (error) {
console.error('Error preparing signature defaults:', error);
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
@@ -108,58 +275,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateSignaturePlacementMode: () => {
if (!annotationApi || !signatureConfig) return;
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
// Skip native text tools - always use stamp for consistent sizing
const activatedTool = null;
if (!activatedTool) {
// Create text image as stamp with actual pixel size matching desired display size
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
const baseFontSize = signatureConfig.fontSize || 16;
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
const textColor = signatureConfig.textColor || '#000000';
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
canvas.height = baseFontSize + 20;
ctx.fillStyle = textColor;
ctx.font = `${baseFontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
const dataURL = canvas.toDataURL();
// Deactivate and reactivate to force refresh
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: dataURL,
subject: `Text Signature - ${signatureConfig.signerName}`,
});
}
}
}
} else if (signatureConfig.signatureData) {
// Use stamp tool for image/canvas signatures
annotationApi.setActiveTool('stamp');
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: signatureConfig.signatureData,
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
});
}
}
} catch (error) {
configureStampDefaults().catch((error) => {
console.error('Error activating signature tool:', error);
}
});
},
updateDrawSettings: (color: string, size: number) => {
@@ -230,7 +348,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return [];
}
},
}), [annotationApi, signatureConfig]);
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
return null; // This is a bridge component with no UI
@@ -0,0 +1,159 @@
import React, { useEffect, useMemo, useState } from 'react';
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';
interface SignaturePlacementOverlayProps {
containerRef: React.RefObject<HTMLElement>;
isActive: boolean;
signatureConfig: SignParameters | null;
}
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
containerRef,
isActive,
signatureConfig,
}) => {
const [preview, setPreview] = useState<SignaturePreview | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const { setPlacementPreviewSize } = useSignature();
useEffect(() => {
let cancelled = false;
const buildPreview = async () => {
try {
const value = await buildSignaturePreview(signatureConfig ?? null);
if (!cancelled) {
setPreview(value);
}
} catch (error) {
console.error('Failed to build signature preview:', error);
if (!cancelled) {
setPreview(null);
}
}
};
buildPreview();
return () => {
cancelled = true;
};
}, [signatureConfig]);
useEffect(() => {
const element = containerRef.current;
if (!isActive || !element) {
setCursor(null);
return;
}
const handleMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
setCursor({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
};
const handleLeave = () => setCursor(null);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseleave', handleLeave);
return () => {
element.removeEventListener('mousemove', handleMove);
element.removeEventListener('mouseleave', handleLeave);
};
}, [containerRef, isActive]);
const scaledSize = useMemo(() => {
if (!preview || !containerRef.current) {
return null;
}
const container = containerRef.current;
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 scale = Math.min(
1,
maxWidth / Math.max(preview.width, 1),
maxHeight / Math.max(preview.height, 1)
);
return {
width: Math.max(12, preview.width * scale),
height: Math.max(12, preview.height * scale),
};
}, [preview, containerRef]);
useEffect(() => {
if (!isActive || !scaledSize) {
setPlacementPreviewSize(null);
} else {
setPlacementPreviewSize(scaledSize);
}
}, [isActive, scaledSize, setPlacementPreviewSize]);
useEffect(() => {
return () => {
setPlacementPreviewSize(null);
};
}, [setPlacementPreviewSize]);
const display = useMemo(() => {
if (!preview || !scaledSize || !cursor || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const width = scaledSize.width;
const height = scaledSize.height;
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));
return {
left: clampedLeft,
top: clampedTop,
width,
height,
dataUrl: preview.dataUrl,
};
}, [preview, scaledSize, cursor, containerRef]);
if (!isActive || !display || !preview) {
return null;
}
return (
<Box
style={{
position: 'absolute',
pointerEvents: 'none',
left: `${display.left}px`,
top: `${display.top}px`,
width: `${display.width}px`,
height: `${display.height}px`,
backgroundImage: `url(${display.dataUrl})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
borderRadius: '4px',
transition: 'transform 70ms ease-out',
transform: 'translateZ(0)',
opacity: 0.6,
}}
/>
);
};
@@ -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;
}
@@ -1,68 +1,254 @@
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 { selectors } = useFileState();
const hasSetInitialZoom = useRef(false);
const lastSpreadMode = useRef(spreadState?.spreadMode);
const lastFileId = useRef<string | undefined>();
const lastAppliedZoom = useRef<number | null>(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<typeof setTimeout> | 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) {
return;
}
const unsubscribe = zoom.onZoomChange((event: { newZoom?: number }) => {
if (typeof event?.newZoom !== 'number') {
return;
}
lastAppliedZoom.current = event.newZoom;
triggerImmediateZoomUpdate(Math.round(event.newZoom * 100));
});
return () => {
unsubscribe();
};
}, [zoom, triggerImmediateZoomUpdate]);
useEffect(() => {
if (!zoom || !zoomState) {
return;
}
const currentZoomLevel =
lastAppliedZoom.current ?? zoomState.currentZoomLevel ?? 1;
const newState = {
currentZoom: currentZoomLevel,
zoomPercent: Math.round(currentZoomLevel * 100),
};
triggerImmediateZoomUpdate(newState.zoomPercent);
registerBridge('zoom', {
state: newState,
api: zoom,
});
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
return null;
}
@@ -21,4 +21,5 @@ export interface HistoryAPI {
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
@@ -10,6 +10,8 @@ interface SignatureState {
isPlacementMode: boolean;
// Whether signatures have been applied (allows export)
signaturesApplied: boolean;
// Size (in screen units) we want newly placed signatures to use
placementPreviewSize: { width: number; height: number } | null;
}
// Signature actions interface
@@ -26,6 +28,7 @@ interface SignatureActions {
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
setSignaturesApplied: (applied: boolean) => void;
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
}
// Combined context interface
@@ -42,6 +45,7 @@ const initialState: SignatureState = {
signatureConfig: null,
isPlacementMode: false,
signaturesApplied: true, // Start as true (no signatures placed yet)
placementPreviewSize: null,
};
// Provider component
@@ -131,6 +135,27 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
}));
}, []);
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
setState(prev => {
const prevSize = prev.placementPreviewSize;
const same =
(prevSize === null && size === null) ||
(prevSize !== null &&
size !== null &&
Math.abs(prevSize.width - size.width) < 0.5 &&
Math.abs(prevSize.height - size.height) < 0.5);
if (same) {
return prev;
}
return {
...prev,
placementPreviewSize: size,
};
});
}, []);
// No auto-activation - all modes use manual buttons
const contextValue: SignatureContextValue = {
@@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
storeImageData,
getImageData,
setSignaturesApplied,
setPlacementPreviewSize,
};
return (
+116 -438
View File
@@ -1,112 +1,54 @@
import React, { createContext, useContext, useState, ReactNode, useRef } from 'react';
import { SpreadMode } from '@embedpdf/plugin-spread/react';
import React, {
createContext,
useContext,
useState,
ReactNode,
useRef,
useCallback,
} from 'react';
import { useNavigation } from '@app/contexts/NavigationContext';
import { createViewerActions } from '@core/contexts/viewer/viewerActions';
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';
// Bridge API interfaces - these match what the bridges provide
interface ScrollAPIWrapper {
scrollToPage: (params: { pageNumber: number }) => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
}
function useImmediateNotifier<Args extends unknown[]>() {
const callbackRef = useRef<((...args: Args) => void) | null>(null);
interface ZoomAPIWrapper {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
}
const register = useCallback((callback: (...args: Args) => void) => {
callbackRef.current = callback;
}, []);
interface PanAPIWrapper {
enable: () => void;
disable: () => void;
toggle: () => void;
}
const trigger = useCallback((...args: Args) => {
callbackRef.current?.(...args);
}, []);
interface SelectionAPIWrapper {
copyToClipboard: () => void;
getSelectedText: () => string | any;
getFormattedSelection: () => any;
}
interface SpreadAPIWrapper {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
}
interface RotationAPIWrapper {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
}
interface SearchAPIWrapper {
search: (query: string) => Promise<any>;
clear: () => void;
next: () => void;
previous: () => void;
}
interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => { toPromise: () => Promise<Blob> };
}
interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
// 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<TState = unknown, TApi = unknown> {
state: TState;
api: TApi;
return { register, trigger };
}
/**
@@ -150,66 +92,25 @@ interface ViewerContextType {
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
triggerImmediateZoomUpdate: (zoomPercent: number) => void;
triggerImmediateSpreadUpdate: (mode: SpreadMode, isDualPage?: boolean) => void;
// Action handlers - call EmbedPDF APIs directly
scrollActions: {
scrollToPage: (page: number) => void;
scrollToFirstPage: () => void;
scrollToPreviousPage: () => void;
scrollToNextPage: () => void;
scrollToLastPage: () => void;
};
zoomActions: {
zoomIn: () => void;
zoomOut: () => void;
toggleMarqueeZoom: () => void;
requestZoom: (level: number) => void;
};
panActions: {
enablePan: () => void;
disablePan: () => void;
togglePan: () => void;
};
selectionActions: {
copyToClipboard: () => void;
getSelectedText: () => string;
getFormattedSelection: () => unknown;
};
spreadActions: {
setSpreadMode: (mode: SpreadMode) => void;
getSpreadMode: () => SpreadMode | null;
toggleSpreadMode: () => void;
};
rotationActions: {
rotateForward: () => void;
rotateBackward: () => void;
setRotation: (rotation: number) => void;
getRotation: () => number;
};
searchActions: {
search: (query: string) => Promise<void>;
next: () => void;
previous: () => void;
clear: () => void;
};
exportActions: {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
registerBridge: (type: BridgeKey, ref: BridgeRef) => void;
}
export const ViewerContext = createContext<ViewerContextType | null>(null);
@@ -229,56 +130,51 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
useNavigation();
// Bridge registry - bridges register their state and APIs here
const bridgeRefs = useRef({
scroll: null as BridgeRef<ScrollState, ScrollAPIWrapper> | null,
zoom: null as BridgeRef<ZoomState, ZoomAPIWrapper> | null,
pan: null as BridgeRef<PanState, PanAPIWrapper> | null,
selection: null as BridgeRef<SelectionState, SelectionAPIWrapper> | null,
search: null as BridgeRef<SearchState, SearchAPIWrapper> | null,
spread: null as BridgeRef<SpreadState, SpreadAPIWrapper> | null,
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
});
const bridgeRefs = useRef<ViewerBridgeRegistry>(createBridgeRegistry());
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
const {
register: registerImmediateZoomUpdate,
trigger: triggerImmediateZoomInternal,
} = useImmediateNotifier<[number]>();
const {
register: registerImmediateScrollUpdate,
trigger: triggerImmediateScrollInternal,
} = useImmediateNotifier<[number, number]>();
const {
register: registerImmediateSpreadUpdate,
trigger: triggerImmediateSpreadInternal,
} = useImmediateNotifier<[SpreadMode, boolean]>();
// Immediate scroll callback for responsive display updates
const immediateScrollUpdateCallback = useRef<((currentPage: number, totalPages: number) => void) | null>(null);
const triggerImmediateZoomUpdate = useCallback(
(percent: number) => {
triggerImmediateZoomInternal(percent);
},
[triggerImmediateZoomInternal]
);
const registerBridge = (type: string, ref: BridgeRef) => {
// Type-safe assignment - we know the bridges will provide correct types
switch (type) {
case 'scroll':
bridgeRefs.current.scroll = ref as BridgeRef<ScrollState, ScrollAPIWrapper>;
break;
case 'zoom':
bridgeRefs.current.zoom = ref as BridgeRef<ZoomState, ZoomAPIWrapper>;
break;
case 'pan':
bridgeRefs.current.pan = ref as BridgeRef<PanState, PanAPIWrapper>;
break;
case 'selection':
bridgeRefs.current.selection = ref as BridgeRef<SelectionState, SelectionAPIWrapper>;
break;
case 'search':
bridgeRefs.current.search = ref as BridgeRef<SearchState, SearchAPIWrapper>;
break;
case 'spread':
bridgeRefs.current.spread = ref as BridgeRef<SpreadState, SpreadAPIWrapper>;
break;
case 'rotation':
bridgeRefs.current.rotation = ref as BridgeRef<RotationState, RotationAPIWrapper>;
break;
case 'thumbnail':
bridgeRefs.current.thumbnail = ref as BridgeRef<unknown, ThumbnailAPIWrapper>;
break;
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
}
};
const triggerImmediateScrollUpdate = useCallback(
(currentPage: number, totalPages: number) => {
triggerImmediateScrollInternal(currentPage, totalPages);
},
[triggerImmediateScrollInternal]
);
const triggerImmediateSpreadUpdate = useCallback(
(mode: SpreadMode, isDualPage: boolean = mode !== SpreadMode.None) => {
triggerImmediateSpreadInternal(mode, isDualPage);
},
[triggerImmediateSpreadInternal]
);
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
[]
);
const toggleThumbnailSidebar = () => {
setIsThumbnailSidebarVisible(prev => !prev);
@@ -334,241 +230,21 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: page });
}
},
scrollToFirstPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage) {
api.scrollToPage({ pageNumber: 1 });
}
},
scrollToPreviousPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPreviousPage) {
api.scrollToPreviousPage();
}
},
scrollToNextPage: () => {
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToNextPage) {
api.scrollToNextPage();
}
},
scrollToLastPage: () => {
const scrollState = getScrollState();
const api = bridgeRefs.current.scroll?.api;
if (api?.scrollToPage && scrollState.totalPages > 0) {
api.scrollToPage({ pageNumber: scrollState.totalPages });
}
}
};
const zoomActions = {
zoomIn: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomIn) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.min(Math.round(currentState.zoomPercent * 1.2), 300);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomIn();
}
},
zoomOut: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.zoomOut) {
// Update display immediately if callback is registered
if (immediateZoomUpdateCallback.current) {
const currentState = getZoomState();
const newPercent = Math.max(Math.round(currentState.zoomPercent / 1.2), 20);
immediateZoomUpdateCallback.current(newPercent);
}
api.zoomOut();
}
},
toggleMarqueeZoom: () => {
const api = bridgeRefs.current.zoom?.api;
if (api?.toggleMarqueeZoom) {
api.toggleMarqueeZoom();
}
},
requestZoom: (level: number) => {
const api = bridgeRefs.current.zoom?.api;
if (api?.requestZoom) {
api.requestZoom(level);
}
}
};
const panActions = {
enablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.enable) {
api.enable();
}
},
disablePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.disable) {
api.disable();
}
},
togglePan: () => {
const api = bridgeRefs.current.pan?.api;
if (api?.toggle) {
api.toggle();
}
}
};
const selectionActions = {
copyToClipboard: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.copyToClipboard) {
api.copyToClipboard();
}
},
getSelectedText: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getSelectedText) {
return api.getSelectedText();
}
return '';
},
getFormattedSelection: () => {
const api = bridgeRefs.current.selection?.api;
if (api?.getFormattedSelection) {
return api.getFormattedSelection();
}
return null;
}
};
const spreadActions = {
setSpreadMode: (mode: SpreadMode) => {
const api = bridgeRefs.current.spread?.api;
if (api?.setSpreadMode) {
api.setSpreadMode(mode);
}
},
getSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.getSpreadMode) {
return api.getSpreadMode();
}
return null;
},
toggleSpreadMode: () => {
const api = bridgeRefs.current.spread?.api;
if (api?.toggleSpreadMode) {
api.toggleSpreadMode();
}
}
};
const rotationActions = {
rotateForward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateForward) {
api.rotateForward();
}
},
rotateBackward: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.rotateBackward) {
api.rotateBackward();
}
},
setRotation: (rotation: number) => {
const api = bridgeRefs.current.rotation?.api;
if (api?.setRotation) {
api.setRotation(rotation);
}
},
getRotation: () => {
const api = bridgeRefs.current.rotation?.api;
if (api?.getRotation) {
return api.getRotation();
}
return 0;
}
};
const searchActions = {
search: async (query: string) => {
const api = bridgeRefs.current.search?.api;
if (api?.search) {
return api.search(query);
}
},
next: () => {
const api = bridgeRefs.current.search?.api;
if (api?.next) {
api.next();
}
},
previous: () => {
const api = bridgeRefs.current.search?.api;
if (api?.previous) {
api.previous();
}
},
clear: () => {
const api = bridgeRefs.current.search?.api;
if (api?.clear) {
api.clear();
}
}
};
const exportActions = {
download: () => {
const api = bridgeRefs.current.export?.api;
if (api?.download) {
api.download();
}
},
saveAsCopy: async () => {
const api = bridgeRefs.current.export?.api;
if (api?.saveAsCopy) {
try {
const result = api.saveAsCopy();
return await result.toPromise();
} catch (error) {
console.error('Failed to save PDF copy:', error);
return null;
}
}
return null;
}
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
const registerImmediateScrollUpdate = (callback: (currentPage: number, totalPages: number) => void) => {
immediateScrollUpdateCallback.current = callback;
};
const triggerImmediateScrollUpdate = (currentPage: number, totalPages: number) => {
if (immediateScrollUpdateCallback.current) {
immediateScrollUpdateCallback.current(currentPage, totalPages);
}
};
const triggerImmediateZoomUpdate = (zoomPercent: number) => {
if (immediateZoomUpdateCallback.current) {
immediateZoomUpdateCallback.current(zoomPercent);
}
};
const {
scrollActions,
zoomActions,
panActions,
selectionActions,
spreadActions,
rotationActions,
searchActions,
exportActions,
} = createViewerActions({
registry: bridgeRefs,
getScrollState,
getZoomState,
triggerImmediateZoomUpdate,
});
const value: ViewerContextType = {
// UI state
@@ -600,8 +276,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// Immediate updates
registerImmediateZoomUpdate,
registerImmediateScrollUpdate,
registerImmediateSpreadUpdate,
triggerImmediateScrollUpdate,
triggerImmediateZoomUpdate,
triggerImmediateSpreadUpdate,
// Actions
scrollActions,
+11 -3
View File
@@ -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<Process
const processedFile = createProcessedFile(
unrotatedResult.pageCount,
unrotatedResult.thumbnail, // Page thumbnails (unrotated)
unrotatedResult.pageRotations
unrotatedResult.pageRotations,
unrotatedResult.pageDimensions
);
// Use rotated thumbnail for file manager
@@ -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<any> | undefined;
next: () => void;
previous: () => void;
clear: () => void;
}
export interface ExportActions {
download: () => void;
saveAsCopy: () => Promise<ArrayBuffer | null>;
}
export interface ViewerActionsBundle {
scrollActions: ScrollActions;
zoomActions: ZoomActions;
panActions: PanActions;
selectionActions: SelectionActions;
spreadActions: SpreadActions;
rotationActions: RotationActions;
searchActions: SearchActions;
exportActions: ExportActions;
}
interface ViewerActionDependencies {
registry: MutableRefObject<ViewerBridgeRegistry>;
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,
};
}
@@ -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<any>;
clear: () => void;
next: () => void;
previous: () => void;
}
export interface ThumbnailAPIWrapper {
renderThumb: (pageIndex: number, scale: number) => {
toPromise: () => Promise<Blob>;
};
}
export interface ExportAPIWrapper {
download: () => void;
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
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<TState = unknown, TApi = unknown> {
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<BridgeStateMap[K], BridgeApiMap[K]> | 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<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
): void {
registry[type] = ref;
}
export function getBridgeState<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
fallback: BridgeStateMap[K]
): BridgeStateMap[K] {
return registry[type]?.state ?? fallback;
}
export function getBridgeApi<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K
): BridgeApiMap[K] | null {
return registry[type]?.api ?? null;
}
@@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
}
// Add signature type
formData.append('signatureType', params.signatureType || 'draw');
formData.append('signatureType', params.signatureType || 'canvas');
// Add other parameters
if (params.reason) {
@@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
...signOperationConfig,
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
});
};
};
@@ -9,7 +9,7 @@ export interface SignaturePosition {
}
export interface SignParameters {
signatureType: 'image' | 'text' | 'draw' | 'canvas';
signatureType: 'image' | 'text' | 'canvas';
signatureData?: string; // Base64 encoded image or text content
signaturePosition?: SignaturePosition;
reason?: string;
@@ -60,4 +60,4 @@ export const useSignParameters = () => {
endpointName: 'add-signature',
validateFn: validateSignParameters,
});
};
};
@@ -87,7 +87,7 @@ export function useTooltipPosition({
if (sidebarTooltip) {
// Require sidebar refs and state for proper positioning
if (!sidebarRefs || !sidebarState) {
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
setPositionReady(false);
return;
}
@@ -97,7 +97,7 @@ export function useTooltipPosition({
// Only show tooltip if we have the tool panel active
if (!sidebarInfo.isToolPanelActive) {
console.log('🚫 Not showing tooltip - tool panel not active');
console.log('Not showing tooltip - tool panel not active');
setPositionReady(false);
return;
}
+25 -2
View File
@@ -115,6 +115,29 @@ const Sign = (props: BaseToolProps) => {
// Deactivate signature placement mode after everything completes
handleDeactivateSignature();
const hasSignatureReady = (() => {
const params = base.params.parameters;
switch (params.signatureType) {
case 'canvas':
case 'image':
return Boolean(params.signatureData);
case 'text':
return Boolean(params.signerName && params.signerName.trim() !== '');
default:
return false;
}
})();
if (hasSignatureReady) {
if (typeof window !== 'undefined') {
window.setTimeout(() => {
handleActivateSignaturePlacement();
}, 150);
} else {
handleActivateSignaturePlacement();
}
}
// File has been consumed - viewer should reload automatically via key prop
} else {
console.error('Signature flattening failed');
@@ -122,7 +145,7 @@ const Sign = (props: BaseToolProps) => {
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
const getSteps = () => {
const steps = [];
@@ -179,4 +202,4 @@ Sign.getDefaultParameters = () => ({
signerName: '',
});
export default Sign as ToolComponent;
export default Sign as ToolComponent;
+2
View File
@@ -14,6 +14,8 @@ export interface ProcessedFilePage {
pageNumber?: number;
rotation?: number;
splitBefore?: boolean;
width?: number;
height?: number;
[key: string]: any;
}
+53
View File
@@ -0,0 +1,53 @@
import {
ProcessedFileMetadata,
ProcessedFilePage,
StirlingFileStub,
} from '@app/types/fileContext';
export interface PageDimensions {
width: number | null;
height: number | null;
}
export function getPageDimensions(
page?: ProcessedFilePage | null
): PageDimensions {
const width =
typeof page?.width === 'number' && page.width > 0 ? page.width : null;
const height =
typeof page?.height === 'number' && page.height > 0 ? page.height : null;
return { width, height };
}
export function getFirstPageDimensionsFromMetadata(
metadata?: ProcessedFileMetadata | null
): PageDimensions {
if (!metadata?.pages?.length) {
return { width: null, height: null };
}
return getPageDimensions(metadata.pages[0]);
}
export function getFirstPageDimensionsFromStub(
file?: StirlingFileStub
): PageDimensions {
return getFirstPageDimensionsFromMetadata(file?.processedFile);
}
export function getFirstPageAspectRatioFromMetadata(
metadata?: ProcessedFileMetadata | null
): number | null {
const { width, height } = getFirstPageDimensionsFromMetadata(metadata);
if (width && height) {
return height / width;
}
return null;
}
export function getFirstPageAspectRatioFromStub(
file?: StirlingFileStub
): number | null {
return getFirstPageAspectRatioFromMetadata(file?.processedFile);
}
+40 -11
View File
@@ -4,6 +4,27 @@ 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<string, unknown>)) {
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1);
if (result) return result;
}
}
return undefined;
};
interface MinimalFileContextSelectors {
getAllFileIds: () => FileId[];
getStirlingFileStub: (id: FileId) => StirlingFileStub | undefined;
@@ -48,18 +69,19 @@ 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);
// 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;
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);
const isSessionAnnotation = hasStoredImageData || (hasDirectImageData && typeof hasDirectImageData === 'string' && hasDirectImageData.startsWith('data:image'));
return isSessionAnnotation;
return Boolean(hasStoredImageData || directImageData);
});
if (sessionAnnotations.length > 0) {
@@ -180,8 +202,15 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
// Try to get annotation image data
let imageDataUrl = annotation.imageData || annotation.appearance || annotation.stampData ||
annotation.imageSrc || annotation.contents || annotation.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);
// If no image data found directly, try to get it from storage
if (!imageDataUrl && annotation.id) {
@@ -0,0 +1,83 @@
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
export interface SignaturePreview {
dataUrl: string;
width: number;
height: number;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
if (!config) {
return null;
}
if (config.signatureType === 'text') {
const text = config.signerName?.trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
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 measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const width = Math.max(1, textWidth + paddingX * 2);
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(text, paddingX, height / 2);
const dataUrl = canvas.toDataURL('image/png');
return { dataUrl, width, height };
}
const dataUrl = config.signatureData;
if (!dataUrl) {
return null;
}
const image = await loadImage(dataUrl);
return {
dataUrl,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
};
+15 -1
View File
@@ -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") {
+188
View File
@@ -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<DOMRect | null> {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
let rafId: number | null = null;
const waitForNextFrame = () =>
new Promise<void>((resolve) => {
rafId = window.requestAnimationFrame(() => {
rafId = null;
resolve();
});
});
try {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (shouldCancel?.()) {
return null;
}
const element = document.querySelector(selector) as HTMLElement | null;
if (element) {
const rect = element.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
return rect;
}
}
await waitForNextFrame();
}
} finally {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
}
return null;
}
export interface FitWidthResizeOptions {
isManaged: boolean;
requestFitWidth: () => void;
onDebouncedResize: () => void;
debounceMs?: number;
}
export function useFitWidthResize({
isManaged,
requestFitWidth,
onDebouncedResize,
debounceMs = 150,
}: FitWidthResizeOptions): void {
const managedRef = useRef(isManaged);
const requestFitWidthRef = useRef(requestFitWidth);
const onDebouncedResizeRef = useRef(onDebouncedResize);
useEffect(() => {
managedRef.current = isManaged;
}, [isManaged]);
useEffect(() => {
requestFitWidthRef.current = requestFitWidth;
}, [requestFitWidth]);
useEffect(() => {
onDebouncedResizeRef.current = onDebouncedResize;
}, [onDebouncedResize]);
useEffect(() => {
let timeoutId: number | undefined;
const handleResize = () => {
if (!managedRef.current) {
return;
}
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
timeoutId = window.setTimeout(() => {
requestFitWidthRef.current?.();
onDebouncedResizeRef.current?.();
}, debounceMs);
};
window.addEventListener('resize', handleResize);
return () => {
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
};
}, [debounceMs]);
}