This commit is contained in:
EthanHealy01 2025-12-17 16:24:53 +00:00 committed by GitHub
commit 439e1906f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1360 additions and 118 deletions

View File

@ -21,6 +21,7 @@
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-redaction": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",
@ -457,7 +458,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -501,7 +501,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -582,7 +581,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz",
"integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.5.0",
"@embedpdf/models": "1.5.0"
@ -682,7 +680,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz",
"integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -699,7 +696,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz",
"integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -717,7 +713,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz",
"integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -766,12 +761,30 @@
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-1.5.0.tgz",
"integrity": "sha512-txiukr5UKAGvJzl6dVBmmIT1v3r/t4e2qYm1hqU2faGgNCa2dwk79x9mDBlvWwxlJXCDFuFE+7Ps9/nU6qmU2w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.5.0",
"@embedpdf/utils": "1.5.0"
},
"peerDependencies": {
"@embedpdf/core": "1.5.0",
"@embedpdf/plugin-interaction-manager": "1.5.0",
"@embedpdf/plugin-selection": "1.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz",
"integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -806,7 +819,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz",
"integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -843,7 +855,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz",
"integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -919,7 +930,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz",
"integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.5.0"
},
@ -1075,7 +1085,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1119,7 +1128,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2150,7 +2158,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2201,7 +2208,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2269,7 +2275,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@ -3202,7 +3207,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -3321,6 +3325,7 @@
"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"
}
@ -4097,7 +4102,6 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4426,7 +4430,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4437,7 +4440,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4507,7 +4509,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -5221,6 +5222,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@ -5230,6 +5232,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@ -5240,6 +5243,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@ -5252,6 +5256,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@ -5278,7 +5283,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5686,6 +5690,7 @@
"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"
}
@ -5962,7 +5967,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -7010,8 +7014,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7406,7 +7409,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7577,7 +7579,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7744,7 +7745,8 @@
"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"
"license": "MIT",
"peer": true
},
"node_modules/espree": {
"version": "10.4.0",
@ -7809,6 +7811,7 @@
"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"
}
@ -8899,7 +8902,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9376,6 +9378,7 @@
"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"
}
@ -9696,7 +9699,6 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@ -10283,7 +10285,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/locate-path": {
"version": "6.0.0",
@ -11442,7 +11445,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11722,7 +11724,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -12105,7 +12106,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -12115,7 +12115,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13627,6 +13626,7 @@
"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"
}
@ -13835,7 +13835,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14137,7 +14136,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14219,7 +14217,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14424,7 +14421,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14595,7 +14591,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14609,7 +14604,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -15221,7 +15215,8 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/zod": {
"version": "3.25.76",

View File

@ -17,6 +17,7 @@
"@embedpdf/plugin-loader": "^1.5.0",
"@embedpdf/plugin-pan": "^1.5.0",
"@embedpdf/plugin-print": "^1.5.0",
"@embedpdf/plugin-redaction": "^1.5.0",
"@embedpdf/plugin-render": "^1.5.0",
"@embedpdf/plugin-rotate": "^1.5.0",
"@embedpdf/plugin-scroll": "^1.5.0",

View File

@ -1,8 +1,11 @@
unsavedChanges = "You have unsaved changes to your PDF."
pendingRedactionsTitle = "Unapplied Redactions"
pendingRedactions = "You have unapplied redactions that will be lost."
areYouSure = "Are you sure you want to leave?"
unsavedChangesTitle = "Unsaved Changes"
keepWorking = "Keep Working"
discardChanges = "Discard & Leave"
discardRedactions = "Discard & Leave"
applyAndContinue = "Save & Leave"
exportAndContinue = "Export & Continue"
cancel = "Cancel"
@ -3253,8 +3256,35 @@ text = "Only match complete words, not partial matches. 'John' won't match 'John
title = "Convert to PDF-Image"
text = "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable."
[redact.tooltip.manual.header]
title = "Manual Redaction Controls"
[redact.tooltip.manual.markText]
title = "Mark Text Tool"
text = "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."
[redact.tooltip.manual.markArea]
title = "Mark Area Tool"
text = "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."
[redact.tooltip.manual.apply]
title = "Apply Redactions"
text = "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."
bullet1 = "Mark as many areas as needed before applying"
bullet2 = "All pending redactions are applied at once"
bullet3 = "Redactions cannot be undone after applying"
[redact.manual]
title = "Redaction Tools"
instructions = "Select text or draw areas on the PDF to mark content for redaction."
markText = "Mark Text"
markArea = "Mark Area"
pendingLabel = "Pending:"
applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted"
apply = "Apply"
noMarks = "No redaction marks. Use the tools above to mark content for redaction."
header = "Manual Redaction"
controlsTitle = "Manual Redaction Controls"
textBasedRedaction = "Text-based Redaction"
pageBasedRedaction = "Page-based Redaction"
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
@ -4028,6 +4058,8 @@ toggleAnnotations = "Toggle Annotations Visibility"
annotationMode = "Toggle Annotation Mode"
print = "Print PDF"
draw = "Draw"
redact = "Redact"
exitRedaction = "Exit Redaction Mode"
save = "Save"
saveChanges = "Save Changes"

View File

@ -12,6 +12,7 @@ import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
@ -95,13 +96,15 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
<RedactionProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>

View File

@ -32,14 +32,15 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
confirmNavigation();
};
const _handleExportAndContinue = async () => {
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
const BUTTON_WIDTH = "12rem";
// Only show modal if there are unsaved changes AND there's an actual pending navigation
// This prevents the modal from showing due to spurious state updates
@ -83,6 +84,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{onExportAndContinue && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
</Group>
</Group>
@ -99,6 +105,11 @@ const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: Nav
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
{onExportAndContinue && (
<Button variant="filled" onClick={handleExportAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("exportAndContinue", "Export & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>

View File

@ -1,12 +1,19 @@
import React from 'react';
import { ActionIcon } from '@mantine/core';
import React, { useState, useEffect, useCallback } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import { useNavigationState, useNavigationGuard, useNavigationActions } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { useRedactionMode, useRedaction } from '@app/contexts/RedactionContext';
import { defaultParameters, RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
interface ViewerAnnotationControlsProps {
currentView: string;
@ -16,22 +23,201 @@ interface ViewerAnnotationControlsProps {
export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
const [pendingAnnotationAfterRedaction, setPendingAnnotationAfterRedaction] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
// Signature context for accessing drawing API
const { signatureApiRef, historyApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode or redaction mode
const { selectedTool, workbench } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const isSignMode = selectedTool === 'sign';
const isRedactMode = selectedTool === 'redact';
// Get redaction pending state and navigation guard
const { pendingCount: redactionPendingCount, isRedacting: _isRedacting } = useRedactionMode();
const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard();
const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction();
const activateDrawingTools = useCallback(() => {
if (!signatureApiRef?.current) return;
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}, [selectedColor, signatureApiRef]);
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Activate draw mode when annotation mode becomes active
useEffect(() => {
if (viewerContext?.isAnnotationMode && currentView === 'viewer') {
activateDrawingTools();
}
}, [viewerContext?.isAnnotationMode, currentView, activateDrawingTools]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
}
// Persist annotations to file if there are unsaved changes
const saveAnnotationsIfNeeded = async () => {
if (!viewerContext?.exportActions?.saveAsCopy || currentView !== 'viewer' || !historyApiRef?.current?.canUndo()) return;
if (activeFiles.length === 0 || state.files.ids.length === 0) return;
try {
const arrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (!arrayBuffer) return;
const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: 'application/pdf' });
const parentStub = selectors.getStirlingFileStub(state.files.ids[0]);
if (!parentStub) return;
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, 'redact');
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
// Clear unsaved changes flags after successful save
setHasUnsavedChanges(false);
setRedactionsApplied(false);
} catch (error) {
console.error('Error auto-saving annotations before redaction:', error);
}
};
const exitRedactionMode = useCallback(() => {
navActions.setToolAndWorkbench(null, 'viewer');
setLeftPanelView('toolPicker');
setRedactionMode(false);
setActiveType(null);
}, [navActions, setLeftPanelView, setRedactionMode, setActiveType]);
// Handle redaction mode toggle
const handleRedactionToggle = async () => {
if (isRedactMode) {
// If already in redact mode, toggle annotation mode off and show redaction layer
if (viewerContext?.isAnnotationMode) {
await saveAnnotationsIfNeeded();
viewerContext.setAnnotationMode(false);
// Deactivate any active annotation tools
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Unable to deactivate annotation tools:', error);
}
}
// Activate redaction tool
setTimeout(() => {
activateTextSelection();
}, 100);
} else {
// Exit redaction mode - keep viewer workbench and show all tools in sidebar
exitRedactionMode();
}
} else {
await saveAnnotationsIfNeeded();
if (viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Unable to deactivate annotation tools:', error);
}
}
}
// Enter redaction mode - select redact tool with manual mode
// If we're already in the viewer, keep the viewer workbench and open the tool sidebar
if (workbench === 'viewer') {
// Set redaction config to manual mode when opening from viewer
const manualConfig: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
setRedactionConfig(manualConfig);
// Set tool and keep viewer workbench
navActions.setToolAndWorkbench('redact', 'viewer');
// Ensure sidebars are visible and open tool content
setSidebarsVisible(true);
setLeftPanelView('toolContent');
} else {
navActions.handleToolSelect('redact');
}
setRedactionMode(true);
// Activate text selection mode after a short delay
setTimeout(() => {
const currentType = redactionApiRef.current?.getActiveType?.();
if (currentType !== 'redactSelection') {
activateTextSelection();
}
}, 200);
}
};
const startAnnotationMode = useCallback(() => {
viewerContext?.setAnnotationMode(true);
activateDrawingTools();
}, [viewerContext, activateDrawingTools]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | null = null;
if (!isRedactMode && pendingAnnotationAfterRedaction) {
timer = setTimeout(() => {
setPendingAnnotationAfterRedaction(false);
startAnnotationMode();
}, 200);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isRedactMode, pendingAnnotationAfterRedaction, startAnnotationMode]);
return (
<>
{/* Redaction Mode Toggle */}
<Tooltip content={isRedactMode && !viewerContext?.isAnnotationMode ? t('rightRail.exitRedaction', 'Exit Redaction Mode') : t('rightRail.redact', 'Redact')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant={isRedactMode && !viewerContext?.isAnnotationMode ? 'filled' : 'subtle'}
color={isRedactMode && !viewerContext?.isAnnotationMode ? 'red' : undefined}
radius="md"
className="right-rail-icon"
onClick={handleRedactionToggle}
disabled={disabled || currentView !== 'viewer'}
>
<LocalIcon
icon="scan-delete-rounded"
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
@ -41,15 +227,172 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer'}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "preview-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.setAnnotationMode(false);
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
const scheduleAnnotationAfterRedaction = () => {
setPendingAnnotationAfterRedaction(true);
exitRedactionMode();
};
const beginAnnotation = () => {
if (isRedactMode) {
scheduleAnnotationAfterRedaction();
} else {
startAnnotationMode();
}
};
// If in redaction mode with pending redactions, show warning modal
if (isRedactMode && redactionPendingCount > 0) {
requestNavigation(beginAnnotation);
} else {
beginAnnotation();
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version
if (activeFiles.length > 0 && state.files.ids.length > 0) {
const parentStub = selectors.getStirlingFileStub(state.files.ids[0]);
if (!parentStub) {
console.error('No file record found for:', state.files.ids[0]);
return;
}
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([newFile], parentStub, 'multiTool');
await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs);
// Clear unsaved changes flags after successful save
setHasUnsavedChanges(false);
setRedactionsApplied(false);
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}

View File

@ -0,0 +1,212 @@
import { useTranslation } from 'react-i18next';
import { useEffect, useRef } from 'react';
import { Button, Stack, Text, Badge, Group, Divider, Tooltip } from '@mantine/core';
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
import CropFreeIcon from '@mui/icons-material/CropFree';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
interface ManualRedactionControlsProps {
disabled?: boolean;
}
/**
* ManualRedactionControls provides UI for manual PDF redaction in the tool panel.
* Displays controls for marking text/areas for redaction and applying them.
* Uses our RedactionContext which bridges to the EmbedPDF API.
*/
export default function ManualRedactionControls({ disabled = false }: ManualRedactionControlsProps) {
const { t } = useTranslation();
// Use our RedactionContext which bridges to EmbedPDF
const { activateTextSelection, activateMarquee, commitAllPending, redactionApiRef } = useRedaction();
const { pendingCount, activeType, isRedacting } = useRedactionMode();
// Get viewer context to manage annotation mode
const { isAnnotationMode, setAnnotationMode } = useViewer();
// Get signature context to deactivate annotation tools when switching to redaction
const { signatureApiRef } = useSignature();
// Check which tool is active based on activeType
const isSelectionActive = activeType === 'redactSelection';
const isMarqueeActive = activeType === 'marqueeRedact';
// Track if we've auto-activated
const hasAutoActivated = useRef(false);
// Auto-activate selection mode when the API becomes available
// This ensures at least one tool is selected when entering manual redaction mode
useEffect(() => {
if (redactionApiRef.current && !disabled && !isRedacting && !hasAutoActivated.current) {
hasAutoActivated.current = true;
// Small delay to ensure EmbedPDF is fully ready
const timer = setTimeout(() => {
// Deactivate annotation mode to show redaction layer
setAnnotationMode(false);
activateTextSelection();
}, 100);
return () => clearTimeout(timer);
}
}, [redactionApiRef.current, disabled, isRedacting, activateTextSelection, setAnnotationMode]);
// Reset auto-activation flag when disabled changes
useEffect(() => {
if (disabled) {
hasAutoActivated.current = false;
}
}, [disabled]);
const handleSelectionClick = () => {
// Deactivate annotation mode and tools to switch to redaction layer
if (isAnnotationMode) {
setAnnotationMode(false);
// Deactivate any active annotation tools (like draw)
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Unable to deactivate annotation tools:', error);
}
}
}
if (isSelectionActive && !isAnnotationMode) {
// If already active and not coming from annotation mode, switch to marquee
activateMarquee();
} else {
activateTextSelection();
}
};
const handleMarqueeClick = () => {
// Deactivate annotation mode and tools to switch to redaction layer
if (isAnnotationMode) {
setAnnotationMode(false);
// Deactivate any active annotation tools (like draw)
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Unable to deactivate annotation tools:', error);
}
}
}
if (isMarqueeActive && !isAnnotationMode) {
// If already active and not coming from annotation mode, switch to selection
activateTextSelection();
} else {
activateMarquee();
}
};
const handleApplyAll = () => {
commitAllPending();
};
// Check if API is available
const isApiReady = redactionApiRef.current !== null;
return (
<>
<Divider my="sm" />
<Stack gap="md">
<Text size="sm" fw={500}>
{t('redact.manual.title', 'Redaction Tools')}
</Text>
<Text size="xs" c="dimmed">
{t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')}
</Text>
<Group gap="sm" grow wrap="nowrap">
{/* Mark Text Selection Tool */}
<Button
variant={isSelectionActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isSelectionActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleSelectionClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: {
minWidth: 0,
},
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markText', 'Mark Text')}
</Button>
{/* Mark Area (Marquee) Tool */}
<Button
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isMarqueeActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleMarqueeClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: {
minWidth: 0,
},
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markArea', 'Mark Area')}
</Button>
</Group>
<Divider />
{/* Pending Count and Apply Button */}
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<Text size="sm" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{t('redact.manual.pendingLabel', 'Pending:')}
</Text>
<Badge
color={pendingCount > 0 ? 'red' : 'gray'}
variant="filled"
size="lg"
>
{pendingCount}
</Badge>
</Group>
<Tooltip
label={t('redact.manual.applyWarning', '⚠️ Permanent application, cannot be undone and the data underneath will be deleted')}
withArrow
position="top"
disabled={disabled || pendingCount === 0 || !isApiReady}
>
<Button
variant="filled"
color="red"
leftSection={<CheckCircleIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleApplyAll}
disabled={disabled || pendingCount === 0 || !isApiReady}
size="sm"
styles={{
root: { flexShrink: 0 },
label: { whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.apply', 'Apply')}
</Button>
</Tooltip>
</Group>
{pendingCount === 0 && (
<Text size="xs" c="dimmed" ta="center">
{t('redact.manual.noMarks', 'No redaction marks. Use the tools above to mark content for redaction.')}
</Text>
)}
</Stack>
</>
);
}

View File

@ -6,9 +6,10 @@ interface RedactModeSelectorProps {
mode: RedactMode;
onModeChange: (mode: RedactMode) => void;
disabled?: boolean;
hasFiles?: boolean;
}
export default function RedactModeSelector({ mode, onModeChange, disabled }: RedactModeSelectorProps) {
export default function RedactModeSelector({ mode, onModeChange, disabled, hasFiles = false }: RedactModeSelectorProps) {
const { t } = useTranslation();
return (
@ -24,7 +25,7 @@ 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: !hasFiles, // Enable manual mode when files are present
},
]}
disabled={disabled}

View File

@ -77,3 +77,32 @@ export const useRedactAdvancedTips = (): TooltipContent => {
]
};
};
export const useRedactManualTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("redact.tooltip.manual.header.title", "Manual Redaction Controls")
},
tips: [
{
title: t("redact.tooltip.manual.markText.title", "Mark Text Tool"),
description: t("redact.tooltip.manual.markText.text", "Select text directly on the PDF to mark it for redaction. Click and drag to highlight specific text that you want to redact."),
},
{
title: t("redact.tooltip.manual.markArea.title", "Mark Area Tool"),
description: t("redact.tooltip.manual.markArea.text", "Draw rectangular areas on the PDF to mark regions for redaction. Useful for redacting images, signatures, or irregular shapes."),
},
{
title: t("redact.tooltip.manual.apply.title", "Apply Redactions"),
description: t("redact.tooltip.manual.apply.text", "After marking content, click 'Apply' to permanently redact all marked areas. The pending count shows how many redactions are ready to be applied."),
bullets: [
t("redact.tooltip.manual.apply.bullet1", "Mark as many areas as needed before applying"),
t("redact.tooltip.manual.apply.bullet2", "All pending redactions are applied at once"),
t("redact.tooltip.manual.apply.bullet3", "Redactions cannot be undone after applying")
]
}
]
};
};

View File

@ -11,6 +11,8 @@ import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { useRedaction } from '@app/contexts/RedactionContext';
import type { RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
@ -73,6 +75,12 @@ const EmbedPdfViewerContent = ({
// Get signature context
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get redaction context
const { isRedactionMode, redactionsApplied, setRedactionsApplied } = useRedaction();
// Ref for redaction pending tracker API
const redactionTrackerRef = useRef<RedactionPendingTrackerAPI>(null);
// Get current file from FileContext
const { selectors, state } = useFileState();
const { actions } = useFileActions();
@ -83,16 +91,25 @@ const EmbedPdfViewerContent = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in an annotation tool
const { selectedTool } = useNavigationState();
// Tools that require the annotation layer (Sign, Add Text, Add Image)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
const isSignatureMode = isInAnnotationTool;
const isManualRedactMode = selectedTool === 'redact';
// Sync isAnnotationMode in ViewerContext with current tool
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
// When in manual redaction mode, annotation mode takes priority if active (user clicked draw tool)
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || (isAnnotationsVisible && !isManualRedactMode);
// Enable redaction when the redact tool is selected and annotation mode is NOT active
// This allows switching between redaction and annotation tools while redact is the selected tool
const shouldEnableRedaction = (isManualRedactMode || isRedactionMode) && !isAnnotationMode;
// Keep annotation mode enabled when entering placement tools without overriding manual toggles
useEffect(() => {
setAnnotationMode(isInAnnotationTool);
if (isInAnnotationTool) {
setAnnotationMode(true);
}
}, [isInAnnotationTool, setAnnotationMode]);
const isPlacementOverlayActive = Boolean(
isInAnnotationTool && isPlacementMode && signatureConfig
);
@ -225,20 +242,39 @@ const EmbedPdfViewerContent = ({
};
}, [isViewerHovered, isSearchInterfaceVisible, zoomActions, searchInterfaceActions]);
// Register checker for unsaved changes (annotations only for now)
// Register checker for unsaved changes
// In redact mode: check for pending (unapplied) OR applied but not saved redactions
// In other modes: check annotation history
useEffect(() => {
if (previewFile) {
return;
}
const checkForChanges = () => {
// Check for annotation changes via history
// Check for pending redactions
const hasPendingRedactions = (redactionTrackerRef.current?.getPendingCount() ?? 0) > 0;
// Check for annotation history changes
const hasAnnotationChanges = historyApiRef.current?.canUndo() || false;
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges
});
return hasAnnotationChanges;
// Always consider applied redactions as unsaved until export
const hasAppliedRedactions = redactionsApplied;
// When in redact mode, still include annotation changes (users may draw)
if (isManualRedactMode) {
console.log('[Viewer] Checking for unsaved changes (redact mode):', {
hasPendingRedactions,
hasAppliedRedactions,
hasAnnotationChanges,
});
} else {
console.log('[Viewer] Checking for unsaved changes:', {
hasAnnotationChanges,
hasPendingRedactions,
hasAppliedRedactions,
});
}
return hasAnnotationChanges || hasPendingRedactions || hasAppliedRedactions;
};
console.log('[Viewer] Registering unsaved changes checker');
@ -248,14 +284,22 @@ const EmbedPdfViewerContent = ({
console.log('[Viewer] Unregistering unsaved changes checker');
unregisterUnsavedChangesChecker();
};
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker]);
}, [historyApiRef, previewFile, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker, isManualRedactMode, redactionsApplied]);
// Apply changes - save annotations to new file version
// Apply changes - save annotations and redactions to new file version
const applyChanges = useCallback(async () => {
if (!currentFile || activeFileIds.length === 0) return;
try {
console.log('[Viewer] Applying changes - exporting PDF with annotations');
console.log('[Viewer] Applying changes - exporting PDF with annotations/redactions');
// Step 0: Commit any pending redactions before export
if (redactionTrackerRef.current?.getPendingCount() ?? 0 > 0) {
console.log('[Viewer] Committing pending redactions before export');
redactionTrackerRef.current?.commitAllPending();
// Give a small delay for the commit to process
await new Promise(resolve => setTimeout(resolve, 100));
}
// Step 1: Export PDF with annotations using EmbedPDF
const arrayBuffer = await exportActions.saveAsCopy();
@ -279,11 +323,13 @@ const EmbedPdfViewerContent = ({
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Clear unsaved changes flags
setHasUnsavedChanges(false);
setRedactionsApplied(false);
} catch (error) {
console.error('Apply changes failed:', error);
}
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges, setRedactionsApplied]);
const sidebarWidthRem = 15;
const totalRightMargin =
@ -337,10 +383,13 @@ const EmbedPdfViewerContent = ({
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={isAnnotationMode}
enableAnnotations={shouldEnableAnnotations}
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
isManualRedactionMode={isManualRedactMode}
signatureApiRef={signatureApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
// Future: Handle signature completion

View File

@ -21,11 +21,10 @@ import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
import { PrintPluginPackage } from '@embedpdf/plugin-print/react';
// Import annotation plugins
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { RedactionPluginPackage, RedactionLayer } from '@embedpdf/plugin-redaction/react';
import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer';
import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge';
import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback';
@ -46,19 +45,36 @@ import { PrintAPIBridge } from '@app/components/viewer/PrintAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
import { absoluteWithBasePath } from '@app/constants/app';
interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableAnnotations?: boolean;
enableRedaction?: boolean;
isManualRedactionMode?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
}
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({
file,
url,
enableAnnotations = false,
enableRedaction = false,
isManualRedactionMode = false,
showBakedAnnotations = true,
onSignatureAdded,
signatureApiRef,
historyApiRef,
redactionTrackerRef,
}: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@ -123,6 +139,11 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectAfterCreate: true,
}),
// Register redaction plugin (depends on InteractionManager, Selection)
createPluginRegistration(RedactionPluginPackage, {
drawBlackBoxes: true, // Draw black boxes over redacted content
}),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
defaultMode: 'mobile', // Try mobile mode which might be more permissive
@ -309,8 +330,12 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
<SignatureAPIBridge ref={signatureApiRef} />
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <HistoryAPIBridge ref={historyApiRef} />}
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
<ExportAPIBridge />
<BookmarkAPIBridge />
<PrintAPIBridge />
@ -377,6 +402,16 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedA
selectionOutlineColor="#007ACC"
/>
)}
{/* Redaction layer for marking areas to redact (only when enabled) */}
{enableRedaction && (
<RedactionLayer
pageIndex={pageIndex}
scale={scale}
rotation={rotation}
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
/>
)}
</div>
</PagePointerProvider>
</Rotate>

View File

@ -0,0 +1,50 @@
import { useEffect, useImperativeHandle } from 'react';
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
import { useRedaction } from '@app/contexts/RedactionContext';
/**
* RedactionAPIBridge connects the EmbedPDF redaction plugin to our RedactionContext.
* It must be rendered inside the EmbedPDF context to access the plugin API.
*
* It does two things:
* 1. Syncs EmbedPDF state (pendingCount, activeType, isRedacting) to our context
* 2. Exposes the EmbedPDF API through our context's ref so outside components can call it
*/
export function RedactionAPIBridge() {
const { state, provides } = useEmbedPdfRedaction();
const {
redactionApiRef,
setPendingCount,
setActiveType,
setIsRedacting,
setRedactionsApplied
} = useRedaction();
// Sync EmbedPDF state to our context
useEffect(() => {
if (state) {
setPendingCount(state.pendingCount ?? 0);
setActiveType(state.activeType ?? null);
setIsRedacting(state.isRedacting ?? false);
}
}, [state?.pendingCount, state?.activeType, state?.isRedacting, setPendingCount, setActiveType, setIsRedacting]);
// Expose the EmbedPDF API through our context's ref
useImperativeHandle(redactionApiRef, () => ({
toggleRedactSelection: () => {
provides?.toggleRedactSelection();
},
toggleMarqueeRedact: () => {
provides?.toggleMarqueeRedact();
},
commitAllPending: () => {
provides?.commitAllPending();
setRedactionsApplied(true);
},
getActiveType: () => state?.activeType ?? null,
getPendingCount: () => state?.pendingCount ?? 0,
}), [provides, state, setRedactionsApplied]);
return null;
}

View File

@ -0,0 +1,41 @@
import { useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { useRedaction as useEmbedPdfRedaction } from '@embedpdf/plugin-redaction/react';
export interface RedactionPendingTrackerAPI {
commitAllPending: () => void;
getPendingCount: () => number;
}
/**
* RedactionPendingTracker monitors pending redactions and exposes an API
* for committing and checking pending redactions.
* Must be rendered inside the EmbedPDF context.
*
* Note: The unsaved changes checker is registered by EmbedPdfViewer, not here,
* to avoid conflicts and allow the viewer to check both annotations and redactions.
*/
export const RedactionPendingTracker = forwardRef<RedactionPendingTrackerAPI>(
function RedactionPendingTracker(_, ref) {
const { state, provides } = useEmbedPdfRedaction();
const pendingCountRef = useRef(0);
// Expose API through ref
useImperativeHandle(ref, () => ({
commitAllPending: () => {
if (provides?.commitAllPending) {
provides.commitAllPending();
}
},
getPendingCount: () => pendingCountRef.current,
}), [provides]);
// Update ref when pending count changes
useEffect(() => {
pendingCountRef.current = state?.pendingCount ?? 0;
}, [state?.pendingCount]);
return null;
}
);

View File

@ -0,0 +1,172 @@
import { useRedaction as useEmbedPdfRedaction, SelectionMenuProps } from '@embedpdf/plugin-redaction/react';
import { ActionIcon, Tooltip, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
/**
* Custom menu component that appears when a pending redaction mark is selected.
* Allows users to remove or apply individual pending marks.
* Uses a portal to ensure it appears above all content, including next pages.
*/
export function RedactionSelectionMenu({ item, selected, menuWrapperProps }: SelectionMenuProps) {
const { t } = useTranslation();
const { provides } = useEmbedPdfRedaction();
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
// Merge refs if menuWrapperProps has a ref
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
if (menuWrapperProps?.ref) {
const ref = menuWrapperProps.ref;
if (typeof ref === 'function') {
ref(node);
} else if (ref && 'current' in ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
}
}, [menuWrapperProps]);
const handleRemove = useCallback(() => {
if (provides?.removePending && item) {
provides.removePending(item.page, item.id);
}
}, [provides, item]);
const handleApply = useCallback(() => {
if (provides?.commitPending && item) {
provides.commitPending(item.page, item.id);
}
}, [provides, item]);
// Calculate position for portal based on wrapper element
useEffect(() => {
if (!selected || !item || !wrapperRef.current) {
setMenuPosition(null);
return;
}
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const rect = wrapper.getBoundingClientRect();
// Position menu below the wrapper, centered
// Use getBoundingClientRect which gives viewport-relative coordinates
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
setMenuPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
};
updatePosition();
// Update position on scroll/resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, item]);
// Early return AFTER all hooks have been called
if (!selected || !item) return null;
const menuContent = menuPosition ? (
<div
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
backgroundColor: 'var(--mantine-color-body)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
border: '1px solid var(--mantine-color-default-border)',
// Fixed size to prevent browser zoom affecting layout
fontSize: '14px',
minWidth: '180px',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
<Tooltip label="Remove this mark">
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={handleRemove}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
<Tooltip
label={t('redact.manual.applyWarning', '⚠️ Permanent application, cannot be undone and the data underneath will be deleted')}
withArrow
position="top"
>
<Button
variant="filled"
color="red"
size="xs"
onClick={handleApply}
leftSection={<CheckCircleIcon style={{ fontSize: 16 }} />}
styles={{
root: { flexShrink: 0, whiteSpace: 'nowrap' },
}}
>
Apply (permanent)
</Button>
</Tooltip>
</Group>
</div>
) : null;
// Extract ref from menuWrapperProps to avoid conflicts
const { ref: _, ...wrapperPropsWithoutRef } = menuWrapperProps || {};
return (
<>
<div
ref={setRef}
{...wrapperPropsWithoutRef}
style={{
// Preserve the original positioning from menuWrapperProps
...(wrapperPropsWithoutRef?.style || {}),
// Override visibility to hide the wrapper (we only need its position)
visibility: 'hidden',
pointerEvents: 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
</>
);
}

View File

@ -230,25 +230,37 @@ export const NavigationProvider: React.FC<{
}, []);
const handleToolSelect = useCallback((toolId: string) => {
if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return;
const performToolSelect = () => {
if (toolId === 'allTools') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: getDefaultWorkbench() } });
return;
}
if (toolId === 'read' || toolId === 'view-pdf') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
return;
}
// Look up the tool in the registry to get its proper workbench
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
// Validate toolId and convert to ToolId type
const validToolId = isValidToolId(toolId) ? toolId : null;
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
};
// Check for unsaved changes using registered checker or state
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
// If switching away from current tool and have unsaved changes, show warning
if (hasUnsavedChanges && state.selectedTool && state.selectedTool !== toolId) {
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performToolSelect } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
} else {
performToolSelect();
}
if (toolId === 'read' || toolId === 'view-pdf') {
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench: 'viewer' } });
return;
}
// Look up the tool in the registry to get its proper workbench
const tool = isValidToolId(toolId)? toolRegistry[toolId] : null;
const workbench = tool ? (tool.workbench || getDefaultWorkbench()) : getDefaultWorkbench();
// Validate toolId and convert to ToolId type
const validToolId = isValidToolId(toolId) ? toolId : null;
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: validToolId, workbench } });
}, [toolRegistry]);
}, [toolRegistry, state.hasUnsavedChanges, state.selectedTool]);
// Memoize the actions object to prevent unnecessary context updates
// This is critical to avoid infinite loops when effects depend on actions

View File

@ -0,0 +1,194 @@
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef, useEffect } from 'react';
import { RedactParameters } from '@app/hooks/tools/redact/useRedactParameters';
import { useNavigationGuard } from '@app/contexts/NavigationContext';
/**
* API interface that the EmbedPDF bridge will implement
*/
export interface RedactionAPI {
toggleRedactSelection: () => void;
toggleMarqueeRedact: () => void;
commitAllPending: () => void;
getActiveType: () => 'redactSelection' | 'marqueeRedact' | null;
getPendingCount: () => number;
}
/**
* State interface for redaction operations
*/
interface RedactionState {
// Current redaction configuration from the tool
redactionConfig: RedactParameters | null;
// Whether we're in redaction mode (viewer should show redaction layer)
isRedactionMode: boolean;
// Whether redactions have been applied
redactionsApplied: boolean;
// Synced state from EmbedPDF
pendingCount: number;
activeType: 'redactSelection' | 'marqueeRedact' | null;
isRedacting: boolean;
}
/**
* Actions interface for redaction operations
*/
interface RedactionActions {
setRedactionConfig: (config: RedactParameters | null) => void;
setRedactionMode: (enabled: boolean) => void;
setRedactionsApplied: (applied: boolean) => void;
// Synced state setters (called from inside EmbedPDF)
setPendingCount: (count: number) => void;
setActiveType: (type: 'redactSelection' | 'marqueeRedact' | null) => void;
setIsRedacting: (isRedacting: boolean) => void;
// Actions that call through to EmbedPDF API
activateTextSelection: () => void;
activateMarquee: () => void;
commitAllPending: () => void;
}
/**
* Combined context interface
*/
interface RedactionContextValue extends RedactionState, RedactionActions {
// Ref that the bridge component will populate
redactionApiRef: React.MutableRefObject<RedactionAPI | null>;
}
// Create context
const RedactionContext = createContext<RedactionContextValue | undefined>(undefined);
// Initial state
const initialState: RedactionState = {
redactionConfig: null,
isRedactionMode: false,
redactionsApplied: false,
pendingCount: 0,
activeType: null,
isRedacting: false,
};
/**
* Provider component for redaction functionality
* Bridges between the tool panel (outside EmbedPDF) and the viewer (inside EmbedPDF)
*/
export const RedactionProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [state, setState] = useState<RedactionState>(initialState);
const redactionApiRef = useRef<RedactionAPI | null>(null);
const { setHasUnsavedChanges } = useNavigationGuard();
// Actions for tool configuration
const setRedactionConfig = useCallback((config: RedactParameters | null) => {
setState(prev => ({
...prev,
redactionConfig: config,
}));
}, []);
const setRedactionMode = useCallback((enabled: boolean) => {
setState(prev => ({
...prev,
isRedactionMode: enabled,
}));
}, []);
const setRedactionsApplied = useCallback((applied: boolean) => {
setState(prev => ({
...prev,
redactionsApplied: applied,
}));
}, []);
// Synced state setters (called from bridge inside EmbedPDF)
const setPendingCount = useCallback((count: number) => {
setState(prev => ({
...prev,
pendingCount: count,
}));
}, []);
const setActiveType = useCallback((type: 'redactSelection' | 'marqueeRedact' | null) => {
setState(prev => ({
...prev,
activeType: type,
}));
}, []);
const setIsRedacting = useCallback((isRedacting: boolean) => {
setState(prev => ({
...prev,
isRedacting,
}));
}, []);
// Keep navigation guard aware of pending or applied redactions so we block navigation
useEffect(() => {
if (state.pendingCount > 0 || state.redactionsApplied) {
setHasUnsavedChanges(true);
}
}, [state.pendingCount, state.redactionsApplied, setHasUnsavedChanges]);
// Actions that call through to EmbedPDF API
const activateTextSelection = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.toggleRedactSelection();
}
}, []);
const activateMarquee = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.toggleMarqueeRedact();
}
}, []);
const commitAllPending = useCallback(() => {
if (redactionApiRef.current) {
redactionApiRef.current.commitAllPending();
}
}, []);
const contextValue: RedactionContextValue = {
...state,
redactionApiRef,
setRedactionConfig,
setRedactionMode,
setRedactionsApplied,
setPendingCount,
setActiveType,
setIsRedacting,
activateTextSelection,
activateMarquee,
commitAllPending,
};
return (
<RedactionContext.Provider value={contextValue}>
{children}
</RedactionContext.Provider>
);
};
/**
* Hook to use redaction context
*/
export const useRedaction = (): RedactionContextValue => {
const context = useContext(RedactionContext);
if (context === undefined) {
throw new Error('useRedaction must be used within a RedactionProvider');
}
return context;
};
/**
* Hook for components that need to check if redaction mode is active
*/
export const useRedactionMode = () => {
const context = useContext(RedactionContext);
return {
isRedactionModeActive: context?.isRedactionMode || false,
hasRedactionConfig: context?.redactionConfig !== null,
pendingCount: context?.pendingCount || 0,
activeType: context?.activeType || null,
isRedacting: context?.isRedacting || false,
};
};

View File

@ -103,13 +103,14 @@ describe('buildRedactFormData', () => {
expect(formData.get('convertPDFToImage')).toBe('false');
});
test('should throw error for manual mode (not implemented)', () => {
test('should return empty form data for manual mode (handled client-side)', () => {
const parameters: RedactParameters = {
...defaultParameters,
mode: 'manual',
};
expect(() => buildRedactFormData(parameters, mockFile)).toThrow('Manual redaction not yet implemented');
const formData = buildRedactFormData(parameters, mockFile);
expect(formData.get('fileInput')).toBeNull();
});
});

View File

@ -6,9 +6,10 @@ import { RedactParameters, defaultParameters } from '@app/hooks/tools/redact/use
// Static configuration that can be used by both the hook and automation executor
export const buildRedactFormData = (parameters: RedactParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// For automatic mode we hit the backend and need full payload
if (parameters.mode === 'automatic') {
formData.append("fileInput", file);
// Convert array to newline-separated string as expected by backend
formData.append("listOfText", parameters.wordsToRedact.join('\n'));
formData.append("useRegex", parameters.useRegex.toString());
@ -17,8 +18,8 @@ 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 redaction uses EmbedPDF in-viewer; we don't call the API.
// Return an empty formData to satisfy shared interfaces without throwing.
}
return formData;
@ -32,10 +33,9 @@ export const redactOperationConfig = {
endpoint: (parameters: RedactParameters) => {
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 redaction is handled by EmbedPDF in the viewer; no endpoint call.
return "";
},
defaultParameters,
} as const;

View File

@ -84,14 +84,14 @@ describe('useRedactParameters', () => {
expect(result.current.getEndpointName()).toBe('/api/v1/security/auto-redact');
});
test('should throw error for manual mode (not implemented)', () => {
test('should return null endpoint for manual mode (handled client-side)', () => {
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
});
expect(() => result.current.getEndpointName()).toThrow('Manual redaction not yet implemented');
expect(result.current.getEndpointName()).toBeNull();
});
});

View File

@ -34,14 +34,14 @@ 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 redaction handled client-side
return '';
},
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
// Manual mode is not yet supported via this flow
return false;
}
});

View File

@ -1,14 +1,17 @@
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { useState, useEffect, useRef } 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 { useRedactParameters, RedactMode } 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 ManualRedactionControls from "@app/components/tools/redact/ManualRedactionControls";
import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext";
import { useRedaction } from "@app/contexts/RedactionContext";
const Redact = (props: BaseToolProps) => {
const { t } = useTranslation();
@ -18,6 +21,12 @@ const Redact = (props: BaseToolProps) => {
const [wordsCollapsed, setWordsCollapsed] = useState(false);
const [advancedCollapsed, setAdvancedCollapsed] = useState(true);
// Navigation and redaction context
const { actions: navActions } = useNavigationActions();
const { setRedactionConfig, setRedactionMode, redactionConfig } = useRedaction();
const { workbench } = useNavigationState();
const hasOpenedViewer = useRef(false);
const base = useBaseTool(
'redact',
useRedactParameters,
@ -29,10 +38,51 @@ const Redact = (props: BaseToolProps) => {
const modeTips = useRedactModeTips();
const wordsTips = useRedactWordsTips();
const advancedTips = useRedactAdvancedTips();
const manualTips = useRedactManualTips();
// Auto-set manual mode if we're in the viewer and redaction config is set to manual
// This ensures when opening redact from viewer, it automatically selects manual mode
useEffect(() => {
if (workbench === 'viewer' && redactionConfig?.mode === 'manual' && base.params.parameters.mode !== 'manual') {
// Set immediately when conditions are met
base.params.updateParameter('mode', 'manual');
}
}, [workbench, redactionConfig, base.params.parameters.mode, base.params.updateParameter]);
// Handle mode change - navigate to viewer when manual mode is selected
const handleModeChange = (mode: RedactMode) => {
base.params.updateParameter('mode', mode);
if (mode === 'manual' && base.hasFiles) {
// Set redaction config and navigate to viewer
setRedactionConfig(base.params.parameters);
setRedactionMode(true);
navActions.setWorkbench('viewer');
hasOpenedViewer.current = true;
}
};
// When files are added and in manual mode, navigate to viewer
useEffect(() => {
if (base.params.parameters.mode === 'manual' && base.hasFiles && !hasOpenedViewer.current) {
setRedactionConfig(base.params.parameters);
setRedactionMode(true);
navActions.setWorkbench('viewer');
hasOpenedViewer.current = true;
}
}, [base.hasFiles, base.params.parameters, navActions, setRedactionConfig, setRedactionMode]);
// Reset viewer flag when mode changes back to automatic
useEffect(() => {
if (base.params.parameters.mode === 'automatic') {
hasOpenedViewer.current = false;
setRedactionMode(false);
}
}, [base.params.parameters.mode, setRedactionMode]);
const isExecuteDisabled = () => {
if (base.params.parameters.mode === 'manual') {
return true; // Manual mode not implemented yet
return true; // Manual mode uses viewer, not execute button
}
return !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled;
};
@ -54,8 +104,9 @@ const Redact = (props: BaseToolProps) => {
content: (
<RedactModeSelector
mode={base.params.parameters.mode}
onModeChange={(mode) => base.params.updateParameter('mode', mode)}
onModeChange={handleModeChange}
disabled={base.endpointLoading}
hasFiles={base.hasFiles}
/>
),
}
@ -88,12 +139,22 @@ const Redact = (props: BaseToolProps) => {
},
);
} else if (base.params.parameters.mode === 'manual') {
// Manual mode steps would go here when implemented
// Manual mode - show redaction controls
steps.push({
title: t("redact.manual.controlsTitle", "Manual Redaction Controls"),
isCollapsed: false,
onCollapsedClick: () => {},
tooltip: manualTips,
content: <ManualRedactionControls disabled={!base.hasFiles} />,
});
}
return steps;
};
// Hide execute button in manual mode (redactions applied via controls)
const isManualMode = base.params.parameters.mode === 'manual';
return createToolFlow({
files: {
selectedFiles: base.selectedFiles,
@ -102,7 +163,7 @@ const Redact = (props: BaseToolProps) => {
steps: buildSteps(),
executeButton: {
text: t("redact.submit", "Redact"),
isVisible: !base.hasResults,
isVisible: !base.hasResults && !isManualMode,
loadingText: t("loading"),
onClick: base.handleExecute,
disabled: isExecuteDisabled(),