From c46156f37f8b2e529f6ee4fc973fe04df4763e77 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:35:39 +0000 Subject: [PATCH] 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 <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/package-lock.json | 324 ++++---- frontend/package.json | 46 +- .../public/locales/en-GB/translation.toml | 35 +- .../annotation/shared/ColorControl.tsx | 16 +- .../annotation/shared/PropertiesPopover.tsx | 21 +- .../components/viewer/AnnotationAPIBridge.tsx | 30 + .../viewer/AnnotationMenuButtons.tsx | 156 ++++ .../viewer/AnnotationSelectionMenu.tsx | 471 ++---------- .../viewer/AnnotationTypeButtons.tsx | 201 +++++ .../components/viewer/AttachmentSidebar.tsx | 5 +- .../components/viewer/CommentsSidebar.tsx | 704 ++++++++++++++++++ .../core/components/viewer/EmbedPdfViewer.tsx | 14 +- .../core/components/viewer/LocalEmbedPDF.tsx | 51 +- .../components/viewer/PdfViewerToolbar.tsx | 6 +- .../components/viewer/SignatureAPIBridge.tsx | 39 +- .../viewer/useAnnotationMenuHandlers.ts | 289 +++++++ .../viewer/useViewerRightRailButtons.tsx | 19 +- .../src/core/components/viewer/viewerTypes.ts | 5 +- .../core/contexts/CommentAuthorContext.tsx | 27 + frontend/src/core/contexts/ViewerContext.tsx | 51 ++ frontend/src/core/styles/theme.css | 25 + frontend/src/core/tools/Annotate.tsx | 15 +- .../core/tools/annotate/AnnotationPanel.tsx | 11 + 23 files changed, 1932 insertions(+), 629 deletions(-) create mode 100644 frontend/src/core/components/viewer/AnnotationMenuButtons.tsx create mode 100644 frontend/src/core/components/viewer/AnnotationTypeButtons.tsx create mode 100644 frontend/src/core/components/viewer/CommentsSidebar.tsx create mode 100644 frontend/src/core/components/viewer/useAnnotationMenuHandlers.ts create mode 100644 frontend/src/core/contexts/CommentAuthorContext.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6eb164ba42..f700feb5b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 3b13d2b173..c53d89c9a4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 66971ce304..3ab03cba75 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx index 821d487e44..16b3f845bb 100644 --- a/frontend/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -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 }, }} > - + @@ -64,8 +69,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color - + diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx index bde9609b5f..2f5d424ab6 100644 --- a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -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) => void; + annotationType: PropertiesAnnotationType; + annotation: TrackedAnnotation | 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; diff --git a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx index 3bf970add3..b5626b18c2 100644 --- a/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx +++ b/frontend/src/core/components/viewer/AnnotationAPIBridge.tsx @@ -81,6 +81,18 @@ type AnnotationDefaults = imageSize?: { width: number; height: number }; customData?: Record; } + | { + type: PdfAnnotationSubtype.TEXT; + strokeColor?: string; + opacity?: number; + customData?: Record; + } + | { + type: PdfAnnotationSubtype.CARET; + strokeColor?: string; + opacity?: number; + customData?: Record; + } | null; type AnnotationApiSurface = { @@ -294,6 +306,24 @@ const TOOL_DEFAULT_BUILDERS: Record = { 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(function AnnotationAPIBridge(_props, ref) { diff --git a/frontend/src/core/components/viewer/AnnotationMenuButtons.tsx b/frontend/src/core/components/viewer/AnnotationMenuButtons.tsx new file mode 100644 index 0000000000..8a471d7a52 --- /dev/null +++ b/frontend/src/core/components/viewer/AnnotationMenuButtons.tsx @@ -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 ( + + + + + + ); +} + +export function EditTextButton({ onEdit }: { onEdit: () => void }) { + const { t } = useTranslation(); + return ( + + + + + + ); +} + +interface AttachCommentButtonProps { + isInSidebar: boolean; + onView: () => void; + onAdd: () => void; +} + +export function AttachCommentButton({ isInSidebar, onView, onAdd }: AttachCommentButtonProps) { + const { t } = useTranslation(); + return ( + + + + + + ); +} + +interface CommentButtonProps { + hasContent: boolean; + onClick: () => void; +} + +export function CommentButton({ hasContent, onClick }: CommentButtonProps) { + const { t } = useTranslation(); + return ( + + + + + + ); +} + +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 ( + + + + + + ); + } + + return ( + setOpen(false)} position="top"> + + + setOpen((o) => !o)} styles={commonButtonStyles}> + + + + + + + setUrl(e.currentTarget.value)} + size="sm" + style={{ minWidth: 220 }} + /> + + + + + ); +} diff --git a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx index 87601c4e8f..ceae6587e3 100644 --- a/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx +++ b/frontend/src/core/components/viewer/AnnotationSelectionMenu.tsx @@ -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 ( - - ); + if (!activeDocumentId) return null; + return ; } -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(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) => { - 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 = () => ( - - - - - - ); - - const DeleteButton = () => ( - - - - - - ); - - switch (annotationType) { - case 'textMarkup': - return ( - <> - handleColorChange(color, 'main')} - label={t('annotation.changeColor', 'Change Colour')} - /> - - - - ); - - case 'ink': - return ( - <> - handleColorChange(color, 'main')} - label={t('annotation.changeColor', 'Change Colour')} - /> - - - - ); - - case 'inkHighlighter': - return ( - <> - handleColorChange(color, 'main')} - label={t('annotation.changeColor', 'Change Colour')} - /> - - - - - ); - - case 'text': - case 'note': - return ( - <> - handleColorChange(color, 'text')} - label={t('annotation.color', 'Color')} - /> - handleColorChange(color, 'background')} - label={t('annotation.backgroundColor', 'Background color')} - /> - - - - - ); - - case 'shape': - return ( - <> - handleColorChange(color, 'stroke')} - label={t('annotation.strokeColor', 'Stroke Colour')} - /> - handleColorChange(color, 'fill')} - label={t('annotation.fillColor', 'Fill Colour')} - /> - - - - ); - - case 'line': - return ( - <> - handleColorChange(color, 'main')} - label={t('annotation.changeColor', 'Change Colour')} - /> - - - - ); - - case 'stamp': - return ; - - default: - return ( - <> - handleColorChange(color, 'main')} - label={t('annotation.changeColor', 'Change Colour')} - /> - - - ); - } - }; - - // 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', }} > - {renderButtons()} + ) : 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. */} -
- {typeof document !== 'undefined' && menuContent - ? createPortal(menuContent, document.body) - : null} + {/* Invisible wrapper for EmbedPDF positioning — pointer-events:none so drag still works */} +
+ {typeof document !== 'undefined' && menuContent ? createPortal(menuContent, document.body) : null} ); } diff --git a/frontend/src/core/components/viewer/AnnotationTypeButtons.tsx b/frontend/src/core/components/viewer/AnnotationTypeButtons.tsx new file mode 100644 index 0000000000..ec626291bd --- /dev/null +++ b/frontend/src/core/components/viewer/AnnotationTypeButtons.tsx @@ -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 | 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 = ( + + ); + + switch (annotationType as AnnotationType) { + case 'textMarkup': + return ( + <> + {attachCommentButton} + onColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'ink': + return ( + <> + {attachCommentButton} + onColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'inkHighlighter': + return ( + <> + {attachCommentButton} + onColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + + ); + + case 'text': + case 'note': + return ( + <> + {attachCommentButton} + onColorChange(color, 'text')} + label={t('annotation.color', 'Color')} + /> + onColorChange(color, 'background')} + label={t('annotation.backgroundColor', 'Background color')} + /> + + + + + ); + + case 'comment': + return ( + <> + + + + + + + ); + + case 'shape': + return ( + <> + {attachCommentButton} + onColorChange(color, 'stroke')} + label={t('annotation.strokeColor', 'Stroke Colour')} + /> + onColorChange(color, 'fill')} + label={t('annotation.fillColor', 'Fill Colour')} + /> + + + + ); + + case 'line': + return ( + <> + {attachCommentButton} + onColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + + ); + + case 'stamp': + return ( + <> + {attachCommentButton} + + + ); + + default: + return ( + <> + {attachCommentButton} + + + onColorChange(color, 'main')} + label={t('annotation.changeColor', 'Change Colour')} + /> + + + ); + } +} diff --git a/frontend/src/core/components/viewer/AttachmentSidebar.tsx b/frontend/src/core/components/viewer/AttachmentSidebar.tsx index 916e6625e2..2498fe84cf 100644 --- a/frontend/src/core/components/viewer/AttachmentSidebar.tsx +++ b/frontend/src/core/components/viewer/AttachmentSidebar.tsx @@ -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 = {}): 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, diff --git a/frontend/src/core/components/viewer/CommentsSidebar.tsx b/frontend/src/core/components/viewer/CommentsSidebar.tsx new file mode 100644 index 0000000000..b037c8f157 --- /dev/null +++ b/frontend/src/core/components/viewer/CommentsSidebar.tsx @@ -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 = { + 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 = { + 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 ( + + ); +} + +export function CommentsSidebar({ documentId, visible, rightOffset }: CommentsSidebarProps) { + const { t } = useTranslation(); + const { displayName } = useCommentAuthor(); + const { highlightCommentRequest, clearHighlightCommentRequest, scrollActions, getZoomState } = useViewer() ?? {}; + const scrollViewportRef = useRef(null); + const { state, provides } = useAnnotation(documentId); + const [draftContents, setDraftContents] = useState>({}); + const [replyDrafts, setReplyDrafts] = useState>({}); + /** Draft text while editing an existing reply (`${pageIndex}_${parentId}_${replyId}`). */ + const [replyEditDrafts, setReplyEditDrafts] = useState>({}); + /** When set, this card's main comment is in edit mode (show textarea for main comment). */ + const [editingMainKey, setEditingMainKey] = useState(null); + /** Which reply is in edit mode (same key shape as replyEditDrafts). */ + const [editingReplyKey, setEditingReplyKey] = useState(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(`[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('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(`[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 = (state as any)?.byUid ?? {}; + const ids = new Set(); + 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; + 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 ( + +
+ + + {t('viewer.comments.title', 'Comments')} + +
+ + + {totalCount === 0 ? ( + + {t('viewer.comments.hint', 'Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page.')} + + ) : ( + pageNumbers.map((pageIndex) => { + const entries = byPage[pageIndex] ?? []; + const pageNum = pageIndex + 1; + return ( + + + {t('viewer.comments.pageLabel', 'Page {{page}}', { page: pageNum })} + + + {entries.length === 1 + ? t('viewer.comments.oneComment', '1 comment') + : t('viewer.comments.nComments', '{{count}} comments', { count: entries.length })} + + + + {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 ( + + + + + + + {authorName} + + + {typeLabel}{mainTimestamp ? ` · ${mainTimestamp}` : ''} + + + + + + handleLocateAnnotation(pageIndex, ann)} + > + + + + + + + + + + + + + } + onClick={() => setEditingMainKey(key)} + > + {t('annotation.editText', 'Edit')} + + } + color="red" + onClick={() => handleDeleteClick(pageIndex, id, ann)} + > + {t('annotation.delete', 'Delete')} + + + + + + + {!hasMainContent || isEditingMain ? ( + <> +