V2 manual redaction in the viewer

This commit is contained in:
EthanHealy01 2025-11-05 15:58:20 +00:00
parent 8f94c7d7b0
commit ff6b1f333c
17 changed files with 1325 additions and 208 deletions

View File

@ -10,24 +10,25 @@
"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-redaction": "^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 +503,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 +569,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,49 +602,71 @@
}
},
"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-redaction": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-1.4.1.tgz",
"integrity": "sha512-lyVIDxAwU8mUbhxh8J1dhWZ90086YSZ1+RB50hfCAyaxftRF3XpHPhmLZhr6lr3z1lDx+w9VIcoCU6IvhcweTw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.4.1",
"@embedpdf/utils": "1.4.1"
},
"peerDependencies": {
"@embedpdf/core": "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",
@ -648,182 +674,192 @@
}
},
"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 +3081,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 +3848,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 +4788,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 +5191,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 +7230,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 +7295,16 @@
"node": ">=0.10"
}
},
"node_modules/esrap": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"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 +8818,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 +9704,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 +13047,42 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": {
"version": "5.43.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.3.tgz",
"integrity": "sha512-kjkAjCk41mJfvJZG56XcJNOdJSke94JxtcX8zFzzz2vrt47E0LnoBzU6azIZ1aBxJgUep8qegAkguSf1GjxLXQ==",
"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 +14571,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",

View File

@ -6,24 +6,25 @@
"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",
"@embedpdf/plugin-redaction": "^1.4.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",

View File

@ -64,7 +64,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
icon={viewerContext?.isAnnotationsVisible ? "layers-rounded" : "layers-clear-rounded"}
width="1.5rem"
height="1.5rem"
/>
@ -136,6 +136,10 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
radius="md"
className="right-rail-icon"
onClick={() => {
// Disable redaction tool while entering draw mode
try { viewerContext?.redactionActions.commitAllPending?.(); } catch {}
try { viewerContext?.redactionActions.deactivate?.(); } catch {}
try { viewerContext?.panActions.disablePan?.(); } catch {}
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
@ -146,6 +150,10 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
console.log('Signature API not ready:', error);
}
}
// Notify after state settles so right rail updates reliably
setTimeout(() => {
try { viewerContext?.triggerToolModeUpdate?.(); } catch {}
}, 0);
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
@ -164,6 +172,8 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
// Commit any pending redactions before exporting
try { await viewerContext.redactionActions.commitAllPending(); } catch {}
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
@ -196,6 +206,8 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
// Clear redaction dirty flag after save
sessionStorage.removeItem('redaction:dirty');
}
}
}

View File

@ -0,0 +1,48 @@
import { Stack } from '@mantine/core';
import ButtonSelector from '@app/components/shared/ButtonSelector';
import React, { useEffect, useState } from 'react';
export type ManualRedactionType = 'redactSelection' | 'marqueeRedact';
interface Props {
value: ManualRedactionType; // reflect current plugin state if you like
onChange?: (v: ManualRedactionType) => void;
disabled?: boolean;
}
const LS_KEY = 'redaction:lastChoice';
export default function RedactManualControls({ value, onChange, disabled = false }: Props) {
const [internal, setInternal] = useState<ManualRedactionType>(value);
useEffect(() => {
if (value && value !== internal) setInternal(value);
}, [value]); // keep UI in sync
const apply = (v: ManualRedactionType) => {
localStorage.setItem(LS_KEY, v);
(document as any)._embedpdf_redactMode = v;
setInternal(v);
onChange?.(v);
// tell the bridge immediately
const epdf = (window as any).__EMBEDPDF__?.bridges?.redaction;
const apiBridge = epdf?.apiBridge;
apiBridge?.setLastClicked?.(v);
apiBridge?.setMode?.(v);
};
return (
<Stack gap="var(--mantine-spacing-sm)">
<ButtonSelector
label="Manual Redaction"
value={internal}
onChange={apply}
options={[
{ value: 'redactSelection', label: 'Redact by Text' },
{ value: 'marqueeRedact', label: 'Redact by Area' },
]}
disabled={disabled}
/>
</Stack>
);
}

View File

@ -24,7 +24,6 @@ export default function RedactModeSelector({ mode, onModeChange, disabled }: Red
{
value: 'manual' as const,
label: t('redact.modeSelector.manual', 'Manual'),
disabled: true, // Keep manual mode disabled until implemented
},
]}
disabled={disabled}

View File

@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import { TooltipContent } from '@app/types/tips';
export const useRedactModeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
return useMemo(() => ({
header: {
title: t("redact.tooltip.mode.header.title", "Redaction Method")
},
@ -18,13 +19,13 @@ export const useRedactModeTips = (): TooltipContent => {
description: t("redact.tooltip.mode.manual.text", "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)")
}
]
};
}), [t]);
};
export const useRedactWordsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
return useMemo(() => ({
header: {
title: t("redact.tooltip.words.header.title", "Words to Redact")
},
@ -43,13 +44,13 @@ export const useRedactWordsTips = (): TooltipContent => {
description: t("redact.tooltip.words.examples.text", "Typical words to redact include: bank details, email addresses, or specific names.")
}
]
};
}), [t]);
};
export const useRedactAdvancedTips = (): TooltipContent => {
const { t } = useTranslation();
return {
return useMemo(() => ({
header: {
title: t("redact.tooltip.advanced.header.title", "Advanced Redaction Settings")
},
@ -75,5 +76,25 @@ export const useRedactAdvancedTips = (): TooltipContent => {
description: t("redact.tooltip.advanced.convert.text", "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable.")
}
]
};
}), [t]);
};
export const useRedactManualTips = (): TooltipContent => {
const { t } = useTranslation();
return useMemo(() => ({
header: {
title: t("redact.tooltip.manual.header.title", "Manual Redaction")
},
tips: [
{
title: t("redact.tooltip.manual.selectionByText.title", "Redact by Text"),
description: t("redact.tooltip.manual.selectionByText.text", "Select and redact specific text in the document. Click and drag to select text, then apply the redaction to permanently remove it."),
},
{
title: t("redact.tooltip.manual.selectionByArea.title", "Redact by Area"),
description: t("redact.tooltip.manual.selectionByArea.text", "Draw a box to redact any area of the document, regardless of content. Click and drag to create a redaction box that covers the area you want to remove."),
}
]
}), [t]);
};

View File

@ -35,7 +35,7 @@ const EmbedPdfViewerContent = ({
const viewerRef = React.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, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions, getRedactionState, redactionActions } = useViewer();
// Register viewer right-rail buttons
useViewerRightRailButtons();
@ -184,6 +184,17 @@ const EmbedPdfViewerContent = ({
}, [isViewerHovered]);
// Register checker for unsaved changes (annotations only for now)
// Use refs for stable references to avoid re-registration
const registerUnsavedChangesCheckerRef = useRef(registerUnsavedChangesChecker);
const unregisterUnsavedChangesCheckerRef = useRef(unregisterUnsavedChangesChecker);
const getRedactionStateRef = useRef(getRedactionState);
useEffect(() => {
registerUnsavedChangesCheckerRef.current = registerUnsavedChangesChecker;
unregisterUnsavedChangesCheckerRef.current = unregisterUnsavedChangesChecker;
getRedactionStateRef.current = getRedactionState;
}, [registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, getRedactionState]);
useEffect(() => {
if (previewFile) {
return;
@ -192,21 +203,25 @@ const EmbedPdfViewerContent = ({
const checkForChanges = () => {
// Check for annotation changes via history
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
// Check for redaction changes: any pending marks
const redactionState = getRedactionStateRef.current();
const hasRedactionChanges = redactionState.pendingCount > 0 || sessionStorage.getItem('redaction:dirty') === 'true';
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
hasAnnotationChanges,
hasRedactionChanges
});
return hasAnnotationChanges;
return hasAnnotationChanges || hasRedactionChanges;
};
console.log('[Viewer] Registering unsaved changes checker');
registerUnsavedChangesChecker(checkForChanges);
registerUnsavedChangesCheckerRef.current(checkForChanges);
return () => {
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker();
unregisterUnsavedChangesCheckerRef.current();
};
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
}, [previewFile, historyApiRef]); // Removed function dependencies - use refs instead
// Apply changes - save annotations to new file version
const applyChanges = useCallback(async () => {
@ -215,6 +230,9 @@ const EmbedPdfViewerContent = ({
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
// Commit any pending redactions before exporting
try { await redactionActions.commitAllPending(); } catch (e) { /* ignore */ }
// Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy();
if (!arrayBuffer) {
@ -238,6 +256,7 @@ const EmbedPdfViewerContent = ({
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
sessionStorage.removeItem('redaction:dirty');
} catch (error) {
console.error('Apply changes failed:', error);
}
@ -298,6 +317,17 @@ const EmbedPdfViewerContent = ({
}}
/>
</Box>
{/* Overlay root for redaction menus - rendered above all PDF pages */}
<div
id="pdf-overlay-root"
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 2147483647,
overflow: 'visible'
}}
/>
</>
)}

View File

@ -0,0 +1,74 @@
import { useEffect, useRef } from 'react';
import { useRedaction } from '@embedpdf/plugin-redaction/react';
type Props = {
pageIndex: number;
scale: number;
getPageEl: () => HTMLElement | null;
};
/**
* When hovering a pending box and the user is in marquee (area) mode,
* temporarily pause drawing so clicks select the box.
* When leaving the box (and nothing is selected), restore the last clicked mode.
*/
export default function HoverToSelectPending({ pageIndex, scale, getPageEl }: Props) {
const { state, provides } = useRedaction();
const pausedRef = useRef(false);
useEffect(() => {
const el = getPageEl();
if (!el || !provides) return;
const restoreIfIdle = () => {
if (!pausedRef.current) return;
if (state.selected) return; // keep paused while a box is selected
pausedRef.current = false;
const desired = (document as any)._embedpdf_redactMode as 'marqueeRedact' | 'redactSelection' | undefined;
const anyProv = provides as any;
if (desired && typeof anyProv.setActiveType === 'function') {
anyProv.setActiveType(desired);
} else if (desired === 'marqueeRedact' && state.activeType !== 'marqueeRedact') {
provides.toggleMarqueeRedact?.();
} else if (desired === 'redactSelection' && state.activeType !== 'redactSelection') {
provides.toggleRedactSelection?.();
}
};
const onMove = (e: PointerEvent) => {
const list: any[] = (state.pending as any)?.[pageIndex] || [];
if (!list.length) { restoreIfIdle(); return; }
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / scale;
const py = (e.clientY - r.top) / scale;
const hit = list.find((it) => {
if (!it?.rect) return false;
const { pos, size } = it.rect;
if (!pos || !size) return false;
return px >= pos.x && px <= pos.x + size.width && py >= pos.y && py <= pos.y + size.height;
});
// Only pause auto-draw when we're in marquee mode and actually hovering a box
if (hit && state.activeType === 'marqueeRedact' && !pausedRef.current) {
pausedRef.current = true;
provides.toggleMarqueeRedact?.(); // turn OFF drawing so clicks select the box
} else if (!hit) {
restoreIfIdle();
}
};
const onLeave = () => restoreIfIdle();
el.addEventListener('pointermove', onMove, { passive: true });
el.addEventListener('pointerleave', onLeave, { passive: true });
return () => {
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerleave', onLeave);
};
}, [provides, state.pending, state.selected, state.activeType, pageIndex, scale]);
return null;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
@ -11,6 +11,7 @@ import { RenderPluginPackage } from '@embedpdf/plugin-render/react';
import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react';
import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react';
import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react';
import { RedactionLayer, RedactionPluginPackage } from '@embedpdf/plugin-redaction/react';
import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react';
import { PanPluginPackage } from '@embedpdf/plugin-pan/react';
import { SpreadPluginPackage, SpreadMode } from '@embedpdf/plugin-spread/react';
@ -38,6 +39,9 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { RedactionAPIBridge } from './RedactionAPIBridge';
import RedactionSelectionMenu from '@app/components/viewer/RedactionSelectionMenu';
import HoverToSelectPending from '@app/components/viewer/HoverToSelectPending';
interface LocalEmbedPDFProps {
file?: File | Blob;
@ -51,6 +55,7 @@ interface LocalEmbedPDFProps {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
const pageElRef = useRef<Record<number, HTMLDivElement | null>>({});
// Convert File to URL if needed
useEffect(() => {
@ -96,6 +101,11 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
// Register selection plugin (depends on InteractionManager)
createPluginRegistration(SelectionPluginPackage),
// Register redaction plugin (depends on InteractionManager and Selection)
createPluginRegistration(RedactionPluginPackage, {
drawBlackBoxes: true,
}),
// Register history plugin for undo/redo (recommended for annotations)
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
@ -273,6 +283,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<RedactionAPIBridge />
<GlobalPointerProvider>
<Viewport
style={{
@ -290,9 +301,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
}}
>
<Scroller
renderPage={({ document, width, height, pageIndex, scale, rotation }) => {
renderPage={({ width, height, pageIndex, scale, rotation }) => {
return (
<Rotate key={document?.id} pageSize={{ width, height }}>
<Rotate key={`page-${pageIndex}`} pageSize={{ width, height }}>
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
<div
style={{
@ -309,6 +320,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
ref={(node) => { pageElRef.current[pageIndex] = node; }}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
@ -318,6 +330,18 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Redaction layer handles pending marks and UI */}
<RedactionLayer
pageIndex={pageIndex}
scale={scale}
rotation={rotation}
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
/>
<HoverToSelectPending
pageIndex={pageIndex}
scale={scale}
getPageEl={() => pageElRef.current[pageIndex] ?? null}
/>
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer

View File

@ -7,7 +7,7 @@ import { useViewer } from '@app/contexts/ViewerContext';
*/
export function PanAPIBridge() {
const { provides: pan, isPanning } = usePan();
const { registerBridge } = useViewer();
const { registerBridge, triggerToolModeUpdate } = useViewer();
// Store state locally
const [_localState, setLocalState] = useState({
@ -38,8 +38,10 @@ export function PanAPIBridge() {
makePanDefault: () => pan.makePanDefault(),
}
});
// Notify listeners whenever pan state changes
triggerToolModeUpdate();
}
}, [pan, isPanning]);
}, [pan, isPanning, triggerToolModeUpdate, registerBridge]);
return null;
}

View File

@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef } from 'react';
import { useRedaction } from '@embedpdf/plugin-redaction/react';
import { useViewer } from '@app/contexts/ViewerContext';
/**
* Behavior contract:
* - The *only* authority for the desired mode is the last toolbar button the user clicked.
* - We persist that in localStorage and mirror it on document for quick reads.
* - We never invert the user's choice during "create/pending/select" frames.
* - After the plugin finishes its internal flip (end of draw, commit, etc),
* we nudge it back to the user's choice using a deferred restore (microtask + 2x rAF).
*/
const LS_KEY = 'redaction:lastChoice'; // 'redactSelection' | 'marqueeRedact'
type Mode = 'redactSelection' | 'marqueeRedact';
export function RedactionAPIBridge() {
const { state, provides } = useRedaction();
const { registerBridge } = useViewer();
// live state ref
const stateRef = useRef(state);
useEffect(() => { stateRef.current = state; }, [state]);
// desired mode (last clicked)
const lastChoiceRef = useRef<Mode | null>(
(localStorage.getItem(LS_KEY) as Mode | null) ?? null
);
const setLastChoice = useCallback((mode: Mode) => {
lastChoiceRef.current = mode;
(document as any)._embedpdf_redactMode = mode; // local mirror only
}, []);
// bridge surface for other UI
const bridgeRef = useRef<{ state: any; api: any } | null>(null);
useEffect(() => {
if (!provides) return;
const bridge = {
state: {
isRedacting: stateRef.current.isRedacting,
activeType: stateRef.current.activeType,
pendingCount: stateRef.current.pendingCount,
selected: stateRef.current.selected,
},
api: provides,
};
bridgeRef.current = bridge;
registerBridge('redaction', bridge);
}, [registerBridge, provides]);
// keep bridge.state fresh
const prev = useRef(bridgeRef.current?.state);
useEffect(() => {
if (!bridgeRef.current) return;
const s = {
isRedacting: state.isRedacting,
activeType: state.activeType,
pendingCount: state.pendingCount,
selected: state.selected,
};
if (!prev.current ||
prev.current.isRedacting !== s.isRedacting ||
prev.current.activeType !== s.activeType ||
prev.current.pendingCount !== s.pendingCount ||
prev.current.selected !== s.selected) {
bridgeRef.current.state = s;
prev.current = s;
}
}, [state.isRedacting, state.activeType, state.pendingCount, state.selected]);
// idempotent "set" using toggles (there is no official setter)
const setMode = useCallback((target: Mode | null) => {
if (!provides || !target) return;
if (stateRef.current.activeType === target) return;
const anyProv = provides as any;
if (typeof anyProv.setActiveType === 'function') {
anyProv.setActiveType(target);
return;
}
target === 'marqueeRedact'
? provides.toggleMarqueeRedact?.()
: provides.toggleRedactSelection?.();
}, [provides]);
// public mini-API for your toolbar
useEffect(() => {
if (!bridgeRef.current) return;
(bridgeRef.current as any).apiBridge = {
setLastClicked: (mode: Mode) => setLastChoice(mode),
setMode,
getLastClicked: () => lastChoiceRef.current,
};
}, [setMode, setLastChoice]);
// defer helper: run after the plugin's own state churn
const restoringRef = useRef(false);
const deferRestore = useCallback(() => {
if (restoringRef.current) return;
restoringRef.current = true;
// microtask + double rAF to reliably run *after* the plugin's flip
Promise.resolve().then(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const desired = lastChoiceRef.current;
const s = stateRef.current;
if (desired && s.activeType !== desired) {
setMode(desired); // idempotent
}
setTimeout(() => { restoringRef.current = false; }, 60);
});
});
});
}, [setMode]);
// remember user-initiated switches (when plugin reports a mode)
useEffect(() => {
if (!restoringRef.current) return;
if (state.activeType === lastChoiceRef.current) {
restoringRef.current = false;
}
}, [state.activeType]);
// steer back to user's choice after *any* redaction event
useEffect(() => {
if (!provides) return;
const off = provides.onRedactionEvent?.((_evt: any) => {
// Mark dirty for your nav guard if you use one
try { sessionStorage.setItem('redaction:dirty', 'true'); } catch {}
// Always defer a restore — hover helper will temporarily pause drawing when needed
deferRestore();
});
return () => { off && off(); };
}, [provides, deferRestore]);
// seed once on tool open
useEffect(() => {
if (!provides) return;
const remembered = (localStorage.getItem(LS_KEY) as Mode | null) ?? null;
if (remembered) {
setLastChoice(remembered);
// set immediately so first interaction is correct
setMode(remembered);
}
}, [provides, setMode, setLastChoice]);
return null;
}

View File

@ -0,0 +1,201 @@
import React, { useEffect, useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import { SelectionMenuProps, useRedaction } from '@embedpdf/plugin-redaction/react';
/**
* Small inline menu rendered by RedactionLayer when a pending mark is selected.
* Shows Accept (commit) and Remove controls.
* Uses a portal to render at document body level to ensure it's always above PDF pages.
*/
export default function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) {
const { provides, state } = useRedaction();
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
// Track the current mode before committing to ensure it's preserved
const modeBeforeCommitRef = useRef<'redactSelection' | 'marqueeRedact' | null>(null);
// Store current mode when menu is shown
useEffect(() => {
if (selected && state.activeType) {
modeBeforeCommitRef.current = state.activeType as 'redactSelection' | 'marqueeRedact' | null;
}
}, [selected, state.activeType]);
// Extract ref from menuWrapperProps - must happen before any conditional returns
const { ref: wrapperPropsRef, ...restWrapperProps } = menuWrapperProps || {};
// Merge refs - must be called unconditionally
const mergedRef = React.useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
if (typeof wrapperPropsRef === 'function') {
wrapperPropsRef(node);
} else if (wrapperPropsRef) {
(wrapperPropsRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
}, [wrapperPropsRef]);
// Get overlay - must be called unconditionally
const overlay = typeof document !== 'undefined' ? document.getElementById('pdf-overlay-root') : null;
// All hooks must be called before any conditional returns
useEffect(() => {
if (!selected) {
setPosition(null);
return;
}
const updatePosition = () => {
// Get the overlay root
const overlayEl = document.getElementById('pdf-overlay-root');
if (!overlayEl || !wrapperRef.current) {
setPosition(null);
return;
}
// Get the wrapper's bounding rect in viewport coordinates
const wrapperRect = wrapperRef.current.getBoundingClientRect();
const overlayRect = overlayEl.getBoundingClientRect();
// Calculate position relative to overlay
const menuHeight = item?.rect?.size?.height || 0;
const top = wrapperRect.top - overlayRect.top + menuHeight + 10;
const left = wrapperRect.left - overlayRect.left;
setPosition({ top, left });
};
// Initial position calculation
updatePosition();
// Update on scroll, resize, and zoom changes
const handleUpdate = () => {
requestAnimationFrame(updatePosition);
};
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
// Use a mutation observer to catch DOM changes that might affect position
const observer = new MutationObserver(handleUpdate);
if (wrapperRef.current) {
observer.observe(document.body, { childList: true, subtree: true });
}
// Also use intersection observer as fallback
const intersectionObserver = new IntersectionObserver(
() => handleUpdate(),
{ threshold: 0, root: null }
);
if (wrapperRef.current) {
intersectionObserver.observe(wrapperRef.current);
}
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
observer.disconnect();
intersectionObserver.disconnect();
};
}, [selected, item?.rect?.size?.height]);
// Now we can conditionally render - but all hooks have been called
if (!selected) {
return (
<div
ref={mergedRef}
{...restWrapperProps}
style={{
...restWrapperProps?.style,
position: 'relative',
pointerEvents: 'none',
display: 'none'
}}
/>
);
}
const menuContent = (
<div
style={{
position: 'absolute',
top: position ? `${position.top}px` : 0,
left: position ? `${position.left}px` : 0,
pointerEvents: 'auto',
display: 'flex',
gap: 12,
background: 'rgba(255,255,255,0.98)',
border: '1px solid rgba(15, 23, 42, 0.08)',
borderRadius: 12,
padding: '8px 10px',
boxShadow: '0 6px 14px rgba(0,0,0,0.12)',
zIndex: 2147483647
}}
>
<button
onClick={async () => {
// Store the current mode before committing to ensure it's preserved
const currentMode = state.activeType as 'redactSelection' | 'marqueeRedact' | null;
if (currentMode) {
modeBeforeCommitRef.current = currentMode;
// Update session storage so bridge can restore it
sessionStorage.setItem('redaction:lastManualType', currentMode);
}
// Commit the redaction
// The RedactionAPIBridge will handle mode restoration after commit
if (provides?.commitPending) {
await provides.commitPending(item.page, item.id);
}
}}
style={{
padding: '10px 18px',
borderRadius: 12,
border: '1px solid #ef4444',
background: '#ef4444',
color: 'white',
fontWeight: 600,
fontSize: 16,
lineHeight: 1.0,
boxShadow: '0 2px 4px rgba(239, 68, 68, 0.4)',
cursor: 'pointer'
}}
>
Apply
</button>
<button
onClick={() => provides?.removePending?.(item.page, item.id)}
style={{
padding: '10px 18px',
borderRadius: 12,
border: '1px solid rgba(15, 23, 42, 0.12)',
background: 'rgba(241, 245, 249, 0.8)',
color: '#334155',
fontWeight: 600,
fontSize: 16,
lineHeight: 1.0,
cursor: 'pointer'
}}
>
Remove
</button>
</div>
);
return (
<>
<div
ref={mergedRef}
{...restWrapperProps}
style={{
...restWrapperProps?.style,
position: 'relative',
pointerEvents: 'none'
}}
/>
{overlay && position && createPortal(menuContent, overlay)}
</>
);
}

View File

@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect, useRef } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
@ -7,18 +7,57 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
export function useViewerRightRailButtons() {
const { t } = useTranslation();
const viewer = useViewer();
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { actions: navActions } = useNavigationActions();
const { handleToolSelect } = useToolWorkflow();
const { workbench, selectedTool } = useNavigationState();
// Lift i18n labels out of memo for clarity
const searchLabel = t('rightRail.search', 'Search PDF');
const panLabel = t('rightRail.panMode', 'Pan Mode');
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
// Extract stable references to viewer methods to avoid re-renders from context changes
const getToolModeRef = useRef(viewer.getToolMode);
const registerToolModeListenerRef = useRef(viewer.registerToolModeListener);
const unregisterToolModeListenerRef = useRef(viewer.unregisterToolModeListener);
const setAnnotationModeRef = useRef(viewer.setAnnotationMode);
const redactionActionsRef = useRef(viewer.redactionActions);
const panActionsRef = useRef(viewer.panActions);
const rotationActionsRef = useRef(viewer.rotationActions);
const toggleThumbnailSidebarRef = useRef(viewer.toggleThumbnailSidebar);
const triggerToolModeUpdateRef = useRef(viewer.triggerToolModeUpdate);
// Update refs when viewer context changes (but don't cause re-renders)
useEffect(() => {
getToolModeRef.current = viewer.getToolMode;
registerToolModeListenerRef.current = viewer.registerToolModeListener;
unregisterToolModeListenerRef.current = viewer.unregisterToolModeListener;
setAnnotationModeRef.current = viewer.setAnnotationMode;
redactionActionsRef.current = viewer.redactionActions;
panActionsRef.current = viewer.panActions;
rotationActionsRef.current = viewer.rotationActions;
toggleThumbnailSidebarRef.current = viewer.toggleThumbnailSidebar;
triggerToolModeUpdateRef.current = viewer.triggerToolModeUpdate;
}, [viewer]);
// Single source of truth for active tool mode (none | pan | redact | draw)
const [activeMode, setActiveMode] = useState<'none' | 'pan' | 'redact' | 'draw'>(() => getToolModeRef.current());
useEffect(() => {
registerToolModeListenerRef.current((mode) => setActiveMode(mode));
return () => unregisterToolModeListenerRef.current();
}, []); // Empty deps - refs are stable
// Memoize i18n labels to prevent re-renders
const searchLabel = useMemo(() => t('rightRail.search', 'Search PDF'), [t]);
const panLabel = useMemo(() => t('rightRail.panMode', 'Pan Mode'), [t]);
const rotateLeftLabel = useMemo(() => t('rightRail.rotateLeft', 'Rotate Left'), [t]);
const rotateRightLabel = useMemo(() => t('rightRail.rotateRight', 'Rotate Right'), [t]);
const sidebarLabel = useMemo(() => t('rightRail.toggleSidebar', 'Toggle Sidebar'), [t]);
const redactLabel = useMemo(() => t('rightRail.redact', 'Redact'), [t]);
const isPanning = activeMode === 'pan';
const isRedacting = activeMode === 'redact';
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
return [
@ -67,8 +106,24 @@ export function useViewerRightRailButtons() {
radius="md"
className="right-rail-icon"
onClick={() => {
viewer.panActions.togglePan();
setIsPanning(prev => !prev);
// Entering pan should disable draw and redaction; leaving pan just toggles off
if (!isPanning) {
try { setAnnotationModeRef.current(false); } catch {}
try { redactionActionsRef.current.deactivate(); } catch {}
const enable = () => {
try { panActionsRef.current.enablePan(); } catch {}
try { triggerToolModeUpdateRef.current(); } catch {}
};
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
requestAnimationFrame(() => setTimeout(enable, 0));
} else {
setTimeout(enable, 0);
}
} else {
try { panActionsRef.current.disablePan(); } catch {}
try { triggerToolModeUpdateRef.current(); } catch {}
}
// activeMode will update via listener
}}
disabled={disabled}
>
@ -85,7 +140,7 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 30,
onClick: () => {
viewer.rotationActions.rotateBackward();
rotationActionsRef.current.rotateBackward();
}
},
{
@ -96,7 +151,7 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 40,
onClick: () => {
viewer.rotationActions.rotateForward();
rotationActionsRef.current.rotateForward();
}
},
{
@ -107,9 +162,56 @@ export function useViewerRightRailButtons() {
section: 'top' as const,
order: 50,
onClick: () => {
viewer.toggleThumbnailSidebar();
toggleThumbnailSidebarRef.current();
}
},
{
id: 'viewer-redaction',
tooltip: redactLabel,
ariaLabel: redactLabel,
section: 'top' as const,
order: 55,
render: ({ disabled }) => (
<Tooltip content={redactLabel} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isRedacting ? 'filled' : 'subtle'}
color={isRedacting ? 'blue' : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
// Ensure the left sidebar opens the Redact tool in viewer with manual mode
sessionStorage.setItem('redaction:init', 'manual');
// Navigate to viewer with the redact tool if we're not already there
if (workbench !== 'viewer' || selectedTool !== 'redact') {
handleToolSelect('redact' as any);
}
// Disable draw and pan when activating redaction
try { setAnnotationModeRef.current(false); } catch {}
try { panActionsRef.current.disablePan(); } catch {}
// Activate last used manual mode inside viewer.
// Defer to next frame to allow annotation plugin to fully release interaction.
const last = (sessionStorage.getItem('redaction:lastManualType') as 'redactSelection' | 'marqueeRedact' | null) || 'redactSelection';
const activate = () => {
if (last === 'marqueeRedact') {
redactionActionsRef.current.activateArea();
} else {
redactionActionsRef.current.activateText();
}
try { triggerToolModeUpdateRef.current(); } catch {}
};
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
requestAnimationFrame(() => setTimeout(activate, 0));
} else {
setTimeout(activate, 0);
}
}}
disabled={disabled}
>
<LocalIcon icon="visibility-off-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)
},
{
id: 'viewer-annotation-controls',
section: 'top' as const,
@ -119,7 +221,7 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
}, [activeMode, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, redactLabel, handleToolSelect, workbench, selectedTool, isPanning, isRedacting]);
useRightRailButtons(viewerButtons);
}

View File

@ -57,6 +57,19 @@ interface ExportAPIWrapper {
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
}
// Redaction bridge wrappers
interface RedactionAPIWrapper {
toggleRedactSelection: () => void;
toggleMarqueeRedact: () => void;
clearPending: () => void;
commitAllPending: () => { toPromise: () => Promise<void> } | Promise<void> | void;
}
interface RedactionState {
isRedacting: boolean;
activeType: 'redactSelection' | 'marqueeRedact' | null;
pendingCount: number;
}
// State interfaces - represent the shape of data from each bridge
interface ScrollState {
@ -103,6 +116,8 @@ interface ExportState {
canExport: boolean;
}
type ToolMode = 'none' | 'pan' | 'redact' | 'draw';
// Bridge registration interface - bridges register with state and API
interface BridgeRef<TState = unknown, TApi = unknown> {
state: TState;
@ -146,6 +161,8 @@ interface ViewerContextType {
getSearchState: () => SearchState;
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
getExportState: () => ExportState;
getToolMode: () => ToolMode;
getRedactionState: () => RedactionState;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
@ -208,6 +225,20 @@ interface ViewerContextType {
saveAsCopy: () => Promise<ArrayBuffer | null>;
};
redactionActions: {
activateText: () => void;
activateArea: () => void;
deactivate: () => void;
commitAllPending: () => Promise<void>;
clearPending: () => void;
isActive: () => boolean;
};
// Live updates for right-rail highlighting
registerToolModeListener: (callback: (mode: ToolMode) => void) => void;
unregisterToolModeListener: () => void;
triggerToolModeUpdate: () => void;
// Bridge registration - internal use by bridges
registerBridge: (type: string, ref: BridgeRef) => void;
}
@ -239,8 +270,11 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotation: null as BridgeRef<RotationState, RotationAPIWrapper> | null,
thumbnail: null as BridgeRef<unknown, ThumbnailAPIWrapper> | null,
export: null as BridgeRef<ExportState, ExportAPIWrapper> | null,
redaction: null as BridgeRef<RedactionState, RedactionAPIWrapper> | null,
});
const toolModeListenerRef = useRef<((mode: ToolMode) => void) | null>(null);
// Immediate zoom callback for responsive display updates
const immediateZoomUpdateCallback = useRef<((percent: number) => void) | null>(null);
@ -277,6 +311,9 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
case 'export':
bridgeRefs.current.export = ref as BridgeRef<ExportState, ExportAPIWrapper>;
break;
case 'redaction':
bridgeRefs.current.redaction = ref as BridgeRef<RedactionState, RedactionAPIWrapper>;
break;
}
};
@ -290,10 +327,14 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const setAnnotationMode = (enabled: boolean) => {
setIsAnnotationModeState(enabled);
// Notify listeners when draw mode changes
triggerToolModeUpdate();
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
// Notify listeners when draw mode changes
setTimeout(() => triggerToolModeUpdate(), 0);
};
// State getters - read from bridge refs
@ -333,6 +374,25 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
return bridgeRefs.current.export?.state || { canExport: false };
};
const getToolMode = (): ToolMode => {
if (isAnnotationMode) return 'draw';
const redactionActive = bridgeRefs.current.redaction?.state?.isRedacting;
if (redactionActive) return 'redact';
const panActive = bridgeRefs.current.pan?.state?.isPanning;
if (panActive) return 'pan';
return 'none';
};
const getRedactionState = (): RedactionState => {
return (
bridgeRefs.current.redaction?.state || {
isRedacting: false,
activeType: null,
pendingCount: 0,
}
);
};
// Action handlers - call APIs directly
const scrollActions = {
scrollToPage: (page: number) => {
@ -550,6 +610,66 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
}
};
// Track redaction dirty state (any commit or pending marks)
const redactionActions = {
activateText: () => {
const api = bridgeRefs.current.redaction?.api;
const state = bridgeRefs.current.redaction?.state;
if (!api) return;
if (state?.activeType === 'redactSelection') return; // already active
// If other mode active, turn it off first
if (state?.activeType === 'marqueeRedact' && api.toggleMarqueeRedact) api.toggleMarqueeRedact();
if (api.toggleRedactSelection) api.toggleRedactSelection();
},
activateArea: () => {
const api = bridgeRefs.current.redaction?.api;
const state = bridgeRefs.current.redaction?.state;
if (!api) return;
if (state?.activeType === 'marqueeRedact') return; // already active
if (state?.activeType === 'redactSelection' && api.toggleRedactSelection) api.toggleRedactSelection();
if (api.toggleMarqueeRedact) api.toggleMarqueeRedact();
},
deactivate: () => {
const state = bridgeRefs.current.redaction?.state;
const api = bridgeRefs.current.redaction?.api;
if (!state || !api) return;
// If text is active, toggling text will deactivate; same for area
if (state.activeType === 'redactSelection' && api.toggleRedactSelection) {
api.toggleRedactSelection();
} else if (state.activeType === 'marqueeRedact' && api.toggleMarqueeRedact) {
api.toggleMarqueeRedact();
}
},
commitAllPending: async () => {
const api = bridgeRefs.current.redaction?.api;
if (!api?.commitAllPending) return;
const result = api.commitAllPending();
if (result && typeof (result as any).toPromise === 'function') {
await (result as any).toPromise();
}
},
clearPending: () => {
const api = bridgeRefs.current.redaction?.api;
if (api?.clearPending) api.clearPending();
},
isActive: () => {
return Boolean(bridgeRefs.current.redaction?.state?.isRedacting);
},
};
const registerToolModeListener = (callback: (mode: ToolMode) => void) => {
toolModeListenerRef.current = callback;
};
const unregisterToolModeListener = () => {
toolModeListenerRef.current = null;
};
const triggerToolModeUpdate = () => {
const cb = toolModeListenerRef.current;
if (cb) cb(getToolMode());
};
const registerImmediateZoomUpdate = (callback: (percent: number) => void) => {
immediateZoomUpdateCallback.current = callback;
};
@ -596,6 +716,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
getSearchState,
getThumbnailAPI,
getExportState,
getToolMode,
getRedactionState,
// Immediate updates
registerImmediateZoomUpdate,
@ -612,6 +734,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
rotationActions,
searchActions,
exportActions,
redactionActions,
registerToolModeListener,
unregisterToolModeListener,
triggerToolModeUpdate,
// Bridge registration
registerBridge,

View File

@ -17,8 +17,9 @@ export const buildRedactFormData = (parameters: RedactParameters, file: File): F
formData.append("customPadding", parameters.customPadding.toString());
formData.append("convertPDFToImage", parameters.convertPDFToImage.toString());
} else {
// Manual mode parameters would go here when implemented
throw new Error('Manual redaction not yet implemented');
// Manual mode is handled interactively in the viewer via EmbedPDF redaction plugin.
// The sidebar tool never posts a request in manual mode, so return minimal form data.
formData.append('mode', 'manual');
}
return formData;
@ -33,8 +34,8 @@ export const redactOperationConfig = {
if (parameters.mode === 'automatic') {
return '/api/v1/security/auto-redact';
} else {
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
// Manual mode does not call backend; return a placeholder that will not be invoked.
return '/noop/manual-redaction';
}
},
defaultParameters,

View File

@ -34,15 +34,15 @@ export const useRedactParameters = (): RedactParametersHook => {
if (params.mode === 'automatic') {
return '/api/v1/security/auto-redact';
}
// Manual redaction endpoint would go here when implemented
throw new Error('Manual redaction not yet implemented');
// Manual mode is handled in the viewer; this endpoint will not be called
return '/noop/manual-redaction';
},
validateFn: (params) => {
if (params.mode === 'automatic') {
return params.wordsToRedact.length > 0 && params.wordsToRedact.some(word => word.trim().length > 0);
}
// Manual mode validation would go here when implemented
return false;
// For manual, validation is not needed for network calls; allow switching
return true;
}
});
};

View File

@ -1,14 +1,19 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
import RedactModeSelector from "@app/components/tools/redact/RedactModeSelector";
import { useRedactParameters } from "@app/hooks/tools/redact/useRedactParameters";
import { useRedactOperation } from "@app/hooks/tools/redact/useRedactOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips } from "@app/components/tooltips/useRedactTips";
import { useRedactModeTips, useRedactWordsTips, useRedactAdvancedTips, useRedactManualTips } from "@app/components/tooltips/useRedactTips";
import RedactAdvancedSettings from "@app/components/tools/redact/RedactAdvancedSettings";
import WordsToRedactInput from "@app/components/tools/redact/WordsToRedactInput";
import RedactManualControls, { ManualRedactionType } from "@app/components/tools/redact/RedactManualControls";
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { Stack, Alert, Button, Text } from "@mantine/core";
import WarningIcon from '@mui/icons-material/Warning';
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
@ -29,6 +34,110 @@ const Redact = (props: BaseToolProps) => {
const modeTips = useRedactModeTips();
const wordsTips = useRedactWordsTips();
const advancedTips = useRedactAdvancedTips();
const manualTips = useRedactManualTips();
// Navigation for switching to viewer on Manual
const { actions: navActions } = useNavigationActions();
const { workbench } = useNavigationState();
const viewer = useViewer();
// Extract stable references to viewer methods to avoid re-renders from context changes
const getRedactionStateRef = useRef(viewer.getRedactionState);
const registerToolModeListenerRef = useRef(viewer.registerToolModeListener);
const unregisterToolModeListenerRef = useRef(viewer.unregisterToolModeListener);
const redactionActionsRef = useRef(viewer.redactionActions);
const setAnnotationModeRef = useRef(viewer.setAnnotationMode);
const panActionsRef = useRef(viewer.panActions);
const triggerToolModeUpdateRef = useRef(viewer.triggerToolModeUpdate);
// Update refs when viewer context changes (but don't cause re-renders)
useEffect(() => {
getRedactionStateRef.current = viewer.getRedactionState;
registerToolModeListenerRef.current = viewer.registerToolModeListener;
unregisterToolModeListenerRef.current = viewer.unregisterToolModeListener;
redactionActionsRef.current = viewer.redactionActions;
setAnnotationModeRef.current = viewer.setAnnotationMode;
panActionsRef.current = viewer.panActions;
triggerToolModeUpdateRef.current = viewer.triggerToolModeUpdate;
}, [viewer]);
// Force re-render when tool mode changes to ensure buttons reflect current state
const [updateCounter, setUpdateCounter] = useState(0);
useEffect(() => {
const handleToolModeUpdate = () => {
setUpdateCounter(prev => prev + 1);
};
registerToolModeListenerRef.current(handleToolModeUpdate);
return () => {
unregisterToolModeListenerRef.current();
};
}, []); // Empty deps - refs are stable
// Track redaction state to ensure UI stays in sync with plugin state
// Use a ref to track the last known state and only update when it changes
const [redactionStateCheck, setRedactionStateCheck] = useState(0);
const lastRedactionStateRef = useRef<{ activeType: string | null; pendingCount: number } | null>(null);
useEffect(() => {
if (base.params.parameters.mode !== 'manual') return;
// Check redaction state periodically, but only update if it actually changed
const interval = setInterval(() => {
const currentState = getRedactionStateRef.current();
const lastState = lastRedactionStateRef.current;
// Only trigger update if activeType changed (not just pendingCount)
if (!lastState || lastState.activeType !== currentState.activeType) {
lastRedactionStateRef.current = {
activeType: currentState.activeType,
pendingCount: currentState.pendingCount,
};
setRedactionStateCheck(prev => prev + 1);
} else {
// Update ref even if we don't trigger re-render
lastRedactionStateRef.current = {
activeType: currentState.activeType,
pendingCount: currentState.pendingCount,
};
}
}, 500); // Check less frequently - only when mode actually changes
return () => clearInterval(interval);
}, [base.params.parameters.mode]); // Removed viewer dependency
// Check if we need to show the viewer warning
const isManualModeOutsideViewer = base.params.parameters.mode === 'manual' && workbench !== 'viewer';
const handleEnterManual = useCallback(() => {
// Mark that manual redaction should be initialized and activate last mode
sessionStorage.setItem('redaction:init', 'manual');
// Persist current choice if any
const last = (sessionStorage.getItem('redaction:lastManualType') as ManualRedactionType | null) || 'redactSelection';
sessionStorage.setItem('redaction:lastManualType', last);
// Switch to viewer and show the Redact tool in sidebar
navActions.setToolAndWorkbench('redact' as any, 'viewer');
// Defer activation to ensure viewer is ready and other tools are disabled
const activate = () => {
try { setAnnotationModeRef.current(false); } catch {}
try { panActionsRef.current.disablePan(); } catch {}
const activateRedaction = () => {
if (last === 'marqueeRedact') {
redactionActionsRef.current.activateArea();
} else {
redactionActionsRef.current.activateText();
}
try { triggerToolModeUpdateRef.current(); } catch {}
};
// Use double deferral to ensure state is settled
if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
requestAnimationFrame(() => setTimeout(activateRedaction, 0));
} else {
setTimeout(activateRedaction, 0);
}
};
// Defer activation slightly to allow navigation to complete
setTimeout(activate, 50);
}, [navActions]);
const isExecuteDisabled = () => {
if (base.params.parameters.mode === 'manual') {
@ -38,12 +147,20 @@ const Redact = (props: BaseToolProps) => {
};
// Compute actual collapsed state based on results and user state
const getActualCollapsedState = (userCollapsed: boolean) => {
const getActualCollapsedState = useCallback((userCollapsed: boolean) => {
return (!base.hasFiles || base.hasResults) ? true : userCollapsed; // Force collapse when results are shown
};
}, [base.hasFiles, base.hasResults]);
// Memoize redaction state to avoid calling getRedactionState on every render
const redactionState = useMemo(() => {
if (base.params.parameters.mode !== 'manual') return null;
// Reference check counter to ensure re-evaluation when it changes
void redactionStateCheck;
return getRedactionStateRef.current();
}, [base.params.parameters.mode, redactionStateCheck]);
// Build conditional steps based on redaction mode
const buildSteps = () => {
const buildSteps = useCallback(() => {
const steps = [
// Method selection step (always present)
{
@ -52,11 +169,41 @@ const Redact = (props: BaseToolProps) => {
onCollapsedClick: () => base.settingsCollapsed ? base.handleSettingsReset() : setMethodCollapsed(!methodCollapsed),
tooltip: modeTips,
content: (
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
disabled={base.endpointLoading}
/>
<Stack gap="md">
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => {
base.params.updateParameter('mode', mode);
if (mode === 'manual') {
handleEnterManual();
}
}}
disabled={base.endpointLoading}
/>
{isManualModeOutsideViewer && (
<Alert
color="yellow"
title={t("redact.manual.viewerWarning.title", "Manual Redaction Requires Viewer")}
icon={<WarningIcon fontSize="small" />}
>
<Stack gap="sm">
<Text size="sm">
{t("redact.manual.viewerWarning.message", "Manual redaction can only be used in the viewer view. Please switch to the viewer to use this feature.")}
</Text>
<Button
size="sm"
variant="light"
onClick={() => {
handleEnterManual();
}}
style={{ alignSelf: 'flex-start' }}
>
{t("redact.manual.viewerWarning.button", "Go to Viewer")}
</Button>
</Stack>
</Alert>
)}
</Stack>
),
}
];
@ -88,11 +235,60 @@ const Redact = (props: BaseToolProps) => {
},
);
} else if (base.params.parameters.mode === 'manual') {
// Manual mode steps would go here when implemented
steps.push({
title: t("redact.manual.settings.title", "Manual Redaction"),
isCollapsed: getActualCollapsedState(false),
onCollapsedClick: () => {},
tooltip: manualTips,
content: (
<RedactManualControls
value={
// Get actual active mode from redaction plugin, fallback to sessionStorage
// redactionStateCheck changes only when activeType actually changes
(redactionState?.activeType as ManualRedactionType) ||
(sessionStorage.getItem('redaction:lastManualType') as ManualRedactionType) ||
'redactSelection'
}
onChange={(val) => {
sessionStorage.setItem('redaction:lastManualType', val);
// Ensure we're in viewer and activate chosen tool
handleEnterManual();
if (val === 'marqueeRedact') redactionActionsRef.current.activateArea();
else redactionActionsRef.current.activateText();
// Trigger a state check to update UI
setRedactionStateCheck(prev => prev + 1);
}}
disabled={base.endpointLoading}
/>
)
});
}
return steps;
};
}, [
base.params.parameters, // Required for RedactAdvancedSettings prop
base.hasFiles,
base.hasResults,
base.endpointLoading,
base.params.updateParameter,
methodCollapsed,
wordsCollapsed,
advancedCollapsed,
modeTips,
wordsTips,
advancedTips,
manualTips,
isManualModeOutsideViewer,
handleEnterManual,
redactionState,
t,
getActualCollapsedState,
base.settingsCollapsed,
base.handleSettingsReset,
setMethodCollapsed,
setWordsCollapsed,
setAdvancedCollapsed,
]);
return createToolFlow({
files: {