Bump/embed pdfv2.8.0 (#5921)

please merge #5919, alternatively, just push this and delete that PR
because this is a continuation of that.

This PR bumps the embed PDF version to 2.8.0 and also adds comments
functionaliy

---------

Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2026-03-23 14:35:39 +00:00
committed by GitHub
co-authored by ConnorYoh Anthony Stirling
parent 41945543e0
commit c46156f37f
23 changed files with 1932 additions and 629 deletions
+162 -162
View File
@@ -12,29 +12,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.7.0",
"@embedpdf/engines": "^2.7.0",
"@embedpdf/models": "^2.7.0",
"@embedpdf/plugin-annotation": "^2.7.0",
"@embedpdf/plugin-attachment": "^2.7.0",
"@embedpdf/plugin-bookmark": "^2.7.0",
"@embedpdf/plugin-document-manager": "^2.7.0",
"@embedpdf/plugin-export": "^2.7.0",
"@embedpdf/plugin-history": "^2.7.0",
"@embedpdf/plugin-interaction-manager": "^2.7.0",
"@embedpdf/plugin-pan": "^2.7.0",
"@embedpdf/plugin-print": "^2.7.0",
"@embedpdf/plugin-redaction": "^2.7.0",
"@embedpdf/plugin-render": "^2.7.0",
"@embedpdf/plugin-rotate": "^2.7.0",
"@embedpdf/plugin-scroll": "^2.7.0",
"@embedpdf/plugin-search": "^2.7.0",
"@embedpdf/plugin-selection": "^2.7.0",
"@embedpdf/plugin-spread": "^2.7.0",
"@embedpdf/plugin-thumbnail": "^2.7.0",
"@embedpdf/plugin-tiling": "^2.7.0",
"@embedpdf/plugin-viewport": "^2.7.0",
"@embedpdf/plugin-zoom": "^2.7.0",
"@embedpdf/core": "^2.8.0",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.8.0",
"@embedpdf/plugin-annotation": "^2.8.0",
"@embedpdf/plugin-attachment": "^2.8.0",
"@embedpdf/plugin-bookmark": "^2.8.0",
"@embedpdf/plugin-document-manager": "^2.8.0",
"@embedpdf/plugin-export": "^2.8.0",
"@embedpdf/plugin-history": "^2.8.0",
"@embedpdf/plugin-interaction-manager": "^2.8.0",
"@embedpdf/plugin-pan": "^2.8.0",
"@embedpdf/plugin-print": "^2.8.0",
"@embedpdf/plugin-redaction": "^2.8.0",
"@embedpdf/plugin-render": "^2.8.0",
"@embedpdf/plugin-rotate": "^2.8.0",
"@embedpdf/plugin-scroll": "^2.8.0",
"@embedpdf/plugin-search": "^2.8.0",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.8.0",
"@embedpdf/plugin-thumbnail": "^2.8.0",
"@embedpdf/plugin-tiling": "^2.8.0",
"@embedpdf/plugin-viewport": "^2.8.0",
"@embedpdf/plugin-zoom": "^2.8.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -576,14 +576,14 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.7.0.tgz",
"integrity": "sha512-dJ9pCWXVJxh6uSJP4sKuJP4v67+6Vlmw4WqJcv+CKJSUPdX9LhOSTIgTjwKLZ5zXo0c3DihNvrUupovb/DrhgQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.8.0.tgz",
"integrity": "sha512-ui0HR4fl7ndiGPw40kMBxXCO9gZHctV1u3Q+/XTd34ONYJ+Pa2LoWNVW2IuPDK7PgKzABPT2axntowlVLPP10g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "2.7.0",
"@embedpdf/models": "2.7.0"
"@embedpdf/engines": "2.8.0",
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -594,9 +594,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.7.0.tgz",
"integrity": "sha512-9BW/0f1EhMtklOXzpq+TtAELpXrvWcgCioFX9tSvPXBwWw/xva9c3UFx8NKehfrAkmcLGNz2c2Kqe8hDlKp9Rw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.8.0.tgz",
"integrity": "sha512-s749nppKxOcgvFraySKrwtiCt2VMXFe8TFuZUV5R7z8TtMagt6o5NOk6VsdvIpggUYxIsiKhLkFvAqvkNgcjng==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -606,8 +606,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.7.0",
"@embedpdf/pdfium": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/pdfium": "2.8.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -660,32 +660,32 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.7.0.tgz",
"integrity": "sha512-SRtDqYUn6Qw677ECIyipg0qqZR0+iLUU1mGI/X70ad4/qhw+TAOF5/aWddoFdPAw5XviEYVw5zR0oTz+MLBUbw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.8.0.tgz",
"integrity": "sha512-kk3Fm8exMmEX9Ce7VQePybmo04NQGdpsO3FsX1YOQqHpLVBk7tiTeOdetjBqI+YhQ2zWLa2naNKSOSGGzYLyxA==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.7.0.tgz",
"integrity": "sha512-PW6JnbBERvRKVrBzeCH/iGorQ8OTTRwr8rciyv2Kstqf3IdIFHjjNWvl6exK1/3K5wbbxYnHSNpRotl0OcsFJA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.8.0.tgz",
"integrity": "sha512-RlNLRNboF1Y6fNDy4sJ/a/FEYxATZyeM+n25r3KZJjG+RaM6bxBWXvWlFlGBU5Vx2eqQ5AzDAmIE9cn1agFmqA==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.7.0.tgz",
"integrity": "sha512-bkn9+91XfcFoKgwZ0kiJLbnvWNyrorhcbX5grsvUw2gJ8mL0RtwHSnCjZVT3WOPleQVwnNkBZUHefJhNT+LEKw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.8.0.tgz",
"integrity": "sha512-h31dT0pvQjFSwsBLytL4BBLf3WDdz9kmAYNKR10filikge7MpgTzgVYFD0C6AOyy2qK1Y/vqworCyh+emVD5aA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-history": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-selection": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-history": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-selection": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -694,15 +694,15 @@
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.7.0.tgz",
"integrity": "sha512-APH9Y/P4KyawckS68bouQ5aGsmQurgkdBG0oRq9kirDM+yIVGonS2P+gzwu9otgNKve880S5mBImC3FndJL4Tg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.8.0.tgz",
"integrity": "sha512-g2jCwjhQsij9zz2JOxZJkIeLTAUxiBKsFh6K4hcsG45ougw/mI3WCw1f+bZlAPkZuOWPo2/nthsHb+wAlrcykQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -711,15 +711,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.7.0.tgz",
"integrity": "sha512-um+naqyEAhBp4uFIis6K+V0t3dY4Dgry/vW17SwK9lFyMPQNCUT8PZMb7ysvj88vBXaDbzqCbtnlV6AyBEqxEQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.8.0.tgz",
"integrity": "sha512-ab49e17amEshweobU2GbtDEuRDHj89vRYiahkRq9nU1ACI16JIyh4t4fE/m/ucL9YhMMXldjwdtTGHceV566Jg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -728,15 +728,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.7.0.tgz",
"integrity": "sha512-eKM0MEUCPZmNkOkz5n6QWBcKN7m1c3RBZlfXB7faHBaScPyO3f+xgfipAht86UvfYwxT9BWk9ozbAtm2UPBHcQ==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.8.0.tgz",
"integrity": "sha512-SS+IKJ2+rk4dHM3PvQQrvnfNdb5oOF1INR5r2w0MKo22C9WPD8Ncg1Jak/mCrGbSrJemW3pkIrKZhoREQtSrbA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -745,15 +745,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.7.0.tgz",
"integrity": "sha512-wlP/ZdWPLCnJpsQWNCyZAlicvNiapbo7Dc95HMWHg2YgmkCjDe26z4C1QoubUanNSerGWJCK0qKXxJ/PVkJSnw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.8.0.tgz",
"integrity": "sha512-AvqfyhB58HLoZMKyXLLT+1ebE7wrMEnIAjr+OqEuaNnekxvb8atD8EBByzgtXLiNLStlxFn5g1CKyQThpw4Pcw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -762,16 +762,16 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.7.0.tgz",
"integrity": "sha512-k2c5M2Nwey+j6EtZyf5Sw+cuyNT4MTWdqPQGR9NbmZUiNnkSrg/TmPWZCyaXZKGRjLmoJocc0qRdyaZnaaTpqw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.8.0.tgz",
"integrity": "sha512-S6TO7DqMqVtBYsztgvPvq8BOJTMl8rWdGjVMuoxD93HZdSgogoculwCATrJGor/BC+X6Vmtaqg6NJWSdIAeBEQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -780,16 +780,16 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.7.0.tgz",
"integrity": "sha512-IfJiugr2k9VD8/gryMb22wC5yk4EQwSS3/Co0EdOhzkmkGSgUHA/KcG83WBu8wPEtj5Gwy473xe41fYKsGMlsA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.8.0.tgz",
"integrity": "sha512-xdRTAp1YiXWm+3WVqIN8dkRT3I/dHTumLJy5Kvt7lc1W2XM3M5bfCk5eTTcjY1DPv2buyt44i/4XNWoXAgBXDg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -798,17 +798,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.7.0.tgz",
"integrity": "sha512-/jUyM4K42gd7eoVsKFDSfN4t8CHIf6ZX7kU4BHBBFvOcNB7bToOvibPiznC4oPrHmOuzcs4e+VOfu+yHKn6DrA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.8.0.tgz",
"integrity": "sha512-EFqTEHk9E7AMRwguRO24jRl0J/5+pG07wlAm5U2rB0LDk30oAqVklFuDygaALdi8ZRBdkWOfD3Wl3gurQvfwAA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -817,15 +817,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.7.0.tgz",
"integrity": "sha512-sQqRSCN+r59EjKibQ3yMt1lKyHMxO1UqhZ6iI+w5hUBm9yTIHvkQRTVbawbkCfty8QNldjMQEwN3iYkf8LNHsg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.8.0.tgz",
"integrity": "sha512-PUdw2/1GwbewYrxVgsb+3lkYyYYcLU5qpazrBZjjx0v+SEpb2SMQOOPNUILeJGKbYUlxSz3qv/loFwqzALkUQA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -834,20 +834,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.7.0.tgz",
"integrity": "sha512-N6LD9k35ZCkpHPDW5I6pW6g/YTDXGuNp/kWTHU4bcRGfTwinu6i0PaFJQKus1t1pkVprhmjJgCXbkM2xmIKXAg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.8.0.tgz",
"integrity": "sha512-pIDFNd9rX7cwrhY6rFCBa5MTnGdLZHX7magToqna/Ffs1KEr0CfNF8jIXG0/E6KB6SsppbHwC7AkwRVWz8NoHg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-annotation": "2.7.0",
"@embedpdf/plugin-history": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/plugin-selection": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-annotation": "2.8.0",
"@embedpdf/plugin-history": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"@embedpdf/plugin-selection": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -856,16 +856,16 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.7.0.tgz",
"integrity": "sha512-qJcBxl2Kgqbw/Yb4qiX3qNsBKmzI8ijAKix3PoYL+nlhX6iFbcCkUbJgF0rPCBbhIy59Bpp7eJL+RyY5DhQvUg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.8.0.tgz",
"integrity": "sha512-jVGSuyg366LmFzbDpqszLbu3G6VOfv1u46D1C0ph6pL3jisTlRswRosaXS4eVE/fAYTryBrI0olCpVYYct4bQw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -874,15 +874,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.7.0.tgz",
"integrity": "sha512-ggqhQzpJB7ydfx9fDcuUrlGS9wxfsLG+rQwf1MMmUMMdEK1NPsHw5abSuQBerAm1vrpZ3Dyz0v2TxPVejvIhjA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.8.0.tgz",
"integrity": "sha512-lWATWEwhkBW77dJaKXlSJbqEMLTSy1lOhn0kDAZ5eyPZRo+7UWe7LHuT5HWzzRlbY9rpvnyixCT6Zp9rKT+WnQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -891,17 +891,17 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.7.0.tgz",
"integrity": "sha512-uEs5XdHZ3XqH3OWnM8E2eHen+7+MxK9SrlBnfM8ZcPkzHR9n7hT/Aqy05BiLKxKem585HvtbRgFzNQZqmNIGHw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.8.0.tgz",
"integrity": "sha512-l6hFH6lsAI+07ZGuOwbC8qcRNdYzWSIfRszjt9UmhKZbvXLEp6YJeS/XOUC/37Kqi30tsmLDyZhMW1AooGAr1A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -910,15 +910,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.7.0.tgz",
"integrity": "sha512-TeUMvlp6j4NXkkbPURSkg7fd6l/X8wUnN+ImFiM01VM+YBLQWTPXcP7AAyFzoI5iAh2m5+ZZEB/uGb5qm3Vamg==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.8.0.tgz",
"integrity": "sha512-HZ7munHdAF2pJ1cT02yc5I0Xcr4b4CQ8GkhD4qhTpZK0yga/tQiDY2Jjstygv7XiZwHIDKvqOuQsQmOXDsEemw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -927,18 +927,18 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.7.0.tgz",
"integrity": "sha512-MhTUHZ5jBh1jQU66xrtkx2jEtcreCfHy4zpmMY7iCoZtWUxrML+zf4jYNrR/ZP93O4i5HNBSnJNTG+RmRJ5KPw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.8.0.tgz",
"integrity": "sha512-DcPyOp2WKoVYVpbZIP5t+JsEmCL9Y7bxe8PmiEBtlm1lustuUezdWyA2G4wsGCR4I/5uNlY85qBGCBhL195sSA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0",
"@embedpdf/utils": "2.7.0"
"@embedpdf/models": "2.8.0",
"@embedpdf/utils": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-interaction-manager": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-interaction-manager": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -947,15 +947,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.7.0.tgz",
"integrity": "sha512-FMW6nXlyZhjQKrj8l4gduu0+IWI/H8Sda9iiKKYOGLrDWcIsSo3Gt3mSJgUsjUxA2YS+l0m48VeuSESa7Ab8oA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.8.0.tgz",
"integrity": "sha512-0Ld5HERaG8cKyWW7ktojG3FK6angkzYnBnw7Rnqf4cQuNdO7nKSTslyxkrLzF3vvmKrB//2ghm/pRVkgci4BOw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -964,16 +964,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.7.0.tgz",
"integrity": "sha512-ZnXt9ebtExFQUMUbWApUK8yC1eXYjDMEzS9631QpbLm0BhW9jYu9cs6U37Sww+zhYi7IFih4zb38lgAsbDgOcA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.8.0.tgz",
"integrity": "sha512-Dq/Cqsn4GClRXNWerqlXNH8WDWK/TtHxwq6yr2kqgXPo1FyREto4I7GfdO1hygTu0Dl6HK2auEVHBVC8QT53fw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-render": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-render": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -982,18 +982,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.7.0.tgz",
"integrity": "sha512-/Cd7dR0kA5/Y1gykYLYS3gecStycc4m5Jjm+a0mFzO/7yQ6BLb0g4zkM95YloBAsFoP5GPXKKhBw3pi32pTObw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.8.0.tgz",
"integrity": "sha512-gPe5mG6hyyruki1eSuQYD2KDbo0Z0TxzSk8TcHIdEYYF6NlI2OghuO8Vczz5WLU8clX8xzn8c/c+tQMHEXfbZA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-render": "2.7.0",
"@embedpdf/plugin-scroll": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-render": "2.8.0",
"@embedpdf/plugin-scroll": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1002,16 +1002,16 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.7.0.tgz",
"integrity": "sha512-nVDoU86CgHme7f2r7OFhziqHz1Yg8vdE2K+bXzRCmtZaubH+qVo5C+77at9brYCSinA67znQn1yy6zbfxs4FwA==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.8.0.tgz",
"integrity": "sha512-E16hc4yPA54XQGHp0Dy3OYyE8ilBaJE7LJirVmha4kMkP7XBu6xHNOJrXtq4GsZmdLQkf+x8ie2DDbWS+tcwnw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/core": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1020,17 +1020,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.7.0.tgz",
"integrity": "sha512-5u2cQcahHkbOfa5Ug5hxXwtIOz4TzrQSgvmXvO7+jV0jXKOp1vFVB9tU26SKwwwW9OL6e4L/P6roKJRPbOnWyw==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.8.0.tgz",
"integrity": "sha512-HepSQ7NFYhMsQsgfnC/G3b3LOYtNCWkCME0C0jBLrEYsgqsWozf97jIgzfOAeJsugyjQmojpgLUNt3xE9CtG6Q==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.7.0"
"@embedpdf/models": "2.8.0"
},
"peerDependencies": {
"@embedpdf/core": "2.7.0",
"@embedpdf/plugin-scroll": "2.7.0",
"@embedpdf/plugin-viewport": "2.7.0",
"@embedpdf/core": "2.8.0",
"@embedpdf/plugin-scroll": "2.8.0",
"@embedpdf/plugin-viewport": "2.8.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1039,9 +1039,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.7.0.tgz",
"integrity": "sha512-QtHOChToxkemBbJSwBTbgUqQbUO+obRj5HBaA/8KGv1ESCjtrdwjfAHMCBYdRuyvMVvrQGe1bHuSbKpb2JVS5w==",
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.8.0.tgz",
"integrity": "sha512-mt3DiQ8pnPk95q0zv7dXfN+y5fzJT2WtXyST8ziYEgmfhz0l2HT/MHCAwLzB3Whlnd2BdHThLfJyG1UD6pC84g==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
+23 -23
View File
@@ -8,29 +8,29 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.7.0",
"@embedpdf/engines": "^2.7.0",
"@embedpdf/models": "^2.7.0",
"@embedpdf/plugin-annotation": "^2.7.0",
"@embedpdf/plugin-attachment": "^2.7.0",
"@embedpdf/plugin-bookmark": "^2.7.0",
"@embedpdf/plugin-document-manager": "^2.7.0",
"@embedpdf/plugin-export": "^2.7.0",
"@embedpdf/plugin-history": "^2.7.0",
"@embedpdf/plugin-interaction-manager": "^2.7.0",
"@embedpdf/plugin-pan": "^2.7.0",
"@embedpdf/plugin-print": "^2.7.0",
"@embedpdf/plugin-redaction": "^2.7.0",
"@embedpdf/plugin-render": "^2.7.0",
"@embedpdf/plugin-rotate": "^2.7.0",
"@embedpdf/plugin-scroll": "^2.7.0",
"@embedpdf/plugin-search": "^2.7.0",
"@embedpdf/plugin-selection": "^2.7.0",
"@embedpdf/plugin-spread": "^2.7.0",
"@embedpdf/plugin-thumbnail": "^2.7.0",
"@embedpdf/plugin-tiling": "^2.7.0",
"@embedpdf/plugin-viewport": "^2.7.0",
"@embedpdf/plugin-zoom": "^2.7.0",
"@embedpdf/core": "^2.8.0",
"@embedpdf/engines": "^2.8.0",
"@embedpdf/models": "^2.8.0",
"@embedpdf/plugin-annotation": "^2.8.0",
"@embedpdf/plugin-attachment": "^2.8.0",
"@embedpdf/plugin-bookmark": "^2.8.0",
"@embedpdf/plugin-document-manager": "^2.8.0",
"@embedpdf/plugin-export": "^2.8.0",
"@embedpdf/plugin-history": "^2.8.0",
"@embedpdf/plugin-interaction-manager": "^2.8.0",
"@embedpdf/plugin-pan": "^2.8.0",
"@embedpdf/plugin-print": "^2.8.0",
"@embedpdf/plugin-redaction": "^2.8.0",
"@embedpdf/plugin-render": "^2.8.0",
"@embedpdf/plugin-rotate": "^2.8.0",
"@embedpdf/plugin-scroll": "^2.8.0",
"@embedpdf/plugin-search": "^2.8.0",
"@embedpdf/plugin-selection": "^2.8.0",
"@embedpdf/plugin-spread": "^2.8.0",
"@embedpdf/plugin-thumbnail": "^2.8.0",
"@embedpdf/plugin-tiling": "^2.8.0",
"@embedpdf/plugin-viewport": "^2.8.0",
"@embedpdf/plugin-zoom": "^2.8.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
+34 -1
View File
@@ -1503,8 +1503,9 @@ editSelected = "Edit Annotation"
editSquare = "Edit Square"
editStampHint = "To change the image, delete this stamp and add a new one."
editSwitchToSelect = "Switch to Select & Edit to edit this annotation."
editText = "Edit Text Box"
editText = "Edit"
editTextMarkup = "Edit Text Markup"
annotationStyle = "Annotation style"
ellipse = "Ellipse"
exit = "Exit annotation mode"
fillColor = "Fill Colour"
@@ -1515,13 +1516,19 @@ highlight = "Highlight"
imagePreview = "Preview"
inkHighlighter = "Freehand Highlighter"
line = "Line"
lineArrow = "Arrow"
noBackground = "No background"
note = "Note"
comment = "Comment"
comments = "Comments"
insertText = "Insert Text"
replaceText = "Replace Text"
noteIcon = "Note Icon"
notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
polyline = "Polyline"
properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
@@ -4463,6 +4470,7 @@ rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks"
toggleComments = "Comments"
print = "Print PDF"
ruler = "Ruler / Measure"
readAloud = "Read Aloud"
@@ -6914,6 +6922,31 @@ loading = "Loading attachments..."
empty = "No attachments in this document"
noMatch = "No attachments match your search"
[viewer.comments]
title = "Comments"
hint = "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page."
placeholder = "Type your comment..."
pageLabel = "Page {{page}}"
oneComment = "1 comment"
nComments = "{{count}} comments"
addCommentPlaceholder = "Add comment..."
addLink = "Add link"
goToLink = "Go to link"
addComment = "Add comment"
viewComment = "View comment"
addReplyPlaceholder = "Add reply..."
saveReply = "Save reply"
send = "Send"
moreActions = "More actions"
typeComment = "Comment"
typeInsertText = "Insert Text"
typeReplaceText = "Replace Text"
locateAnnotation = "Locate in document"
deleteTitle = "Remove annotation from comments?"
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
removeCommentOnly = "Remove comment only"
deleteAnnotationAndComment = "Delete annotation & comment"
[viewer.formBar]
title = "Form Fields"
unsavedBadge = "Unsaved"
@@ -1,5 +1,5 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core';
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import ColorizeIcon from '@mui/icons-material/Colorize';
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
@@ -20,6 +20,11 @@ interface ColorControlProps {
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates)
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
const [localColor, setLocalColor] = useState(value);
useEffect(() => { setLocalColor(value); }, [value]);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
@@ -56,7 +61,7 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
},
}}
>
<ColorSwatch color={value} size={18} />
<ColorSwatch color={localColor} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
@@ -64,8 +69,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={value}
onChange={onChange}
value={localColor}
onChange={setLocalColor}
onChangeEnd={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
@@ -76,7 +82,7 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={handleEyeDropper} style={{ color: 'var(--text-primary)' }}>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
@@ -1,17 +1,20 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch } from '@app/components/viewer/viewerTypes';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
type AnnotationType = 'text' | 'note' | 'shape';
export type PropertiesAnnotationType = 'text' | 'note' | 'shape';
interface PropertiesPopoverProps {
annotationType: AnnotationType;
annotation: any;
onUpdate: (patch: Record<string, any>) => void;
annotationType: PropertiesAnnotationType;
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
onUpdate: (patch: AnnotationPatch) => void;
disabled?: boolean;
}
@@ -24,7 +27,15 @@ export function PropertiesPopover({
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const obj = annotation?.object;
interface AnnotationObjectProps {
fontSize?: number;
textAlign?: number | string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
}
const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined;
// Get current values
const fontSize = obj?.fontSize ?? 14;
@@ -81,6 +81,18 @@ type AnnotationDefaults =
imageSize?: { width: number; height: number };
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.TEXT;
strokeColor?: string;
opacity?: number;
customData?: Record<string, unknown>;
}
| {
type: PdfAnnotationSubtype.CARET;
strokeColor?: string;
opacity?: number;
customData?: Record<string, unknown>;
}
| null;
type AnnotationApiSurface = {
@@ -294,6 +306,24 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
stamp: buildStampDefaults,
signatureStamp: buildStampDefaults,
signatureInk: (options) => buildInkDefaults(options),
textComment: (options) => ({
type: PdfAnnotationSubtype.TEXT,
strokeColor: options?.color ?? DEFAULTS.note,
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
insertText: (options) => ({
type: PdfAnnotationSubtype.CARET,
strokeColor: options?.color ?? '#E44234',
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
replaceText: (options) => ({
type: PdfAnnotationSubtype.CARET,
strokeColor: options?.color ?? '#E44234',
opacity: options?.opacity ?? 1,
...withCustomData(options),
}),
};
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
@@ -0,0 +1,156 @@
import { ActionIcon, Tooltip, Popover, TextInput, Button, Stack } from '@mantine/core';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import CommentIcon from '@mui/icons-material/ChatBubbleOutlineRounded';
import AddCommentIcon from '@mui/icons-material/AddCommentOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNewRounded';
import LocalIcon from '@app/components/shared/LocalIcon';
import type { FirstLinkTarget } from '@app/components/viewer/useAnnotationMenuHandlers';
export const commonButtonStyles = {
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)',
},
},
};
export function DeleteButton({ onDelete }: { onDelete: () => void }) {
const { t } = useTranslation();
return (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={onDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
export function EditTextButton({ onEdit }: { onEdit: () => void }) {
const { t } = useTranslation();
return (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onEdit} styles={commonButtonStyles}>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface AttachCommentButtonProps {
isInSidebar: boolean;
onView: () => void;
onAdd: () => void;
}
export function AttachCommentButton({ isInSidebar, onView, onAdd }: AttachCommentButtonProps) {
const { t } = useTranslation();
return (
<Tooltip label={isInSidebar ? t('viewer.comments.viewComment', 'View comment') : t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant={isInSidebar ? 'filled' : 'subtle'}
color={isInSidebar ? 'blue' : 'gray'}
size="md"
onClick={isInSidebar ? onView : onAdd}
styles={isInSidebar ? undefined : commonButtonStyles}
>
<AddCommentIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface CommentButtonProps {
hasContent: boolean;
onClick: () => void;
}
export function CommentButton({ hasContent, onClick }: CommentButtonProps) {
const { t } = useTranslation();
return (
<Tooltip label={hasContent ? t('viewer.comments.viewComment', 'View comment') : t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onClick} styles={commonButtonStyles}>
<CommentIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
interface LinkButtonProps {
firstLinkTarget: FirstLinkTarget | null;
onGoToLink: () => void;
onAddLink: (url: string) => void;
}
export function LinkButton({ firstLinkTarget, onGoToLink, onAddLink }: LinkButtonProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [url, setUrl] = useState('');
if (firstLinkTarget) {
return (
<Tooltip label={t('viewer.comments.goToLink', 'Go to link')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={onGoToLink} styles={commonButtonStyles}>
<OpenInNewIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
}
return (
<Popover opened={open} onClose={() => setOpen(false)} position="top">
<Popover.Target>
<Tooltip label={t('viewer.comments.addLink', 'Add link')}>
<ActionIcon variant="subtle" color="gray" size="md" onClick={() => setOpen((o) => !o)} styles={commonButtonStyles}>
<LocalIcon icon="link" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<TextInput
placeholder="https://..."
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
size="sm"
style={{ minWidth: 220 }}
/>
<Button
size="xs"
disabled={!url.trim()}
onClick={() => {
onAddLink(url);
setUrl('');
setOpen(false);
}}
>
{t('viewer.comments.addLink', 'Add link')}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,18 +1,14 @@
import { ActionIcon, Tooltip, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Group } from '@mantine/core';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationPatch, AnnotationObject } from '@app/components/viewer/viewerTypes';
import { PdfAnnotationSubtype, type PdfAnnotationObject } from '@embedpdf/models';
import type { AnnotationObject } from '@app/components/viewer/viewerTypes';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAnnotationMenuHandlers } from '@app/components/viewer/useAnnotationMenuHandlers';
import { AnnotationTypeButtons } from '@app/components/viewer/AnnotationTypeButtons';
/**
* Props interface matching EmbedPDF's annotation selection menu pattern
@@ -34,22 +30,10 @@ export interface AnnotationSelectionMenuProps {
export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
const activeDocumentId = useActiveDocumentId();
// Don't render until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return (
<AnnotationSelectionMenuInner
documentId={activeDocumentId}
{...props}
/>
);
if (!activeDocumentId) return null;
return <AnnotationSelectionMenuInner documentId={activeDocumentId} {...props} />;
}
type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
function AnnotationSelectionMenuInner({
documentId,
context,
@@ -58,366 +42,56 @@ function AnnotationSelectionMenuInner({
}: AnnotationSelectionMenuProps & { documentId: string }) {
const annotation = context?.annotation;
const pageIndex = context?.pageIndex;
const { t } = useTranslation();
const { provides } = useAnnotation(documentId);
const { scrollActions, requestCommentFocus } = useViewer();
const wrapperRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
// Merge refs - menuWrapperProps.ref is a callback ref
const handlers = useAnnotationMenuHandlers({
annotation,
pageIndex,
documentId,
provides,
scrollActions,
requestCommentFocus,
wrapperRef,
});
// Auto-open the comments sidebar when a comment annotation is selected
useEffect(() => {
const annObj = annotation?.object as AnnotationObject | undefined;
const annId = annObj?.id;
if (!selected || !annId || pageIndex === undefined) return;
const toolId = annObj?.customData?.toolId;
const annType = annObj?.type;
const isComment =
(annType === PdfAnnotationSubtype.TEXT && toolId === 'textComment') ||
(annType === PdfAnnotationSubtype.CARET && (toolId === 'insertText' || toolId === 'replaceText'));
if (!isComment) return;
requestCommentFocus(documentId, pageIndex, annId, (annObj?.contents ?? '').trim().length > 0);
}, [selected, annotation?.object]);
// Click outside to deselect
useEffect(() => {
if (!selected) return;
const handlePointerDown = (e: PointerEvent) => {
const target = e.target as HTMLElement;
if (target.closest('[data-annotation-selection-menu]')) return;
if (target.closest('[data-no-interaction]')) return;
if (target.closest('.mantine-Popover-dropdown')) return;
provides?.deselectAnnotation?.();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [selected, provides]);
// Merge refs — menuWrapperProps.ref is a callback ref from EmbedPDF
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
// Call the EmbedPDF ref callback
menuWrapperProps?.ref?.(node);
}, [menuWrapperProps]);
// Type detection
const getAnnotationType = useCallback((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
// Map type numbers to categories
if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) {
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
}
if (type === 3) {
return toolId === 'note' ? 'note' : 'text';
}
if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
if (type !== undefined && [4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
// Calculate menu width based on annotation type
const calculateWidth = (annotationType: AnnotationType): number => {
switch (annotationType) {
case 'stamp':
return 80;
case 'inkHighlighter':
return 220;
case 'shape':
return 200;
default:
return 180;
}
};
// Get annotation properties
const obj = annotation?.object as AnnotationObject | undefined;
const annotationType = getAnnotationType();
const annotationId = obj?.id;
// Get current colors
const getCurrentColor = (): string => {
if (!obj) return '#000000';
const type = obj.type;
// Text annotations use textColor
if (type === 3) return obj.textColor || obj.color || '#000000';
// Shape annotations use strokeColor
if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
// Default to color property
return obj.color || obj.strokeColor || '#000000';
};
const getStrokeColor = (): string => {
return obj?.strokeColor || obj?.color || '#000000';
};
const getFillColor = (): string => {
return obj?.color || obj?.fillColor || '#0000ff';
};
const getBackgroundColor = (): string => {
// Check multiple possible properties for background color
return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
};
const getTextColor = (): string => {
return obj?.textColor || obj?.color || '#000000';
};
const getOpacity = (): number => {
return Math.round((obj?.opacity ?? 1) * 100);
};
const getWidth = (): number => {
return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
};
// Handlers
const handleDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
// Focus inline text input (same as double-clicking the note/text box). Dispatches dblclick on the
// annotation hit-area div (EmbedPDF's inner div with onDoubleClick) so built-in FreeText editing is used.
const handleFocusTextEdit = useCallback(() => {
const root = wrapperRef.current?.closest('[data-no-interaction]');
const main = root?.firstElementChild;
// EmbedPDF puts onDoubleClick on the content div. For text/note (no rotation) it's the first child.
const hitArea = main?.lastElementChild ?? main?.firstElementChild;
if (!hitArea) return;
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
}, []);
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: AnnotationPatch = {};
if (target === 'stroke') {
// Shape stroke - preserve fill color
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff'; // Preserve fill
patch.strokeWidth = getWidth();
} else if (target === 'fill') {
// Shape fill - preserve stroke color
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
patch.strokeWidth = getWidth();
} else if (target === 'background') {
// Background color for text/note - set multiple properties for compatibility
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
// Text color for text/note - TRY PROPERTY COMBINATIONS
patch.textColor = color;
patch.fontColor = color; // EmbedPDF might expect this instead
// Include font metadata (EmbedPDF might require these together)
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
// Re-submit text content
patch.contents = obj?.contents ?? '';
} else {
// Main color - for highlights, ink, etc.
patch.color = color;
// For text markup annotations (highlight, underline, strikeout, squiggly)
if (type !== undefined && [9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
// For line annotations (type 4, 8), include stroke properties
if (type !== undefined && [4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
// For ink annotations (type 15), include all stroke-related properties
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj]);
const handleOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
opacity: opacity / 100,
});
}, [provides, annotationId, pageIndex]);
const handleWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
strokeWidth: width,
});
}, [provides, annotationId, pageIndex]);
const handlePropertiesUpdate = useCallback((patch: Record<string, any>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
// Render button groups based on annotation type
const renderButtons = () => {
const commonButtonStyles = {
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)',
},
},
};
const EditTextButton = () => (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={handleFocusTextEdit}
styles={commonButtonStyles}
>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
const DeleteButton = () => (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={handleDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
switch (annotationType) {
case 'textMarkup':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'ink':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'inkHighlighter':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'text':
case 'note':
return (
<>
<ColorControl
value={getTextColor()}
onChange={(color) => handleColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={getBackgroundColor()}
onChange={(color) => handleColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton />
<PropertiesPopover
annotationType={annotationType}
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'shape':
return (
<>
<ColorControl
value={getStrokeColor()}
onChange={(color) => handleColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={getFillColor()}
onChange={(color) => handleColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'line':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'stamp':
return <DeleteButton />;
default:
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton />
</>
);
}
};
// Calculate position for portal based on wrapper element
// Track menu position via MutationObserver (handles drag repositioning)
useEffect(() => {
if (!selected || !annotation || !wrapperRef.current) {
setMenuPosition(null);
@@ -426,24 +100,15 @@ function AnnotationSelectionMenuInner({
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
setMenuPosition({
top: wrapperRect.bottom + 8,
left: wrapperRect.left + wrapperRect.width / 2,
});
if (!wrapper) { setMenuPosition(null); return; }
const rect = wrapper.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 8, left: rect.left + rect.width / 2 });
};
updatePosition();
// MutationObserver catches EmbedPDF updating the wrapper's inline style during drag
const observer = new MutationObserver(updatePosition);
observer.observe(wrapperRef.current, { attributes: true, attributeFilter: ['style'] });
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
@@ -452,9 +117,8 @@ function AnnotationSelectionMenuInner({
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, annotation]);
}, [selected]);
// Early return AFTER all hooks have been called
if (!selected || !annotation) return null;
const menuContent = menuPosition ? (
@@ -466,39 +130,34 @@ function AnnotationSelectionMenuInner({
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
zIndex: 10000,
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)',
fontSize: '14px',
minWidth: `${calculateWidth(annotationType)}px`,
minWidth: `${handlers.menuWidth}px`,
transition: 'min-width 0.2s ease',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
{renderButtons()}
<AnnotationTypeButtons
{...handlers}
annotation={annotation}
documentId={documentId}
pageIndex={pageIndex}
annotationId={handlers.annotationId}
/>
</Group>
</div>
) : null;
return (
<>
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps.
Must stay pointerEvents:none so EmbedPDF's internal drag handlers receive events.
Edit Text button dispatches dblclick on this wrapper's parent to use EmbedPDF's built-in inline editing. */}
<div
ref={setRef}
style={{
...menuWrapperProps?.style,
opacity: 0,
pointerEvents: 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
{/* Invisible wrapper for EmbedPDF positioning — pointer-events:none so drag still works */}
<div ref={setRef} style={{ ...menuWrapperProps?.style, opacity: 0, pointerEvents: 'none' }} />
{typeof document !== 'undefined' && menuContent ? createPortal(menuContent, document.body) : null}
</>
);
}
@@ -0,0 +1,201 @@
import { useTranslation } from 'react-i18next';
import type { TrackedAnnotation } from '@embedpdf/plugin-annotation';
import type { PdfAnnotationObject } from '@embedpdf/models';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover, type PropertiesAnnotationType } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
import {
DeleteButton,
EditTextButton,
AttachCommentButton,
CommentButton,
LinkButton,
} from '@app/components/viewer/AnnotationMenuButtons';
import type { AnnotationType, AnnotationMenuState, AnnotationMenuHandlers } from '@app/components/viewer/useAnnotationMenuHandlers';
export interface AnnotationTypeButtonsProps extends AnnotationMenuState, AnnotationMenuHandlers {
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
documentId: string;
pageIndex: number | undefined;
annotationId: string | undefined;
}
export function AnnotationTypeButtons(props: AnnotationTypeButtonsProps) {
const { t } = useTranslation();
const {
annotationType,
currentColor,
strokeColor,
fillColor,
backgroundColor,
textColor,
currentOpacity,
currentWidth,
hasCommentContent,
isInSidebar,
firstLinkTarget,
obj,
annotation,
onDelete,
onEdit,
onColorChange,
onOpacityChange,
onWidthChange,
onPropertiesUpdate,
onGoToLink,
onAddLink,
onAddToSidebar,
onViewComment,
onCommentColorChange,
} = props;
const attachCommentButton = (
<AttachCommentButton isInSidebar={isInSidebar} onView={onViewComment} onAdd={onAddToSidebar} />
);
switch (annotationType as AnnotationType) {
case 'textMarkup':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={currentOpacity} onChange={onOpacityChange} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'ink':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={12} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'inkHighlighter':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={20} />
<OpacityControl value={currentOpacity} onChange={onOpacityChange} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'text':
case 'note':
return (
<>
{attachCommentButton}
<ColorControl
value={textColor}
onChange={(color) => onColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={backgroundColor}
onChange={(color) => onColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton onEdit={onEdit} />
<PropertiesPopover
annotationType={annotationType as PropertiesAnnotationType}
annotation={annotation}
onUpdate={onPropertiesUpdate}
/>
<DeleteButton onDelete={onDelete} />
</>
);
case 'comment':
return (
<>
<CommentButton hasContent={hasCommentContent} onClick={onViewComment} />
<EditTextButton onEdit={onEdit} />
<ColorControl
value={obj?.strokeColor || obj?.color || '#ffa000'}
onChange={onCommentColorChange}
label={t('annotation.annotationStyle', 'Annotation style')}
/>
<LinkButton firstLinkTarget={firstLinkTarget} onGoToLink={onGoToLink} onAddLink={onAddLink} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'shape':
return (
<>
{attachCommentButton}
<ColorControl
value={strokeColor}
onChange={(color) => onColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={fillColor}
onChange={(color) => onColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={onPropertiesUpdate}
/>
<DeleteButton onDelete={onDelete} />
</>
);
case 'line':
return (
<>
{attachCommentButton}
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={currentWidth} onChange={onWidthChange} min={1} max={12} />
<DeleteButton onDelete={onDelete} />
</>
);
case 'stamp':
return (
<>
{attachCommentButton}
<DeleteButton onDelete={onDelete} />
</>
);
default:
return (
<>
{attachCommentButton}
<CommentButton hasContent={hasCommentContent} onClick={onViewComment} />
<LinkButton firstLinkTarget={firstLinkTarget} onGoToLink={onGoToLink} onAddLink={onAddLink} />
<ColorControl
value={currentColor}
onChange={(color) => onColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton onDelete={onDelete} />
</>
);
}
}
@@ -11,6 +11,7 @@ import '@app/components/viewer/AttachmentSidebar.css';
interface AttachmentSidebarProps {
visible: boolean;
thumbnailVisible: boolean;
bookmarkVisible: boolean;
documentCacheKey?: string;
preloadCacheKeys?: string[];
}
@@ -32,7 +33,7 @@ const createEntry = (overrides: Partial<AttachmentCacheEntry> = {}): AttachmentC
...overrides,
});
export const AttachmentSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: AttachmentSidebarProps) => {
export const AttachmentSidebar = ({ visible, thumbnailVisible, bookmarkVisible, documentCacheKey, preloadCacheKeys = [] }: AttachmentSidebarProps) => {
const { t } = useTranslation();
const { attachmentActions, hasAttachmentSupport } = useViewer();
const [searchTerm, setSearchTerm] = useState('');
@@ -286,7 +287,7 @@ export const AttachmentSidebar = ({ visible, thumbnailVisible, documentCacheKey,
className="attachment-sidebar"
style={{
position: 'fixed',
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
right: `${(thumbnailVisible ? 15 : 0) + (bookmarkVisible ? 15 : 0)}rem`,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
@@ -0,0 +1,704 @@
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { Box, ScrollArea, Text, Textarea, Stack, ActionIcon, Group, Tooltip, TextInput, Menu, Modal, Button, UnstyledButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckIcon from '@mui/icons-material/CheckRounded';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import EditIcon from '@mui/icons-material/Edit';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import { getSidebarAnnotationsWithRepliesGroupedByPage } from '@embedpdf/plugin-annotation';
import { PdfAnnotationSubtype, PdfAnnotationReplyType } from '@embedpdf/models';
import { useCommentAuthor } from '@app/contexts/CommentAuthorContext';
import { useViewer } from '@app/contexts/ViewerContext';
import LocalIcon from '@app/components/shared/LocalIcon';
const SIDEBAR_WIDTH = '18rem';
/** Format annotation date for display (e.g. "Mar 11, 6:05 PM"). */
function formatCommentDate(obj: any): string {
const raw = obj?.modifiedDate ?? obj?.creationDate ?? obj?.customData?.modifiedDate ?? obj?.M;
if (raw == null) return '';
const d = raw instanceof Date ? raw : new Date(raw);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
interface CommentsSidebarProps {
documentId: string;
visible: boolean;
rightOffset: string;
}
function getCommentDisplayContent(entry: { annotation: { object: any }; replies: Array<{ object: any }> }): string {
const main = entry.annotation?.object?.contents;
if (main != null && String(main).trim()) return String(main).trim();
const firstReply = entry.replies?.[0]?.object?.contents;
if (firstReply != null && String(firstReply).trim()) return String(firstReply).trim();
return '';
}
/** Placeholder authors we never show; use current user's name from context instead. */
const PLACEHOLDER_AUTHORS = new Set(['Guest', 'Digital Signature', '']);
function getAuthorName(obj: any, currentDisplayName: string): string {
const stored = (obj?.author ?? 'Guest').trim() || 'Guest';
if (PLACEHOLDER_AUTHORS.has(stored)) return currentDisplayName || 'Guest';
return stored;
}
/** Replies store an explicit author; only allow edit when it matches the current comment author name. */
function isReplyAuthoredByCurrentUser(obj: any, currentDisplayName: string): boolean {
const stored = (obj?.author ?? '').trim() || 'Guest';
// Resolve current user the same way getAuthorName does
const resolvedMine = PLACEHOLDER_AUTHORS.has((currentDisplayName ?? '').trim())
? 'Guest'
: (currentDisplayName ?? '').trim();
const resolvedStored = PLACEHOLDER_AUTHORS.has(stored) ? 'Guest' : stored;
// Both are guest/anonymous → same unauthenticated user, allow editing
if (resolvedStored === 'Guest' && resolvedMine === 'Guest') return true;
if (!resolvedMine) return false;
return resolvedStored === resolvedMine;
}
// Map toolId → LocalIcon icon name (matches AnnotationPanel icon definitions)
const TOOL_ICON_MAP: Record<string, string> = {
highlight: 'highlight',
underline: 'format-underlined',
strikeout: 'strikethrough-s',
squiggly: 'show-chart',
ink: 'edit',
inkHighlighter: 'brush',
square: 'crop-square',
circle: 'radio-button-unchecked',
line: 'show-chart',
lineArrow: 'show-chart',
polyline: 'show-chart',
polygon: 'change-history',
text: 'text-fields',
note: 'sticky-note-2',
stamp: 'add-photo-alternate',
textComment: 'comment',
insertText: 'add-comment',
replaceText: 'find-replace',
};
// Type-based fallback icon when no toolId is present
function getIconByType(type: number | undefined): string {
if (type === 1) return 'comment';
if (type === 3) return 'sticky-note-2';
if (type === 4 || type === 8) return 'show-chart';
if (type === 5) return 'crop-square';
if (type === 6) return 'radio-button-unchecked';
if (type === 7 || type === 8) return 'change-history';
if (type === 9) return 'highlight';
if (type === 10) return 'format-underlined';
if (type === 11) return 'show-chart';
if (type === 12) return 'strikethrough-s';
if (type === 13) return 'add-photo-alternate';
if (type === 14) return 'add-comment';
if (type === 15) return 'edit';
return 'comment';
}
function isCommentAnnotation(ann: any): boolean {
const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId;
if (toolId === 'textComment' || toolId === 'insertText' || toolId === 'replaceText') return true;
// Any annotation explicitly added to comments via the "Add comment" button
if (ann?.customData?.isComment === true) return true;
// CARET (type 14) = insertText/replaceText; TEXT (type 1) = textComment
if (!toolId && (ann?.type === 14 || ann?.type === 1)) return true;
return false;
}
function getAnnotationToolId(ann: any): string {
return ann?.customData?.toolId ?? ann?.customData?.annotationToolId ?? '';
}
function getAnnotationTypeLabel(ann: any, t: (key: string, fallback: string) => string): string {
const toolId = getAnnotationToolId(ann);
const labels: Record<string, string> = {
highlight: t('annotation.highlight', 'Highlight'),
underline: t('annotation.underline', 'Underline'),
strikeout: t('annotation.strikeout', 'Strikeout'),
squiggly: t('annotation.squiggly', 'Squiggly'),
ink: t('annotation.pen', 'Pen'),
inkHighlighter: t('annotation.freehandHighlighter', 'Freehand Highlighter'),
square: t('annotation.square', 'Square'),
circle: t('annotation.circle', 'Circle'),
line: t('annotation.line', 'Line'),
lineArrow: t('annotation.lineArrow', 'Arrow'),
polyline: t('annotation.polyline', 'Polyline'),
polygon: t('annotation.polygon', 'Polygon'),
text: t('annotation.text', 'Text box'),
note: t('annotation.note', 'Note'),
stamp: t('annotation.stamp', 'Stamp'),
textComment: t('viewer.comments.typeComment', 'Comment'),
insertText: t('viewer.comments.typeInsertText', 'Insert Text'),
replaceText: t('viewer.comments.typeReplaceText', 'Replace Text'),
};
if (labels[toolId]) return labels[toolId];
// Type-based fallback (mirrors getIconByType) for annotations without customData.toolId
const type = ann?.type;
if (type === 14) return t('viewer.comments.typeInsertText', 'Insert Text');
if (type === 1) return t('viewer.comments.typeComment', 'Comment');
return t('viewer.comments.typeComment', 'Comment');
}
function AnnotationTypeIcon({ ann }: { ann: any }) {
const toolId = getAnnotationToolId(ann);
const iconName = TOOL_ICON_MAP[toolId] ?? getIconByType(ann?.type);
return (
<LocalIcon
icon={iconName}
width="1.25rem"
height="1.25rem"
style={{ flexShrink: 0, color: 'var(--mantine-color-blue-5)' }}
/>
);
}
export function CommentsSidebar({ documentId, visible, rightOffset }: CommentsSidebarProps) {
const { t } = useTranslation();
const { displayName } = useCommentAuthor();
const { highlightCommentRequest, clearHighlightCommentRequest, scrollActions, getZoomState } = useViewer() ?? {};
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const { state, provides } = useAnnotation(documentId);
const [draftContents, setDraftContents] = useState<Record<string, string>>({});
const [replyDrafts, setReplyDrafts] = useState<Record<string, string>>({});
/** Draft text while editing an existing reply (`${pageIndex}_${parentId}_${replyId}`). */
const [replyEditDrafts, setReplyEditDrafts] = useState<Record<string, string>>({});
/** When set, this card's main comment is in edit mode (show textarea for main comment). */
const [editingMainKey, setEditingMainKey] = useState<string | null>(null);
/** Which reply is in edit mode (same key shape as replyEditDrafts). */
const [editingReplyKey, setEditingReplyKey] = useState<string | null>(null);
// React to request to focus or highlight a comment card (e.g. from "Add comment" / "View comment" in selection menu)
useEffect(() => {
if (!visible || !highlightCommentRequest || highlightCommentRequest.documentId !== documentId) return;
const { pageIndex, annotationId, action } = highlightCommentRequest;
const cardKey = `${pageIndex}_${annotationId}`;
const root = scrollViewportRef.current;
if (!root) return;
const card = root.querySelector<HTMLElement>(`[data-comment-card="${cardKey}"]`);
if (!card) {
clearHighlightCommentRequest?.();
return;
}
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (action === 'highlight') {
card.classList.remove('comment-card-flash-highlight');
void card.offsetWidth;
card.classList.add('comment-card-flash-highlight');
const tId = window.setTimeout(() => {
card.classList.remove('comment-card-flash-highlight');
clearHighlightCommentRequest?.();
}, 1500);
return () => window.clearTimeout(tId);
}
// action === 'focus': focus the first textarea or reply input in the card
const input = card.querySelector<HTMLTextAreaElement | HTMLInputElement>('textarea, input');
if (input) {
requestAnimationFrame(() => {
input.focus();
});
}
clearHighlightCommentRequest?.();
}, [visible, highlightCommentRequest, documentId, clearHighlightCommentRequest]);
const handleLocateAnnotation = useCallback((pageIndex: number, ann: any) => {
scrollActions?.scrollToPage(pageIndex + 1, 'smooth');
setTimeout(() => {
const pageEl = document.querySelector<HTMLElement>(`[data-page-index="${pageIndex}"]`);
if (!pageEl || !ann?.rect) return;
const zoom = getZoomState?.()?.currentZoom ?? 1;
const { origin, size } = ann.rect as { origin: { x: number; y: number }; size: { width: number; height: number } };
const flashEl = document.createElement('div');
// Append to page element so it scrolls with the page (position: absolute relative to page)
flashEl.style.cssText = `
position: absolute;
left: ${origin.x * zoom}px;
top: ${origin.y * zoom}px;
width: ${size.width * zoom}px;
height: ${size.height * zoom}px;
background: rgba(255, 213, 0, 0.55);
border: 2px solid rgba(255, 170, 0, 0.8);
border-radius: 3px;
pointer-events: none;
z-index: 9998;
animation: annotation-locate-flash 1.6s ease-out forwards;
`;
if (!document.getElementById('annotation-locate-flash-style')) {
const style = document.createElement('style');
style.id = 'annotation-locate-flash-style';
style.textContent = `@keyframes annotation-locate-flash {
0% { opacity: 0; transform: scale(1.08); }
15% { opacity: 1; transform: scale(1); }
70% { opacity: 1; }
100% { opacity: 0; }
}`;
document.head.appendChild(style);
}
pageEl.appendChild(flashEl);
setTimeout(() => flashEl.remove(), 1700);
}, 550);
}, [scrollActions, getZoomState]);
const byPage = useMemo(() => {
try {
const all = getSidebarAnnotationsWithRepliesGroupedByPage(state) ?? {};
const filtered: typeof all = {};
for (const [page, entries] of Object.entries(all)) {
const commentEntries = (entries as typeof entries).filter(
(e) => isCommentAnnotation((e as any).annotation?.object)
);
if (commentEntries.length > 0) {
filtered[Number(page)] = commentEntries;
}
}
return filtered;
} catch {
return {};
}
}, [state]);
// Derive the set of selected annotation IDs from EmbedPDF's selection state.
// state is AnnotationDocumentState — selectedUids are keys in byUid, and may equal id.
const selectedAnnotationIds = useMemo(() => {
const selectedUids: string[] = state?.selectedUids ?? [];
const byUid: Record<string, any> = (state as any)?.byUid ?? {};
const ids = new Set<string>();
for (const uid of selectedUids) {
// uid itself may be the annotation id
ids.add(uid);
const annId = byUid[uid]?.object?.id;
if (annId) ids.add(annId);
}
return ids;
}, [state]);
const pageNumbers = useMemo(() => Object.keys(byPage).map(Number).sort((a, b) => a - b), [byPage]);
const totalCount = useMemo(() => pageNumbers.reduce((sum, p) => sum + (byPage[p]?.length ?? 0), 0), [pageNumbers, byPage]);
const handleContentsChange = useCallback(
(pageIndex: number, annotationId: string, value: string) => {
setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: value }));
if (!provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, annotationId, { contents: value });
},
[provides]
);
const [deleteModal, setDeleteModal] = useState<{ pageIndex: number; id: string; ann: any } | null>(null);
const isLinkedAnnotation = (ann: any) => {
const toolId = ann?.customData?.toolId ?? ann?.customData?.annotationToolId;
return ann?.customData?.isComment === true &&
toolId !== 'textComment' && toolId !== 'insertText' && toolId !== 'replaceText';
};
const handleDeleteClick = useCallback(
(pageIndex: number, annotationId: string, ann: any) => {
if (isLinkedAnnotation(ann)) {
setDeleteModal({ pageIndex, id: annotationId, ann });
} else {
provides?.deleteAnnotation?.(pageIndex, annotationId);
}
},
[provides]
);
const handleRemoveFromSidebar = useCallback(() => {
if (!deleteModal || !provides?.updateAnnotation) return;
const { pageIndex, id, ann } = deleteModal;
const existing = (ann?.customData ?? {}) as Record<string, unknown>;
const { isComment: _removed, ...rest } = existing;
provides.updateAnnotation(pageIndex, id, { customData: rest } as any);
setDeleteModal(null);
}, [deleteModal, provides]);
const handleDeleteAnnotation = useCallback(() => {
if (!deleteModal) return;
provides?.deleteAnnotation?.(deleteModal.pageIndex, deleteModal.id);
setDeleteModal(null);
}, [deleteModal, provides]);
const handleSendMainComment = useCallback(
(pageIndex: number, annotationId: string, value: string) => {
const trimmed = value.trim();
if (!trimmed || !provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, annotationId, { contents: trimmed, author: displayName });
setDraftContents((prev) => ({ ...prev, [pageIndex + '_' + annotationId]: trimmed }));
},
[provides, displayName]
);
const handleSendReply = useCallback(
(pageIndex: number, parentId: string, parentRect: any) => {
const key = `${pageIndex}_${parentId}_reply`;
const text = replyDrafts[key]?.trim();
if (!text || !provides?.createAnnotation) return;
const rect = parentRect ?? { origin: { x: 0, y: 0 }, size: { width: 1, height: 1 } };
provides.createAnnotation(pageIndex, {
type: PdfAnnotationSubtype.TEXT,
id: `reply-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
pageIndex,
rect,
contents: text,
inReplyToId: parentId,
replyType: PdfAnnotationReplyType.Reply,
author: displayName,
} as any);
setReplyDrafts((prev) => ({ ...prev, [key]: '' }));
},
[provides, replyDrafts, displayName]
);
const handleSaveReplyEdit = useCallback(
(editKey: string, pageIndex: number, replyId: string, value: string) => {
const trimmed = value.trim();
if (!trimmed || !provides?.updateAnnotation) return;
provides.updateAnnotation(pageIndex, replyId, { contents: trimmed, author: displayName });
setReplyEditDrafts((prev) => {
const next = { ...prev };
delete next[editKey];
return next;
});
setEditingReplyKey(null);
},
[provides, displayName]
);
if (!visible) return null;
return (
<Box
ref={scrollViewportRef}
style={{
position: 'fixed',
right: rightOffset,
top: 0,
bottom: 0,
width: SIDEBAR_WIDTH,
backgroundColor: 'var(--right-rail-bg)',
borderLeft: '1px solid var(--border-subtle)',
zIndex: 998,
display: 'flex',
flexDirection: 'column',
boxShadow: '-2px 0 8px rgba(0, 0, 0, 0.1)',
}}
>
<div
style={{
padding: '0.75rem 1rem',
borderBottom: '1px solid var(--border-subtle)',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
}}
>
<LocalIcon icon="comment" width="1.25rem" height="1.25rem" style={{ color: 'var(--mantine-color-dimmed)', flexShrink: 0 }} />
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
{t('viewer.comments.title', 'Comments')}
</Text>
</div>
<ScrollArea style={{ flex: 1 }}>
<Stack p="sm" gap="md">
{totalCount === 0 ? (
<Text size="sm" c="dimmed">
{t('viewer.comments.hint', 'Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.')}
</Text>
) : (
pageNumbers.map((pageIndex) => {
const entries = byPage[pageIndex] ?? [];
const pageNum = pageIndex + 1;
return (
<Box key={pageIndex} mb="md">
<Text size="sm" fw={700} mb={2}>
{t('viewer.comments.pageLabel', 'Page {{page}}', { page: pageNum })}
</Text>
<Text size="xs" c="dimmed" mb="sm">
{entries.length === 1
? t('viewer.comments.oneComment', '1 comment')
: t('viewer.comments.nComments', '{{count}} comments', { count: entries.length })}
</Text>
<Box
mb="xs"
style={{
borderBottom: '1px solid var(--border-subtle)',
}}
/>
<Stack gap="sm">
{entries.map((entry) => {
const ann = entry.annotation?.object;
const id = ann?.id;
if (!id) return null;
const key = `${pageIndex}_${id}`;
const replyKey = `${pageIndex}_${id}_reply`;
const displayContent = getCommentDisplayContent(entry);
const draft = draftContents[key] !== undefined ? draftContents[key] : displayContent;
const replyDraft = replyDrafts[replyKey] ?? '';
const authorName = getAuthorName(ann, displayName);
/** Only treat as "comment posted" when annotation actually has content (user clicked Send), not on every keystroke. */
const hasMainContent = (displayContent ?? '').trim().length > 0;
const isEditingMain = editingMainKey === key;
const mainTimestamp = formatCommentDate(ann);
const typeLabel = getAnnotationTypeLabel(ann, t);
return (
<Box
key={key}
data-comment-card={key}
p="sm"
style={{
border: selectedAnnotationIds.has(id)
? '1px solid var(--mantine-color-blue-3)'
: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-raised)',
}}
>
<Group wrap="nowrap" gap="xs" justify="space-between" align="flex-start" mb="xs">
<Group wrap="nowrap" gap="xs" style={{ minWidth: 0, flex: 1 }}>
<AnnotationTypeIcon ann={ann} />
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{authorName}
</Text>
<Text size="xs" c="dimmed">
{typeLabel}{mainTimestamp ? ` · ${mainTimestamp}` : ''}
</Text>
</Box>
</Group>
<Group gap={2} wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={t('viewer.comments.locateAnnotation', 'Locate in document')}>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={() => handleLocateAnnotation(pageIndex, ann)}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
<Menu position="bottom-end" withArrow>
<Menu.Target>
<Tooltip label={t('viewer.comments.moreActions', 'More actions')}>
<ActionIcon variant="subtle" size="sm" color="gray">
<MoreHorizIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 18 }} />}
onClick={() => setEditingMainKey(key)}
>
{t('annotation.editText', 'Edit')}
</Menu.Item>
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 18 }} />}
color="red"
onClick={() => handleDeleteClick(pageIndex, id, ann)}
>
{t('annotation.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
{!hasMainContent || isEditingMain ? (
<>
<Textarea
placeholder={t('viewer.comments.addCommentPlaceholder', 'Add comment...')}
minRows={2}
autosize
value={draft ?? ''}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setDraftContents((prev) => ({ ...prev, [key]: v }));
if (isEditingMain) {
handleContentsChange(pageIndex, id, v);
}
}}
styles={{ root: { width: '100%' } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() => {
handleSendMainComment(pageIndex, id, draft ?? '');
setEditingMainKey(null);
}}
disabled={!(draft ?? '').trim()}
>
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<>
<Text size="sm" mb="sm" style={{ whiteSpace: 'pre-wrap' }}>
{displayContent}
</Text>
{entry.replies?.length ? (
<Stack gap="sm" mb="sm">
{entry.replies.map((r) => {
const rObj = r?.object;
const rId = rObj?.id;
if (!rId) return null;
const rAuthor = getAuthorName(rObj, displayName);
const rTimestamp = formatCommentDate(rObj);
const replyEditKey = `${pageIndex}_${id}_${rId}`;
const isEditingReply = editingReplyKey === replyEditKey;
const canEditReply = isReplyAuthoredByCurrentUser(rObj, displayName);
const replyBody =
replyEditDrafts[replyEditKey] !== undefined
? replyEditDrafts[replyEditKey]
: (rObj?.contents ?? '');
return (
<Box key={rId} pl="xs" style={{ borderLeft: '2px solid var(--mantine-color-blue-3)' }}>
<Box style={{ minWidth: 0 }}>
<Group wrap="nowrap" justify="space-between" align="flex-start" gap={4} mb={2}>
<Text size="sm" fw={600}>
{rAuthor}
</Text>
<Group wrap="nowrap" gap="xs" align="center">
{canEditReply && !isEditingReply ? (
<UnstyledButton
type="button"
onClick={() => {
setEditingReplyKey(replyEditKey);
setReplyEditDrafts(() => ({
[replyEditKey]: String(rObj?.contents ?? ''),
}));
}}
>
<Text size="xs" c="blue">
{t('annotation.editText', 'Edit')}
</Text>
</UnstyledButton>
) : null}
{rTimestamp ? (
<Text size="xs" c="dimmed">
{rTimestamp}
</Text>
) : null}
</Group>
</Group>
{isEditingReply ? (
<>
<Textarea
minRows={2}
autosize
value={replyBody}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setReplyEditDrafts((p) => ({ ...p, [replyEditKey]: v }));
}}
styles={{ root: { width: '100%' } }}
mb="xs"
/>
<Group gap={4} wrap="nowrap" justify="flex-end">
<Tooltip label={t('viewer.comments.saveReply', 'Save reply')}>
<ActionIcon
variant="filled"
size="sm"
color="blue"
onClick={() =>
handleSaveReplyEdit(replyEditKey, pageIndex, rId, replyBody)
}
disabled={!replyBody.trim()}
>
<CheckIcon style={{ fontSize: 18, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
) : (
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
{rObj?.contents ?? ''}
</Text>
)}
</Box>
</Box>
);
})}
</Stack>
) : null}
<Group gap="xs" wrap="nowrap" align="flex-end">
<TextInput
placeholder={t('viewer.comments.addReplyPlaceholder', 'Add reply...')}
size="xs"
value={replyDraft}
onChange={(e) => {
const v = (e?.currentTarget ?? e?.target)?.value ?? '';
setReplyDrafts((p) => ({ ...p, [replyKey]: v }));
}}
style={{ flex: 1, minWidth: 0 }}
styles={{
input: {
borderColor: 'var(--mantine-color-blue-3)',
},
}}
/>
<Tooltip label={t('viewer.comments.addComment', 'Add comment')}>
<ActionIcon
variant="filled"
size="md"
color="blue"
style={{ backgroundColor: 'var(--mantine-color-blue-6)' }}
onClick={() => handleSendReply(pageIndex, id, ann?.rect)}
disabled={!replyDraft.trim()}
>
<CheckIcon style={{ fontSize: 20, color: 'white' }} />
</ActionIcon>
</Tooltip>
</Group>
</>
)}
</Box>
);
})}
</Stack>
</Box>
);
})
)}
</Stack>
</ScrollArea>
<Modal
opened={!!deleteModal}
onClose={() => setDeleteModal(null)}
title={t('viewer.comments.deleteTitle', 'Remove annotation from comments?')}
centered
size="sm"
>
<Text size="sm" c="dimmed" mb="lg">
{t(
'viewer.comments.deleteDescription',
'This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything.'
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={handleRemoveFromSidebar}>
{t('viewer.comments.removeCommentOnly', 'Remove comment only')}
</Button>
<Button color="red" onClick={handleDeleteAnnotation}>
{t('viewer.comments.deleteAnnotationAndComment', 'Delete annotation & comment')}
</Button>
</Group>
</Modal>
</Box>
);
}
@@ -126,6 +126,7 @@ const EmbedPdfViewerContent = ({
toggleThumbnailSidebar,
isBookmarkSidebarVisible,
isAttachmentSidebarVisible,
isCommentsSidebarVisible,
isSearchInterfaceVisible,
searchInterfaceActions,
zoomActions,
@@ -201,8 +202,9 @@ const EmbedPdfViewerContent = ({
const isSignatureMode = isInAnnotationTool;
const isManualRedactMode = selectedTool === 'redact';
// Enable annotations only when annotation tool is selected
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode;
// Enable annotations when annotation tool is selected OR when annotations are visible
// (so users can interact with existing comment annotations in reader/viewer mode)
const shouldEnableAnnotations = selectedTool === 'annotate' || isSignatureMode || isAnnotationsVisible;
// Enable redaction only when redaction tool is selected
const shouldEnableRedaction = selectedTool === 'redact';
@@ -910,10 +912,12 @@ const EmbedPdfViewerContent = ({
}, [isFormFillToolActive, currentFile, currentFileId, fetchFormFields]);
const sidebarWidthRem = 15;
const commentsSidebarWidthRem = 18;
const totalRightMargin =
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) +
(isBookmarkSidebarVisible ? sidebarWidthRem : 0) +
(isAttachmentSidebarVisible ? sidebarWidthRem : 0);
(isAttachmentSidebarVisible ? sidebarWidthRem : 0) +
(isCommentsSidebarVisible ? commentsSidebarWidthRem : 0);
return (
<Box
@@ -969,6 +973,7 @@ const EmbedPdfViewerContent = ({
(effectiveFile?.file instanceof File ? effectiveFile.file.name : undefined))
}
enableAnnotations={shouldEnableAnnotations}
isSignMode={selectedTool === 'sign'}
showBakedAnnotations={isAnnotationsVisible}
enableRedaction={shouldEnableRedaction}
enableFormFill={shouldEnableFormFill}
@@ -978,6 +983,8 @@ const EmbedPdfViewerContent = ({
historyApiRef={historyApiRef as React.RefObject<any>}
redactionTrackerRef={redactionTrackerRef as React.RefObject<RedactionPendingTrackerAPI>}
fileId={currentFileId}
isCommentsSidebarVisible={isCommentsSidebarVisible}
commentsSidebarRightOffset={`${(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0) + (isAttachmentSidebarVisible ? sidebarWidthRem : 0)}rem`}
onSignatureAdded={() => {
// Handle signature added - for debugging, enable console logs as needed
// Future: Handle signature completion
@@ -1043,6 +1050,7 @@ const EmbedPdfViewerContent = ({
<AttachmentSidebar
visible={isAttachmentSidebarVisible}
thumbnailVisible={isThumbnailSidebarVisible}
bookmarkVisible={isBookmarkSidebarVisible}
documentCacheKey={bookmarkCacheKey}
preloadCacheKeys={allBookmarkCacheKeys}
/>
@@ -61,6 +61,9 @@ import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrappe
import { ActiveDocumentProvider } from '@app/components/viewer/ActiveDocumentContext';
import { absoluteWithBasePath } from '@app/constants/app';
import { FormFieldOverlay } from '@app/tools/formFill/FormFieldOverlay';
import { CommentsSidebar } from '@app/components/viewer/CommentsSidebar';
import { CommentAuthorProvider } from '@app/contexts/CommentAuthorContext';
import { accountService } from '@app/services/accountService';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -78,12 +81,24 @@ interface LocalEmbedPDFProps {
redactionTrackerRef?: React.RefObject<RedactionPendingTrackerAPI>;
/** File identity passed through to FormFieldOverlay for stale-field guards */
fileId?: string | null;
/** Comments sidebar visibility and offset (from EmbedPdfViewer) */
isCommentsSidebarVisible?: boolean;
commentsSidebarRightOffset?: string;
/** When true, blocks the general ink/pen annotation tool (sign tool context). */
isSignMode?: boolean;
}
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false, enableRedaction = false, enableFormFill = false, isManualRedactionMode = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, annotationApiRef, historyApiRef, redactionTrackerRef, fileId, isCommentsSidebarVisible = false, commentsSidebarRightOffset = '0rem', isSignMode = false }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: Rect}>>([]);
const [commentAuthorName, setCommentAuthorName] = useState<string>('Guest');
useEffect(() => {
accountService.getAccountData().then((data) => {
if (data?.username) setCommentAuthorName(data.username);
}).catch(() => {/* not logged in or security disabled */});
}, []);
// Convert File to URL if needed
useEffect(() => {
@@ -655,6 +670,27 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
rect: event.annotation.rect
}]);
// If the annotation doesn't have customData.toolId, patch it from the active tool.
// EmbedPDF doesn't always persist customData from setToolDefaults into created annotations.
const annotationId = event.annotation.id;
const existingCustomData = (event.annotation as unknown as { customData?: Record<string, unknown> }).customData;
if (annotationId && !existingCustomData?.toolId) {
const activeTool = (annotationApi as unknown as { getActiveTool?: () => { id: string } | null }).getActiveTool?.();
if (activeTool?.id && activeTool.id !== 'select') {
(annotationApi as unknown as { updateAnnotation?: (page: number, id: string, patch: Record<string, unknown>) => void })
.updateAnnotation?.(event.pageIndex, annotationId, {
customData: { ...(existingCustomData ?? {}), toolId: activeTool.id },
});
}
}
// Auto-select the annotation after creation so the selection menu appears immediately,
// letting users discover the editing options before they click away.
if (annotationId) {
(annotationApi as unknown as { selectAnnotation?: (pageIndex: number, id: string) => void })
.selectAnnotation?.(event.pageIndex, annotationId);
}
if (onSignatureAdded) {
onSignatureAdded(event.annotation);
}
@@ -678,7 +714,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
{/* Always render RedactionAPIBridge when in manual redaction mode so buttons can switch from annotation mode */}
{(enableRedaction || isManualRedactionMode) && <RedactionAPIBridge />}
{/* Always render SignatureAPIBridge so annotation tools (draw) can be activated even when starting in redaction mode */}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} />}
{(enableAnnotations || enableRedaction || isManualRedactionMode) && <SignatureAPIBridge ref={signatureApiRef} isSignMode={isSignMode} />}
{(enableRedaction || isManualRedactionMode) && <RedactionPendingTracker ref={redactionTrackerRef} />}
{enableAnnotations && <AnnotationAPIBridge ref={annotationApiRef} />}
@@ -699,6 +735,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}
>
{(documentId) => (
<>
<GlobalPointerProvider documentId={documentId}>
<Viewport
documentId={documentId}
@@ -793,6 +830,16 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
/>
</Viewport>
</GlobalPointerProvider>
{enableAnnotations && (
<CommentAuthorProvider displayName={commentAuthorName}>
<CommentsSidebar
documentId={documentId}
visible={isCommentsSidebarVisible}
rightOffset={commentsSidebarRightOffset}
/>
</CommentAuthorProvider>
)}
</>
)}
</DocumentReadyWrapper>
</ActiveDocumentProvider>
@@ -97,11 +97,13 @@ export function PdfViewerToolbar({
};
const handlePreviousPage = () => {
scrollActions.scrollToPreviousPage();
const { currentPage: cur } = getScrollState();
if (cur > 1) scrollActions.scrollToPage(cur - 1);
};
const handleNextPage = () => {
scrollActions.scrollToNextPage();
const { currentPage: cur, totalPages: tot } = getScrollState();
if (cur < tot) scrollActions.scrollToPage(cur + 1);
};
const handleLastPage = () => {
@@ -133,7 +133,12 @@ const createTextStampImage = (
};
};
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
interface SignatureAPIBridgeProps {
/** When true (sign tool context), the general ink/pen annotation tool is blocked. */
isSignMode?: boolean;
}
export const SignatureAPIBridge = forwardRef<SignatureAPI, SignatureAPIBridgeProps>(function SignatureAPIBridge({ isSignMode = false }, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
@@ -151,6 +156,14 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
};
}, [getZoomState, registerImmediateZoomUpdate]);
// When entering sign mode, deactivate any active annotation tool immediately.
// Only signature-specific tools (signatureInk, stamp) should be usable.
useEffect(() => {
if (isSignMode && annotationApi && documentReady) {
annotationApi.setActiveTool(null);
}
}, [isSignMode, annotationApi, documentReady]);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
@@ -302,13 +315,12 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateDrawMode: () => {
if (!annotationApi) return;
// Activate the built-in ink tool for drawing
annotationApi.setActiveTool('ink');
// Use signatureInk (not ink) so the annotation pen tool stays unaffected
annotationApi.setActiveTool('signatureInk');
// Set default ink tool properties (black color, 2px width)
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'ink') {
annotationApi.setToolDefaults('ink', {
if (activeTool && activeTool.id === 'signatureInk') {
annotationApi.setToolDefaults('signatureInk', {
color: '#000000',
thickness: 2,
lineWidth: 2,
@@ -338,11 +350,16 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
width: size
});
// Force reactivate ink tool to ensure new settings take effect
annotationApi.setActiveTool(null); // Deactivate first
setTimeout(() => {
annotationApi.setActiveTool('ink'); // Reactivate with new settings
}, 50);
// Only reactivate ink if it was already the active tool — never auto-activate it.
// This prevents the ink (pen annotation) tool from being activated when
// updateDrawSettings is called on sign-tool mount.
const currentTool = annotationApi.getActiveTool();
if (currentTool?.id === 'ink') {
annotationApi.setActiveTool(null);
setTimeout(() => {
annotationApi.setActiveTool('ink');
}, 50);
}
},
activateDeleteMode: () => {
@@ -0,0 +1,289 @@
import { useCallback, useMemo, type RefObject } from 'react';
import { PdfActionType, PdfAnnotationSubtype, PdfAnnotationReplyType, type PdfAnnotationObject } from '@embedpdf/models';
import type { TrackedAnnotation, AnnotationScope } from '@embedpdf/plugin-annotation';
import type { AnnotationObject, AnnotationPatch } from '@app/components/viewer/viewerTypes';
import type { ScrollActions } from '@app/contexts/viewer/viewerActions';
export type AnnotationType =
| 'textMarkup'
| 'ink'
| 'inkHighlighter'
| 'text'
| 'note'
| 'comment'
| 'shape'
| 'line'
| 'stamp'
| 'unknown';
export type FirstLinkTarget =
| { type: 'uri'; uri: string }
| { type: 'goto'; pageIndex: number };
interface UseAnnotationMenuHandlersParams {
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
pageIndex: number | undefined;
documentId: string;
provides: AnnotationScope | null;
scrollActions: ScrollActions;
requestCommentFocus: (documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => void;
wrapperRef: RefObject<HTMLDivElement | null>;
}
export interface AnnotationMenuState {
annotationType: AnnotationType;
menuWidth: number;
obj: AnnotationObject | undefined;
annotationId: string | undefined;
firstLinkTarget: FirstLinkTarget | null;
hasCommentContent: boolean;
isInSidebar: boolean;
currentColor: string;
strokeColor: string;
fillColor: string;
backgroundColor: string;
textColor: string;
currentOpacity: number;
currentWidth: number;
}
export interface AnnotationMenuHandlers {
onDelete: () => void;
onEdit: () => void;
onColorChange: (color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => void;
onOpacityChange: (opacity: number) => void;
onWidthChange: (width: number) => void;
onPropertiesUpdate: (patch: AnnotationPatch) => void;
onGoToLink: () => void;
onAddLink: (url: string) => void;
onAddToSidebar: () => void;
onViewComment: () => void;
onCommentColorChange: (color: string) => void;
}
export function useAnnotationMenuHandlers({
annotation,
pageIndex,
documentId,
provides,
scrollActions,
requestCommentFocus,
wrapperRef,
}: UseAnnotationMenuHandlersParams): AnnotationMenuState & AnnotationMenuHandlers {
const obj = annotation?.object as AnnotationObject | undefined;
const annotationId = obj?.id;
const attachedLinks = useMemo(() => {
if (!annotationId || !provides?.getAttachedLinks) return [];
try {
return provides.getAttachedLinks(annotationId) ?? [];
} catch {
return [];
}
}, [annotationId, provides]);
const firstLinkTarget = useMemo<FirstLinkTarget | null>(() => {
const linkObj = (attachedLinks[0]?.object as {
target?: {
type: string;
action?: { type: number; uri?: string; destination?: { pageIndex: number } };
};
} | undefined);
if (!linkObj?.target || linkObj.target.type !== 'action') return null;
const act = linkObj.target.action;
if (act && act.type === PdfActionType.URI && act.uri) return { type: 'uri', uri: act.uri };
if (act && (act.type === PdfActionType.Goto || act.type === PdfActionType.RemoteGoto) && act.destination)
return { type: 'goto', pageIndex: act.destination.pageIndex };
return null;
}, [attachedLinks]);
const annotationType = useMemo((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = (annotation?.object as AnnotationObject | undefined)?.customData?.toolId;
if (type !== undefined && [9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
if (type === 1 && toolId === 'textComment') return 'comment';
if (type === 14 && (toolId === 'insertText' || toolId === 'replaceText')) return 'comment';
if (type === 3) return 'note';
if (type !== undefined && [5, 6, 7].includes(type)) return 'shape';
if (type !== undefined && [4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
const menuWidth = useMemo((): number => {
switch (annotationType) {
case 'stamp': return 80;
case 'inkHighlighter':
case 'comment':
case 'textMarkup':
case 'text':
case 'note': return 280;
case 'shape': return 200;
default: return 260;
}
}, [annotationType]);
const hasCommentContent = (obj?.contents ?? '').trim().length > 0;
const isInSidebar = (obj?.customData as Record<string, unknown> | undefined)?.isComment === true;
// Derived style values
const currentColor = (() => {
if (!obj) return '#000000';
const type = obj.type;
if (type === 3) return obj.textColor || obj.color || '#000000';
if (type !== undefined && [4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
return obj.color || obj.strokeColor || '#000000';
})();
const strokeColor = obj?.strokeColor || obj?.color || '#000000';
const fillColor = obj?.color || obj?.fillColor || '#0000ff';
const backgroundColor = obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
const textColor = obj?.textColor || obj?.color || '#000000';
const currentOpacity = Math.round((obj?.opacity ?? 1) * 100);
const currentWidth = obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
// Handlers
const onDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
const onEdit = useCallback(() => {
const root = wrapperRef.current?.closest('[data-no-interaction]');
const main = root?.firstElementChild;
const hitArea = main?.lastElementChild ?? main?.firstElementChild;
if (!hitArea) return;
hitArea.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window }));
}, [wrapperRef]);
const onColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: AnnotationPatch = {};
if (target === 'stroke') {
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff';
patch.strokeWidth = currentWidth;
} else if (target === 'fill') {
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000';
patch.strokeWidth = currentWidth;
} else if (target === 'background') {
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
patch.textColor = color;
patch.fontColor = color;
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
patch.contents = obj?.contents ?? '';
} else {
patch.color = color;
if (type !== undefined && [9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
if (type !== undefined && [4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj, currentWidth]);
const onOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { opacity: opacity / 100 });
}, [provides, annotationId, pageIndex]);
const onWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { strokeWidth: width });
}, [provides, annotationId, pageIndex]);
const onPropertiesUpdate = useCallback((patch: Record<string, unknown>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
const onGoToLink = useCallback(() => {
if (!firstLinkTarget) return;
if (firstLinkTarget.type === 'uri') {
window.open(firstLinkTarget.uri, '_blank', 'noopener,noreferrer');
} else {
scrollActions.scrollToPage(firstLinkTarget.pageIndex + 1);
}
}, [firstLinkTarget, scrollActions]);
const onAddLink = useCallback((url: string) => {
const uri = url.trim();
if (!uri || !provides?.createAnnotation || pageIndex === undefined || !annotationId || !obj?.rect) return;
provides.createAnnotation(pageIndex, {
type: PdfAnnotationSubtype.LINK,
id: `link-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
pageIndex,
rect: obj.rect,
target: { type: 'action', action: { type: PdfActionType.URI, uri } },
inReplyToId: annotationId,
replyType: PdfAnnotationReplyType.Group,
});
}, [provides, pageIndex, annotationId, obj?.rect]);
const onAddToSidebar = useCallback(() => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const existingCustomData = (obj?.customData ?? {}) as Record<string, unknown>;
provides.updateAnnotation(pageIndex, annotationId, {
customData: { ...existingCustomData, isComment: true },
} as AnnotationPatch);
requestCommentFocus(documentId, pageIndex, annotationId, false);
}, [provides, annotationId, pageIndex, obj, documentId, requestCommentFocus]);
const onViewComment = useCallback(() => {
requestCommentFocus(documentId, pageIndex ?? 0, annotationId ?? '', hasCommentContent);
}, [requestCommentFocus, documentId, pageIndex, annotationId, hasCommentContent]);
const onCommentColorChange = useCallback((color: string) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, { strokeColor: color, color });
}, [provides, annotationId, pageIndex]);
return {
annotationType,
menuWidth,
obj,
annotationId,
firstLinkTarget,
hasCommentContent,
isInSidebar,
currentColor,
strokeColor,
fillColor,
backgroundColor,
textColor,
currentOpacity,
currentWidth,
onDelete,
onEdit,
onColorChange,
onOpacityChange,
onWidthChange,
onPropertiesUpdate,
onGoToLink,
onAddLink,
onAddToSidebar,
onViewComment,
onCommentColorChange,
};
}
@@ -26,7 +26,7 @@ export function useViewerRightRailButtons(
) {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isCommentsSidebarVisible, toggleCommentsSidebar, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
@@ -63,10 +63,8 @@ export function useViewerRightRailButtons(
if (selectedTool === 'annotate') {
setIsAnnotationsActive(true);
} else if (selectedTool) {
// Any other tool is active — annotate button should not be highlighted
setIsAnnotationsActive(false);
} else {
// No tool selected — fall back to URL path check
setIsAnnotationsActive(isAnnotationsPath());
}
}, [selectedTool, isAnnotationsPath]);
@@ -85,6 +83,7 @@ export function useViewerRightRailButtons(
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
const attachmentLabel = t('rightRail.toggleAttachments', 'Toggle Attachments');
const commentsLabel = t('rightRail.toggleComments', 'Comments');
const printLabel = t('rightRail.print', 'Print PDF');
const annotationsLabel = t('rightRail.annotations', 'Annotations');
const formFillLabel = t('rightRail.formFill', 'Fill Form');
@@ -237,13 +236,25 @@ export function useViewerRightRailButtons(
viewer.toggleAttachmentSidebar();
}
},
{
id: 'viewer-toggle-comments',
icon: <LocalIcon icon="comment" width="1.5rem" height="1.5rem" />,
tooltip: commentsLabel,
ariaLabel: commentsLabel,
section: 'top' as const,
order: 56.5,
active: isCommentsSidebarVisible,
onClick: () => {
toggleCommentsSidebar();
}
},
{
id: 'viewer-print',
icon: <LocalIcon icon="print" width="1.5rem" height="1.5rem" />,
tooltip: printLabel,
ariaLabel: printLabel,
section: 'top' as const,
order: 56,
order: 57,
onClick: () => {
viewer.printActions.print();
}
@@ -71,6 +71,9 @@ export type AnnotationToolId =
| 'inkHighlighter'
| 'text'
| 'note'
| 'textComment'
| 'insertText'
| 'replaceText'
| 'square'
| 'circle'
| 'line'
@@ -84,7 +87,6 @@ export type AnnotationToolId =
// Import for internal use within this file, and re-export for external consumers
import type { AnnotationEvent } from '@embedpdf/plugin-annotation';
export type { AnnotationEvent } from '@embedpdf/plugin-annotation';
export type { PdfAnnotationObject } from '@embedpdf/models';
export type AnnotationPatch = Record<string, unknown>;
@@ -142,4 +144,5 @@ export interface AnnotationToolOptions {
icon?: 'Comment' | 'Key' | 'Note' | 'Help' | 'NewParagraph' | 'Paragraph' | 'Insert';
contents?: string;
customData?: Record<string, unknown>;
flags?: ('invisible' | 'hidden' | 'print' | 'noZoom' | 'noRotate' | 'noView' | 'readOnly' | 'locked' | 'toggleNoView')[];
}
@@ -0,0 +1,27 @@
import { createContext, useContext, type ReactNode } from 'react';
export interface CommentAuthorValue {
displayName: string;
}
const defaultValue: CommentAuthorValue = { displayName: 'Guest' };
const CommentAuthorContext = createContext<CommentAuthorValue>(defaultValue);
export function CommentAuthorProvider({
children,
displayName = 'Guest',
}: {
children: ReactNode;
displayName?: string;
}) {
return (
<CommentAuthorContext.Provider value={{ displayName }}>
{children}
</CommentAuthorContext.Provider>
);
}
export function useCommentAuthor(): CommentAuthorValue {
return useContext(CommentAuthorContext);
}
@@ -85,6 +85,19 @@ export interface ViewerContextType {
toggleBookmarkSidebar: () => void;
isAttachmentSidebarVisible: boolean;
toggleAttachmentSidebar: () => void;
isCommentsSidebarVisible: boolean;
setCommentsSidebarVisible: (visible: boolean) => void;
toggleCommentsSidebar: () => void;
/** Request focus or highlight of a comment card in the sidebar (opens sidebar, then scrolls + flashes or focuses input). */
highlightCommentRequest: {
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null;
requestCommentFocus: (documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => void;
clearHighlightCommentRequest: () => void;
// Search interface visibility
isSearchInterfaceVisible: boolean;
@@ -170,6 +183,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
const [isAttachmentSidebarVisible, setIsAttachmentSidebarVisible] = useState(false);
const [isCommentsSidebarVisible, setIsCommentsSidebarVisible] = useState(false);
const [highlightCommentRequest, setHighlightCommentRequest] = useState<{
documentId: string;
pageIndex: number;
annotationId: string;
action: 'focus' | 'highlight';
} | null>(null);
const [isSearchInterfaceVisible, setSearchInterfaceVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
@@ -261,6 +281,31 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsAttachmentSidebarVisible(prev => !prev);
};
const setCommentsSidebarVisible = (visible: boolean) => {
setIsCommentsSidebarVisible(visible);
};
const toggleCommentsSidebar = () => {
setIsCommentsSidebarVisible(prev => !prev);
};
const requestCommentFocus = useCallback(
(documentId: string, pageIndex: number, annotationId: string, hasContent: boolean) => {
setIsCommentsSidebarVisible(true);
setHighlightCommentRequest({
documentId,
pageIndex,
annotationId,
action: hasContent ? 'highlight' : 'focus',
});
},
[]
);
const clearHighlightCommentRequest = useCallback(() => {
setHighlightCommentRequest(null);
}, []);
const searchInterfaceActions = {
open: () => setSearchInterfaceVisible(true),
close: () => setSearchInterfaceVisible(false),
@@ -392,6 +437,12 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleBookmarkSidebar,
isAttachmentSidebarVisible,
toggleAttachmentSidebar,
isCommentsSidebarVisible,
setCommentsSidebarVisible,
toggleCommentsSidebar,
highlightCommentRequest,
requestCommentFocus,
clearHighlightCommentRequest,
// Search interface
isSearchInterfaceVisible,
+25
View File
@@ -671,6 +671,31 @@
box-shadow: 0 0 0 0.5px var(--pdf-selection-ring);
}
/* Flash highlight for comment card (e.g. "View comment" from annotation menu) */
@keyframes comment-card-flash {
0% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
20% {
background-color: rgba(255, 235, 59, 0.35);
box-shadow: 0 0 20px rgba(255, 235, 59, 0.5);
}
50% {
background-color: rgba(255, 235, 59, 0.25);
box-shadow: 0 0 15px rgba(255, 235, 59, 0.4);
}
100% {
background-color: rgba(255, 235, 59, 0);
box-shadow: none;
}
}
.comment-card-flash-highlight {
animation: comment-card-flash 1.5s ease-out;
border-radius: 8px;
}
/* Smooth transitions for theme switching */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
+13 -2
View File
@@ -22,6 +22,9 @@ const DRAWING_TOOLS: AnnotationToolId[] = [
'inkHighlighter',
'text',
'note',
'textComment',
'insertText',
'replaceText',
'square',
'circle',
'line',
@@ -43,6 +46,9 @@ const KNOWN_ANNOTATION_TOOLS: AnnotationToolId[] = [
'inkHighlighter',
'text',
'note',
'textComment',
'insertText',
'replaceText',
'square',
'circle',
'line',
@@ -75,7 +81,7 @@ const Annotate = (_props: BaseToolProps) => {
const viewerContext = useContext(ViewerContext);
const viewerContextRef = useRef(viewerContext);
useEffect(() => { viewerContextRef.current = viewerContext; }, [viewerContext]);
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions } = useViewer();
const { getZoomState, registerImmediateZoomUpdate, applyChanges, activeFileIndex, panActions, setCommentsSidebarVisible } = useViewer();
const [activeTool, setActiveTool] = useState<AnnotationToolId>('select');
@@ -312,6 +318,11 @@ const Annotate = (_props: BaseToolProps) => {
annotationApiRef?.current?.setAnnotationStyle?.(toolId, options);
annotationApiRef?.current?.activateAnnotationTool?.(toolId === 'stamp' ? 'stamp' : toolId, options);
// Auto-open comments sidebar when a comment tool is selected
if (['textComment', 'insertText', 'replaceText'].includes(toolId)) {
setCommentsSidebarVisible(true);
}
// Reset flag after a short delay
setTimeout(() => {
manualToolSwitch.current = false;
@@ -346,7 +357,7 @@ const Annotate = (_props: BaseToolProps) => {
const unsubscribe = annotationApiRef?.current?.onAnnotationEvent?.((event: AnnotationEvent) => {
if (event.type === 'create' && event.committed) {
const toolId = activeToolRef.current;
if (toolId === 'text' || toolId === 'note') {
if (['text', 'note', 'textComment', 'insertText', 'replaceText'].includes(toolId)) {
setActiveTool('select');
activeToolRef.current = 'select';
annotationApiRef?.current?.activateAnnotationTool?.('select');
@@ -209,6 +209,12 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{ id: 'polygon', label: t('annotation.polygon', 'Polygon'), icon: 'change-history' },
];
const commentTools: { id: AnnotationToolId; label: string; icon: string }[] = [
{ id: 'textComment', label: t('annotation.comment', 'Comment'), icon: 'comment' },
{ id: 'insertText', label: t('annotation.insertText', 'Insert Text'), icon: 'add-comment' },
{ id: 'replaceText', label: t('annotation.replaceText', 'Replace Text'), icon: 'find-replace' },
];
const otherTools: { id: AnnotationToolId; label: string; icon: string }[] = [
{ id: 'text', label: t('annotation.text', 'Text box'), icon: 'text-fields' },
{ id: 'note', label: t('annotation.note', 'Note'), icon: 'sticky-note-2' },
@@ -778,6 +784,11 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{renderToolButtons(shapeTools)}
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">{t('annotation.comments', 'Comments')}</Text>
{renderToolButtons(commentTools)}
</Box>
<Box>
<Text size="sm" fw={600} mb="xs">{t('annotation.notesStamps', 'Notes & Stamps')}</Text>
{renderToolButtons(otherTools)}