diff --git a/LICENSE b/LICENSE index 16ee40dfdd..262e34d607 100644 --- a/LICENSE +++ b/LICENSE @@ -1,2 +1,2 @@ -Copyright 2018 PDFTron Systems Inc. All rights reserved. -WebViewer React UI project/codebase or any derived works is only permitted in solutions with an active commercial PDFTron WebViewer license. For exact licensing terms please refer to your commercial WebViewer license. For use in other scenario, please contact sales@pdftron.com \ No newline at end of file +Copyright 2023 Apryse Software Inc. All rights reserved. +WebViewer React UI project/codebase or any derived works is only permitted in solutions with an active commercial Apryse WebViewer license. For exact licensing terms please refer to your commercial WebViewer license. For use in other scenario, please contact sales@apryse.com \ No newline at end of file diff --git a/README.md b/README.md index 1814e5e568..aacd84aa84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # WebViewer UI -WebViewer UI sits on top of [WebViewer](https://www.pdftron.com/webviewer), a powerful JavaScript-based PDF Library that's part of the [PDFTron PDF SDK](https://www.pdftron.com). Built in React, WebViewer UI provides a slick out-of-the-box responsive UI that interacts with the core library to view, annotate and manipulate PDFs that can be embedded into any web project. +WebViewer UI sits on top of [WebViewer](https://apryse.com/products/webviewer), a powerful JavaScript-based PDF Library that's part of the [Apryse PDF SDK](https://www.apryse.com). Built in React, WebViewer UI provides a slick out-of-the-box responsive UI that interacts with the core library to view, annotate and manipulate PDFs that can be embedded into any web project. ![WebViewer UI](https://www.pdftron.com/downloads/pl/webviewer-ui.png) @@ -9,7 +9,7 @@ This repo is specifically designed for any users interested in advanced customiz Any approved pull requests made to this repository are merged into WebViewer's internal builds, and can be accessed through the nightly builds. Any approved pull requests to the master branch will go to WebViewer's [nightly experimental builds](https://www.pdftron.com/nightly/#experimental/) and pull requests to version number branches will go to that version's [nightly stable](https://www.pdftron.com/nightly/#stable/). -Nightly stable and experimental builds can also be downloaded from [WebViewer's NPM package](https://www.pdftron.com/documentation/web/faq/webviewer-nightly-build/#npm). +Nightly stable and experimental builds can also be downloaded from [WebViewer's NPM package](https://docs.apryse.com/documentation/web/faq/webviewer-nightly-build/#npm). ## Install @@ -19,7 +19,7 @@ npm install ### Install WebViewer Core Dependencies -The preferred method to install the Core dependencies is to use the [WebViewer NPM package](https://www.pdftron.com/documentation/web/get-started/npm/#1-install-via-npm). +The preferred method to install the Core dependencies is to use the [WebViewer NPM package](https://docs.apryse.com/documentation/web/get-started/npm/#1-install-via-npm). Once installed, copy the Core folder into the path being used by the viewer for its dependencies (/lib by default). @@ -35,6 +35,12 @@ npm start npm run build ``` +## Troubleshooting + +If you are using NPM version 7 or higher, you may get an error indicating an issue with the dependency tree. There are two possible solutions for this: +- Downgrade your version of Node to v14, which uses NPM version 6. +- When running `npm install` add the flag `--legacy-peer-deps`. You can read more about this flag in this [Stack Overflow post](https://stackoverflow.com/questions/66239691/what-does-npm-install-legacy-peer-deps-do-exactly-when-is-it-recommended-wh). + ## Project structure ``` @@ -51,7 +57,7 @@ src/ ## API documentation -See [API documentation](https://www.pdftron.com/documentation/web/guides/ui/apis). +See [API documentation](https://docs.apryse.com/api/web/UI.html). ## Contributing diff --git a/assets/fonts/Satisfy.woff b/assets/fonts/Satisfy.woff new file mode 100644 index 0000000000..8a26153434 Binary files /dev/null and b/assets/fonts/Satisfy.woff differ diff --git a/assets/fonts/Whisper.woff b/assets/fonts/Whisper.woff new file mode 100644 index 0000000000..9692a86d0a Binary files /dev/null and b/assets/fonts/Whisper.woff differ diff --git a/assets/fonts/webfonts/Arimo.ttf b/assets/fonts/webfonts/Arimo.ttf new file mode 100644 index 0000000000..55af77f1b0 Binary files /dev/null and b/assets/fonts/webfonts/Arimo.ttf differ diff --git a/assets/fonts/webfonts/Caladea.ttf b/assets/fonts/webfonts/Caladea.ttf new file mode 100644 index 0000000000..16b2e60ebd Binary files /dev/null and b/assets/fonts/webfonts/Caladea.ttf differ diff --git a/assets/fonts/webfonts/Carlito.ttf b/assets/fonts/webfonts/Carlito.ttf new file mode 100644 index 0000000000..72c1b39516 Binary files /dev/null and b/assets/fonts/webfonts/Carlito.ttf differ diff --git a/assets/fonts/webfonts/Cousine.ttf b/assets/fonts/webfonts/Cousine.ttf new file mode 100644 index 0000000000..848aff881e Binary files /dev/null and b/assets/fonts/webfonts/Cousine.ttf differ diff --git a/assets/fonts/webfonts/LiberationSerif.ttf b/assets/fonts/webfonts/LiberationSerif.ttf new file mode 100644 index 0000000000..db482eb020 Binary files /dev/null and b/assets/fonts/webfonts/LiberationSerif.ttf differ diff --git a/assets/fonts/webfonts/OpenSans.ttf b/assets/fonts/webfonts/OpenSans.ttf new file mode 100644 index 0000000000..591c7e1cd2 Binary files /dev/null and b/assets/fonts/webfonts/OpenSans.ttf differ diff --git a/assets/fonts/webfonts/Roboto.ttf b/assets/fonts/webfonts/Roboto.ttf new file mode 100644 index 0000000000..d489d878cf Binary files /dev/null and b/assets/fonts/webfonts/Roboto.ttf differ diff --git a/assets/fonts/webfonts/RobotoMono.ttf b/assets/fonts/webfonts/RobotoMono.ttf new file mode 100644 index 0000000000..02c48a5879 Binary files /dev/null and b/assets/fonts/webfonts/RobotoMono.ttf differ diff --git a/assets/fonts/webfonts/Tinos.ttf b/assets/fonts/webfonts/Tinos.ttf new file mode 100644 index 0000000000..d5eac84c8c Binary files /dev/null and b/assets/fonts/webfonts/Tinos.ttf differ diff --git a/assets/icons/apryse-logo.svg b/assets/icons/apryse-logo.svg new file mode 100644 index 0000000000..e3e443602f --- /dev/null +++ b/assets/icons/apryse-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic-copy-color.svg b/assets/icons/ic-copy-color.svg new file mode 100644 index 0000000000..8fe8fbae59 --- /dev/null +++ b/assets/icons/ic-copy-color.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ic-delete.svg b/assets/icons/ic-delete.svg new file mode 100644 index 0000000000..4d0792687a --- /dev/null +++ b/assets/icons/ic-delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic-file-cad.svg b/assets/icons/ic-file-cad.svg new file mode 100644 index 0000000000..f3dafa45fb --- /dev/null +++ b/assets/icons/ic-file-cad.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic-file-doc.svg b/assets/icons/ic-file-doc.svg new file mode 100644 index 0000000000..dbfc492ab3 --- /dev/null +++ b/assets/icons/ic-file-doc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic-file-etc.svg b/assets/icons/ic-file-etc.svg new file mode 100644 index 0000000000..6f26b16492 --- /dev/null +++ b/assets/icons/ic-file-etc.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic-file-img.svg b/assets/icons/ic-file-img.svg new file mode 100644 index 0000000000..cea76ed3d1 --- /dev/null +++ b/assets/icons/ic-file-img.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic-file-pdf.svg b/assets/icons/ic-file-pdf.svg new file mode 100644 index 0000000000..9e202b7fe2 --- /dev/null +++ b/assets/icons/ic-file-pdf.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic-file-ppt.svg b/assets/icons/ic-file-ppt.svg new file mode 100644 index 0000000000..c103974447 --- /dev/null +++ b/assets/icons/ic-file-ppt.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic-file-xls.svg b/assets/icons/ic-file-xls.svg new file mode 100644 index 0000000000..31f3e4d010 --- /dev/null +++ b/assets/icons/ic-file-xls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/ic-fill-and-sign.svg b/assets/icons/ic-fill-and-sign.svg new file mode 100644 index 0000000000..aaf4d6078d --- /dev/null +++ b/assets/icons/ic-fill-and-sign.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/ic-hamburger-menu.svg b/assets/icons/ic-hamburger-menu.svg new file mode 100644 index 0000000000..b74d01521b --- /dev/null +++ b/assets/icons/ic-hamburger-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/ic-indent-decrease.svg b/assets/icons/ic-indent-decrease.svg new file mode 100644 index 0000000000..dff5b469b3 --- /dev/null +++ b/assets/icons/ic-indent-decrease.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/ic-indent-increase.svg b/assets/icons/ic-indent-increase.svg new file mode 100644 index 0000000000..1bdc1bd1a5 --- /dev/null +++ b/assets/icons/ic-indent-increase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/ic-insert text.svg b/assets/icons/ic-insert text.svg new file mode 100644 index 0000000000..5be64447a4 --- /dev/null +++ b/assets/icons/ic-insert text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ic-paragraph.svg b/assets/icons/ic-paragraph.svg new file mode 100644 index 0000000000..5597e07fb5 --- /dev/null +++ b/assets/icons/ic-paragraph.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/ic-replace text.svg b/assets/icons/ic-replace text.svg new file mode 100644 index 0000000000..f62f4cd376 --- /dev/null +++ b/assets/icons/ic-replace text.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ic-table.svg b/assets/icons/ic-table.svg new file mode 100644 index 0000000000..4f91fa3ccf --- /dev/null +++ b/assets/icons/ic-table.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/ic-wv3d-properties-panel-menu.svg b/assets/icons/ic-wv3d-properties-panel-menu.svg new file mode 100644 index 0000000000..b91c2447eb --- /dev/null +++ b/assets/icons/ic-wv3d-properties-panel-menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/ic_snipping_black_24px.svg b/assets/icons/ic_snipping_black_24px.svg new file mode 100644 index 0000000000..5c63158a8b --- /dev/null +++ b/assets/icons/ic_snipping_black_24px.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-action-redo.svg b/assets/icons/icon-action-redo.svg new file mode 100644 index 0000000000..c35646d0b2 --- /dev/null +++ b/assets/icons/icon-action-redo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/icon-action-undo.svg b/assets/icons/icon-action-undo.svg new file mode 100644 index 0000000000..7c887f8ef3 --- /dev/null +++ b/assets/icons/icon-action-undo.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-add-file.svg b/assets/icons/icon-add-file.svg new file mode 100644 index 0000000000..7f0f75d8fb --- /dev/null +++ b/assets/icons/icon-add-file.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-add-folder.svg b/assets/icons/icon-add-folder.svg new file mode 100644 index 0000000000..a18523b571 --- /dev/null +++ b/assets/icons/icon-add-folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-compare-change.svg b/assets/icons/icon-compare-change.svg new file mode 100644 index 0000000000..a3124c5293 --- /dev/null +++ b/assets/icons/icon-compare-change.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-compare-file-open.svg b/assets/icons/icon-compare-file-open.svg new file mode 100644 index 0000000000..76259e7cef --- /dev/null +++ b/assets/icons/icon-compare-file-open.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/icons/icon-content-edit.svg b/assets/icons/icon-content-edit.svg new file mode 100644 index 0000000000..efdbc81314 --- /dev/null +++ b/assets/icons/icon-content-edit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/icon-copy.svg b/assets/icons/icon-copy.svg new file mode 100644 index 0000000000..fd50adbec2 --- /dev/null +++ b/assets/icons/icon-copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-copy2.svg b/assets/icons/icon-copy2.svg new file mode 100644 index 0000000000..0308455453 --- /dev/null +++ b/assets/icons/icon-copy2.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/assets/icons/icon-cut.svg b/assets/icons/icon-cut.svg new file mode 100644 index 0000000000..d17d4c902f --- /dev/null +++ b/assets/icons/icon-cut.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-dark-mode-option.svg b/assets/icons/icon-dark-mode-option.svg new file mode 100644 index 0000000000..1def230aab --- /dev/null +++ b/assets/icons/icon-dark-mode-option.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/icon-double-chevron-down.svg b/assets/icons/icon-double-chevron-down.svg new file mode 100644 index 0000000000..e0f5e872c9 --- /dev/null +++ b/assets/icons/icon-double-chevron-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/icon-folder.svg b/assets/icons/icon-folder.svg new file mode 100644 index 0000000000..9198d4691e --- /dev/null +++ b/assets/icons/icon-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-header-compare.svg b/assets/icons/icon-header-compare.svg new file mode 100644 index 0000000000..769f7f25ae --- /dev/null +++ b/assets/icons/icon-header-compare.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/icon-light-mode-option.svg b/assets/icons/icon-light-mode-option.svg new file mode 100644 index 0000000000..09b65567d3 --- /dev/null +++ b/assets/icons/icon-light-mode-option.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/icon-magnifying-glass.svg b/assets/icons/icon-magnifying-glass.svg new file mode 100644 index 0000000000..9ca2e9c98c --- /dev/null +++ b/assets/icons/icon-magnifying-glass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-menu-both-align.svg b/assets/icons/icon-menu-both-align.svg new file mode 100644 index 0000000000..42ae10b189 --- /dev/null +++ b/assets/icons/icon-menu-both-align.svg @@ -0,0 +1,4 @@ + + + diff --git a/assets/icons/icon-office-editor-circle.svg b/assets/icons/icon-office-editor-circle.svg new file mode 100644 index 0000000000..ef87f22bd0 --- /dev/null +++ b/assets/icons/icon-office-editor-circle.svg @@ -0,0 +1,10 @@ + + + diff --git a/assets/icons/icon-open-folder.svg b/assets/icons/icon-open-folder.svg new file mode 100644 index 0000000000..cd691bc0f5 --- /dev/null +++ b/assets/icons/icon-open-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-paste-without-formatting.svg b/assets/icons/icon-paste-without-formatting.svg new file mode 100644 index 0000000000..e66dac4886 --- /dev/null +++ b/assets/icons/icon-paste-without-formatting.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/icon-paste.svg b/assets/icons/icon-paste.svg new file mode 100644 index 0000000000..5e886dd667 --- /dev/null +++ b/assets/icons/icon-paste.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-pdf-portfolio.svg b/assets/icons/icon-pdf-portfolio.svg new file mode 100644 index 0000000000..73583c8e07 --- /dev/null +++ b/assets/icons/icon-pdf-portfolio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-portfolio-file.svg b/assets/icons/icon-portfolio-file.svg new file mode 100644 index 0000000000..3d5fc8fd9b --- /dev/null +++ b/assets/icons/icon-portfolio-file.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-portfolio-folder.svg b/assets/icons/icon-portfolio-folder.svg new file mode 100644 index 0000000000..9198d4691e --- /dev/null +++ b/assets/icons/icon-portfolio-folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/icon-signature-plus-disabled.svg b/assets/icons/icon-signature-plus-disabled.svg new file mode 100644 index 0000000000..68562af79a --- /dev/null +++ b/assets/icons/icon-signature-plus-disabled.svg @@ -0,0 +1,18 @@ + + + Group 10 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-signature-plus-sign.svg b/assets/icons/icon-signature-plus-sign.svg new file mode 100644 index 0000000000..63ac60bd1e --- /dev/null +++ b/assets/icons/icon-signature-plus-sign.svg @@ -0,0 +1,18 @@ + + + Group 10 + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-sync.svg b/assets/icons/icon-sync.svg new file mode 100644 index 0000000000..5d65fb6184 --- /dev/null +++ b/assets/icons/icon-sync.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/icon-tool-unlink.svg b/assets/icons/icon-tool-unlink.svg index aceeab938b..1939f9de40 100644 --- a/assets/icons/icon-tool-unlink.svg +++ b/assets/icons/icon-tool-unlink.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + \ No newline at end of file diff --git a/assets/icons/icon-tools-more-active.svg b/assets/icons/icon-tools-more-active.svg new file mode 100644 index 0000000000..5c420b1c3e --- /dev/null +++ b/assets/icons/icon-tools-more-active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/icon-tools-more-vertical.svg b/assets/icons/icon-tools-more-vertical.svg new file mode 100644 index 0000000000..03ac54f12f --- /dev/null +++ b/assets/icons/icon-tools-more-vertical.svg @@ -0,0 +1 @@ +icon - tools - more \ No newline at end of file diff --git a/dev-server.js b/dev-server.js index 5b40b5c7f1..ed7ecdb204 100644 --- a/dev-server.js +++ b/dev-server.js @@ -34,9 +34,12 @@ app.get('/', (req, res) => { res.sendFile(path.resolve(__dirname, 'src/index.html')); }); +const sampleURL = encodeURIComponent(JSON.stringify('https://pdftron.s3.amazonaws.com/downloads/pl/demo-annotated.pdf')); + app.get('/sample-url', (req, res) => { + res.redirect( - `/#d=https://pdftron.s3.amazonaws.com/downloads/pl/demo-annotated.pdf&a=1`, + `/#d=${sampleURL}&a=1`, ); }); @@ -47,7 +50,7 @@ app.listen(3000, '0.0.0.0', err => { // eslint-disable-next-line console.info(`Listening at localhost:3000 (http://${ip.address()}:3000)`); open( - 'http://localhost:3000/#d=https://pdftron.s3.amazonaws.com/downloads/pl/demo-annotated.pdf&a=1', + `http://localhost:3000/#d=${sampleURL}&a=1`, ); } }); diff --git a/jest.config.js b/jest.config.js index f1f8e7cf12..2f188b8262 100644 --- a/jest.config.js +++ b/jest.config.js @@ -64,6 +64,9 @@ module.exports = { // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. // maxWorkers: "50%", + //Specifies the memory limit for workers before they are recycled + workerIdleMemoryLimit: '512MB', + // An array of directory names to be searched recursively up from the requiring module's location moduleDirectories: [ "node_modules" @@ -140,7 +143,7 @@ module.exports = { // runner: "jest-runner", // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], + setupFiles: ["jest-canvas-mock"], // A list of paths to modules that run some code to configure or set up the testing framework before each test setupFilesAfterEnv: [ @@ -155,7 +158,7 @@ module.exports = { // snapshotSerializers: [], // The test environment that will be used for testing - // testEnvironment: "jest-environment-jsdom", + testEnvironment: "jsdom", // Options that will be passed to the testEnvironment // testEnvironmentOptions: {}, diff --git a/package.json b/package.json index ff19f00ed7..00ac7bcceb 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,10 @@ "@pdftron/webviewer-downloader": "^1.4.2", "@pdftron/webviewer-reading-mode": "1.0.7", "@reduxjs/toolkit": "^1.8.1", - "@storybook/addon-actions": "6.5.16", - "@storybook/addon-essentials": "6.5.16", - "@storybook/addon-links": "6.5.16", - "@storybook/react": "6.5.16", + "@storybook/addon-actions": "^6.1.11", + "@storybook/addon-essentials": "^6.1.11", + "@storybook/addon-links": "^6.1.11", + "@storybook/react": "^6.1.11", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^10.4.9", "@testing-library/react-hooks": "^5.0.3", @@ -125,4 +125,4 @@ "webpack-dev-middleware": "^3.7.2", "webpack-hot-middleware": "^2.25.0" } -} \ No newline at end of file +} diff --git a/src/apis/ModularComponents/customButton.js b/src/apis/ModularComponents/customButton.js new file mode 100644 index 0000000000..443c0fd5b0 --- /dev/null +++ b/src/apis/ModularComponents/customButton.js @@ -0,0 +1,36 @@ +import { ITEM_TYPE } from 'src/constants/customizationVariables'; +import Item from './item'; + +/** + * Creates a new instance of CustomButton. + * @name CustomButton + * @memberOf UI.Components + * @class UI.Components.CustomButton + * @extends UI.Components.Item + * @property {ItemProperties} properties An object that contains the properties of the CustomButton. + * @property {string} [properties.label] The label of the button. + * @property {string} [properties.img] The icon of the button. + * @property {function} [properties.onClick] The function that is called when the button is clicked. + * @property {boolean} [properties.isActive] Whether the button shows an active state or not. + * @example +const testButton = new instance.UI.Components.CustomButton({ + label: 'test', + title: 'this is a test button', + onClick: () => console.log('button clicked!'), + img: 'icon-save', +}); + */ +class CustomButton extends Item { + constructor(props) { + const { isActive, label, img, onClick } = props; + super(props); + this.type = ITEM_TYPE.BUTTON; + this.isActive = isActive; + this.label = label; + this.img = img; + this.onClick = onClick; + this.type = ITEM_TYPE.BUTTON; + } +} + +export default CustomButton; \ No newline at end of file diff --git a/src/apis/ModularComponents/toggleElementButton.js b/src/apis/ModularComponents/toggleElementButton.js index 8d29090e2b..e792e270b3 100644 --- a/src/apis/ModularComponents/toggleElementButton.js +++ b/src/apis/ModularComponents/toggleElementButton.js @@ -1,6 +1,25 @@ import Item from './item'; import { ITEM_TYPE } from 'constants/customizationVariables'; + +/** + * Creates a new instance of ToggleElementButton. + * @name ToggleElementButton + * @memberOf UI.Components + * @class UI.Components.ToggleElementButton + * @extends UI.Components.Item + * @param {ItemProperties} properties An object that contains the properties of the ToggleElementButton. + * @property {string} properties.toggleElement The dataElement of the element to toggle. + * @property {string} [properties.label] The label of the button. + * @property {string} [properties.img] The title of the button which appears in a tooltip. + * @example +const toggleButton = new instance.UI.Components.ToggleElementButton({ + label: 'Toggle', + title: 'Toggle the visibility of the element', + img: 'icon-save', + toggleElement: 'elementToToggle', +}); + */ class ToggleElementButton extends Item { constructor(props) { const { label, img, toggleElement } = props; diff --git a/src/apis/ModularComponents/toolButton.js b/src/apis/ModularComponents/toolButton.js index 5e3e34af0e..cc6d51af9a 100644 --- a/src/apis/ModularComponents/toolButton.js +++ b/src/apis/ModularComponents/toolButton.js @@ -1,6 +1,24 @@ import { ITEM_TYPE } from 'src/constants/customizationVariables'; import Item from './item'; +/** + * Creates a new instance of ToolButton. + * @name ToolButton + * @memberOf UI.Components + * @class UI.Components.ToolButton + * @extends UI.Components.Item + * @property {ItemProperties} properties An object that contains the properties of the ToolButton. + * @property {string} [properties.label] The label of the button. + * @property {string} [properties.img] The icon of the button. + * @property {string} properties.toolName The name of the tool that the button activates. Refer to: {@link Core.Tools.ToolNames} + * @example +const toolButton = new instance.UI.Components.ToolButton({ + label: 'Pan', + title: 'Pan the document', + img: 'icon-header-pan', + toolName: 'Pan', +}); + */ class ToolButton extends Item { constructor(props) { const { isActive, label, img, onClick, toolName, color } = props; diff --git a/src/apis/TabManagerAPI.js b/src/apis/TabManagerAPI.js index b7b11d8221..c1ba59ccc5 100644 --- a/src/apis/TabManagerAPI.js +++ b/src/apis/TabManagerAPI.js @@ -76,6 +76,7 @@ const TabManagerAPI = { * @param {boolean} [options.officeOptions.formatOptions.hideTotalNumberOfPages] If true will hide total number of pages from page number labels (i.e, Page 1, Page 2, vs Page 1 of 2, Page 2 of 2) * @param {boolean} [options.officeOptions.formatOptions.applyPageBreaksToSheet] If true will split Excel worksheets into pages so that the output resembles print output. * @param {boolean} [options.officeOptions.formatOptions.displayChangeTracking] If true will display office change tracking markup present in the document (i.e, red strikethrough of deleted content and underlining of new content). Otherwise displays the resolved document content, with no markup. Defaults to true. + * @param {boolean} [officeOptions.formatOptions.displayHiddenText] If true will display hidden text in document. Otherwise hidden text will not be shown. Defaults to false. * @param {number} [options.officeOptions.formatOptions.excelDefaultCellBorderWidth] Cell border width for table cells that would normally be drawn with no border. In units of points. Can be used to achieve a similar effect to the "show gridlines" display option within Microsoft Excel. * @param {number} [options.officeOptions.formatOptions.excelMaxAllowedCellCount] An exception will be thrown if the number of cells in an Excel document is above the value. Used for early termination of resource intensive documents. Setting this value to 250000 will allow the vast majority of Excel documents to convert without issue, while keeping RAM usage to a reasonable level. By default there is no limit to the number of allowed cells. * @param {string} [options.officeOptions.formatOptions.locale] Sets the value for Locale in the options object ISO 639-1 code of the current system locale. For example: 'en-US', 'ar-SA', 'de-DE', etc. diff --git a/src/apis/addModularHeaders.js b/src/apis/addModularHeaders.js new file mode 100644 index 0000000000..513effdfc2 --- /dev/null +++ b/src/apis/addModularHeaders.js @@ -0,0 +1,19 @@ +/** + * Adds new custom Header(s) to the Header list + * @method UI.addModularHeaders + * @param {array} headerList The list of headers to be added on the application + * @example + * WebViewer(...) + .then(function (instance) { + const newHeader = new instance.UI.Components.ModularHeader({ + dataElement: 'top-header', + location: 'top' + }); + + instance.UI.addModularHeaders([newHeader]); + */ +import actions from 'actions'; + +export default (store) => (headerList) => { + store.dispatch(actions.addModularHeaders(headerList)); +}; \ No newline at end of file diff --git a/src/apis/disableApplyCropWarningModal.js b/src/apis/disableApplyCropWarningModal.js new file mode 100644 index 0000000000..37ef8f2170 --- /dev/null +++ b/src/apis/disableApplyCropWarningModal.js @@ -0,0 +1,17 @@ +import actions from 'actions'; + +/** + * Disable the confirmation modal when applying a crop to a page + * + * @method UI.disableApplyCropWarningModal + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.disableApplyCropWarningModal(); + }); + */ +const disableApplyCropWarningModal = (store) => () => { + store.dispatch(actions.disableApplyCropWarningModal()); +}; + +export default disableApplyCropWarningModal; diff --git a/src/apis/disableApplySnippingWarningModal.js b/src/apis/disableApplySnippingWarningModal.js new file mode 100644 index 0000000000..02a67c5f0d --- /dev/null +++ b/src/apis/disableApplySnippingWarningModal.js @@ -0,0 +1,17 @@ +import actions from 'actions'; + +/** + * Disable the confirmation modal when snipping a page + * + * @method UI.disableApplySnippingWarningModal + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.disableApplySnippingWarningModal(); + }); + */ +const disableApplySnippingWarningModal = (store) => () => { + store.dispatch(actions.disableApplySnippingWarningModal()); +}; + +export default disableApplySnippingWarningModal; diff --git a/src/apis/disableBookmarkIconShortcutVisibility.js b/src/apis/disableBookmarkIconShortcutVisibility.js new file mode 100644 index 0000000000..cb91cbf8ad --- /dev/null +++ b/src/apis/disableBookmarkIconShortcutVisibility.js @@ -0,0 +1,16 @@ +import core from 'core'; +import actions from 'actions'; + +/** + * Hide bookmark icon shortcuts on the top right corner of each page. + * @method UI.disableBookmarkIconShortcutVisibility + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.disableBookmarkIconShortcutVisibility(); + }); + */ +export default (store) => () => { + store.dispatch(actions.setBookmarkIconShortcutVisibility(false)); + core.setBookmarkIconShortcutVisibility(false); +}; \ No newline at end of file diff --git a/src/apis/disableFeatureFlag.js b/src/apis/disableFeatureFlag.js new file mode 100644 index 0000000000..74832ff0d2 --- /dev/null +++ b/src/apis/disableFeatureFlag.js @@ -0,0 +1,23 @@ +/** + * Disables a specified feature flag. + * @ignore + * @method UI.disableFeatureFlag + * @param {string} featureFlag The feature flag to disable. To find feature flag names, refer to Feature flags. + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.disableFeatureFlag(instance.UI.FeatureFlags.CUSTOMIZABLE_UI); + }); + */ + +import actions from 'actions'; +import FEATURE_FLAGS from 'constants/featureFlags'; + +export default (store) => (featureFlag) => { + const featureFlags = Object.values(FEATURE_FLAGS); + if (!featureFlags.includes(featureFlag)) { + console.warn(`Feature flag ${featureFlag} does not exist.`); + } else { + store.dispatch(actions.disableFeatureFlag(featureFlag)); + } +}; \ No newline at end of file diff --git a/src/apis/enableApplyCropWarningModal.js b/src/apis/enableApplyCropWarningModal.js new file mode 100644 index 0000000000..e6c3bd9720 --- /dev/null +++ b/src/apis/enableApplyCropWarningModal.js @@ -0,0 +1,17 @@ +import actions from 'actions'; + +/** + * Enable the confirmation modal when applying a crop to a page + * + * @method UI.enableApplyCropWarningModal + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.enableApplyCropWarningModal(); + }); + */ +const enableApplyCropWarningModal = (store) => () => { + store.dispatch(actions.enableApplyCropWarningModal()); +}; + +export default enableApplyCropWarningModal; diff --git a/src/apis/enableApplySnippingWarningModal.js b/src/apis/enableApplySnippingWarningModal.js new file mode 100644 index 0000000000..da3b72882e --- /dev/null +++ b/src/apis/enableApplySnippingWarningModal.js @@ -0,0 +1,17 @@ +import actions from 'actions'; + +/** + * Enable the confirmation modal when snipping a page + * + * @method UI.enableApplySnippingWarningModal + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.enableApplySnippingWarningModal(); + }); + */ +const enableApplySnippingWarningModal = (store) => () => { + store.dispatch(actions.enableApplySnippingWarningModal()); +}; + +export default enableApplySnippingWarningModal; diff --git a/src/apis/enableBookmarkIconShortcutVisibility.js b/src/apis/enableBookmarkIconShortcutVisibility.js new file mode 100644 index 0000000000..04a9b49e64 --- /dev/null +++ b/src/apis/enableBookmarkIconShortcutVisibility.js @@ -0,0 +1,21 @@ +import core from 'core'; +import actions from 'actions'; +import selectors from 'selectors'; +import DataElements from '../constants/dataElement'; + +/** + * Show bookmark icon shortcuts on the top right corner of each page for quickly adding or removing a bookmark. + * @method UI.enableBookmarkIconShortcutVisibility + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.enableBookmarkIconShortcutVisibility(); + }); + */ +export default (store) => () => { + store.dispatch(actions.setBookmarkIconShortcutVisibility(true)); + const isBookmarkPanelEnabled = !selectors.isElementDisabled(store.getState(), DataElements.BOOKMARK_PANEL); + if (isBookmarkPanelEnabled) { + core.setBookmarkIconShortcutVisibility(true); + } +}; \ No newline at end of file diff --git a/src/apis/enableFeatureFlag.js b/src/apis/enableFeatureFlag.js new file mode 100644 index 0000000000..b5615a2084 --- /dev/null +++ b/src/apis/enableFeatureFlag.js @@ -0,0 +1,23 @@ +/** + * Enables a specified feature flag. + * @ignore + * @method UI.enableFeatureFlag + * @param {string} featureFlag The feature flag to enable. To find feature flag names, refer to Feature flags. + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.enableFeatureFlag(instance.UI.FeatureFlags.CUSTOMIZABLE_UI); + }); + */ + +import actions from 'actions'; +import FEATURE_FLAGS from 'constants/featureFlags'; + +export default (store) => (featureFlag) => { + const featureFlags = Object.values(FEATURE_FLAGS); + if (!featureFlags.includes(featureFlag)) { + console.warn(`Feature flag ${featureFlag} does not exist.`); + } else { + store.dispatch(actions.enableFeatureFlag(featureFlag)); + } +}; \ No newline at end of file diff --git a/src/apis/getAnnotationStylePopupTabs.js b/src/apis/getAnnotationStylePopupTabs.js new file mode 100644 index 0000000000..64c83f74ed --- /dev/null +++ b/src/apis/getAnnotationStylePopupTabs.js @@ -0,0 +1,49 @@ +import { getMap } from '../constants/map'; +/** + * @typedef {string} StyleTab The style tab in the annotation style popup window. See {@link UI.AnnotationStylePopupTabs} for valid style tabs. + */ + +/** + * @typedef {object} AnnotationStyleTabConfiguration + * @property {string[]} styleTabs Indicates the available style tabs in the annotation style popup window. See {@link UI.AnnotationStylePopupTabs}. + * @property {string} currentStyleTab The current tab in the annotation style popup window. + */ + +/** + * Returns the style popup tab configurations for the specified annotation type. + * If there was no annotation key specified, it will return the style popup tab configurations for all the annotations. + * @method UI.getAnnotationStylePopupTabs + * @param {string} [annotationKey] Indicate the type of an annotation, see {@link UI.AnnotationKeys}. + * @return {AnnotationStyleTabConfiguration[]} + @example + WebViewer(...) + .then(function(instance) { + console.log(instance.UI.getAnnotationStylePopupTabs()); + ); + }); + */ + +export default (annotationKey) => { + const map = getMap(); + const annotationTabs = {}; + if (annotationKey) { + const targetAnnotation = map[annotationKey]; + if (!targetAnnotation) { + return console.error('No such annotation exist. Please provide a valid annotation key.'); + } + annotationTabs[annotationKey] = { + styleTabs: targetAnnotation.styleTabs, + currentStyleTab: targetAnnotation.currentStyleTab, + }; + } else { + const annotationKeys = Object.keys(map); + annotationKeys.forEach((annotationKey) => { + annotationTabs[annotationKey] = { + styleTabs: map[annotationKey].styleTabs, + currentStyleTab: map[annotationKey].currentStyleTab, + }; + }); + return annotationTabs; + } + return annotationTabs; +}; \ No newline at end of file diff --git a/src/apis/getCurrentLanguage.js b/src/apis/getCurrentLanguage.js new file mode 100644 index 0000000000..b931429f8c --- /dev/null +++ b/src/apis/getCurrentLanguage.js @@ -0,0 +1,14 @@ +/** + * Return the current language used in WebViewer. + * @method UI.getCurrentLanguage + * @return {string} Current language code + * @example +WebViewer(...) + .then(function(instance) { + console.log(instance.UI.getCurrentLanguage()); + }); + */ + +import selectors from 'selectors'; + +export default (store) => () => selectors.getCurrentLanguage(store.getState()); diff --git a/src/apis/getDocumentViewer.js b/src/apis/getDocumentViewer.js new file mode 100644 index 0000000000..49861cd943 --- /dev/null +++ b/src/apis/getDocumentViewer.js @@ -0,0 +1,9 @@ +import core from 'core'; + +export default (documentViewerKey) => { + if (!documentViewerKey) { + return core.getDocumentViewer(1); + } + + return core.getDocumentViewer(documentViewerKey); +}; \ No newline at end of file diff --git a/src/apis/getLocalizedText.js b/src/apis/getLocalizedText.js new file mode 100644 index 0000000000..afb6947eef --- /dev/null +++ b/src/apis/getLocalizedText.js @@ -0,0 +1,38 @@ +import i18next from 'i18next'; + +/** + * Return the localized text for the given key. This functions exactly the same as the t API from the i18n library. + *
+ * This may be used to leverage the existing localization setup in WebViewer in custom elements, modals, etc. + * @method UI.getLocalizedText + * @return {string|Array} The translation key + * @example +WebViewer(...) + .then(function(instance) { + const button = document.createElement('button'); + button.innerText = instance.UI.getLocalizedText('action.add'); + + instance.UI.setHeaderItems(header => { + const renderButton = () => button; + + const newCustomElement = { + type: 'customElement', + title: 'action.add', + render: renderButton + }; + header.push(newCustomElement); + }); + + instance.UI.addEventListener(instance.UI.Events.LANGUAGE_CHANGED, () => { + // Manually update components + button.innerText = instance.UI.getLocalizedText('action.add'); + }); + + instance.UI.setLanguage(instance.UI.Languages.FR); + // The button text will be 'Ajouter' (French) instead of 'Add' (English) + }); + */ + +const getLocalizedText = (key) => i18next.t(key); + +export default getLocalizedText; diff --git a/src/apis/getModularHeaderList.js b/src/apis/getModularHeaderList.js new file mode 100644 index 0000000000..f54cbcadf3 --- /dev/null +++ b/src/apis/getModularHeaderList.js @@ -0,0 +1,26 @@ +/** + * Return the list of Custom Headers. + * @method UI.getModularHeaderList + * @return {UI.Components.ModularHeader} Custom Header List + * @example +WebViewer(...) + .then(function(instance) { + const headerList = instance.UI.getModularHeaderList() + }); + */ + + +import selectors from 'selectors'; +import ModularHeader from './ModularComponents/modularHeader'; +import createModularInstance from './ModularComponents/createModularInstance'; + +export default (store) => () => { + const hydratedHeaders = selectors.getHydratedHeaders(store.getState()); + + // Now we create instance of each class and return it + return hydratedHeaders.map((header) => { + const nestedItems = header.items.map((item) => createModularInstance(item, store)); + const headerInstance = new ModularHeader(store)({ ...header, items: nestedItems }); + return headerInstance; + }); +}; \ No newline at end of file diff --git a/src/apis/hideFormFieldIndicators.js b/src/apis/hideFormFieldIndicators.js new file mode 100644 index 0000000000..ad98421607 --- /dev/null +++ b/src/apis/hideFormFieldIndicators.js @@ -0,0 +1,14 @@ +import actions from 'actions'; + +/** + * Hide form field indicators. + * @method UI.hideFormFieldIndicators + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.hideFormFieldIndicators(); + }); + */ +export default (store) => () => { + store.dispatch(actions.disableElement('formFieldIndicatorContainer')); +}; \ No newline at end of file diff --git a/src/apis/loadDocument.js b/src/apis/loadDocument.js index 5d06d43ca4..4e103e5acf 100644 --- a/src/apis/loadDocument.js +++ b/src/apis/loadDocument.js @@ -45,6 +45,7 @@ export default (store) => (src, options) => { * @property {boolean} [officeOptions.formatOptions.hideTotalNumberOfPages] If true will hide total number of pages from page number labels (i.e, Page 1, Page 2, vs Page 1 of 2, Page 2 of 2) * @property {boolean} [officeOptions.formatOptions.applyPageBreaksToSheet] If true will split Excel worksheets into pages so that the output resembles print output. * @property {boolean} [officeOptions.formatOptions.displayChangeTracking] If true will display office change tracking markup present in the document (i.e, red strikethrough of deleted content and underlining of new content). Otherwise displays the resolved document content, with no markup. Defaults to true. + * @property {boolean} [officeOptions.formatOptions.displayHiddenText] If true will display hidden text in document. Otherwise hidden text will not be shown. Defaults to false. * @property {number} [officeOptions.formatOptions.excelDefaultCellBorderWidth] Cell border width for table cells that would normally be drawn with no border. In units of points. Can be used to achieve a similar effect to the "show gridlines" display option within Microsoft Excel. * @property {number} [officeOptions.formatOptions.excelMaxAllowedCellCount] An exception will be thrown if the number of cells in an Excel document is above the value. Used for early termination of resource intensive documents. Setting this value to 250000 will allow the vast majority of Excel documents to convert without issue, while keeping RAM usage to a reasonable level. By default there is no limit to the number of allowed cells. * @property {string} [officeOptions.formatOptions.locale] Sets the value for Locale in the options object ISO 639-1 code of the current system locale. For example: 'en-US', 'ar-SA', 'de-DE', etc. diff --git a/src/apis/measurementScale.js b/src/apis/measurementScale.js new file mode 100644 index 0000000000..26da9e7df8 --- /dev/null +++ b/src/apis/measurementScale.js @@ -0,0 +1,92 @@ +import selectors from 'selectors'; +import actions from 'actions'; + +/** + * Returns all the measurement scale preset options for the given measurement system. + * @method UI.getMeasurementScalePreset + * @param {string} measurementSystem The measurement system, can be either 'metric' or 'imperial'. + * @returns {Array.>} All the measurement scale preset options for the given measurement system. + * @example +WebViewer(...) + .then(function(instance) { + console.log(instance.UI.getMeasurementScalePreset('metric')); + }); + */ +export const getMeasurementScalePreset = (store) => (measurementSystem) => selectors.getMeasurementScalePreset(store.getState())[measurementSystem]; + +/** + * Adds a new preset option for measurement scales. + * @method UI.addMeasurementScalePreset + * @param {object} options Options for adding a new preset option. + * @param {string} options.measurementSystem The measurement system, can be either 'metric' or 'imperial'. + * @param {string} options.displayName The display name of the new preset option. + * @param {Core.Scale} options.presetScale The scale object of the new preset option. + * @param {number} [options.index] The index at which to insert the new preset option. If not provided, the new preset will be added to the last of the preset options list. + * @example +WebViewer(...) + .then(function(instance) { + const newScale = new instance.Core.Scale([[1, 'mm'], [300, 'mm']]); + instance.UI.addMeasurementScalePreset({ + measurementSystem: 'metric', + displayName: '1:300', + presetScale: newScale, + index: 5 + }); + }); + */ +export const addMeasurementScalePreset = (store) => ({ measurementSystem, displayName, presetScale, index }) => { + store.dispatch(actions.addMeasurementScalePreset(measurementSystem, [displayName, presetScale], index)); +}; + +/** + * Adds an existing preset option for measurement scales. + * @method UI.removeMeasurementScalePreset + * @param {string} measurementSystem The measurement system, can be either 'metric' or 'imperial'. + * @param {number} index The index at which to remove the existing preset option. + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.removeMeasurementScalePreset('metric', 5); + }); + */ +export const removeMeasurementScalePreset = (store) => (measurementSystem, index) => { + store.dispatch(actions.removeMeasurementScalePreset(measurementSystem, index)); +}; + +/** + * Enable multiple scales mode. + * @method UI.enableMultipleScalesMode + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.enableMultipleScalesMode(); + }); + */ +export const enableMultipleScalesMode = (store) => () => { + store.dispatch(actions.setIsMultipleScalesMode(true)); +}; + +/** + * Disable multiple scales mode. + * @method UI.disableMultipleScalesMode + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.disableMultipleScalesMode(); + }); + */ +export const disableMultipleScalesMode = (store) => () => { + store.dispatch(actions.setIsMultipleScalesMode(false)); +}; + +/** + * Returns whether multiple scales mode is enabled. + * @method UI.isMultipleScalesModeEnabled + * @returns {boolean} True if multiple scales mode is enabled, false if multiple scales mode is disabled. + * @example +WebViewer(...) + .then(function(instance) { + console.log(instance.UI.isMultipleScalesModeEnabled()); + }); + */ +export const isMultipleScalesModeEnabled = (store) => () => selectors.getIsMultipleScalesMode(store.getState()); diff --git a/src/apis/multiViewerSync.js b/src/apis/multiViewerSync.js new file mode 100644 index 0000000000..04b9efa0e6 --- /dev/null +++ b/src/apis/multiViewerSync.js @@ -0,0 +1,47 @@ +import actions from 'actions'; +import selectors from 'selectors'; + +/** + * @method UI.enableMultiViewerSync + * @param {number} [primaryViewerKey=1] Which DocumentViewer to set as primary for initial zoom sync (1 or 2) + * @example + WebViewer(...) + .then((instance) => { + instance.UI.enableMultiViewerSync(1) // Value can be 1 (for left side) or 2 (for right side) + }); + */ +const enableMultiViewerSync = (store) => (primaryViewerKey) => { + if (!primaryViewerKey || (primaryViewerKey !== 1 && primaryViewerKey !== 2)) { + primaryViewerKey = 1; + } + store.dispatch(actions.setSyncViewer(primaryViewerKey)); +}; + +/** + * @method UI.disableMultiViewerSync + * @example + WebViewer(...) + .then((instance) => { + instance.UI.disableMultiViewerSync(); + }); + */ +const disableMultiViewerSync = (store) => () => { + store.dispatch(actions.setSyncViewer(null)); +}; + +/** + * @method UI.isMultiViewerSyncing + * @return {boolean} returns true if sync is enabled false if disabled + * @example + WebViewer(...) + .then((instance) => { + console.log(instance.UI.isMultiViewerSyncing()); + }); + */ +const isMultiViewerSyncing = (store) => () => !!selectors.getSyncViewer(store.getState()); + +export { + isMultiViewerSyncing, + enableMultiViewerSync, + disableMultiViewerSync, +}; \ No newline at end of file diff --git a/src/apis/outlinesPanel.js b/src/apis/outlinesPanel.js new file mode 100644 index 0000000000..14a8e8f0ee --- /dev/null +++ b/src/apis/outlinesPanel.js @@ -0,0 +1,36 @@ +import actions from 'actions'; + +/** + * @namespace UI.OutlinesPanel + */ + +/** + * @method UI.OutlinesPanel.setDefaultOptions + * @param {object} [options] Options for the OutlinesPanel. + * @param {boolean} [options.autoExpandOutlines] If set to true, will expand outlines. + * @example +WebViewer(...) + .then((instance) => { + instance.UI.OutlinesPanel.setDefaultOptions({ + autoExpandOutlines: true, + }) + }); + */ + +const setDefaultOptions = (store) => (options) => { + const defaultOptions = { + autoExpandOutlines: false, + }; + + const outlinesPanelOptions = { + ...defaultOptions, + ...options, + }; + + const { autoExpandOutlines } = outlinesPanelOptions; + store.dispatch(actions.setAutoExpandOutlines(autoExpandOutlines)); +}; + +export { + setDefaultOptions, +}; diff --git a/src/apis/searchTextFull.js b/src/apis/searchTextFull.js index 9c313ddf30..10d81983c1 100644 --- a/src/apis/searchTextFull.js +++ b/src/apis/searchTextFull.js @@ -53,14 +53,14 @@ function buildSearchModeFlag(options = {}) { return searchMode; } -export default (store) => (searchValue, options) => { +export default (store) => (searchValue, options, isUserTriggered = true) => { const dispatch = store?.dispatch; // Store is optional. Default activeDocumentViewerKey is 1 const activeDocumentViewerKey = store ? selectors.getActiveDocumentViewerKey(store.getState()) : 1; if (dispatch) { // dispatch is only set when doing search through API (instance.searchText()) // When triggering search through UI, then redux updates are already handled inside component - dispatch(actions.openElement('searchPanel')); + isUserTriggered && dispatch(actions.openElement('searchPanel')); dispatch(actions.searchTextFull(searchValue, options)); } diff --git a/src/apis/setAnnotationStylePopupTabs.js b/src/apis/setAnnotationStylePopupTabs.js new file mode 100644 index 0000000000..a46e945e97 --- /dev/null +++ b/src/apis/setAnnotationStylePopupTabs.js @@ -0,0 +1,32 @@ + +/** + * Sets the available style tabs in the style popup for a specific annotation type. + * @method UI.setAnnotationStylePopupTabs + * @param {string} annotationKey The annotation type. See {@link UI.AnnotationKeys}. + * @param {string[]} newAnnotationStyleTabs Indicates the available style tabs for the annotation. See {@link UI.AnnotationStylePopupTabs}. + * @param {string} [initialTab] The initial style tab. It should be one of the elements in newAnnotationStyleTabs if passed to the API. + @example + WebViewer(...) + .then(function(instance) { + instance.UI.setAnnotationStylePopupTabs( + instance.UI.AnnotationKeys.FREE_TEXT, + [ + instance.UI.AnnotationStylePopupTabs.TEXT_COLOR, + instance.UI.AnnotationStylePopupTabs.FILL_COLOR + ], + instance.UI.AnnotationStylePopupTabs.FILL_COLOR + ); + }); + */ + +import { updateAnnotationStylePopupTabs, copyMapWithDataProperties } from '../constants/map'; +import actions from 'actions'; + +export default (store) => (annotationKey, newAnnotationStyleTabs, initialTab) => { + const result = + updateAnnotationStylePopupTabs(annotationKey, newAnnotationStyleTabs, initialTab); + if (result === true) { + const newColorMap = copyMapWithDataProperties('currentStyleTab', 'iconColor'); + store.dispatch(actions.setColorMap(newColorMap)); + } +}; \ No newline at end of file diff --git a/src/apis/setClickMiddleware.js b/src/apis/setClickMiddleware.js new file mode 100644 index 0000000000..4394e1b0cf --- /dev/null +++ b/src/apis/setClickMiddleware.js @@ -0,0 +1,33 @@ +import { setClickMiddleWare as _setClickMiddleware } from 'helpers/clickTracker'; + +/** + * @name UI.ClickedItemTypes + * @enum {string} + * @property {string} BUTTON button type + */ + +/** + * @callback UI.clickMiddleware + * @param {string} dataElement The dataElement of the clicked item + * @param {object} info + * @param {string} info.type The type of the clicked item. Will be one of {@link UI.ClickedItemTypes} + */ + +/** + * Sets a function to be called before the default click handler. + * Can be used to track clicks on buttons in the UI. + * @method UI.setClickMiddleware + * @param {UI.clickMiddleware} middleware A callback function that will be called before the default click handler. + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.setClickMiddleware(function(dataElement, { type }) { + if (type === instance.UI.ClickedItemTypes.BUTTON) { + console.log('clicked button: ', dataElement) + } + }) + }) + */ +export const setClickMiddleware = (middleware) => { + _setClickMiddleware(middleware); +}; diff --git a/src/apis/setCustomMultiViewerAcceptedFileFormats.js b/src/apis/setCustomMultiViewerAcceptedFileFormats.js new file mode 100644 index 0000000000..d5179a9c5c --- /dev/null +++ b/src/apis/setCustomMultiViewerAcceptedFileFormats.js @@ -0,0 +1,21 @@ +import actions from 'actions'; + +/** + * @callback CustomMultiViewerAcceptedFileFormats + * @memberof UI + * @param {Array} acceptedFileFormats The file formats to support when accepting files in multiviewer mode + */ + +/** + * @method UI.setCustomMultiViewerAcceptedFileFormats + * @param {UI.CustomMultiViewerAcceptedFileFormats} customMultiViewerAcceptedFileFormats + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.setCustomMultiViewerAcceptedFormats(['pdf']); + }); + */ + +export default (store) => (customMultiViewerAcceptedFileFormats) => { + store.dispatch(actions.setCustomMultiViewerAcceptedFileFormats(customMultiViewerAcceptedFileFormats)); +}; \ No newline at end of file diff --git a/src/apis/setCustomMultiViewerSyncHandler.js b/src/apis/setCustomMultiViewerSyncHandler.js new file mode 100644 index 0000000000..b5498e6b92 --- /dev/null +++ b/src/apis/setCustomMultiViewerSyncHandler.js @@ -0,0 +1,24 @@ +import actions from 'actions'; + +/** + * @callback CustomMultiViewerSyncHandler + * @memberof UI + * @param {number} primaryDocumentViewerKey The primary documentViewerKey to be used when syncing + * @param {Array} removeHandlerFunctions The event listeners to remove when syncing is finished + */ + +/** + * @method UI.setCustomMultiViewerSyncHandler + * @param {UI.CustomMultiViewerSyncHandler} customMultiViewerSyncHandler The function that will be invoked when syncing documents in multi viewer mode. + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.setCustomMultiViewerSyncHandler((primaryDocumentViewerKey, removeHandlerFunctions) => { + // some code + }) + }); + */ + +export default (store) => (customMultiViewerSyncHandler) => { + store.dispatch(actions.setCustomMultiViewerSyncHandler(customMultiViewerSyncHandler)); +}; \ No newline at end of file diff --git a/src/apis/setEmbeddedJSPopupStyle.js b/src/apis/setEmbeddedJSPopupStyle.js new file mode 100644 index 0000000000..ac846449fa --- /dev/null +++ b/src/apis/setEmbeddedJSPopupStyle.js @@ -0,0 +1,20 @@ +/** + * Set a custom style for menus displayed through embedded JavaScript. + * @method UI.setEmbeddedPopupMenuStyle + * @param {object} customStyle A style object that overrides the existing embedded JS menu style. Properties/keys must follow the React style naming convention. + * @example +WebViewer(...) + .then(function(instance) { + // Size width to fit content and let menu flow off the screen + instance.UI.setEmbeddedPopupMenuStyle({ + minWidth: 'fit-content', + minHeight: 'inheirit', + }); + }); + */ + +import actions from 'actions'; + +export default (store) => (customStyle) => { + store.dispatch(actions.setEmbeddedPopupMenuStyle(customStyle)); +}; diff --git a/src/apis/setGrayscaleDarknessFactor.js b/src/apis/setGrayscaleDarknessFactor.js new file mode 100644 index 0000000000..5fb293d270 --- /dev/null +++ b/src/apis/setGrayscaleDarknessFactor.js @@ -0,0 +1,16 @@ +import { setGrayscaleDarknessFactor } from 'helpers/print'; + +/** + * Set Grayscale Darkness Factor for printing in Grayscale + * @method UI.setGrayscaleDarknessFactor + * @param {number} darknessFactor Default is '1', '0' is fully black and white + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.setGrayscaleDarknessFactor(0.5); + }); + */ + +export default (darknessFactor) => { + setGrayscaleDarknessFactor(darknessFactor); +}; diff --git a/src/apis/setInlineCommentFilter.js b/src/apis/setInlineCommentFilter.js new file mode 100644 index 0000000000..b9a7e3f3bf --- /dev/null +++ b/src/apis/setInlineCommentFilter.js @@ -0,0 +1,28 @@ +/** + * Return the annotations that have inline comment enabled on select + * @method UI.setInlineCommentFilter + * @param {UI.filterAnnotation} filterAnnotation Function that takes an annotation and returns if the annotation should have inline comment feature enabled when it's selected + * @example + WebViewer(...) + .then(function(instance) { + // only enable inline comment for free-hand annotations on select + instance.UI.setInlineCommentFilter((annotation) => { + return annotation.ToolName === instance.Core.Tools.ToolNames.FREEHAND; + }); + }); + */ + +/** + * Callback that gets passed to {@link UI.setInlineCommentFilter setInlineCommentFilter}. + * @callback UI.filterAnnotation + * @param {Core.Annotations.Annotation} annotation Annotation object + * @returns {boolean} Whether the annotation should have inline comment feature enabled on select. + */ + +import actions from 'actions'; + +export default (store) => (filterFunc) => { + const { TYPES, checkTypes } = window.Core; + checkTypes([filterFunc], [TYPES.FUNCTION], 'UI.setInlineCommentFilter'); + store.dispatch(actions.setInlineCommentFilter(filterFunc)); +}; \ No newline at end of file diff --git a/src/apis/setMultiViewerSyncScrollingMode.js b/src/apis/setMultiViewerSyncScrollingMode.js new file mode 100644 index 0000000000..0dddab95ec --- /dev/null +++ b/src/apis/setMultiViewerSyncScrollingMode.js @@ -0,0 +1,22 @@ +/** + * Set the scrolling behavior of sync scrolling in semantic compare mode. + * Must be one of the following values: + * - 'SYNC': scroll synchronously in both documents + * - 'SKIP_UNMATCHED': scroll according to the next matched position in both documents + * @method UI.setMultiViewerSyncScrollingMode + * @param {(string)} multiViewerSyncScrollingMode the scrolling behavior of sync scrolling in semantic comparing mode. + * @example + WebViewer(...) + .then(function(instance) { + instance.UI.setMultiViewerSyncScrollingMode('SYNC'); + }); + */ + +import actions from 'actions'; +import { SYNC_MODES } from 'constants/multiViewerContants'; + +export default (store) => (multiViewerComparedSyncScrollingMode) => { + const { TYPES, checkTypes } = window.Core; + checkTypes([multiViewerComparedSyncScrollingMode], [TYPES.ONE_OF(SYNC_MODES.SYNC, SYNC_MODES.SKIP_UNMATCHED)], 'UI.setMultiViewerSyncScrollingMode'); + store.dispatch(actions.setMultiViewerSyncScrollingMode(multiViewerComparedSyncScrollingMode)); +}; diff --git a/src/apis/setPanelWidth.js b/src/apis/setPanelWidth.js new file mode 100644 index 0000000000..192c1b87b1 --- /dev/null +++ b/src/apis/setPanelWidth.js @@ -0,0 +1,40 @@ +/** + * Sets the current width of a panel + * @method UI.setPanelWidth + * @param {string} dataElement Panel element to set width of + * @example + WebViewer(...) + .then(function(instance) { + // open left panel + instance.UI.openElements([ 'leftPanel' ]); + // Set the width of the left panel to 238px + instance.UI.setPanelWidth('leftPanel', 238); + */ + +import actions from 'actions'; +import { panelMinWidth } from 'constants/panel'; + +export default (store) => (dataElement, width) => { + const { checkTypes, TYPES } = window.Core; + checkTypes([dataElement, width], [TYPES.STRING, TYPES.NUMBER], 'UI.setPanelWidth'); + + const minAllowedWidth = panelMinWidth; + const maxAllowedWidth = window.innerWidth; + if (width < minAllowedWidth) { + console.warn(`UI.setPanelWidth: width cannot be less than ${minAllowedWidth}px. Setting width to ${minAllowedWidth}px`); + width = minAllowedWidth; + } else if (width > maxAllowedWidth) { + console.warn(`UI.setPanelWidth: width cannot be greater than ${maxAllowedWidth}px. Setting width to ${maxAllowedWidth}px`); + width = maxAllowedWidth; + } + + // For default panels, we use a dataElement specific action to set the width + // Check if the dataElement specific action exists, if it does, use it as well + const testAction = actions[`set${capitalize(dataElement)}Width`]; + if (testAction && typeof testAction === 'function') { + store.dispatch(testAction(width)); + } + store.dispatch(actions.setPanelWidth(dataElement, width)); +}; + +const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); \ No newline at end of file diff --git a/src/apis/setPresetNewPageDimensions.js b/src/apis/setPresetNewPageDimensions.js new file mode 100644 index 0000000000..dfb95de7d6 --- /dev/null +++ b/src/apis/setPresetNewPageDimensions.js @@ -0,0 +1,42 @@ +/** + * Sets preset page dimensions to be used when selecting a page size in the Insert Page Modal + * @method UI.setPresetNewPageDimensions + * @param {string} presetName The name of a current preset or the name to give to a new preset + * @param {object} newPreset A set of dimensions to use for a preset new page + * @param {number} newPreset.height The height of the new page in inches + * @param {number} newPreset.width The width of the new page in inches + * + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.setPresetNewPageDimensions('Letter', {'height': 11, 'width': 8.5}); + }); + */ + +import actions from 'actions'; +import selectors from 'selectors'; + +const dimensions = ['height', 'width']; + +export default (store) => (presetName, newPreset) => { + if (!presetName || typeof presetName !== 'string' || presetName.trim().length < 1) { + return console.error('presetName must be a string of length 1 or more'); + } + if (!newPreset || typeof newPreset !== 'object') { + return console.error('newPreset must be an object with "height", and "width" properties'); + } + + for (const dimension of dimensions) { + if (!(dimension in newPreset)) { + return console.error(`${dimension} must be included in newPreset`); + } + if (typeof newPreset[dimension] !== 'number' || newPreset[dimension] < 0) { + return console.error(`${dimension} must be a number greater than or equal to 0`); + } + } + + const presets = selectors.getPresetCropDimensions(store.getState()); + presets[presetName] = newPreset; + + store.dispatch(actions.setPresetNewPageDimensions(presets)); +}; diff --git a/src/apis/setTimezone.js b/src/apis/setTimezone.js new file mode 100644 index 0000000000..53aad85d5d --- /dev/null +++ b/src/apis/setTimezone.js @@ -0,0 +1,18 @@ +/** + * Sets the timezone that will be used in the UI anywhere a date is displayed. + * A list of timezone names can be found {@link https://momentjs.com/timezone/ at momentjs docs}. + * @method UI.setTimezone + * @param {string} timezone Name of the timezone, e.g. "America/New_York", "America/Los_Angeles", "Europe/London". + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.setTimezone('Europe/London'); + */ + +import actions from 'actions'; + +export default (store) => (timezone) => { + const { TYPES, checkTypes } = window.Core; + checkTypes([timezone], [TYPES.STRING], 'UI.setTimezone'); + store.dispatch(actions.setTimezone(timezone)); +}; \ No newline at end of file diff --git a/src/apis/setWv3dPropertiesPanelModelData.js b/src/apis/setWv3dPropertiesPanelModelData.js new file mode 100644 index 0000000000..b0e9a333e7 --- /dev/null +++ b/src/apis/setWv3dPropertiesPanelModelData.js @@ -0,0 +1,16 @@ +/** + * Set the WV3D Properties Panel with an array of model data objects + * @method UI.setWv3dPropertiesPanelModelData + * @param {array} modelData Array of objects defining 3d metadata properties. + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.setWv3dPropertiesPanelModelData([{'name':'roof', 'height':'55cm'}, {'name':'wall', 'height':'100cm'}]); + }); + */ + +import actions from 'actions'; + +export default (store) => (modelData) => { + store.dispatch(actions.setWv3dPropertiesPanelModelData(modelData)); +}; diff --git a/src/apis/setWv3dPropertiesPanelSchema.js b/src/apis/setWv3dPropertiesPanelSchema.js new file mode 100644 index 0000000000..acfff9422d --- /dev/null +++ b/src/apis/setWv3dPropertiesPanelSchema.js @@ -0,0 +1,56 @@ +/** + * Set the configuration schema for the WV3D Properties Panel + * @method UI.setWv3dPropertiesPanelSchema + * @param {object} schema Object containing options for configuring the 3d properties panel. + * @param {string} schema.headerName Sets the Title Header + * @param {object} schema.defaultValues Defines the key/value pairs that will appear under the title, outside of a group. + * @param {object} schema.groups Defines the collapsible groups that appear below the default values. + * @param {array} schema.groupOrder Defines the order of the groups. If a group is not included it is appended to the end of the defined groups. + * @param {boolean} schema.removeEmptyRows Defines whether to remove rows that contain empty string values. + * @param {boolean} schema.removeEmptyGroups Defines whether to remove groups that contain only empty string values. + * @param {boolean} schema.createRawValueGroup Defines whether to create a final group that has all the raw values. + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.setWv3dPropertiesPanelSchema({ + headerName: 'Name', + defaultValues: { + Description: 'Description', + GlobalID: 'GlobalId', + Handle: 'handle', + EmptyRow1: 'EmptyRow1', + }, + groups: { + SampleGroup01: { + SampleField01: 'Sample01', + SampleField02: 'Sample02', + SampleField03: 'Sample03', + EmptyRow2: 'EmptyRow2', + GrossFootprintArea: 'GrossFootprintArea', + GrossSideArea: 'GrossSideArea', + GrossVolume: 'GrossVolume', + }, + SampleGroup02: { + SampleField01: 'Sample01', + SampleField02: 'Sample02', + SampleField03: 'Sample03', + }, + SampleGroup03: { + ObjectType: 'Elephants', + EmptyRow3: 'Tigers', + ObjectPlacement: 'Bears', + }, + }, + groupOrder: ['Dimensions', 'RandomStuff'], + removeEmptyRows: false, + removeEmptyGroups: true, + createRawValueGroup: true, + }) + }); + */ + +import actions from 'actions'; + +export default (store) => (schema) => { + store.dispatch(actions.setWv3dPropertiesPanelSchema(schema)); +}; diff --git a/src/apis/showFormFieldIndicators.js b/src/apis/showFormFieldIndicators.js new file mode 100644 index 0000000000..8c43a36676 --- /dev/null +++ b/src/apis/showFormFieldIndicators.js @@ -0,0 +1,14 @@ +import actions from 'actions'; + +/** + * Show form field indicators to help navigate or guide users through the process of form filling. + * @method UI.showFormFieldIndicators + * @example +WebViewer(...) + .then(function(instance) { + instance.UI.showFormFieldIndicators(); + }); + */ +export default (store) => () => { + store.dispatch(actions.enableElement('formFieldIndicatorContainer')); +}; \ No newline at end of file diff --git a/src/components/AnnotationPopup/AnnotationPopup.spec.js b/src/components/AnnotationPopup/AnnotationPopup.spec.js new file mode 100644 index 0000000000..69fd86259a --- /dev/null +++ b/src/components/AnnotationPopup/AnnotationPopup.spec.js @@ -0,0 +1,142 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { BasicHorizontal, IsReadOnlyMode } from './AnnotationPopup.stories'; +import AnnotationPopup from './AnnotationPopup'; + +const TestAnnotationPopup = withProviders(BasicHorizontal); + +describe('AnnotationPopup Component', () => { + it('Should not throw any errors when rendering storybook component', () => { + expect(() => { + render(); + }).not.toThrow(); + }); +}); + +const TestReadOnlyAnnotationPopup = withProviders(IsReadOnlyMode); + +const AnnotationPopupWithProviders = withProviders(AnnotationPopup); + +describe('AnnotationPopup in read-only mode', () => { + const mockFileAttachmentAnnotation = new window.Core.Annotations.FileAttachmentAnnotation(); + const fileAttachmentProps = { + isOpen: true, + isRightClickMenu: false, + focusedAnnotation: mockFileAttachmentAnnotation, + position: { top: 0, left: 0 }, + showCommentButton: true, + onCommentAnnotation: () => console.log('Comment'), + showEditStyleButton: false, + showLinkButton: false, + linkAnnotationToURL: () => console.log('Link'), + showDeleteButton: false, + onDeleteAnnotation: () => console.log('Delete'), + showFileDownloadButton: true, + showCalibrateButton: false, + }; + + it('Should no throw any error', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('Should render the "download button" when annotation is FileAttachmentAnnotation', () => { + const mockFileAttachmentAnnotation = new window.Core.Annotations.FileAttachmentAnnotation(); + const fileAttachmentProps = { + isOpen: true, + isRightClickMenu: false, + focusedAnnotation: mockFileAttachmentAnnotation, + position: { top: 0, left: 0 }, + showCommentButton: true, + onCommentAnnotation: () => console.log('Comment'), + showEditStyleButton: false, + showLinkButton: false, + linkAnnotationToURL: () => console.log('Link'), + showDeleteButton: false, + onDeleteAnnotation: () => console.log('Delete'), + showFileDownloadButton: true, + showCalibrateButton: false, + }; + + render( + + ); + + screen.getByLabelText('Download attached file'); + screen.getByLabelText('Comment'); + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(2); + }); + + describe('should only render the "Comment Button"', () => { + // The following properties are related to canModify in AnnotationPopupContainer + // showCalibrateButton, showDeleteButton, showEditStyleButton, showGroupButton + const basicProps = { + isOpen: true, + isRightClickMenu: false, + position: { top: 0, left: 0 }, + showCommentButton: true, + onCommentAnnotation: () => console.log('Comment'), + showEditStyleButton: false, + showLinkButton: false, + linkAnnotationToURL: () => console.log('Link'), + showDeleteButton: false, + onDeleteAnnotation: () => console.log('Delete'), + showCalibrateButton: false, + isInReadOnlyMode: true + }; + + function createAnnotationProps(annotation) { + return { + ...basicProps, + focusedAnnotation: annotation + }; + } + + function shouldOnlyRenderCommentButton(annotation) { + render( + + ); + + screen.getByLabelText('Comment'); + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBe(1); + } + + it('for FreeTextAnnotation', () => { + const freeTextAnnotation = new window.Core.Annotations.FreeTextAnnotation(); + shouldOnlyRenderCommentButton(freeTextAnnotation); + }); + + it('for freehand annotations', () => { + const freeHandAnnotation = new window.Core.Annotations.FreeHandAnnotation(); + shouldOnlyRenderCommentButton(freeHandAnnotation); + }); + + it('for Line Annotation', () => { + const lineAnnotation = new window.Core.Annotations.LineAnnotation(); + shouldOnlyRenderCommentButton(lineAnnotation); + }); + + it('for EllipseAnnotation', () => { + const ellipseAnnotation = new window.Core.Annotations.EllipseAnnotation(); + shouldOnlyRenderCommentButton(ellipseAnnotation); + }); + + it('for RedactionAnnotation', () => { + const redactionAnnotation = new window.Core.Annotations.RedactionAnnotation(); + shouldOnlyRenderCommentButton(redactionAnnotation); + }); + + it('for RectangleAnnotation', () => { + const rectangleAnnotation = new window.Core.Annotations.RectangleAnnotation(); + shouldOnlyRenderCommentButton(rectangleAnnotation); + }); + + it('for Model3DAnnotation', () => { + const Model3DAnnotation = new window.Core.Annotations.Model3DAnnotation(); + shouldOnlyRenderCommentButton(Model3DAnnotation); + }); + }); +}); \ No newline at end of file diff --git a/src/components/Bookmark/Bookmark.spec.js b/src/components/Bookmark/Bookmark.spec.js new file mode 100644 index 0000000000..a7a638739a --- /dev/null +++ b/src/components/Bookmark/Bookmark.spec.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { Basic } from './Bookmark.stories'; + +const BasicOutline = withProviders(Basic); + +describe('Outline', () => { + it('Story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('Double clicking on the title should show a renaming input', () => { + const { container } = render(); + const bookmarkElements = container.querySelector('.bookmark-outline-text'); + let textInput = container.querySelector('.bookmark-outline-input'); + expect(textInput).toBeNull(); + + expect(bookmarkElements.className).toContain('bookmark-text-input'); + fireEvent.doubleClick(bookmarkElements); + textInput = container.querySelector('.bookmark-outline-input'); + expect(textInput).not.toBeNull(); + }); +}); diff --git a/src/components/Bookmark/Bookmark.stories.js b/src/components/Bookmark/Bookmark.stories.js new file mode 100644 index 0000000000..40e6e6cae3 --- /dev/null +++ b/src/components/Bookmark/Bookmark.stories.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import Bookmark from './Bookmark'; +import '../LeftPanel/LeftPanel.scss'; + +const NOOP = () => { }; + +export default { + title: 'Components/Bookmark', + component: Bookmark, + includeStories: ['Basic', 'Adding'], +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + pageLabels: [ + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + ], + currentPage: 3, + }, + document: { + bookmarks: { + 0: 'B1', + 1: 'B2', + } + } +}; + +export const Basic = () => { + return ( +
+
+ initialState })}> + + +
+
+ ); +}; + +export const Adding = () => { + return ( +
+
+ initialState })}> + + +
+
+ ); +}; diff --git a/src/components/BookmarksPanel/BookmarksPanel.spec.js b/src/components/BookmarksPanel/BookmarksPanel.spec.js new file mode 100644 index 0000000000..07cec21277 --- /dev/null +++ b/src/components/BookmarksPanel/BookmarksPanel.spec.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { Basic } from './BookmarksPanel.stories'; + +const BasicBookmarksPanel = withProviders(Basic); + +const NOOP = () => { }; + +jest.mock('core', () => ({ + setBookmarkIconShortcutVisibility: NOOP, +})); + +describe('BookmarksPanel', () => { + it('Story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('Clicks the Add Bookmark button should show an input element', async () => { + const { container } = render(); + const addNewBookmarkButton = container.querySelector('[data-element="addNewBookmarkButton"]'); + expect(addNewBookmarkButton.className).toContain('add-new-button'); + + let textInput = container.querySelector('.bookmark-outline-input'); + expect(textInput).toBeNull(); + await addNewBookmarkButton.click(); + textInput = container.querySelector('.bookmark-outline-input'); + expect(textInput).not.toBeNull(); + }); + + it('In multi-select mode, add button is enabled when no bookmark is selected and delete button is enabled when at least one bookmark is selected', async () => { + const { container } = render(); + const multiSelectButton = container.querySelector('[data-element="bookmarkMultiSelect"]'); + expect(multiSelectButton.className).toContain('bookmark-outline-control-button'); + await multiSelectButton.click(); + + const addNewContainer = container.querySelector('[data-element="addNewBookmarkButtonContainer"]'); + const addNewButton = addNewContainer.firstChild; + const deleteButton = addNewContainer.lastChild; + expect(addNewButton).not.toBeDisabled(); + expect(deleteButton).toBeDisabled(); + + const checkboxContainer = container.querySelector('.bookmark-outline-checkbox'); + const checkboxInput = checkboxContainer.querySelector('input[type="checkbox"]'); + await checkboxInput.click(); + expect(checkboxContainer.className).toContain('ui__choice--checked'); + expect(addNewButton).toBeDisabled(); + expect(deleteButton).not.toBeDisabled(); + }); +}); diff --git a/src/components/Button/Button.scss b/src/components/Button/Button.scss index c55713467a..3e2097b0e7 100644 --- a/src/components/Button/Button.scss +++ b/src/components/Button/Button.scss @@ -40,6 +40,10 @@ background: none!important; .Icon { color: var(--disabled-icon); + + g { + stroke: var(--disabled-icon); + } } span { color: var(--disabled-icon); diff --git a/src/components/Button/Button.spec.js b/src/components/Button/Button.spec.js new file mode 100644 index 0000000000..9978a86dc2 --- /dev/null +++ b/src/components/Button/Button.spec.js @@ -0,0 +1,19 @@ +import Button from './Button'; +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { setClickMiddleWare } from 'helpers/clickTracker'; + +const ButtonWithProviders = withProviders(Button); + + +describe('Button component', () => { + it('Triggers middleware when clicked', () => { + const container = render(); + const middleware = jest.fn(); + setClickMiddleWare(middleware); + const button = container.getByRole('button'); + fireEvent.click(button); + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith('test', { type: 'button' }); + }); +}); diff --git a/src/components/CalibrationPopup/CalibrationPopup.js b/src/components/CalibrationPopup/CalibrationPopup.js new file mode 100644 index 0000000000..a11cc823ae --- /dev/null +++ b/src/components/CalibrationPopup/CalibrationPopup.js @@ -0,0 +1,240 @@ +import React, { useState, useEffect, useRef } from 'react'; +import selectors from 'selectors'; +import { useSelector, useDispatch, shallowEqual } from 'react-redux'; +import core from 'core'; +import actions from 'actions'; +import { Choice } from '@pdftron/webviewer-react-toolkit'; +import Dropdown from '../Dropdown'; +import Tooltip from '../Tooltip'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import { + metricUnits, + convertUnit, + fractionalUnits, + floatRegex, + inFractionalRegex, + ftInFractionalRegex, + ftInDecimalRegex, + parseFtInDecimal, + parseInFractional, + parseFtInFractional, + hintValues +} from 'constants/measurementScale'; +import classNames from 'classnames'; + +import './CalibrationPopup.scss'; + +const Scale = window.Core.Scale; + +const parseMeasurementContentsByAnnotation = (annotation) => { + const factor = annotation.Measure.axis[0].factor; + const unit = annotation.Scale[1][1]; + + switch (unit) { + case 'ft-in': + return (annotation.getLineLength() * factor) / 12; + case 'in': + default: + return annotation.getLineLength() * factor; + } +}; + +const getDefaultPageUnit = (pageUnit) => { + if (pageUnit === 'pt') { + return 'pt'; + } + if (metricUnits.includes(pageUnit)) { + return 'mm'; + } + return 'in'; +}; + +const CalibrationPropType = { + annotation: PropTypes.shape({ + Scale: PropTypes.arrayOf(PropTypes.array), + }), +}; + +const CalibrationPopup = ({ annotation }) => { + const [t] = useTranslation(); + const dispatch = useDispatch(); + + const [ + measurementUnits, + { tempScale, isFractionalUnit, defaultUnit } + ] = useSelector((state) => [ + selectors.getMeasurementUnits(state), + selectors.getCalibrationInfo(state) + ], shallowEqual); + const [valueDisplay, setValueDisplay] = useState(''); + const inputRef = useRef(null); + + const unitTo = new Scale(tempScale).worldScale?.unit || 'mm'; + const unitToOptions = isFractionalUnit ? measurementUnits.to.filter((unit) => fractionalUnits.includes(unit)) : measurementUnits.to; + const isFractionalUnitsToggleDisabled = !fractionalUnits.includes(unitTo); + const valueInputType = (isFractionalUnit || unitTo === 'ft-in') ? 'text' : 'number'; + const inputValueClass = classNames('input-field', { + 'invalid-value': !(tempScale && new Scale(tempScale).worldScale?.value > 0) + }); + + const updateTempScale = (scaleValue, scaleUnit) => { + const currentDistance = parseMeasurementContentsByAnnotation(annotation); + const currentScale = annotation.Scale; + const newRatio = currentDistance / currentScale[1][0]; + const pageScale = [currentScale[0][0] * newRatio, currentScale[0][1]]; + const defaultPageUnit = getDefaultPageUnit(scaleUnit); + const defaultPageValue = convertUnit(pageScale[0], pageScale[1], defaultPageUnit); + dispatch(actions.updateCalibrationInfo({ tempScale: `${defaultPageValue} ${defaultPageUnit} = ${scaleValue} ${scaleUnit}`, isFractionalUnit })); + }; + + const setValue = (scaleValue) => { + updateTempScale(scaleValue, new Scale(tempScale).worldScale?.unit); + }; + + const setUnitTo = (scaleUnit) => { + updateTempScale(new Scale(tempScale).worldScale?.value, scaleUnit); + }; + + const toggleFractionalUnits = () => { + dispatch(actions.updateCalibrationInfo({ tempScale, isFractionalUnit: !isFractionalUnit })); + }; + + const onValueInputChange = (e) => { + setValueDisplay(e.target.value); + const inputValue = e.target.value.trim(); + if (!isFractionalUnit) { + if (unitTo === 'ft-in' && ftInDecimalRegex.test(inputValue)) { + const result = parseFtInDecimal(inputValue); + if (result > 0) { + setValue(result); + return; + } + } else if (floatRegex.test(inputValue)) { + const result = parseFloat(inputValue) || 0; + setValue(result); + return; + } + } else { + if (unitTo === 'in') { + if (inFractionalRegex.test(inputValue)) { + const result = parseInFractional(inputValue); + if (result > 0) { + setValue(result); + return; + } + } + } else if (unitTo === 'ft-in') { + if (ftInFractionalRegex.test(inputValue)) { + const result = parseFtInFractional(inputValue); + if (result > 0) { + setValue(result); + return; + } + } + } + } + setValue(0); + }; + + const onValueInputBlur = () => { + updateValueDisplay(); + }; + + const updateValueDisplay = () => { + const scaleValue = new Scale(tempScale).worldScale?.value; + let newValueDisplay; + if (!isFractionalUnit && unitTo !== 'ft-in') { + newValueDisplay = `${scaleValue}`; + } else { + newValueDisplay = Scale.getFormattedValue(scaleValue, unitTo, isFractionalUnit ? 1 / 64 : 0.0001, false, true); + } + setValueDisplay(newValueDisplay || ''); + }; + + const tempScaleRef = useRef(tempScale); + useEffect(() => { + tempScaleRef.current = tempScale; + }, [tempScale]); + useEffect(() => { + if (annotation) { + const value = parseMeasurementContentsByAnnotation(annotation); + const unit = annotation.Scale[1][1]; + if (defaultUnit) { + updateTempScale(convertUnit(value, unit, defaultUnit), defaultUnit); + } else { + updateTempScale(value, unit); + } + } + + const onAnnotationChanged = (annotations, action) => { + if (action === 'modify' && annotations.length === 1 && annotations[0] === annotation) { + const value = parseMeasurementContentsByAnnotation(annotation); + const unit = annotation.Scale[1][1]; + const currentUnit = new Scale(tempScaleRef.current).worldScale?.unit; + if (currentUnit) { + updateTempScale(convertUnit(value, unit, currentUnit), currentUnit); + } else { + updateTempScale(value, unit); + } + } + }; + core.addEventListener('annotationChanged', onAnnotationChanged); + + return () => { + core.removeEventListener('annotationChanged', onAnnotationChanged); + core.deleteAnnotations([annotation]); + }; + }, [annotation]); + + useEffect(() => { + if (inputRef?.current !== document.activeElement) { + updateValueDisplay(); + } + }, [tempScale, isFractionalUnit]); + + return ( +
+
+ + +
+ +
+
+
+ +
+ +
+
+
+ ); +}; + +CalibrationPopup.propTypes = CalibrationPropType; + +export default CalibrationPopup; diff --git a/src/components/CalibrationPopup/CalibrationPopup.scss b/src/components/CalibrationPopup/CalibrationPopup.scss new file mode 100644 index 0000000000..7ba831ac5a --- /dev/null +++ b/src/components/CalibrationPopup/CalibrationPopup.scss @@ -0,0 +1,57 @@ +@import '../../constants/styles'; +@import '../../constants/popup'; + +.CalibrationPopup { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 12px; + + width: 220px; + height: 84px; + + background: #ffffff; + + box-shadow: 0px 0px 3px #868e96; + border-radius: 4px; + + .pop-switch { + margin-top: 12px; + + &.ui__choice--disabled .ui__choice__label { + color: #CFD4DA; + } + } + + .input-container { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + width: 196px; + height: 32px; + + .input-field { + width: 94px; + height: 32px; + + &.invalid-value { + border-color: #ff0000; + } + + .Dropdown__wrapper { + width: 100%; + height: 100%; + + .Dropdown { + height: 100%; + text-align: left; + } + + .Dropdown__items { + width: 100%; + } + } + } + } +} diff --git a/src/components/CalibrationPopup/CalibrationPopup.stories.js b/src/components/CalibrationPopup/CalibrationPopup.stories.js new file mode 100644 index 0000000000..d7165bb39e --- /dev/null +++ b/src/components/CalibrationPopup/CalibrationPopup.stories.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { createStore, combineReducers } from 'redux'; +import { Provider as ReduxProvider } from 'react-redux'; +import CalibrationPopup from './CalibrationPopup'; +import viewerReducer from 'reducers/viewerReducer'; +import initialState from 'src/redux/initialState'; + +export default { + title: 'Components/CalibrationPopup', + component: CalibrationPopup, + argTypes: { + // when annotation or the annotations style are updated using storybook controls + // Annotation.Color objects are converted to plain JS objects and component will crash + // so disabling controls for now. There are ways to get this working (like converting them back to Annotation.Color object) but not going to do it at this point + annotation: { + table: { + disable: true + } + }, + style: { + table: { + disable: true + } + } + } +}; + + +const reducer = combineReducers({ + viewer: viewerReducer(initialState.viewer), +}); +const store = createStore(reducer); + +const BasicTemplate = (args) => { + return ( + + + + ); +}; + +const distanceMeasurementAnnot = new window.Core.Annotations.LineAnnotation(); +distanceMeasurementAnnot['Measure'] = { + 'scale': '1 in = 1 in', + 'axis': [ + { + 'factor': 0.0138889, + 'unit': 'in', + 'decimalSymbol': '.', + 'thousandsSymbol': ',', + 'display': 'D', + 'precision': 100, + 'unitPrefix': '', + 'unitSuffix': '', + 'unitPosition': 'S', + }, + ], + 'distance': [ + { + 'factor': 1, + 'unit': 'in', + 'decimalSymbol': '.', + 'thousandsSymbol': ',', + 'display': 'D', + 'precision': 100, + 'unitPrefix': '', + 'unitSuffix': '', + 'unitPosition': 'S', + }, + ], + 'area': [ + { + 'factor': 1, + 'unit': 'sq in', + 'decimalSymbol': '.', + 'thousandsSymbol': ',', + 'display': 'D', + 'precision': 100, + 'unitPrefix': '', + 'unitSuffix': '', + 'unitPosition': 'S', + }, + ], +}; +distanceMeasurementAnnot['IT'] = 'LineDimension'; +distanceMeasurementAnnot['DisplayUnits'] = ['in']; +distanceMeasurementAnnot['Scale'] = [[1, 'in'], [1, 'in']]; +distanceMeasurementAnnot['Precision'] = 0.01; + +export const Basic = BasicTemplate.bind({}); +Basic.args = { + annotation: distanceMeasurementAnnot, +}; diff --git a/src/components/CalibrationPopup/index.js b/src/components/CalibrationPopup/index.js new file mode 100644 index 0000000000..5ba41ae612 --- /dev/null +++ b/src/components/CalibrationPopup/index.js @@ -0,0 +1,3 @@ +import CalibrationPopup from './CalibrationPopup'; + +export default CalibrationPopup; \ No newline at end of file diff --git a/src/components/ColorPalettePicker/ColorPalettePicker.js b/src/components/ColorPalettePicker/ColorPalettePicker.js index 00d327a1f6..329d131eb2 100644 --- a/src/components/ColorPalettePicker/ColorPalettePicker.js +++ b/src/components/ColorPalettePicker/ColorPalettePicker.js @@ -18,30 +18,26 @@ const ColorPalettePicker = ({ enableEdit, disableTitle = false, colorsAreHex = false, - colorsToIgnore }) => { + const [t] = useTranslation(); + useEffect(() => { - if (!customColors.includes(colorsAreHex ? color : getHexColor(color))) { + const isNotInCustomColors = !customColors.includes(colorsAreHex ? color : getHexColor(color)); + if (isNotInCustomColors) { setColorToBeDeleted(''); } else { setColorToBeDeleted(colorsAreHex ? color : getHexColor(color)); } }, [color]); - const [t] = useTranslation(); - const handleAddColor = () => { if (openColorPicker) { openColorPicker(true); } }; - if (colorsToIgnore) { - customColors = customColors.filter((color) => !colorsToIgnore.includes(color)); - } - return ( -
+
{!disableTitle &&
{t('annotation.custom')} @@ -50,6 +46,7 @@ const ColorPalettePicker = ({ {customColors.map((bg, i) => (
+ +
+ + ); +}; + +export default ContentEditLinkModal; \ No newline at end of file diff --git a/src/components/ContentEditLinkModal/ContentEditLinkModalContainer.js b/src/components/ContentEditLinkModal/ContentEditLinkModalContainer.js new file mode 100644 index 0000000000..23b1013ddb --- /dev/null +++ b/src/components/ContentEditLinkModal/ContentEditLinkModalContainer.js @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import ContentEditLinkModal from './ContentEditLinkModal'; +import selectors from 'selectors'; +import DataElements from 'constants/dataElement'; +import { useSelector, useDispatch } from 'react-redux'; +import actions from 'src/redux/actions'; + +const ContentEditLinkModalContainer = () => { + const [ + isOpen, + contentBoxEditor, + ] = useSelector((state) => [ + selectors.isElementOpen(state, DataElements.CONTENT_EDIT_LINK_MODAL), + selectors.getContentBoxEditor(state), + ]); + + const dispatch = useDispatch(); + + let existingLinkUrl = ''; + if (contentBoxEditor) { + existingLinkUrl = contentBoxEditor.hyperlink; + } + + const closeModal = useCallback(() => { + dispatch(actions.closeElement(DataElements.CONTENT_EDIT_LINK_MODAL)); + if (contentBoxEditor) { + contentBoxEditor.blur(); + } + }, [contentBoxEditor]); + + const addLink = useCallback((url) => { + if (contentBoxEditor) { + contentBoxEditor.insertHyperlink(url); + contentBoxEditor.blur(); + } + }, [contentBoxEditor]); + + return isOpen ? () : null; +}; + +export default ContentEditLinkModalContainer; \ No newline at end of file diff --git a/src/components/ContentEditLinkModal/index.js b/src/components/ContentEditLinkModal/index.js new file mode 100644 index 0000000000..a0d9e46325 --- /dev/null +++ b/src/components/ContentEditLinkModal/index.js @@ -0,0 +1,3 @@ +import ContentEditLinkModal from './ContentEditLinkModalContainer'; + +export default ContentEditLinkModal; \ No newline at end of file diff --git a/src/components/CreatePortfolioModal/CreatePortfolioModal.scss b/src/components/CreatePortfolioModal/CreatePortfolioModal.scss new file mode 100644 index 0000000000..797c4cd96a --- /dev/null +++ b/src/components/CreatePortfolioModal/CreatePortfolioModal.scss @@ -0,0 +1,210 @@ +@import "../../constants/styles"; +@import "../../constants/modal"; +@import "../../constants/tabs"; + +.CreatePortfolioModal { + @extend %modal; + + .container { + @extend %modal-shared-container-style; + @extend %modal-shared-container-style-mobile; + @extend %tab-list; + @extend %tab-panel; + + display: flex; + flex-direction: column; + justify-content: space-between; + width: 737px; + height: 584px; + padding: 0px; + border-radius: 4px; + box-shadow: 0px 0px 3px var(--document-box-shadow); + background: var(--component-background); + + .header { + display: flex; + justify-content: space-between; + margin: 16px; + margin-bottom: 10px; + font-size: 16px; + font-weight: bold; + align-items: center; + height: 24px; + + .Button { + height: 24px; + } + } + + .divider { + height: 1px; + width: 100%; + background: var(--divider); + } + + .tab-list { + font-size: 13px; + margin: 0 16px 16px 16px; + width: calc(100% - 32px); + + .tab-options-button { + @include button-reset; + } + } + + .tab-panels { + height: 100%; + padding: 16px; + + .tab-panel { + height: 100%; + + .file-picker-body .modal-btn-file { + height: 36px; + display: flex; + align-items: center; + } + } + } + + .footer { + display: flex; + padding: 16px; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 0; + + .Button { + @include button-reset; + background: var(--primary-button); + border-radius: 4px; + padding: 0 8px; + height: 32px; + min-width: 72px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + color: var(--primary-button-text); + cursor: pointer; + + @include mobile { + font-size: 13px; + } + + &:enabled:hover { + background: var(--primary-button-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .create-portfolio { + @include button-reset; + background: var(--primary-button); + border-radius: 4px; + padding: 0 8px; + height: 32px; + min-width: 70px; + display: flex; + align-items: center; + justify-content: center; + position: relative; + color: var(--primary-button-text); + cursor: pointer; + + @include mobile { + font-size: 13px; + } + + &:enabled:hover { + background: var(--primary-button-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + .add-item-option { + visibility: hidden; + display: flex; + align-items: center; + margin-left: 16px; + color: var(--secondary-button-text); + position: relative; + cursor: pointer; + + &.show-popup { + color: var(--secondary-button-hover); + + .add-item-icon { + color: var(--secondary-button-hover); + } + + .Button .Icon { + color: var(--secondary-button-hover); + } + } + + .add-item-icon { + color: var(--secondary-button-text); + } + + .add-item-option-text { + margin-left: 4px; + } + + .Button { + background-color: transparent; + padding-left: 0; + min-width: 0px; + width: 50px; + + &:hover { + background-color: transparent; + } + + &.active { + background-color: transparent; + } + + .Icon { + width: 12px; + height: 12px; + color: var(--secondary-button-text); + } + } + + .add-item-trigger { + width: 1px; + height: 1px; + visibility: hidden; + position: absolute; + left: 100px; + top: 30px; + } + } + } + } + + &.is-editing .container { + height: 604px; + width: 786px; + + .header { + margin-bottom: 20px; + } + + .footer { + .add-item-option { + visibility: visible; + } + } + } +} diff --git a/src/components/CreatePortfolioModal/CreatePortfolioModal.stories.js b/src/components/CreatePortfolioModal/CreatePortfolioModal.stories.js new file mode 100644 index 0000000000..0844a00efe --- /dev/null +++ b/src/components/CreatePortfolioModal/CreatePortfolioModal.stories.js @@ -0,0 +1,35 @@ +import React from 'react'; +import CreatePortfolioModal from './CreatePortfolioModal'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; +import DataElements from 'constants/dataElement'; + +export default { + title: 'Components/CreatePortfolioModal', + component: CreatePortfolioModal +}; + +const getStore = () => { + const initialState = { + viewer: { + openElements: { [DataElements.CREATE_PORTFOLIO_MODAL]: true }, + disabledElements: {}, + tab: { [DataElements.CREATE_PORTFOLIO_MODAL]: DataElements.PORTFOLIO_UPLOAD_FILES_TAB }, + customElementOverrides: {}, + } + }; + + function rootReducer(state = initialState, action) { + return state; + } + + return createStore(rootReducer); +}; + +export function Basic() { + return ( + + + + ); +} diff --git a/src/components/CreatePortfolioModal/PortfolioItemPreview.js b/src/components/CreatePortfolioModal/PortfolioItemPreview.js new file mode 100644 index 0000000000..8fad1d2f83 --- /dev/null +++ b/src/components/CreatePortfolioModal/PortfolioItemPreview.js @@ -0,0 +1,56 @@ +import React, { useState, useRef, useEffect, memo } from 'react'; +import core from 'core'; +import Icon from 'components/Icon'; + +const options = { loadAsPDF: true }; + +const PortfolioItemPreview = ({ item }) => { + const canvasContainer = useRef(); + const [showIcon, setShowIcon] = useState(false); + + useEffect(() => { + let document; + let requestId; + + const fn = async () => { + try { + document = await core.createDocument(item, options); + const pageCount = document.getPageCount(); + if (pageCount < 1) { + setShowIcon(true); + return; + } + requestId = await document.loadThumbnail(1, (canvas) => { + const canvasContainerWidth = canvasContainer.current.clientWidth; + const canvasContainerHeight = canvasContainer.current.clientHeight; + const canvasWidth = canvas.width; + const canvasHeight = canvas.height; + if (canvasContainerWidth < canvasWidth || canvasContainerHeight < canvasHeight) { + const ratio = Math.min(canvasContainerWidth / canvasWidth, canvasContainerHeight / canvasHeight); + canvas.style.width = `${canvasWidth * ratio}px`; + canvas.style.height = `${canvasHeight * ratio}px`; + } + canvasContainer.current?.appendChild(canvas); + }); + } catch (e) { + setShowIcon(true); + } + }; + + fn(); + + return () => { + requestId && document.cancelLoadThumbnail(requestId); + }; + }, []); + + return ( +
+ {showIcon && ( + + )} +
+ ); +}; + +export default memo(PortfolioItemPreview); diff --git a/src/components/CreatePortfolioModal/index.js b/src/components/CreatePortfolioModal/index.js new file mode 100644 index 0000000000..0896b6e233 --- /dev/null +++ b/src/components/CreatePortfolioModal/index.js @@ -0,0 +1,3 @@ +import CreatePortfolioModal from './CreatePortfolioModal'; + +export default CreatePortfolioModal; diff --git a/src/components/DimensionInput/DimensionInput.js b/src/components/DimensionInput/DimensionInput.js new file mode 100644 index 0000000000..8b031d50da --- /dev/null +++ b/src/components/DimensionInput/DimensionInput.js @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { isIE11 } from 'helpers/device'; +import classNames from 'classnames'; + +import './DimensionInput.scss'; + +const DimensionInput = ({ className, label, initialValue, onChange, unit, maxLength = 10, disabled }) => { + const [value, setValue] = useState(initialValue); + + const handleDimensionChange = (e) => { + setValue(e.target.value); + onChange(e.target.value); + }; + + // Resizes number input boxes so that units of measurement can be shown next to them as if they are also in the same box + const resizeInput = (input) => { + let length = input.toString().length; + let decimalSize = 0.3; + if (isIE11) { + const IE_ADJUSTMENT = 1.25; + length *= IE_ADJUSTMENT; + maxLength *= IE_ADJUSTMENT; + decimalSize *= IE_ADJUSTMENT; + } + if (input.toString().includes('.')) { + length -= decimalSize; + } else { + length += decimalSize; + } + if (length > maxLength) { + return maxLength; + } + return length; + }; + + return ( +
+ +
+ ); +}; + +export default DimensionInput; diff --git a/src/components/DimensionInput/DimensionInput.scss b/src/components/DimensionInput/DimensionInput.scss new file mode 100644 index 0000000000..fa1c8734fd --- /dev/null +++ b/src/components/DimensionInput/DimensionInput.scss @@ -0,0 +1,47 @@ +.dimension-input-container { + display: flex; + align-items: center; + border: 1px solid var(--border); + background: var(--component-background); + color: var(--text-color); + border-radius: 4px; + width: 100%; + max-width: 80px; + min-width: 64px; + height: 28px; + padding: 1px 2px; + + /* Chrome, Safari, Edge, Opera */ + input::-webkit-outer-spin-button, + input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + input[type=number] { + -moz-appearance: textfield; + } + + input { + border: 0; + } + + input:focus { + border: 0; + } +} + +.dimension-input { + border: 0; + border-radius: 0; + padding: 0; + margin-right: 0; + margin-left: 4px; + min-width: 1ch; +} + +.dimension-unit { + color: var(--text-color); + font-size: 13px; +} \ No newline at end of file diff --git a/src/components/DimensionInput/DimensionInput.stories.js b/src/components/DimensionInput/DimensionInput.stories.js new file mode 100644 index 0000000000..6afa7bef43 --- /dev/null +++ b/src/components/DimensionInput/DimensionInput.stories.js @@ -0,0 +1,26 @@ +import React from 'react'; +import DimensionInput from './DimensionInput'; + +export default { + title: 'Components/DimensionInput', + component: DimensionInput, +}; + +export function Basic() { + const props = { + label: 'label', + initialValue: 3.22, + onChange: () => {}, + unit: 'cm', + maxLength: 5, + disabled: false + }; + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/DimensionInput/index.js b/src/components/DimensionInput/index.js new file mode 100644 index 0000000000..3e3ac5ff97 --- /dev/null +++ b/src/components/DimensionInput/index.js @@ -0,0 +1,3 @@ +import DimensionInput from './DimensionInput'; + +export default DimensionInput; \ No newline at end of file diff --git a/src/components/EmbeddedJSPopup/EmbeddedJSPopup.js b/src/components/EmbeddedJSPopup/EmbeddedJSPopup.js new file mode 100644 index 0000000000..262fcfff3e --- /dev/null +++ b/src/components/EmbeddedJSPopup/EmbeddedJSPopup.js @@ -0,0 +1,102 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useSelector, useDispatch, shallowEqual } from 'react-redux'; +import DataElements from 'constants/dataElement'; +import DataElementWrapper from 'components/DataElementWrapper'; +import EmbeddedJSPopupMenu from './EmbeddedJSPopupMenu'; + +import useOnClickOutside from 'hooks/useOnClickOutside'; +import actions from 'actions'; +import selectors from 'selectors'; +import core from 'core'; + +import './EmbeddedJSPopup.scss'; + +const EmbeddedJSPopup = () => { + const documentViewer = core.getDocumentViewer(); + const annotManager = core.getAnnotationManager(); + const fieldManager = annotManager.getFieldManager(); + const [isOpen, isDisabled] = useSelector( + (state) => [ + selectors.isElementOpen(state, DataElements.EMBEDDED_JS_POPUP), + selectors.isElementDisabled(state, DataElements.EMBEDDED_JS_POPUP), + ], + shallowEqual, + ); + + if (isDisabled) { + return (null); + } + + const dispatch = useDispatch(); + const [position, setPosition] = useState({ left: 0, top: 0 }); + const [popUpMenuItems, setPopUpMenuItems] = useState([]); + const [popupData, setPopupData] = useState(null); + const popupRef = useRef(); + + useOnClickOutside(popupRef, () => { + dispatch(actions.closeElement(DataElements.EMBEDDED_JS_POPUP)); + }); + + useEffect(() => { + if (isOpen) { + dispatch(actions.closeElements([ + DataElements.ANNOTATION_POPUP, + DataElements.TEXT_POPUP, + ])); + } + }, [dispatch, isOpen]); + + const onFieldClick = (e) => { + setPosition({ + left: `${parseInt(e.x, 10) + 10}px`, + top: `${parseInt(e.y, 10)}px`, + }); + }; + + useEffect(() => { + let field; + const onPopUpMenu = (e) => { + setPopupData(e); + setPopUpMenuItems(e.popUpMenuItems); + field = fieldManager.getField(e.fieldName); + field.widgets.forEach((widget) => { + const element = widget.element; + element.addEventListener('click', onFieldClick); + }); + dispatch(actions.openElement(DataElements.EMBEDDED_JS_POPUP)); + }; + + documentViewer.addEventListener('embeddedPopUpMenu', onPopUpMenu); + return () => { + documentViewer.removeEventListener('embeddedPopUpMenu', onPopUpMenu); + }; + }, [dispatch]); + + const clickMenuItem = (value) => { + if (popupData) { + popupData.onSelect(value); + } + dispatch(actions.closeElement(DataElements.EMBEDDED_JS_POPUP)); + }; + + if (!isOpen) { + return null; + } + + return ( + + + + ); +}; + +export default EmbeddedJSPopup; diff --git a/src/components/EmbeddedJSPopup/EmbeddedJSPopup.scss b/src/components/EmbeddedJSPopup/EmbeddedJSPopup.scss new file mode 100644 index 0000000000..32634e6c3a --- /dev/null +++ b/src/components/EmbeddedJSPopup/EmbeddedJSPopup.scss @@ -0,0 +1,29 @@ +@import '../../constants/popup'; + +.EmbeddedJSPopup { + @extend %popup; + box-shadow: 0 0 3px 0 var(--document-box-shadow); + background: var(--component-background); + border-radius: 4px; + + @include mobile { + display: none !important; + } + + .menu-option { + width: 100%; + display: flex; + justify-content: space-between; + padding: 1em; + + &:hover { + background: var(--popup-button-hover); + } + } + + hr { + padding: 0; + margin: 0; + width: 100%; + } +} diff --git a/src/components/EmbeddedJSPopup/EmbeddedJSPopupMenu.js b/src/components/EmbeddedJSPopup/EmbeddedJSPopupMenu.js new file mode 100644 index 0000000000..2da00e6e23 --- /dev/null +++ b/src/components/EmbeddedJSPopup/EmbeddedJSPopupMenu.js @@ -0,0 +1,94 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import classNames from 'classnames'; +import EmbeddedJSPopupOption from './EmbeddedJSPopupOption'; +import EmbeddedJSPopupSubMenu from './EmbeddedJSPopupSubMenu'; + +import actions from 'actions'; +import core from 'core'; +import selectors from 'selectors'; + +const EmbeddedJSPopupMenu = React.forwardRef(({ dataElement, isSubOpen, left = 0, top = 0, onSelectOption, popUpMenuItems }, ref) => { + if (!isSubOpen || !popUpMenuItems || popUpMenuItems.length <= 0) { + return <>; + } + + const [ + embeddedJSPopupStyle, + ] = useSelector((state) => [ + selectors.getEmbeddedJSPopupStyle(state), + ], shallowEqual); + + const scrollContainer = core.getScrollViewElement(); + const dispatch = useDispatch(); + const containerRef = useRef(); + + // Adjust scrolling based on distance from bottom + const [maxHeight, setMaxHeight] = useState('auto'); + // Options need to know how much to shift by if there is scrolling + const [scrollTop, setScrollTop] = useState(0); + + useEffect(() => { + if (isSubOpen) { + dispatch(actions.closeElements(['annotationPopup', 'textPopup'])); + } + const containerRect = containerRef.current.getBoundingClientRect(); + // Update the menu size if it is too long + const mHeight = containerRect.bottom >= scrollContainer.clientHeight ? containerRect.height - (containerRect.bottom - scrollContainer.clientHeight) : null; + setMaxHeight(mHeight); + }, [dispatch, isSubOpen]); + + const onScrollContainer = (e) => { + if (e.target === containerRef.current) { + setScrollTop(e.target.scrollTop); + } + }; + + const customMenuStyle = embeddedJSPopupStyle || {}; + + return ( +
+
+ { + popUpMenuItems.map((popUpMenuItem, index) => { + if (typeof popUpMenuItem === 'string' || popUpMenuItem instanceof String) { + return ( + + ); + } + if (Array.isArray(popUpMenuItem)) { + return ( + + ); + } + return null; + }) + } +
+
+ ); +}); + +EmbeddedJSPopupMenu.displayName = 'EmbeddedJSPopupMenu'; + +export default EmbeddedJSPopupMenu; diff --git a/src/components/EmbeddedJSPopup/EmbeddedJSPopupOption.js b/src/components/EmbeddedJSPopup/EmbeddedJSPopupOption.js new file mode 100644 index 0000000000..1d2cdba230 --- /dev/null +++ b/src/components/EmbeddedJSPopup/EmbeddedJSPopupOption.js @@ -0,0 +1,21 @@ +import React from 'react'; + +const EmbeddedJSPopupMenuOption = ({ title, onClick }) => { + if (title === '-') { + return
; + } + + const onOptionClick = () => { + if (onClick) { + onClick(title); + } + }; + + return ( +
+
{title}
+
+ ); +}; + +export default EmbeddedJSPopupMenuOption; diff --git a/src/components/EmbeddedJSPopup/EmbeddedJSPopupSubMenu.js b/src/components/EmbeddedJSPopup/EmbeddedJSPopupSubMenu.js new file mode 100644 index 0000000000..a004f59feb --- /dev/null +++ b/src/components/EmbeddedJSPopup/EmbeddedJSPopupSubMenu.js @@ -0,0 +1,47 @@ +import React, { useRef, useState } from 'react'; + +import Icon from 'components/Icon'; +import EmbeddedJSPopupMenu from './EmbeddedJSPopupMenu'; + +const EmbeddedJSPopupSubMenu = ({ title, onClick, popUpMenuItems, scrollTop = 0 }) => { + const [isSubMenuOpen, setSubMenuOpen] = useState(false); + const [position, setPosition] = useState({ left: undefined, top: undefined }); + const optionRef = useRef(); + + const onSubMenuHover = () => { + setSubMenuOpen(true); + setPosition({ + left: optionRef.current.clientWidth, + top: optionRef.current.offsetTop - scrollTop, + }); + }; + + const onSubMenuLeave = () => { + setSubMenuOpen(false); + setPosition({ + left: optionRef.current.clientWidth, + top: 0, + }); + }; + + return ( +
+
{title}
+ { + <> +
+ + + } +
+ ); +}; + +export default EmbeddedJSPopupSubMenu; diff --git a/src/components/EmbeddedJSPopup/index.js b/src/components/EmbeddedJSPopup/index.js new file mode 100644 index 0000000000..356b9e82b8 --- /dev/null +++ b/src/components/EmbeddedJSPopup/index.js @@ -0,0 +1,3 @@ +import EmbeddedJSPopup from './EmbeddedJSPopup'; + +export default EmbeddedJSPopup; diff --git a/src/components/ErrorModal/ErrorModal.js b/src/components/ErrorModal/ErrorModal.js index dba26d774d..f8f491b437 100644 --- a/src/components/ErrorModal/ErrorModal.js +++ b/src/components/ErrorModal/ErrorModal.js @@ -13,9 +13,10 @@ import DataElements from 'constants/dataElement'; import './ErrorModal.scss'; const ErrorModal = () => { - const [message, isDisabled, isOpen, isMultiTab] = useSelector( + const [message, title, isDisabled, isOpen, isMultiTab] = useSelector( (state) => [ selectors.getErrorMessage(state), + selectors.getErrorTitle(state), selectors.isElementDisabled(state, DataElements.ERROR_MODAL), selectors.isElementOpen(state, DataElements.ERROR_MODAL), selectors.getIsMultiTab(state), @@ -79,11 +80,11 @@ const ErrorModal = () => { style={isMultiTab ? { height: `calc(100% - ${tabsPadding}px)` } : undefined} data-element={DataElements.ERROR_MODAL} > - -
+
diff --git a/src/components/FileAttachmentPanel/FileAttachmentPanel.stories.js b/src/components/FileAttachmentPanel/FileAttachmentPanel.stories.js new file mode 100644 index 0000000000..ef08c4250a --- /dev/null +++ b/src/components/FileAttachmentPanel/FileAttachmentPanel.stories.js @@ -0,0 +1,74 @@ +import React from 'react'; +import FileAttachmentPanelComponent from './FileAttachmentPanel'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import Panel from 'components/Panel'; + +export default { + title: 'ModularComponents/FileAttachmentPanel', + component: FileAttachmentPanelComponent +}; + +const initialState = { + viewer: { + openElements: { + panel: true, + }, + disabledElements: {}, + customElementOverrides: {}, + tab: {}, + panelWidths: { panel: 300 }, + modularHeaders: {}, + } +}; + +export function FileAttachmentPanelLeftEmpty() { + return ( + initialState })}> + + + + + ); +} + +export function FileAttachmentPanelRightEmpty() { + return ( + initialState })}> + + + + + ); +} + +const filesMock = { + embeddedFiles: [ + { filename: '1.png' }, + { filename: '15pages.pdf' }, + ], + fileAttachmentAnnotations: [], +}; +filesMock.fileAttachmentAnnotations[1] = [{ PageNumber: 1, filename: '2.png' }]; +filesMock.fileAttachmentAnnotations[5] = [{ PageNumber: 5, filename: 'signature.png' }]; +filesMock.fileAttachmentAnnotations[8] = [{ PageNumber: 8, filename: 'q.jpeg' }]; + +export function FileAttachmentPanelLeftWithFiles() { + return ( + initialState })}> + + + + + ); +} + +export function FileAttachmentPanelRightWithFiles() { + return ( + initialState })}> + + + + + ); +} diff --git a/src/components/FilePicker/FilePicker.js b/src/components/FilePicker/FilePicker.js new file mode 100644 index 0000000000..1dad49a155 --- /dev/null +++ b/src/components/FilePicker/FilePicker.js @@ -0,0 +1,125 @@ +import React, { useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import Icon from 'components/Icon'; +import classNames from 'classnames'; +import { isMobile } from 'helpers/device'; + +import './FilePicker.scss'; + +const FilePicker = ({ + onChange = () => { }, + onDrop = () => { }, + shouldShowIcon = false, + acceptFormats, + allowMultiple = false, + errorMessage = '' +}) => { + const [t] = useTranslation(); + const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null); + + const onClick = () => { + fileInputRef?.current?.click(); + }; + + const onKeyDown = (event) => { + if (event.key === 'Enter') { + onClick(); + } + }; + + const handleChange = (e) => { + const files = e.target.files; + files.length > 0 && onChange(Array.from(files)); + }; + + const handleDragEnter = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragOver = (e) => { + e.preventDefault(); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + + if (!e.target.parentNode.contains(e.relatedTarget)) { + setIsDragging(false); + } + }; + + const handleDragExit = (e) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleFileDrop = async (e) => { + e.preventDefault(); + setIsDragging(false); + const { files } = e.dataTransfer; + files.length > 0 && onDrop(Array.from(files)); + }; + + const renderPrompt = () => { + if (isMobile()) { + return ( +
+ {t('filePicker.selectFile')} +
+ ); + } + return ( + <> +
+ {t('filePicker.dragAndDrop')} +
+
+ {t('filePicker.or')} +
+ + ); + }; + + return ( +
+
+
+ {shouldShowIcon && } + {renderPrompt()} +
{t('action.browse')} + { + handleChange(event); + event.target.value = null; + }} + /> +
+
+ {errorMessage && ( +
{errorMessage}
+ )} +
+
+ ); +}; + +export default FilePicker; diff --git a/src/components/FilePicker/FilePicker.scss b/src/components/FilePicker/FilePicker.scss new file mode 100644 index 0000000000..c0bb05c3c7 --- /dev/null +++ b/src/components/FilePicker/FilePicker.scss @@ -0,0 +1,74 @@ +@import '../../constants/styles'; +@import '../../constants/modal'; + +.file-picker-component { + width: 100%; + height: 100%; + position: relative; + border-radius: 4px; + + .file-picker-container { + position: relative; + border: 1px dashed var(--modal-stroke-and-border); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + + &.dragging { + background: var(--file-picker-drop-background); + border: 1px dashed var(--file-picker-drop-border); + } + + .file-picker-body { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + position: absolute; + + .label-separator { + margin: 10px; + } + + color: var(--faded-text); + + .modal-btn-file { + border-radius: 4px; + border: 1px solid var(--primary-button); + color: var(--primary-button); + padding-top: 2px; + padding-right: 20px; + padding-bottom: 4px; + padding-left: 20px; + cursor: pointer; + } + + .Icon { + width: fit-content; + height: fit-content; + margin-bottom: 15px; + + svg { + height: 45px; + } + } + + .file-picker-separator { + margin: 10px; + } + } + + .file-picker-error { + position: absolute; + color: red; + bottom: 0px; + right: 0px; + margin: 0px 5px 5px 0px; + } + } +} diff --git a/src/components/FilePicker/index.js b/src/components/FilePicker/index.js new file mode 100644 index 0000000000..e6a188ce94 --- /dev/null +++ b/src/components/FilePicker/index.js @@ -0,0 +1,3 @@ +import FilePicker from './FilePicker'; + +export default FilePicker; diff --git a/src/components/FilterAnnotModal/FilterAnnotModal.spec.js b/src/components/FilterAnnotModal/FilterAnnotModal.spec.js new file mode 100644 index 0000000000..718fde7f65 --- /dev/null +++ b/src/components/FilterAnnotModal/FilterAnnotModal.spec.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { UserPanel, ColorPanel, TypePanel, DocumentFilterActive } from './FilterAnnotModal.stories'; + +const noop = () => { }; + +jest.mock('core', () => ({ + getDisplayAuthor: () => 'Test Author', + addEventListener: noop, + removeEventListener: noop, + getAnnotationsList: () => [], + getDocumentViewers: () => [{ + getAnnotationManager: () => ({ + getAnnotationsList: () => [] + }) + }], +})); + +const UserPanelFilterAnnotModalStory = withI18n(UserPanel); +const ColorPanelFilterAnnotModalStory = withI18n(ColorPanel); +const TypePanelFilterAnnotModalStory = withI18n(TypePanel); +const DocumentFilterActiveStory = withI18n(DocumentFilterActive); + +describe('FilterAnnotModal', () => { + it('Stories should not throw any errors', () => { + expect(() => { + render(); + render(); + render(); + render(); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/components/FontSizeDropdown/FontSizeDropdown.scss b/src/components/FontSizeDropdown/FontSizeDropdown.scss index 96c8159abe..e42ad93c0c 100644 --- a/src/components/FontSizeDropdown/FontSizeDropdown.scss +++ b/src/components/FontSizeDropdown/FontSizeDropdown.scss @@ -21,11 +21,15 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - &.error { border-color: var(--error-border-color); } } + + .disabledText { + color: var(--disabled-text); + } + // To remove arrows from input /* Chrome, Safari, Edge, Opera */ input::-webkit-outer-spin-button, diff --git a/src/components/FontSizeDropdown/pdfEditHelper.js b/src/components/FontSizeDropdown/pdfEditHelper.js new file mode 100644 index 0000000000..585a982193 --- /dev/null +++ b/src/components/FontSizeDropdown/pdfEditHelper.js @@ -0,0 +1,111 @@ +const SELECTION_BACKGROUND = '#50A5F5FE'; +let range; +let docViewer; +let inputElement; + +/** + * @ignore + * Helper function to keep the highlight of the selected text in the text edit box before the elemnt focus is changed. + */ +export function keepTextEditSelectionOnInputFocus(core) { + inputElement = document.activeElement; + docViewer = core.getDocumentViewer(); + // When the input is still in focus but we changed page, we need to un-focus the input. + docViewer.addEventListener('pageNumberUpdated', handlePageChange, { once: true }); + // When we click anywhere other than the input field itself, it should unfocus. + document.addEventListener('mousedown', handleClick); + + const currentRange = window.getSelection().getRangeAt(0); + const isFocusOutsideTextBox = currentRange.startContainer.nodeName === 'DIV'; + // Component re-renders when we focus into the input field because it is a dropdown/input combo. + // In that case the focus is already shifted out from text edit box and this function is executed again + // due to re-render. The selection will no longer include the text nodes we initially selected + // in the text edit box. + if (isFocusOutsideTextBox) { + return; + } + + // When we have nothing selected, simply return + const isEmptySelection = currentRange.startContainer === currentRange.endContainer && currentRange.startOffset === currentRange.endOffset; + if (isEmptySelection) { + return; + } + + // When the color / font style of the selected text was changed at least once before changing + // the font size with input field, the selection we get from `getSelection` would have been modified + // by worker API. In this case, we need to reinitialize the range so that the range object returns to its initial state. + const isRangeModifiedByWorkerAPI = currentRange.startContainer.nodeName === 'SPAN'; + if (isRangeModifiedByWorkerAPI) { + range = reinitializeRange(currentRange); + } else { + range = currentRange; + } + + toggleSelectionHighlight(range.startContainer, range.endContainer); +} + +/** + * @ignore + * Helper function to restore the text edit box selection + */ +export function restoreSelection() { + if (!range) { + return; + } + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + range = null; + docViewer.removeEventListener('pageNumberUpdated', handlePageChange); + document.removeEventListener('mousedown', handleClick); +} + +/** + * @ignore + * Toggles the background color of the selected elements recursively. + * @param {Text} startTextNode + * @param {Text} endTextNode + */ +function toggleSelectionHighlight(startTextNode, endTextNode) { + const startElement = startTextNode.parentElement; + const selectionEnd = !startElement.nextElementSibling && startElement.parentElement.nextElementSibling === endTextNode; + if (selectionEnd) { + return; + } + startElement.style.background = SELECTION_BACKGROUND; + const highlighNextCharacterSameLine = startElement.nextElementSibling?.tagName === 'SPAN' && startTextNode !== endTextNode; + const highlighNextCharacterNextLine = startElement.nextElementSibling === null && startElement.parentElement.nextElementSibling; + if (highlighNextCharacterSameLine) { + // When the next character in selection is in the same line of current character, we simply pass that in + toggleSelectionHighlight(startElement.nextElementSibling.firstChild, endTextNode); + } else if (highlighNextCharacterNextLine) { + // when the next character in selection is in the next line, we need to go into the next line and continue our recursion + toggleSelectionHighlight(startElement.parentElement.nextElementSibling.firstElementChild.firstChild, endTextNode); + } +} + +function reinitializeRange(workerAPIEditedRange) { + const selection = window.getSelection(); + selection.removeAllRanges(); + const startNode = workerAPIEditedRange.startContainer.firstChild; + const endNode = workerAPIEditedRange.endContainer.previousSibling.firstChild; + workerAPIEditedRange.setStart(startNode, 0); + workerAPIEditedRange.setEnd(endNode, 1); + return workerAPIEditedRange; +} + +function handlePageChange() { + if (document.activeElement?.tagName === 'INPUT') { + document.activeElement.blur(); + } + docViewer.removeEventListener('pageNumberUpdated', handlePageChange); + document.removeEventListener('mousedown', handleClick); +} + +function handleClick(e) { + const isClickingFontSizeInput = e.target === inputElement; + if (!isClickingFontSizeInput) { + document.activeElement.blur(); + document.removeEventListener('mousedown', handleClick); + } +} \ No newline at end of file diff --git a/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/FormFieldEditPopupIndicator.js b/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/FormFieldEditPopupIndicator.js new file mode 100644 index 0000000000..453917ec9c --- /dev/null +++ b/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/FormFieldEditPopupIndicator.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { Choice, Input } from '@pdftron/webviewer-react-toolkit'; +import { useTranslation } from 'react-i18next'; + + +const FormFieldEditPopupIndicator = ({ indicator, indicatorPlaceholder }) => { + const { t } = useTranslation(); + const onIndicatorChange = (showIndicator) => { + if (indicator.value.length < 1 && showIndicator) { + indicator.onChange(indicatorPlaceholder); + } + indicator.toggleIndicator(showIndicator); + }; + + return ( +
+ {t('formField.formFieldPopup.fieldIndicator')} + onIndicatorChange(event.target.checked)} + label={t(indicator.label)} + /> +
+ indicator.onChange(event.target.value)} + value={indicator.value} + fillWidth="false" + placeholder={indicatorPlaceholder} + disabled={!indicator.isChecked} + /> +
+
+ ); +}; + +export default FormFieldEditPopupIndicator; \ No newline at end of file diff --git a/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/index.js b/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/index.js new file mode 100644 index 0000000000..da4b611b59 --- /dev/null +++ b/src/components/FormFieldEditPopup/FormFieldEditPopupIndicator/index.js @@ -0,0 +1,3 @@ +import FormFieldEditPopupIndicator from './FormFieldEditPopupIndicator'; + +export default FormFieldEditPopupIndicator; \ No newline at end of file diff --git a/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/FormFieldEditSignaturePopup.stories.js b/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/FormFieldEditSignaturePopup.stories.js new file mode 100644 index 0000000000..b09af8cdab --- /dev/null +++ b/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/FormFieldEditSignaturePopup.stories.js @@ -0,0 +1,75 @@ +import React from 'react'; +import FormFieldEditSignaturePopup from './FormFieldEditSignaturePopup'; +import { configureStore } from '@reduxjs/toolkit'; + + +import { Provider } from 'react-redux'; + + +export default { + title: 'Components/FormFieldEditPopup', + component: FormFieldEditSignaturePopup, +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + } +}; + +const store = configureStore({ reducer: () => initialState }); + +const annotation = { + Width: 100, + Height: 100, +}; + +export function SignatureFieldPopup() { + const signatureFields = [ + { + label: 'formField.formFieldPopup.fieldName', + onChange: () => { }, + value: 'SignatureField1', + required: true, + type: 'text', + }, + ]; + + const signatureFlags = [ + { + label: 'formField.formFieldPopup.readOnly', + onChange: () => { }, + isChecked: true, + }, + { + label: 'formField.formFieldPopup.required', + onChange: () => { }, + isChecked: true, + } + ]; + + const indicator = { + label: 'formField.formFieldPopup.documentFieldIndicator', + toggleIndicator: () => { }, + isChecked: true, + onChange: () => { }, + value: 'This is an indicator' + }; + + const props = { + fields: signatureFields, + flags: signatureFlags, + annotation, + isValid: true, + getSignatureOptionHandler: () => 'initialsSignature', + indicator, + }; + return ( + +
+ +
+
+ ); +} diff --git a/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/SignatureOptionsDropdown/SignatureOptionsDropdown.js b/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/SignatureOptionsDropdown/SignatureOptionsDropdown.js new file mode 100644 index 0000000000..dd643496b8 --- /dev/null +++ b/src/components/FormFieldEditPopup/FormFieldEditSignaturePopup/SignatureOptionsDropdown/SignatureOptionsDropdown.js @@ -0,0 +1,82 @@ +import React, { useState } from 'react'; +import Select from 'react-select'; +import { useTranslation } from 'react-i18next'; +import DataElementWrapper from 'components/DataElementWrapper'; +import SignatureModes from 'constants/signatureModes'; +import ReactSelectCustomArrowIndicator from 'components/ReactSelectCustomArrowIndicator'; +import ReactSelectWebComponentProvider from 'src/components/ReactSelectWebComponentProvider'; + +import './SignatureOptionsDropdown.scss'; + +const getStyles = () => ({ + control: (provided) => ({ + ...provided, + minHeight: '28px', + backgroundColor: 'var(--component-background)', + borderColor: 'hsl(0, 0%, 80%)', + boxShadow: null, + '&:hover': null, + }), + valueContainer: (provided) => ({ + ...provided, + padding: '2px', + }), + singleValue: (provided) => ({ + ...provided, + color: 'var(--text-color)', + }), + menu: (provided) => ({ + ...provided, + backgroundColor: 'var(--component-background)', + }), + option: (provided) => ({ + ...provided, + backgroundColor: 'var(--component-background)', + color: 'var(--text-color)', + '&:hover': { + backgroundColor: 'var(--popup-button-hover)', + } + }), + indicatorsContainer: (provided) => ({ + ...provided, + paddingRight: '6px', + height: '26px', + }), +}); + +const SignatureOptionsDropdown = (props) => { + const { onChangeHandler, initialOption } = props; + const { t } = useTranslation(); + const styles = getStyles(); + const signatureOptions = [ + { value: SignatureModes.FULL_SIGNATURE, label: t('formField.types.signature') }, + { value: SignatureModes.INITIALS, label: t('option.type.initials') }, + ]; + + const init = signatureOptions.find((option) => option.value === initialOption); + const [value, setValue] = useState(init); + + const onChange = (option) => { + setValue(option); + onChangeHandler(option); + }; + + return ( + + + + onWidthChange(e.target.value)} + /> {t('formField.formFieldPopup.width')} +
+
+ onHeightChange(e.target.value)} + /> {t('formField.formFieldPopup.height')} +
+
+ ); +}; + +export default FormFieldPopupDimensionsInput; \ No newline at end of file diff --git a/src/components/FormFieldEditPopup/FormFieldPopupDimensionsInput/index.js b/src/components/FormFieldEditPopup/FormFieldPopupDimensionsInput/index.js new file mode 100644 index 0000000000..928474c06d --- /dev/null +++ b/src/components/FormFieldEditPopup/FormFieldPopupDimensionsInput/index.js @@ -0,0 +1,3 @@ +import FormFieldPopupDimensionsInput from './FormFieldPopupDimensionsInput'; + +export default FormFieldPopupDimensionsInput; \ No newline at end of file diff --git a/src/components/FormFieldIndicator/FormFieldIndicator.scss b/src/components/FormFieldIndicator/FormFieldIndicator.scss new file mode 100644 index 0000000000..64ef8fad85 --- /dev/null +++ b/src/components/FormFieldIndicator/FormFieldIndicator.scss @@ -0,0 +1,60 @@ +@import '../../constants/modal'; +@import '../../constants/popup'; + +@media print { + #form-field-indicator-wrapper { + opacity: 0; + } +} + +#form-field-indicator-wrapper { + position: relative; + z-index: min($modal-z-index, $popup-z-index) - 10; +} + +.formFieldIndicator { + background-color: var(--color-blue-gray-5); + color: var(--color-gray-1); + width: 98px; + min-height: 32px; + font-size: 13px; + position: fixed; + padding: 4px 0px; + display: flex; + justify-content: center; + align-items: center; + font-family: 'Lato', sans-serif; + + .formFieldIndicator-text { + margin: 0; + position: absolute; + padding: 0 8px; + } +} + +.formFieldIndicator::before { + content: ''; + display: block; + position: absolute; + right: -20px; + bottom: 0; + width: 0; + height: 0; + border-left: 20px solid var(--color-blue-gray-5); + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; +} + +.formFieldIndicator.rightSidePage::before { + content: ''; + display: block; + position: absolute; + left: -20px; + bottom: 0; + width: 0; + height: 0; + border-right: 20px solid var(--color-blue-gray-5); + border-left: 0; + border-top: 20px solid transparent; + border-bottom: 20px solid transparent; +} \ No newline at end of file diff --git a/src/components/FormFieldIndicator/FormFieldIndicatorContainer.js b/src/components/FormFieldIndicator/FormFieldIndicatorContainer.js new file mode 100644 index 0000000000..4aa3d6634e --- /dev/null +++ b/src/components/FormFieldIndicator/FormFieldIndicatorContainer.js @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import debounce from 'lodash/debounce'; +import useOnFormFieldsChanged from '../../hooks/useOnFormFieldsChanged'; +import core from 'core'; +import selectors from 'selectors'; +import FormFieldIndicator from './FormFieldIndicator'; +import './FormFieldIndicator.scss'; +import DataElements from 'src/constants/dataElement'; +import getRootNode from 'helpers/getRootNode'; +import { createPortal } from 'react-dom'; + +const FormFieldIndicatorContainer = () => { + const [ + isOpen, + isDisabled, + documentContainerWidth, + documentContainerHeight, + leftPanelWidth, + notePanelWidth, + ] = useSelector((state) => [ + selectors.isElementOpen(state, DataElements['FORM_FIELD_INDICATOR_CONTAINER']), + selectors.isElementDisabled(state, DataElements['FORM_FIELD_INDICATOR_CONTAINER']), + selectors.getDocumentContainerWidth(state), + selectors.getDocumentContainerHeight(state), + selectors.getLeftPanelWidth(state), + selectors.getDocumentContentContainerWidthStyle(state), + selectors.getNotesPanelWidth(state), + ]); + const formFieldAnnotationsList = useOnFormFieldsChanged(); + const [indicators, setIndicators] = useState([]); + + const getIndicators = () => { + if (!core.getDocument()) { + return []; + } + return formFieldAnnotationsList + .filter((fieldAnnotation) => { + return fieldAnnotation.getCustomData('trn-form-field-show-indicator') === 'true'; + }).map((fieldAnnotation) => { + return createFormFieldIndicator(fieldAnnotation); + }); + }; + + const resetIndicators = () => { + setIndicators([]); + }; + + useEffect(() => { + core.addEventListener('documentUnloaded', resetIndicators); + return () => { + core.removeEventListener('documentUnloaded', resetIndicators); + }; + }, []); + + + useEffect(() => { + setIndicators(getIndicators()); + + const onScroll = debounce(() => { + if (isOpen && !isDisabled) { + setIndicators(getIndicators()); + } + }, 0); + + const scrollViewElement = core.getScrollViewElement(); + + scrollViewElement?.addEventListener('scroll', onScroll); + return () => { + scrollViewElement?.removeEventListener('scroll', onScroll); + }; + }, [ + formFieldAnnotationsList, + isOpen, + isDisabled, + documentContainerWidth, + documentContainerHeight, + leftPanelWidth, + notePanelWidth, + ]); + + const createFormFieldIndicator = (annotation) => { + const { scrollLeft, scrollTop } = core.getScrollViewElement(); + const payload = { + displayMode: core.getDocumentViewer().getDisplayModeManager().getDisplayMode(), + viewerBoundingRect: core.getViewerElement().getBoundingClientRect(), + appBoundingRect: getRootNode().getElementById('app').getBoundingClientRect(), + scrollLeft: scrollLeft, + scrollTop: scrollTop, + }; + return (); + }; + + if (isOpen && !isDisabled) { + return (<> + { + createPortal(
+
+ {indicators} +
+
, (window.isApryseWebViewerWebComponent) + ? getRootNode().getElementById('app') : document.body) + } + ); + } + + return null; +}; + +export default FormFieldIndicatorContainer; diff --git a/src/components/FormFieldIndicator/index.js b/src/components/FormFieldIndicator/index.js new file mode 100644 index 0000000000..33db0c2d60 --- /dev/null +++ b/src/components/FormFieldIndicator/index.js @@ -0,0 +1,3 @@ +import FormFieldIndicator from './FormFieldIndicatorContainer'; + +export default FormFieldIndicator; diff --git a/src/components/InlineCommentingPopup/InlineCommentingPopup.scss b/src/components/InlineCommentingPopup/InlineCommentingPopup.scss new file mode 100644 index 0000000000..cdfb46b88f --- /dev/null +++ b/src/components/InlineCommentingPopup/InlineCommentingPopup.scss @@ -0,0 +1,171 @@ +@import '../../constants/styles.scss'; +@import '../../constants/popup'; + +.InlineCommentingPopup { + @extend %popup; + border-radius: 4px; + box-shadow: 0 0 3px 0 var(--document-box-shadow); + background: var(--component-background); + align-items: flex-start; + + @include mobile { + position: fixed; + left: 0; + bottom: 0; + z-index: 100; + flex-direction: column; + justify-content: flex-end; + width: 100%; + background: var(--modal-negative-space); + } + + @include tablet-and-desktop { + overflow: auto; + max-height: calc(100% - 100px); + } + + .inline-comment-container { + display: flex; + flex-direction: column; + + @include mobile { + flex-basis: auto; + width: 100%; + max-height: 40%; + background: var(--component-background); + box-shadow: 0 0 3px 0 var(--document-box-shadow); + border-radius: 4px 4px 0px 0px; + } + + @include tablet-and-desktop { + max-width: 260px; + } + + &.expanded { + @include mobile { + flex-grow: 1; + max-height: 90%; + } + } + } + + .Note { + border-radius: 0; + background: none; + margin: 0; + cursor: default; + + @include mobile { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: auto; + box-shadow: 0 0 3px 0 var(--document-box-shadow); + } + + @include tablet-and-desktop { + box-shadow: none; + } + + &>div:not(:nth-last-child(2)) { + @include mobile { + flex-grow: 0; + } + } + + &>div:nth-last-child(2) { + @include mobile { + flex-grow: 1; + } + } + + &>.NoteContent:only-child { + @include mobile { + flex-grow: 1; + } + + .edit-content { + @include mobile { + flex-grow: 0; + } + } + } + } + + .NoteHeader { + @include mobile { + flex-grow: 0; + } + } + + .NoteContent .edit-content { + margin-top: 16px; + } + + .note-popup-options:not(.options-reply) { + top: 33px; + } + + .quill, + .ql-container, + .ql-editor { + @include mobile { + font-size: 16px; + } + } + + .inline-comment-header { + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: center; + } + + .inline-comment-header-title { + flex-grow: 1; + font-size: 16px; + } + + .Button { + + &.add-attachment, + &.reply-button { + margin: 0; + + .Icon { + width: 22px; + height: 22px; + } + } + + &.add-attachment { + width: 24px; + height: 24px; + + @include mobile { + width: 24px; + height: 24px; + } + } + + &.reply-button { + width: 28px; + height: 28px; + + @include mobile { + width: 28px; + height: 28px; + } + } + } +} + +// fix for storybook +.sb-show-main { + .InlineCommentingPopup { + .quill.comment-textarea { + padding: 0; + } + } +} \ No newline at end of file diff --git a/src/components/InlineCommentingPopup/InlineCommentingPopup.spec.js b/src/components/InlineCommentingPopup/InlineCommentingPopup.spec.js new file mode 100644 index 0000000000..d02323dba1 --- /dev/null +++ b/src/components/InlineCommentingPopup/InlineCommentingPopup.spec.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import * as reactRedux from 'react-redux'; +import { Basic, Mobile, initialState } from './InlineCommentingPopup.stories'; + +const TestInlineCommentPopup = withProviders(Basic); +const TestInlineCommentPopupMobile = withProviders(Mobile); + +jest.mock('core', () => ({ + getGroupAnnotations: () => [], + getDisplayAuthor: () => '', + canModify: () => true, + canModifyContents: () => true, + addEventListener: () => { }, + removeEventListener: () => { }, +})); + +describe('InlineCommentPopup Component', () => { + beforeEach(() => { + const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch'); + useDispatchMock.mockImplementation(() => { }); + + const useSelectorMock = jest.spyOn(reactRedux, 'useSelector'); + useSelectorMock.mockImplementation((selector) => selector(initialState)); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Should not throw any errors when rendering storybook component', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('Should show header for mobile correctly', () => { + const { container } = render( + + ); + const mobileHeader = container.querySelector('.inline-comment-header'); + expect(mobileHeader).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/components/InlineCommentingPopup/InlineCommentingPopup.stories.js b/src/components/InlineCommentingPopup/InlineCommentingPopup.stories.js new file mode 100644 index 0000000000..a8d6ed08dd --- /dev/null +++ b/src/components/InlineCommentingPopup/InlineCommentingPopup.stories.js @@ -0,0 +1,83 @@ +import React from 'react'; +import InlineCommentingPopup from './InlineCommentingPopup'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +const noop = () => { }; + +export default { + title: 'Components/InlineCommentPopup', + component: InlineCommentingPopup, + includeStories: ['Basic', 'Mobile'], +}; + +export const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + openElements: { inlineCommentPopup: true }, + customPanels: [], + unreadAnnotationIdSet: new Set(), + colorMap: [{ colorMapKey: () => '#F1A099' }], + }, +}; + +export const context = { + searchInput: '', + resize: noop, + isSelected: true, + setCurAnnotId: noop, + onTopNoteContentClicked: noop, + pendingEditTextMap: {}, + pendingReplyMap: {}, + pendingAttachmentMap: {} +}; + + +const mockAnnotation = { + Author: 'Mikel Landa', + isFormFieldPlaceholder: () => false, + getReplies: () => [], + getStatus: () => '', + isReply: () => false, + getAssociatedNumber: () => 1, + getContents: noop, + getCustomData: () => '', + getAttachments: noop, + getRichTextStyle: noop, +}; + +export const basicProps = { + isOpen: true, + isNotesPanelOpen: false, + commentingAnnotation: mockAnnotation, + position: { top: 0, left: 0 }, + contextValue: context, +}; + +export const Basic = () => { + return ( + initialState })}> + + + ); +}; + +export const mobileProps = { + ...basicProps, + isMobile: true, +}; + +export const Mobile = () => { + return ( + initialState })}> + + + ); +}; + +Mobile.parameters = { + viewport: { + defaultViewport: 'mobile1', + }, +}; \ No newline at end of file diff --git a/src/components/InlineCommentingPopup/index.js b/src/components/InlineCommentingPopup/index.js new file mode 100644 index 0000000000..6af61d6503 --- /dev/null +++ b/src/components/InlineCommentingPopup/index.js @@ -0,0 +1,3 @@ +import InlineCommentingPopupContainer from './InlineCommentingPopupContainer'; + +export default InlineCommentingPopupContainer; \ No newline at end of file diff --git a/src/components/InsertPageModal/InsertBlankPagePanel/InsertBlankPagePanel.scss b/src/components/InsertPageModal/InsertBlankPagePanel/InsertBlankPagePanel.scss new file mode 100644 index 0000000000..ebf9c666ea --- /dev/null +++ b/src/components/InsertPageModal/InsertBlankPagePanel/InsertBlankPagePanel.scss @@ -0,0 +1,202 @@ +@import '../../../constants/styles'; + +.insert-blank-page-panel { + width: 100%; + + .dimension-input-container { + min-width: 100%; + margin: 0; + height: 32px; + } + + .subheader { + font-size: 13px; + font-weight: 700; + padding-top: 8px; + padding-bottom: 8px; + } + + .panel-container { + + .section { + display: flex; + padding-top: 8px; + padding-bottom: 8px; + gap: 16px; + + .input-container { + display: flex; + flex-direction: column; + + p { + margin: 0; + padding-bottom: 8px; + font-size: 13px; + } + + .page-number-input { + width: 100%; + height: 32px; + margin: 0; + } + + .customSelector { + margin-left: 0; + height: 28px; + + .customSelector__selectedItem { + width: 100%; + border-radius: 4px; + } + + .Icon { + width: 13px; + height: 13px; + } + + ul { + width: 100%; + + @include mobile { + top: auto; + bottom: calc(100% + 4px); + } + } + + & li:first-child { + color: var(--faded-text); + font-size: 13px; + + @include mobile { + display: none; + } + } + + li .optionSelected { + color: var(--text-color); + background: var(--popup-button-active); + } + } + + select { + height: 28px; + width: 100%; + } + + .Dropdown { + height: 32px; + min-width: 150px; + width: 100% !important; + + .arrow { + flex: 0 1 auto; + } + + .picked-option .picked-option-text { + width: 150px; + text-align: left; + } + } + + .Dropdown__items { + top: -52px; + z-index: 80; + width: 100%; + } + + .input-sub-text { + margin-top: 8px; + padding-bottom: 0; + color: var(--faded-text); + } + + .page-number-error { + margin-top: 8px; + font-size: 13px; + color: var(--color-message-error); + } + } + + @include mobile { + .ui__choice__label, input, button { + font-size: 13px; + } + } + } + + .section > * { + flex: 1; + } + } +} +.incrementNumberInput { + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + height: 32px; + + input[type=number]::-webkit-inner-spin-button, + input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; + } + + input[type=number] { + -moz-appearance:textfield; + } + + .ui__input { + border: 0; + height: 100%; + + .ui__input__input { + width: 100%; + height: 100%; + padding: 6px; + line-height: normal; + + } + } + + .ui__input--message-default.ui__input--focused { + outline: none; + box-shadow: none; + } + + .increment-buttons { + @include mobile { + display: none; + } + + display: flex; + flex-direction: column; + gap: 2px; + justify-content: center; + padding: 2px; + + .increment-arrow-button { + border: 0; + border-radius: 2px; + height: 10px; + width: 20px; + display: flex; + justify-content: center; + align-items: center; + line-height: 10px; + padding: 0; + + .Icon { + height: 10px; + width: 10px; + } + + &:active { + box-shadow: 0 0 1px 0 var(--document-box-shadow); + } + } + } + + &:focus-within { + border: 1px solid var(--focus-border); + } +} diff --git a/src/components/InsertPageModal/InsertBlankPagePanel/index.js b/src/components/InsertPageModal/InsertBlankPagePanel/index.js new file mode 100644 index 0000000000..35b87e586d --- /dev/null +++ b/src/components/InsertPageModal/InsertBlankPagePanel/index.js @@ -0,0 +1,3 @@ +import InsertBlankPagePanel from './InsertBlankPagePanel'; + +export default InsertBlankPagePanel; \ No newline at end of file diff --git a/src/components/InsertPageModal/InsertUploadedPagePanel/InsertUploadedPagePanel.scss b/src/components/InsertPageModal/InsertUploadedPagePanel/InsertUploadedPagePanel.scss index 8ba7533d5f..8691b1083e 100644 --- a/src/components/InsertPageModal/InsertUploadedPagePanel/InsertUploadedPagePanel.scss +++ b/src/components/InsertPageModal/InsertUploadedPagePanel/InsertUploadedPagePanel.scss @@ -17,7 +17,7 @@ width: 100%; font-size: 16px; line-height: 24px; - color: var(--gray-9); + color: var(--gray-8); font-weight: 700; box-shadow: inset 0px -1px 0px var(--divider); padding: 20px 16px 20px 16px; diff --git a/src/components/LazyLoadWrapper/index.js b/src/components/LazyLoadWrapper/index.js new file mode 100644 index 0000000000..c0f8af0a5e --- /dev/null +++ b/src/components/LazyLoadWrapper/index.js @@ -0,0 +1,5 @@ +import LazyLoadWrapper from './LazyLoadWrapper'; + +export { default as LazyLoadComponents } from './LazyLoadComponents'; + +export default LazyLoadWrapper; diff --git a/src/components/LeftPanelOverlay/ThumbnailMoreOptionsPopupSmall.js b/src/components/LeftPanelOverlay/ThumbnailMoreOptionsPopupSmall.js new file mode 100644 index 0000000000..5fe573e7f5 --- /dev/null +++ b/src/components/LeftPanelOverlay/ThumbnailMoreOptionsPopupSmall.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import selectors from 'selectors'; +import FlyoutMenu from 'components/FlyoutMenu/FlyoutMenu'; +import PageAdditionalControls from 'components/PageManipulationOverlay/PageAdditionalControls'; +import PageManipulationControls from '../PageManipulationOverlay/PageManipulationControls'; +import DataElements from 'constants/dataElement'; + +const ThumbnailMoreOptionsPopupSmall = () => { + const selectedPageIndexes = useSelector((state) => selectors.getSelectedThumbnailPageIndexes(state)); + + return ( + + i + 1)} + warn + /> + i + 1)} + warn + /> + + ); +}; + +export default ThumbnailMoreOptionsPopupSmall; diff --git a/src/components/LinkAnnotationPopup/LinkAnnotationPopup.js b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.js new file mode 100644 index 0000000000..e5639f5d9f --- /dev/null +++ b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.js @@ -0,0 +1,71 @@ +import ActionButton from 'components/ActionButton'; +import classNames from 'classnames'; +import DataElements from 'constants/dataElement'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import './LinkAnnotationPopup.scss'; + +const propTypes = { + handleUnLink: PropTypes.func, + isAnnotation: PropTypes.bool, + isMobileDevice: PropTypes.bool, + linkText: PropTypes.string, + handleOnMouseEnter: PropTypes.func, + handleOnMouseLeave: PropTypes.func, + handleMouseMove: PropTypes.func, +}; + +const LinkAnnotationPopup = ({ + handleUnLink, + isAnnotation, + isMobileDevice, + linkText, + handleOnMouseEnter, + handleOnMouseLeave, + handleMouseMove +}) => { + const renderContents = () => ( +
+ {linkText && ( + <> +
+ {linkText} +
+
+ + )} + +
+ ); + + if (isMobileDevice || !isAnnotation) { + return null; + } + + return ( +
+ {renderContents()} +
+ ); +}; + +LinkAnnotationPopup.propTypes = propTypes; + +export default LinkAnnotationPopup; diff --git a/src/components/LinkAnnotationPopup/LinkAnnotationPopup.scss b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.scss new file mode 100644 index 0000000000..c09a744bbe --- /dev/null +++ b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.scss @@ -0,0 +1,52 @@ +@import '../../constants/styles.scss'; +@import '../../constants/popup'; + +.LinkAnnotationPopupContainer { + @extend %popup; + border-radius: 4px; + box-shadow: 0 0 3px 0 var(--document-box-shadow); + background: var(--component-background); +} + +.LinkAnnotationPopup { + &.is-horizontal { + .contents { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + + .link-annot-input { + margin: 8px 0 8px 8px; + color: #485056; + font-style: normal; + font-weight: 400; + line-height: normal; + /* top right bottom left */ + border: none; + width: fit-content; + max-width: 240px; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + box-sizing: border-box; + word-break: break-all; + } + + .divider { + width: 1px; + height: 20px; + background: var(--divider); + flex-shrink: 0; + } + + .main-menu-button { + margin: 4px 8px 4px 0; + } + } + } +} \ No newline at end of file diff --git a/src/components/LinkAnnotationPopup/LinkAnnotationPopup.stories.js b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.stories.js new file mode 100644 index 0000000000..8065cd5f77 --- /dev/null +++ b/src/components/LinkAnnotationPopup/LinkAnnotationPopup.stories.js @@ -0,0 +1,40 @@ +import React from 'react'; +import LinkAnnotationPopup from './LinkAnnotationPopup'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +const noop = () => { }; + +export default { + title: 'Components/LinkAnnotationPopup', + component: LinkAnnotationPopup, +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + annotationPopup: [ + ], + activeDocumentViewerKey: 1, + }, +}; + +export const Basic = () => { + const props = { + linkText: 'https://www.Apryse.com', + handleUnLink: noop, + handleOnMouseEnter: noop, + handleOnMouseLeave: noop, + handleMouseMove: noop, + isAnnotation: true, + isMobileDevice: false, + }; + return ( + initialState })}> +
+ +
+
+ ); +}; diff --git a/src/components/LinkAnnotationPopup/index.js b/src/components/LinkAnnotationPopup/index.js new file mode 100644 index 0000000000..a60b394985 --- /dev/null +++ b/src/components/LinkAnnotationPopup/index.js @@ -0,0 +1,3 @@ +import LinkAnnotationPopupContainer from './LinkAnnotationPopupContainer'; + +export default LinkAnnotationPopupContainer; \ No newline at end of file diff --git a/src/components/LinkModal/LinkModal.spec.js b/src/components/LinkModal/LinkModal.spec.js new file mode 100644 index 0000000000..5ebc24e6a3 --- /dev/null +++ b/src/components/LinkModal/LinkModal.spec.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { NoURLInput } from './LinkModal.stories'; +import core from 'core'; + +core.addEventListener = jest.fn(); + +describe('LinkModal', () => { + describe('Component', () => { + it('Story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/components/LinkModal/LinkModal.stories.js b/src/components/LinkModal/LinkModal.stories.js new file mode 100644 index 0000000000..61a3896bac --- /dev/null +++ b/src/components/LinkModal/LinkModal.stories.js @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider as ReduxProvider } from 'react-redux'; +import LinkModal from './LinkModal'; +import core from 'core'; + +export default { + title: 'Components/LinkModal', + component: LinkModal, +}; + +const initialState = { + viewer: { + disabledElements: {}, + openElements: { + 'linkModal': true, + }, + currentPage: 1, + selectedTab: 'notesPanel', + tab: { + linkModal: 'URLPanelButton' + }, + customElementOverrides: {}, + pageLabels: [] + }, + document: { + totalPages: 1 + } +}; +const store = configureStore({ + reducer: () => initialState +}); + +export function NoURLInput() { + core.getDocumentViewer = () => ({ + getAnnotationManager: () => ({ + isAnnotationSelected: () => true + }), + getSelectedText: () => 'selected text' + }); + + core.getSelectedAnnotations = () => ({}); + + return ( + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/LogoBar/LogoBar.js b/src/components/LogoBar/LogoBar.js new file mode 100644 index 0000000000..f28018de26 --- /dev/null +++ b/src/components/LogoBar/LogoBar.js @@ -0,0 +1,42 @@ +import './LogoBar.scss'; +import React from 'react'; +import selectors from 'selectors'; +import DataElements from 'constants/dataElement'; +import { useSelector } from 'react-redux'; +import packageConfig from '../../../package.json'; +import Button from '../Button'; +import { isMobileSize } from 'src/helpers/getDeviceSize'; + +const LogoBar = () => { + const [ + isDisabled, + ] = useSelector((state) => [ + selectors.isElementDisabled(state, DataElements.LOGO_BAR), + ]); + + const logoText = isMobileSize() ? 'Apryse' : 'Powered by Apryse'; + const versionText = isMobileSize() ? packageConfig.version : `Version ${packageConfig.version}`; + const apryseURL = 'https://apryse.com/products/webviewer'; + const apryseRedirect = () => { + window.top.location.href = apryseURL; + }; + + return isDisabled ? null : ( +
+
+
+ +
+ ); +}; + +export default LogoBar; diff --git a/src/components/LogoBar/LogoBar.scss b/src/components/LogoBar/LogoBar.scss new file mode 100644 index 0000000000..e203b2202c --- /dev/null +++ b/src/components/LogoBar/LogoBar.scss @@ -0,0 +1,38 @@ +@import '../../constants/styles'; + +.LogoBar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 4px 16px; + width: 100%; + height: $logo-bar-height; + background: var(--gray-0); + color: #747C84; + font-size: 12px; + line-height: 16px; + + .logo-container { + display: flex; + flex-direction: row; + align-items: center; + } + + .logo-button { + width: 100%; + gap: 4px; + color: #747C84; + font-size: 12px !important; + + .Icon { + width: 14px; + height: 14px; + } + } + + .version { + text-decoration: none; + color: #747C84; + } +} \ No newline at end of file diff --git a/src/components/LogoBar/LogoBar.stories.js b/src/components/LogoBar/LogoBar.stories.js new file mode 100644 index 0000000000..e90c79b544 --- /dev/null +++ b/src/components/LogoBar/LogoBar.stories.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import LogoBar from './LogoBar'; + +import { Provider } from 'react-redux'; + +export default { + title: 'Components/LogoBar', + component: LogoBar, +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + }, +}; + +const store = configureStore({ + reducer: () => initialState +}); + +export const LogoBarComponent = () => ( + +
+ +
+
+); \ No newline at end of file diff --git a/src/components/LogoBar/index.js b/src/components/LogoBar/index.js new file mode 100644 index 0000000000..561567d51e --- /dev/null +++ b/src/components/LogoBar/index.js @@ -0,0 +1,3 @@ +import LogoBar from './LogoBar'; + +export default LogoBar; diff --git a/src/components/ModularComponents/BottomHeader/BottomHeader.scss b/src/components/ModularComponents/BottomHeader/BottomHeader.scss new file mode 100644 index 0000000000..ac3c7ba51c --- /dev/null +++ b/src/components/ModularComponents/BottomHeader/BottomHeader.scss @@ -0,0 +1,23 @@ +.bottom-headers-wrapper { + position: absolute; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.BottomHeader { + display: inline-block; + width: 100%; + pointer-events: auto; +} + +.BottomHeader.closed { + display: none; +} + +.BottomHeader.stroke { + border-top-width: 1px; + border-top-style: solid; + border-top-color: var(--gray-5); +} \ No newline at end of file diff --git a/src/components/ModularComponents/BottomHeader/index.js b/src/components/ModularComponents/BottomHeader/index.js new file mode 100644 index 0000000000..e4d6606372 --- /dev/null +++ b/src/components/ModularComponents/BottomHeader/index.js @@ -0,0 +1,3 @@ +import BottomHeader from './BottomHeaderContainer'; + +export default BottomHeader; \ No newline at end of file diff --git a/src/components/ModularComponents/CustomButton/CustomButton.js b/src/components/ModularComponents/CustomButton/CustomButton.js new file mode 100644 index 0000000000..029a7407c6 --- /dev/null +++ b/src/components/ModularComponents/CustomButton/CustomButton.js @@ -0,0 +1,47 @@ +import React from 'react'; +import '../../Button/Button.scss'; +import './CustomButton.scss'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import Button from 'components/Button'; +import { PLACEMENT } from 'constants/customizationVariables'; + +const CustomButton = (props) => { + const { title, dataElement, label, img, onClick, disabled, className, preset, headerPlacement, ariaLabel } = props; + let forceTooltipPosition; + if ([PLACEMENT.LEFT, PLACEMENT.RIGHT].includes(headerPlacement)) { + forceTooltipPosition = headerPlacement === PLACEMENT.LEFT ? PLACEMENT.RIGHT : PLACEMENT.LEFT; + } else if ([PLACEMENT.TOP, PLACEMENT.BOTTOM].includes(headerPlacement)) { + forceTooltipPosition = headerPlacement === PLACEMENT.TOP ? PLACEMENT.BOTTOM : PLACEMENT.TOP; + } + return ( + + ); +}; + +CustomButton.propTypes = { + dataElement: PropTypes.string, + title: PropTypes.string, + label: PropTypes.string, + img: PropTypes.string, + onClick: PropTypes.func, + disabled: PropTypes.bool, +}; + +export default CustomButton; diff --git a/src/components/ModularComponents/CustomButton/CustomButton.scss b/src/components/ModularComponents/CustomButton/CustomButton.scss new file mode 100644 index 0000000000..3a5f9f65b6 --- /dev/null +++ b/src/components/ModularComponents/CustomButton/CustomButton.scss @@ -0,0 +1,46 @@ +@import '../../../constants/styles'; + +.CustomButton { + padding: 5px; + width: fit-content; + + &:hover { + background-color: var(--view-header-button-hover); + } +} + +.confirm-button { + background-color: var(--primary-button); + border: 1px solid var(--primary-button); + color: var(--primary-button-text); + padding: 7px 14px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + border-radius: 5px; + height: 30px; + cursor: pointer; + + &:hover { + background: var(--primary-button-hover) !important; + border: 1px solid var(--primary-button-hover) !important; + border-radius: 5px !important; + } +} + +.cancel-button { + color: var(--secondary-button-text); + background-color: transparent; + padding: 7px 14px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + border-radius: 5px; + height: 30px; + cursor: pointer; + + &:hover { + color: var(--secondary-button-hover); + background: transparent; + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/CustomButton/CustomButton.stories.js b/src/components/ModularComponents/CustomButton/CustomButton.stories.js new file mode 100644 index 0000000000..a099cfae2a --- /dev/null +++ b/src/components/ModularComponents/CustomButton/CustomButton.stories.js @@ -0,0 +1,56 @@ +import React from 'react'; +import CustomButton from './CustomButton'; +import initialState from 'src/redux/initialState'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; + +export default { + title: 'Components/CustomButton', + component: CustomButton, +}; + +const store = configureStore({ + reducer: () => initialState +}); + +const BasicComponent = (props) => { + return ( + + + + ); +}; + +export const DefaultButton = BasicComponent.bind({}); +DefaultButton.args = { + dataElement: 'button-data-element', + title: 'Button title', + disabled: false, + label: 'Click', + img: 'icon-save', + onClick: () => { + alert('Clicked!'); + } +}; + +export const ConfirmButton = BasicComponent.bind({}); +ConfirmButton.args = { + dataElement: 'button-data-element', + title: 'Apply Fields', + label: 'Apply Fields', + preset: 'confirm', + onClick: () => { + alert('Apply Fields button clicked!'); + } +}; + +export const CancelButton = BasicComponent.bind({}); +CancelButton.args = { + dataElement: 'button-data-element', + title: 'Cancel', + label: 'Cancel', + preset: 'cancel', + onClick: () => { + alert('Cancel button clicked!'); + } +}; \ No newline at end of file diff --git a/src/components/ModularComponents/CustomButton/index.js b/src/components/ModularComponents/CustomButton/index.js new file mode 100644 index 0000000000..e5f8cd2a41 --- /dev/null +++ b/src/components/ModularComponents/CustomButton/index.js @@ -0,0 +1,3 @@ +import CustomButton from './CustomButton'; + +export default CustomButton; \ No newline at end of file diff --git a/src/components/ModularComponents/Divider/Divider.js b/src/components/ModularComponents/Divider/Divider.js new file mode 100644 index 0000000000..c7b9aa1e30 --- /dev/null +++ b/src/components/ModularComponents/Divider/Divider.js @@ -0,0 +1,13 @@ +import React from 'react'; +import classNames from 'classnames'; +import './Divider.scss'; + +const Divider = ({ headerDirection }) => { + const className = classNames('Divider', `${headerDirection || 'column'}`); + + return ( +
+ ); +}; + +export default Divider; \ No newline at end of file diff --git a/src/components/ModularComponents/Divider/Divider.scss b/src/components/ModularComponents/Divider/Divider.scss new file mode 100644 index 0000000000..03f39f4228 --- /dev/null +++ b/src/components/ModularComponents/Divider/Divider.scss @@ -0,0 +1,15 @@ +.Divider { + background: var(--gray-5); + + &.row { + width: 1px; + height: auto; + margin: 4px 0px; + } + + &.column { + width: auto; + height: 1px; + margin: 0px 4px; + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/Divider/index.js b/src/components/ModularComponents/Divider/index.js new file mode 100644 index 0000000000..7909d0ef6b --- /dev/null +++ b/src/components/ModularComponents/Divider/index.js @@ -0,0 +1,3 @@ +import Divider from './Divider'; + +export default Divider; \ No newline at end of file diff --git a/src/components/ModularComponents/FlexDropdown/FlexDropdown.js b/src/components/ModularComponents/FlexDropdown/FlexDropdown.js new file mode 100644 index 0000000000..62a510a650 --- /dev/null +++ b/src/components/ModularComponents/FlexDropdown/FlexDropdown.js @@ -0,0 +1,12 @@ +import React from 'react'; +import Dropdown from 'components/Dropdown'; + +import './FlexDropdown.scss'; + +const FlexDropdown = (props) => { + return ( + + ); +}; + +export default FlexDropdown; \ No newline at end of file diff --git a/src/components/ModularComponents/FlexDropdown/FlexDropdown.scss b/src/components/ModularComponents/FlexDropdown/FlexDropdown.scss new file mode 100644 index 0000000000..94b98f1f51 --- /dev/null +++ b/src/components/ModularComponents/FlexDropdown/FlexDropdown.scss @@ -0,0 +1,51 @@ +@import '../../../constants/styles'; + +.FlexDropdown { + &__wrapper { + position: relative; + } + + .Dropdown__items { + z-index: $headers-z-index; + } + + .Dropdown__item-object { + display: flex; + align-items: center; + gap: 4px; + } + + &.column { + height: auto; + padding: 0; + .picked-option { + flex-direction: column; + gap: 4px; + padding: 4px; + } + .Dropdown__items { + z-index: $headers-z-index; + width: 100%; + padding: 0; + gap: 12px; + } + .Dropdown__item { + min-height: 28px; + height: 100%; + padding: 4px; + } + .Dropdown__item-object { + display: flex; + flex-direction: column; + max-width: 100%; + flex: 1; + } + } + + .Dropdown__item-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/FlexDropdown/index.js b/src/components/ModularComponents/FlexDropdown/index.js new file mode 100644 index 0000000000..4d12ec3a2d --- /dev/null +++ b/src/components/ModularComponents/FlexDropdown/index.js @@ -0,0 +1,3 @@ +import FlexDropdown from './FlexDropdown'; + +export default FlexDropdown; \ No newline at end of file diff --git a/src/components/ModularComponents/FloatingHeader/FloatingHeader.scss b/src/components/ModularComponents/FloatingHeader/FloatingHeader.scss new file mode 100644 index 0000000000..c556e61fed --- /dev/null +++ b/src/components/ModularComponents/FloatingHeader/FloatingHeader.scss @@ -0,0 +1,117 @@ +@import '../../../constants/styles.scss'; + +.FloatingHeaderContainer { + transition: all .3s ease-in-out; + position: absolute; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: baseline; + min-height: $top-bar-height; + padding: 24px; + z-index: $headers-z-index; + pointer-events: none; + + &.bottom { + position: relative; + } + + &.left { + width: $left-header-width; + padding: 24px 0px 24px 24px; + } + + &.right { + width: $right-header-width; + align-items: end; + } + + &.vertical { + flex-direction: column; + } + + .FloatSection { + display: flex; + flex: 1; + + &.vertical { + flex-direction: column; + } + + // Maybe we'll need these maybe we wont + // &.start__top, + // &.start__bottom { + // // align-self: flex-start; + // } + + // &.start__left, + // &.start__right { + // // align-self: flex-start; + // } + + &.center__top, + &.center__bottom { + justify-content: center; + } + + &.center__left, + &.center__right { + justify-content: center; + } + + &.end__top, + &.end__bottom { + justify-content: flex-end; + } + + &.end__left, + &.end__right { + justify-content: flex-end; + } + } +} + +.FloatingHeader { + border-radius: 4px; + pointer-events: auto; + transition: opacity .2s ease; + + // customizable styles + padding: 8px 12px; + background: var(--gray-0); + + &.opacity-full { + opacity: 1; + } + + &.opacity-low { + opacity: 0.5; + + &.isVisible { + opacity: 1; + } + } + + &.opacity-none { + opacity: 0; + + &.isVisible { + opacity: 1; + } + } + + &.opacity-mode-dynamic:hover { + opacity: 1; + } +} + +.FloatingHeader.stroke { + border-width: 1px; + border-style: solid; + border-color: var(--gray-5); +} + +.FloatingHeader.VerticalHeader { + padding: 12px 8px; +} \ No newline at end of file diff --git a/src/components/ModularComponents/FloatingHeader/FloatingHeaderContainer.js b/src/components/ModularComponents/FloatingHeader/FloatingHeaderContainer.js new file mode 100644 index 0000000000..9e95a0ae35 --- /dev/null +++ b/src/components/ModularComponents/FloatingHeader/FloatingHeaderContainer.js @@ -0,0 +1,120 @@ +import React, { useMemo } from 'react'; +import useFloatingHeaderSelectors from 'hooks/useFloatingHeaderSelectors'; +import FloatingHeader from './FloatingHeader'; +import './FloatingHeader.scss'; +import classNames from 'classnames'; +import { PLACEMENT, POSITION, DEFAULT_GAP } from 'src/constants/customizationVariables'; + +const FloatSection = ({ position, isVertical, children, gap = DEFAULT_GAP }) => { + const className = classNames('FloatSection', position, { 'vertical': isVertical }); + return ( +
+ {children} +
+ ); +}; + +const FloatingHeaderContainer = React.forwardRef((props, ref) => { + const { floatingHeaders, placement } = props; + const isHorizontalHeader = [PLACEMENT.TOP, PLACEMENT.BOTTOM].includes(placement); + const selectors = useFloatingHeaderSelectors(); + + const style = useMemo(() => computeFloatContainerStyle({ + ...selectors, + isHorizontalHeader, + placement, + }), [selectors, isHorizontalHeader, placement]); + + const renderHeaders = (headers, positionPrefix) => ( + + {headers.map((header) => )} + + ); + + return ( +
+ {renderHeaders(floatingHeaders.filter((h) => h.position === POSITION.START), POSITION.START)} + {renderHeaders(floatingHeaders.filter((h) => h.position === POSITION.CENTER), POSITION.CENTER)} + {renderHeaders(floatingHeaders.filter((h) => h.position === POSITION.END), POSITION.END)} +
+ ); +}); + +FloatingHeaderContainer.displayName = 'FloatingHeaderContainer'; + +function computeFloatContainerStyle(params) { + const { + isLeftPanelOpen, + leftPanelWidth, + isRightPanelOpen, + rightPanelWidth, + leftHeaderWidth, + rightHeaderWidth, + isHorizontalHeader, + topFloatingContainerHeight, + bottomFloatingContainerHeight, + topStartFloatingHeaders, + bottomStartFloatingHeaders, + topHeadersHeight, + bottomHeadersHeight, + bottomEndFloatingHeaders, + topEndFloatingHeaders, + placement + } = params; + + const styles = {}; + const verticalHeaderWidth = rightHeaderWidth + leftHeaderWidth; + let panelsWidth = 0; + let leftOffset = leftHeaderWidth; + + if (isLeftPanelOpen) { + panelsWidth += leftPanelWidth; + leftOffset += leftPanelWidth; + } + if (isRightPanelOpen) { + panelsWidth += rightPanelWidth; + } + + if (leftOffset !== 0) { + styles.transform = `translate(${leftOffset}px, 0px)`; + } + if (placement === PLACEMENT.RIGHT) { + styles.transform = 'translate(-48px, 0px)'; + } + if (isHorizontalHeader && (panelsWidth || verticalHeaderWidth)) { + styles.width = `calc(100% - ${panelsWidth + verticalHeaderWidth}px)`; + } + if (!isHorizontalHeader) { + // if it is the left float header, and there are no top start floating headers, then we can take the full height + // otherwise the height must accotun for the floating header container + let topFloatingHeaderOffset = 0; + let bottomFloatingHeaderOffset = 0; + + if (placement === PLACEMENT.LEFT) { + topFloatingHeaderOffset = topStartFloatingHeaders.length === 0 ? 0 : topFloatingContainerHeight; + bottomFloatingHeaderOffset = bottomStartFloatingHeaders.length === 0 ? 0 : bottomFloatingContainerHeight; + } + + if (placement === PLACEMENT.RIGHT) { + topFloatingHeaderOffset = topEndFloatingHeaders.length === 0 ? 0 : topFloatingContainerHeight; + bottomFloatingHeaderOffset = bottomEndFloatingHeaders.length === 0 ? 0 : bottomFloatingContainerHeight; + } + + styles.height = `calc(100% - ${topHeadersHeight + bottomHeadersHeight + topFloatingHeaderOffset + bottomFloatingHeaderOffset}px)`; + if (topFloatingHeaderOffset) { + styles.marginTop = `${topFloatingContainerHeight}px`; + styles.paddingTop = '0px'; + } + if (bottomFloatingHeaderOffset) { + styles.paddingBottom = '0px'; + } + } + + return styles; +} + +export default FloatingHeaderContainer; \ No newline at end of file diff --git a/src/components/ModularComponents/FloatingHeader/Stories/HeadersInApp.stories.js b/src/components/ModularComponents/FloatingHeader/Stories/HeadersInApp.stories.js new file mode 100644 index 0000000000..b86672fa3c --- /dev/null +++ b/src/components/ModularComponents/FloatingHeader/Stories/HeadersInApp.stories.js @@ -0,0 +1,233 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import App from 'components/App'; +import initialState from 'src/redux/initialState'; +import rootReducer from 'reducers/rootReducer'; +import { + defaultLeftHeader, + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, + secondFloatStartLeftHeader, + floatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultRightHeader, + secondFloatStartRightHeader, + floatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, + floatStarTopHeaderStatic, + floatCenterTopHeaderDynamic, + floatEndTopHeaderNone, + mockModularComponents, +} from '../../Helpers/mockHeaders'; + +export default { + title: 'ModularComponents/FloatingHeader/App', + component: App, +}; + + +const noop = () => { }; + +const MockApp = ({ initialState }) => { + return ( + getDefaultMiddleware({ serializableCheck: false }) + })}> + + + ); +}; + +const Template = (args) => { + const stateWithHeaders = { + ...initialState, + viewer: { + ...initialState.viewer, + modularHeaders: args.headers, + modularComponents: mockModularComponents, + openElements: {}, + }, + featureFlags: { + customizableUI: true, + }, + }; + return ; +}; + +function createTemplate(headers) { + const template = Template.bind({}); + template.args = { headers }; + template.parameters = { + layout: 'fullscreen', + chromatic: { disableSnapshot: true } + }; + return template; +} + +export const TopAndLeftHeaders = createTemplate({ + defaultLeftHeader, + secondFloatStartLeftHeader, + floatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, +}); + +export const TopCenterAndLeftHeaders = createTemplate({ + defaultLeftHeader, + secondFloatStartLeftHeader, + floatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultTopHeader, + floatCenterHeader, + floatEndHeader, +}); + +export const TopAndRightHeaders = createTemplate({ + defaultRightHeader, + secondFloatStartRightHeader, + floatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, +}); + +export const TopCenterAndRightHeaders = createTemplate({ + defaultRightHeader, + secondFloatStartRightHeader, + floatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultTopHeader, + floatCenterHeader, +}); + +export const BottomAndLeftHeaders = createTemplate({ + defaultLeftHeader, + secondFloatStartLeftHeader, + floatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, +}); + +export const BottomCenterAndLeftHeaders = createTemplate({ + defaultLeftHeader, + secondFloatStartLeftHeader, + floatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, +}); + +export const BottomAndRightHeaders = createTemplate({ + defaultRightHeader, + secondFloatStartRightHeader, + floatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, +}); + +export const BottomCenterAndRightHeaders = createTemplate({ + defaultRightHeader, + secondFloatStartRightHeader, + floatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultBottomHeader, + floatCenterBottomHeader, +}); + +export const TopAndBottomHeaders = createTemplate({ + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, +}); + +export const FloatingOnAllSides = createTemplate({ + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, + floatStartLeftHeader, + secondFloatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + floatStartRightHeader, + secondFloatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, +}); + +export const FloatiesWithOpacityLevels = createTemplate({ + floatStarTopHeaderStatic, + floatCenterTopHeaderDynamic, + floatEndTopHeaderNone, +}); + +export const AllHeaders = createTemplate({ + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, + defaultLeftHeader, + floatStartLeftHeader, + secondFloatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultRightHeader, + floatStartRightHeader, + secondFloatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, +}); \ No newline at end of file diff --git a/src/components/ModularComponents/FloatingHeader/index.js b/src/components/ModularComponents/FloatingHeader/index.js new file mode 100644 index 0000000000..6f3f435969 --- /dev/null +++ b/src/components/ModularComponents/FloatingHeader/index.js @@ -0,0 +1,3 @@ +import FloatingHeaderContainer from './FloatingHeaderContainer'; + +export default FloatingHeaderContainer; \ No newline at end of file diff --git a/src/components/ModularComponents/GenericOutlinesPanel/GenericOutlinesPanel.stories.js b/src/components/ModularComponents/GenericOutlinesPanel/GenericOutlinesPanel.stories.js index 974a922073..7328dccd98 100644 --- a/src/components/ModularComponents/GenericOutlinesPanel/GenericOutlinesPanel.stories.js +++ b/src/components/ModularComponents/GenericOutlinesPanel/GenericOutlinesPanel.stories.js @@ -30,7 +30,7 @@ export const Editable = () => { }, panelWidths: { 'outlines-panel': DEFAULT_NOTES_PANEL_WIDTH }, isInDesktopOnlyMode: true, - modularHeaders: [] + modularHeaders: {} }, document: { outlines: getDefaultOutlines(), @@ -63,7 +63,7 @@ export const NonEditable = () => { }, panelWidths: { 'outlines-panel': DEFAULT_NOTES_PANEL_WIDTH }, isInDesktopOnlyMode: true, - modularHeaders: [] + modularHeaders: {} }, document: { outlines: getDefaultOutlines(), @@ -98,7 +98,7 @@ export const Expanded = () => { }, panelWidths: { 'outlines-panel': DEFAULT_NOTES_PANEL_WIDTH }, isInDesktopOnlyMode: true, - modularHeaders: [] + modularHeaders: {} }, document: { outlines: getDefaultOutlines(), @@ -132,7 +132,7 @@ export const NoOutlines = () => { }, panelWidths: { 'outlines-panel': DEFAULT_NOTES_PANEL_WIDTH }, isInDesktopOnlyMode: true, - modularHeaders: [] + modularHeaders: {} }, document: { outlines: [], diff --git a/src/components/ModularComponents/GroupedItems/GroupedItems.scss b/src/components/ModularComponents/GroupedItems/GroupedItems.scss new file mode 100644 index 0000000000..e68163fef2 --- /dev/null +++ b/src/components/ModularComponents/GroupedItems/GroupedItems.scss @@ -0,0 +1,3 @@ +.GroupedItems { + display: flex; +} \ No newline at end of file diff --git a/src/components/ModularComponents/GroupedItems/index.js b/src/components/ModularComponents/GroupedItems/index.js new file mode 100644 index 0000000000..bf4e4bb82f --- /dev/null +++ b/src/components/ModularComponents/GroupedItems/index.js @@ -0,0 +1,3 @@ +import GroupedItems from './GroupedItems'; + +export default GroupedItems; \ No newline at end of file diff --git a/src/components/ModularComponents/Helpers/menuItems.js b/src/components/ModularComponents/Helpers/menuItems.js new file mode 100644 index 0000000000..7120298b93 --- /dev/null +++ b/src/components/ModularComponents/Helpers/menuItems.js @@ -0,0 +1,129 @@ +import React from 'react'; +import DataElements from 'constants/dataElement'; +import { PRESET_BUTTON_TYPES } from 'constants/customizationVariables'; +import ActionButton from 'components/ActionButton'; + +export const menuItems = { + [PRESET_BUTTON_TYPES.UNDO]: { + dataElement: 'undoButton', + presetDataElement: DataElements.UNDO_PRESET_BUTTON, + icon: 'icon-operation-undo', + label: 'action.undo', + title: 'action.undo', + hidden: false, + }, + [PRESET_BUTTON_TYPES.REDO]: { + dataElement: 'redoButton', + presetDataElement: DataElements.REDO_PRESET_BUTTON, + icon: 'icon-operation-redo', + label: 'action.redo', + title: 'action.redo', + hidden: false, + }, + [PRESET_BUTTON_TYPES.FORM_FIELD_EDIT]: { + dataElement: 'formFieldEditButton', + presetDataElement: DataElements.FORM_FIELD_EDIT_PRESET_BUTTON, + icon: 'ic-fill-and-sign', + label: 'action.formFieldEditMode', + title: 'action.formFieldEditMode', + hidden: false, + }, + [PRESET_BUTTON_TYPES.CONTENT_EDIT]: { + dataElement: 'contentEditButton', + presetDataElement: DataElements.CONTENT_EDIT_PRESET_BUTTON, + icon: 'icon-content-edit', + label: 'action.contentEditMode', + title: 'action.contentEditMode', + hidden: false, + }, + [PRESET_BUTTON_TYPES.NEW_DOCUMENT]: { + dataElement: DataElements.NEW_DOCUMENT_BUTTON, + presetDataElement: DataElements.NEW_DOCUMENT_PRESET_BUTTON, + icon: 'icon-plus-sign', + label: 'action.newDocument', + title: 'action.newDocument', + isActive: false, + hidden: false, + }, + [PRESET_BUTTON_TYPES.FILE_PICKER]: { + dataElement: DataElements.FILE_PICKER_BUTTON, + presetDataElement: DataElements.FILE_PICKER_PRESET_BUTTON, + icon: 'icon-header-file-picker-line', + label: 'action.openFile', + title: 'action.openFile', + hidden: false, + }, + [PRESET_BUTTON_TYPES.DOWNLOAD]: { + dataElement: DataElements.DOWNLOAD_BUTTON, + presetDataElement: DataElements.DOWNLOAD_PRESET_BUTTON, + icon: 'icon-download', + label: 'action.download', + title: 'action.download', + hidden: false, + }, + [PRESET_BUTTON_TYPES.SAVE_AS]: { + dataElement: DataElements.SAVE_AS_BUTTON, + presetDataElement: DataElements.SAVE_AS_PRESET_BUTTON, + icon: 'icon-save', + label: 'saveModal.saveAs', + title: 'saveModal.saveAs', + isActive: false, + hidden: false, + }, + [PRESET_BUTTON_TYPES.PRINT]: { + dataElement: DataElements.PRINT_BUTTON, + presetDataElement: DataElements.PRINT_PRESET_BUTTON, + icon: 'icon-header-print-line', + label: 'action.print', + title: 'action.print', + isActive: false, + hidden: false, + }, + [PRESET_BUTTON_TYPES.CREATE_PORTFOLIO]: { + dataElement: DataElements.CREATE_PORTFOLIO_BUTTON, + presetDataElement: DataElements.CREATE_PORTFOLIO_PRESET_BUTTON, + icon: 'icon-pdf-portfolio', + label: 'portfolio.createPDFPortfolio', + title: 'portfolio.createPDFPortfolio', + isActive: false, + hidden: false, + }, + [PRESET_BUTTON_TYPES.SETTINGS]: { + dataElement: DataElements.SETTINGS_BUTTON, + presetDataElement: DataElements.SETTINGS_PRESET_BUTTON, + icon: 'icon-header-settings-line', + label: 'option.settings.settings', + title: 'option.settings.settings', + isActive: false, + hidden: false, + }, + [PRESET_BUTTON_TYPES.FULLSCREEN]: { + dataElement: DataElements.FULLSCREEN_BUTTON, + presetDataElement: DataElements.FULLSCREEN_PRESET_BUTTON, + icon: 'icon-header-full-screen', + label: 'action.enterFullscreen', + title: 'action.enterFullscreen', + hidden: false, + }, +}; + +export const getPresetButtonDOM = (buttonType, isDisabled, onClick, isFullScreen) => { + const { dataElement, presetDataElement } = menuItems[buttonType]; + let { icon, title } = menuItems[buttonType]; + + if (buttonType === PRESET_BUTTON_TYPES.FULLSCREEN) { + icon = isFullScreen ? 'icon-header-full-screen-exit' : 'icon-header-full-screen'; + title = isFullScreen ? 'action.exitFullscreen' : 'action.enterFullscreen'; + } + + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/ModularComponents/Helpers/mockHeaders.js b/src/components/ModularComponents/Helpers/mockHeaders.js new file mode 100644 index 0000000000..4ff660e6e1 --- /dev/null +++ b/src/components/ModularComponents/Helpers/mockHeaders.js @@ -0,0 +1,301 @@ +/* eslint-disable no-alert */ +const baseButton = { + dataElement: 'button', + onClick: () => alert('Added'), + disabled: false, + title: 'Button 1', + label: 'Add', + type: 'customButton' +}; + +const divider = { + type: 'divider', + dataElement: 'divider-1', +}; + +const leftPanelToggle = { + dataElement: 'leftPanelToggle', + toggleElement: 'leftPanel', + disabled: false, + title: 'Left Panel', + img: 'icon-header-sidebar-line', + type: 'toggleButton', +}; + +const notesPanelToggle = { + dataElement: 'notesPanelToggle', + toggleElement: 'notesPanel', + disabled: false, + title: 'Notes Panel', + img: 'icon-header-chat-line', + type: 'toggleButton', +}; + +// Handy mock buttons +const button1 = { ...baseButton, dataElement: 'button1', label: 'Button 1' }; +const button2 = { ...baseButton, dataElement: 'button2', label: 'Button 2' }; +const button3 = { ...baseButton, dataElement: 'button3', label: 'Button 3' }; +const button4 = { ...baseButton, dataElement: 'button4', label: 'Button 4' }; +const button5 = { ...baseButton, dataElement: 'button5', label: 'Button 5' }; +const button6 = { ...baseButton, dataElement: 'button6', label: 'Button 6' }; +const button7 = { ...baseButton, dataElement: 'button7', label: 'Button 7' }; +const button8 = { ...baseButton, dataElement: 'button8', label: 'Button 8' }; +const button9 = { ...baseButton, dataElement: 'button9', label: 'Button 9' }; + +// These are our headers +const defaultTopHeader = { + dataElement: 'defaultHeader', + placement: 'top', + gap: 20, + items: ['button1', 'button2', 'divider', 'button3'], +}; + +const floatStartHeader = { + dataElement: 'floatStartHeader', + placement: 'top', + float: true, + position: 'start', + items: ['button1', 'button2'], + gap: 20 +}; + +const secondFloatStartHeader = { + dataElement: 'floatStartHeader-2', + placement: 'top', + float: true, + position: 'start', + items: ['button3', 'button4'], + gap: 20 +}; + +const floatCenterHeader = { + dataElement: 'floatCenterHeader', + placement: 'top', + float: true, + position: 'center', + items: ['button5', 'divider', 'button6'], + gap: 20 +}; + +const floatEndHeader = { + dataElement: 'floatEndHeader', + placement: 'top', + float: true, + position: 'end', + items: ['button7', 'divider', 'button8', 'button9'], + gap: 20 +}; + +const defaultLeftHeader = { + dataElement: 'defaultHeader', + placement: 'left', + gap: 20, + items: ['button1', 'button2', 'divider', 'button3'], +}; + +const floatStartLeftHeader = { + dataElement: 'floatStartLeftHeader', + placement: 'left', + float: true, + position: 'start', + items: ['button3', 'button4', 'leftPanelToggle'], + gap: 20 +}; + +const secondFloatStartLeftHeader = { + dataElement: 'secondFloatLeftBottomHeader', + placement: 'left', + float: true, + position: 'start', + items: ['button5', 'button6'], + gap: 20 +}; + +const floatCenterLeftHeader = { + dataElement: 'floatCenterLeftHeader', + placement: 'left', + float: true, + position: 'center', + items: ['button1', 'button2'], + gap: 20 +}; + +const floatEndLeftHeader = { + dataElement: 'floatEndLeftHeader', + placement: 'left', + float: true, + position: 'end', + items: ['button7', 'button8', 'divider', 'button9'], + gap: 20 +}; + +const defaultRightHeader = { + dataElement: 'defaultHeader', + placement: 'right', + gap: 20, + items: ['button1', 'button2', 'divider', 'button3'], +}; + +const floatStartRightHeader = { + dataElement: 'floatStartRightHeader', + placement: 'right', + float: true, + position: 'start', + items: ['button3', 'button4', 'notesPanelToggle'], + gap: 20 +}; + +const secondFloatStartRightHeader = { + dataElement: 'secondFloatRightBottomHeader', + placement: 'right', + float: true, + position: 'start', + items: ['button5', 'button6'], + gap: 20 +}; + +const floatCenterRightHeader = { + dataElement: 'floatCenterRightHeader', + placement: 'right', + float: true, + position: 'center', + items: ['button1', 'button2'], + gap: 20 +}; + +const floatEndRightHeader = { + dataElement: 'floatEndRightHeader', + placement: 'right', + float: true, + position: 'end', + items: ['button7', 'button8', 'divider', 'button9'], + gap: 20 +}; + +const defaultBottomHeader = { + dataElement: 'defaultBottomHeader', + placement: 'bottom', + gap: 20, + items: ['button1', 'button2', 'divider', 'button3'], + getDimensionTotal: () => { + return 32; + } +}; + +const floatStartBottomHeader = { + dataElement: 'floatStartBottomHeader', + placement: 'bottom', + float: true, + position: 'start', + items: ['button3', 'button4'], + gap: 20 +}; + +const secondFloatStartBottomHeader = { + dataElement: 'secondFloatStartBottomHeader', + placement: 'bottom', + float: true, + position: 'start', + items: ['button5', 'button6'], + gap: 20 +}; + +const floatCenterBottomHeader = { + dataElement: 'floatCenterBottomHeader', + placement: 'bottom', + float: true, + position: 'center', + items: ['button1', 'button2'], + gap: 20 +}; + +const floatEndBottomHeader = { + dataElement: 'floatEndBottomHeader', + placement: 'bottom', + float: true, + position: 'end', + items: ['button7', 'button8', 'divider', 'button9'], + gap: 20 +}; + +const floatStarTopHeaderStatic = { + dataElement: 'floatStarTopHeaderStatic', + placement: 'top', + float: true, + position: 'start', + items: ['button1', 'button2'], + opacityMode: 'static', + opacity: 'full', +}; + +const floatCenterTopHeaderDynamic = { + dataElement: 'floatStarTopHeaderDynamic', + placement: 'top', + float: true, + position: 'center', + items: ['button1', 'button2'], + opacityMode: 'dynamic', + opacity: 'low', +}; + +const floatEndTopHeaderNone = { + dataElement: 'floatStarTopHeaderNone', + placement: 'top', + float: true, + position: 'end', + items: ['button1', 'button2'], + opacityMode: 'dynamic', + opacity: 'none', +}; + +const mockModularComponents = { + 'button1': button1, + 'button2': button2, + 'button3': button3, + 'button4': button4, + 'button5': button5, + 'button6': button6, + 'button7': button7, + 'button8': button8, + 'button9': button9, + 'divider': divider, + 'leftPanelToggle': leftPanelToggle, + 'notesPanelToggle': notesPanelToggle, +}; + +export { + button1, + button2, + button3, + button4, + button5, + button6, + button7, + button8, + button9, + defaultTopHeader, + floatStartHeader, + secondFloatStartHeader, + floatCenterHeader, + floatEndHeader, + defaultLeftHeader, + floatStartLeftHeader, + secondFloatStartLeftHeader, + floatCenterLeftHeader, + floatEndLeftHeader, + defaultRightHeader, + floatStartRightHeader, + secondFloatStartRightHeader, + floatCenterRightHeader, + floatEndRightHeader, + defaultBottomHeader, + floatStartBottomHeader, + secondFloatStartBottomHeader, + floatCenterBottomHeader, + floatEndBottomHeader, + floatStarTopHeaderStatic, + floatCenterTopHeaderDynamic, + floatEndTopHeaderNone, + // Modular stuff + mockModularComponents, +}; \ No newline at end of file diff --git a/src/components/ModularComponents/Helpers/validation-helper.js b/src/components/ModularComponents/Helpers/validation-helper.js new file mode 100644 index 0000000000..44c9e533f9 --- /dev/null +++ b/src/components/ModularComponents/Helpers/validation-helper.js @@ -0,0 +1,26 @@ +import { JUSTIFY_CONTENT } from 'constants/customizationVariables'; + +export const isJustifyContentValid = (justifyContent) => { + const validJustifications = Object.values(JUSTIFY_CONTENT); + if (!validJustifications.includes(justifyContent)) { + console.warn(`${justifyContent} is not a valid value for justifyContent. Please use one of the following: ${validJustifications}`); + return false; + } + return true; +}; + +export const isGapValid = (gap) => { + if (isNaN(gap) || gap < 0) { + console.warn(`${gap} is not a valid value for gap. Please use a number, which represents the gap between items in pixels.`); + return false; + } + return true; +}; + +export const isGrowValid = (grow) => { + if (isNaN(grow) || grow < 0) { + console.warn(`${grow} is not a valid value for grow. Please use a number, which represents the flex-grow property of item.`); + return false; + } + return true; +}; \ No newline at end of file diff --git a/src/components/ModularComponents/InnerItem/index.js b/src/components/ModularComponents/InnerItem/index.js new file mode 100644 index 0000000000..9549d3edc3 --- /dev/null +++ b/src/components/ModularComponents/InnerItem/index.js @@ -0,0 +1,3 @@ +import InnerItem from './InnerItem'; + +export default InnerItem; \ No newline at end of file diff --git a/src/components/ModularComponents/LeftHeader/LeftHeader.scss b/src/components/ModularComponents/LeftHeader/LeftHeader.scss new file mode 100644 index 0000000000..313ddc7cfe --- /dev/null +++ b/src/components/ModularComponents/LeftHeader/LeftHeader.scss @@ -0,0 +1,20 @@ +@import '../../../constants/styles.scss'; + +.LeftHeader { + height: 100%; + width: $left-header-width; + min-width: fit-content; + padding: 12px 8px; +} + +.LeftHeader.closed { + position: fixed; + left: 0; + display: none; +} + +.LeftHeader.stroke { + border-right-width: 1px; + border-right-style: solid; + border-right-color: var(--gray-5); +} \ No newline at end of file diff --git a/src/components/ModularComponents/LeftHeader/index.js b/src/components/ModularComponents/LeftHeader/index.js new file mode 100644 index 0000000000..aa454c14b8 --- /dev/null +++ b/src/components/ModularComponents/LeftHeader/index.js @@ -0,0 +1,3 @@ +import LeftHeader from './LeftHeaderContainer'; + +export default LeftHeader; \ No newline at end of file diff --git a/src/components/ModularComponents/PageControls/PageControls.js b/src/components/ModularComponents/PageControls/PageControls.js new file mode 100644 index 0000000000..c2f0f1a6dd --- /dev/null +++ b/src/components/ModularComponents/PageControls/PageControls.js @@ -0,0 +1,119 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import CustomButton from '../CustomButton'; +import ToggleElementButton from '../ToggleElementButton'; +import { isMobileSize } from 'helpers/getDeviceSize'; +import { DIRECTION } from 'constants/customizationVariables'; +import { useTranslation } from 'react-i18next'; +import './PageControls.scss'; + +function PageControls(props) { + const { + size, + dataElement, + onFlyoutToggle, + leftChevron, + rightChevron, + currentPage, + totalPages, + elementRef, + headerDirection, + onBlur, + onFocus, + onClick, + onChange, + onSubmit, + isFocused, + input, + inputRef, + allowPageNavigation, + } = props; + + const { t } = useTranslation(); + const isMobile = isMobileSize(); + let inputWidth = 0; + if (input) { + inputWidth = 26 + input.length * (isMobile ? 10 : 7); + } + + const style = { width: inputWidth }; + if (headerDirection === DIRECTION.COLUMN) { + style.minHeight = 32; + } + + return ( +
+ {size === 0 && <> + +
+ +
+ +
{totalPages}
+ + } + {size === 1 && + + } + + {t('action.currentPageIs')} {currentPage} + +
+ ); +} + +PageControls.propTypes = { + size: PropTypes.number, + dataElement: PropTypes.string.isRequired, + onFlyoutToggle: PropTypes.func, + leftChevron: PropTypes.object, + rightChevron: PropTypes.object, + currentPage: PropTypes.number, + totalPages: PropTypes.number, + elementRef: PropTypes.any, + headerDirection: PropTypes.string, + onBlur: PropTypes.func, + onFocus: PropTypes.func, + onClick: PropTypes.func, + onChange: PropTypes.func, + onSubmit: PropTypes.func, + isFocused: PropTypes.bool, + input: PropTypes.string, + inputRef: PropTypes.any, + allowPageNavigation: PropTypes.bool, +}; + +export default PageControls; \ No newline at end of file diff --git a/src/components/ModularComponents/PageControls/PageControls.scss b/src/components/ModularComponents/PageControls/PageControls.scss new file mode 100644 index 0000000000..8da2251226 --- /dev/null +++ b/src/components/ModularComponents/PageControls/PageControls.scss @@ -0,0 +1,74 @@ +@import '../../../constants/styles'; +@import '../../../constants/overlay'; + +.PageControlsWrapper { + display: flex; + gap: 8px; + flex-direction: row; + justify-content: flex-start; + flex-grow: 0; + + .total-page { + margin: auto; + } + + .paddingTop { + padding-top: 4px; + } + + .paddingLeft { + padding-left: 4px; + } + + form { + height: 100%; + } + + form input { + height: 100%; + text-align: center; + font-family: Lato, sans-serif; + font-weight: 700; + background: var(--gray-0); + cursor: pointer; + outline: none; + border-radius: 3px; + min-width: 32px; + } +} + +.pageNavFlyoutMenu { + .page-nav-display .flyout-item-label>* { + margin: auto; + } + + form input { + font-size: 13px !important; + width: 17px; + font-family: Lato, sans-serif; + cursor: pointer; + outline: none; + margin: 0px 6px; + padding: 0; + text-align: center; + background: transparent; + + min-width: 26px; + min-height: 22px; + } + + form input:not(:focus) { + font-weight: 700; + border: none; + border-bottom: 1px solid var(--icon-color); + border-radius: 0; + } + + form input:focus { + height: 100%; + background: var(--gray-0); + border-radius: 3px; + border: 1px solid var(--border); + min-height: 26px; + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/PageControls/PageControls.spec.js b/src/components/ModularComponents/PageControls/PageControls.spec.js new file mode 100644 index 0000000000..63ac8dac5c --- /dev/null +++ b/src/components/ModularComponents/PageControls/PageControls.spec.js @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PageControls from './PageControls'; +import core from 'core'; + +const PageControlWithRedux = withProviders(PageControls); + +const props = { + dataElement: 'page-controls-container', + size: 0, + leftChevron: { + dataElement: 'leftChevronBtn', + title: 'action.pagePrev', + label: null, + img: 'icon-chevron-up', + type: 'customButton', + disabled: false, + ariaLabel: 'action.pagePrev', + onClick: jest.fn(), + }, + rightChevron: { + dataElement: 'rightChevronBtn', + title: 'action.pageNext', + label: null, + img: 'icon-chevron-right', + type: 'customButton', + disabled: false, + ariaLabel: 'action.pageNext', + onClick: jest.fn(), + }, + input: '7', + totalPages: 11, + onChange: jest.fn(), +}; + +describe('Page Controls Container component', () => { + beforeEach(() => { + const documentViewer = core.setDocumentViewer(1, new window.Core.DocumentViewer()); + documentViewer.doc = new window.Core.Document('dummy', 'pdf'); + }); + + it('Should be able to find input and check input value', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input.value).toEqual(props.input); + }); + + it('Should be able to type into input of Page Controls', () => { + render(); + const input = screen.getByRole('textbox'); + userEvent.type(input, '8'); + expect(input.value).toEqual(props.input); + }); + + it('Should call onClick on left/right button on Page Controls component', () => { + render(); + const leftBtn = screen.getByRole('button', { name: 'action.pagePrev' }); + const rightBtn = screen.getByRole('button', { name: 'action.pageNext' }); + expect(leftBtn).toBeInTheDocument(); + expect(rightBtn).toBeInTheDocument(); + fireEvent.click(leftBtn); + fireEvent.click(rightBtn); + expect(props.leftChevron.onClick).toHaveBeenCalledTimes(1); + expect(props.rightChevron.onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ModularComponents/PageControls/PageControlsFlyout.js b/src/components/ModularComponents/PageControls/PageControlsFlyout.js new file mode 100644 index 0000000000..406e6ef3d1 --- /dev/null +++ b/src/components/ModularComponents/PageControls/PageControlsFlyout.js @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; + +function PageControlsFlyout(props) { + const { + onSubmit, + onChange, + input, + totalPages, + inputWidth, + } = props; + const [isFocused, setIsFocused] = useState(false); + + const onBlur = () => { + setIsFocused(false); + props.onBlur(); + }; + + const onFocus = () => { + setIsFocused(true); + props.onFocus(); + }; + + const style = {}; + if (isFocused) { + style.width = inputWidth; + } else { + style.width = inputWidth - 10; + } + + return ( +
+
{'Pages: '}
+
+ +
+
{` of ${totalPages}`}
+
+ ); +} + +PageControlsFlyout.propTypes = { + onSubmit: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + input: PropTypes.string, + totalPages: PropTypes.number, + inputWidth: PropTypes.number, +}; + +export default PageControlsFlyout; \ No newline at end of file diff --git a/src/components/ModularComponents/PageControls/index.js b/src/components/ModularComponents/PageControls/index.js new file mode 100644 index 0000000000..df6986a39e --- /dev/null +++ b/src/components/ModularComponents/PageControls/index.js @@ -0,0 +1,3 @@ +import PageControls from './PageControlsContainer'; + +export default PageControls; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/PresetButton.js b/src/components/ModularComponents/PresetButton/PresetButton.js new file mode 100644 index 0000000000..d0ca16cbd8 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/PresetButton.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { PRESET_BUTTON_TYPES } from 'constants/customizationVariables'; +import './PresetButton.scss'; +import NewDocumentButton from './buttons/NewDocument'; +import FilePickerButton from './buttons/FilePicker'; +import UndoButton from './buttons/Undo'; +import RedoButton from './buttons/Redo'; +import DownloadButton from './buttons/Download'; +import FullScreenButton from './buttons/FullScreen'; +import SaveAsButton from './buttons/SaveAs'; +import PrintButton from './buttons/Print'; +import NewPortfolioButton from './buttons/NewPortfolio'; +import SettingsButton from './buttons/Settings'; +import FormFieldEditButton from './buttons/FormFieldEdit'; +import ContentEditButton from './buttons/ContentEdit'; + +const PresetButton = (props) => { + const { buttonType } = props; + + switch (buttonType) { + case PRESET_BUTTON_TYPES.UNDO: + return ; + case PRESET_BUTTON_TYPES.REDO: + return ; + case PRESET_BUTTON_TYPES.NEW_DOCUMENT: + return ; + case PRESET_BUTTON_TYPES.FILE_PICKER: + return ; + case PRESET_BUTTON_TYPES.DOWNLOAD: + return ; + case PRESET_BUTTON_TYPES.FULLSCREEN: + return ; + case PRESET_BUTTON_TYPES.SAVE_AS: + return ; + case PRESET_BUTTON_TYPES.PRINT: + return ; + case PRESET_BUTTON_TYPES.CREATE_PORTFOLIO: + return ; + case PRESET_BUTTON_TYPES.SETTINGS: + return ; + case PRESET_BUTTON_TYPES.FORM_FIELD_EDIT: + return ; + case PRESET_BUTTON_TYPES.CONTENT_EDIT: + return ; + default: + console.warn(`${buttonType} is not a valid item type.`); + return null; + } +}; + +PresetButton.propTypes = { + buttonType: PropTypes.string.isRequired +}; + +export default PresetButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/PresetButton.scss b/src/components/ModularComponents/PresetButton/PresetButton.scss new file mode 100644 index 0000000000..b0df6a3610 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/PresetButton.scss @@ -0,0 +1,19 @@ +@import '../../../../src/constants/styles'; + +.PresetButton { + &:hover { + background: var(--tools-button-hover); + } + + &.formFieldEditButton, &.contentEditButton { + &.active { + background: var(--tools-button-active); + color: var(--view-header-icon-active-fill); + cursor: default; + + .Icon { + color: var(--view-header-icon-active-fill) + } + } + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/PresetButton.stories.js b/src/components/ModularComponents/PresetButton/PresetButton.stories.js new file mode 100644 index 0000000000..44494bd676 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/PresetButton.stories.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import initialState from 'src/redux/initialState'; +import PresetButton from './PresetButton'; +import { PRESET_BUTTON_TYPES } from 'src/constants/customizationVariables'; + +export default { + title: 'ModularComponents/PresetButton', + component: PresetButton, +}; + +initialState.viewer.activeDocumentViewerKey = 1; +const store = configureStore({ reducer: () => initialState }); + +const prepareButtonStory = (buttonType) => { + const props = { + buttonType: buttonType, + }; + + return ( + + + + ); +}; + +export function UndoButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.UNDO); +} + +export function RedoButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.REDO); +} + +export function NewDocumentButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.NEW_DOCUMENT); +} + +export function FilePickerButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.FILE_PICKER); +} + +export function DownloadButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.DOWNLOAD); +} + +export function FullscreenButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.FULLSCREEN); +} + +export function SaveAsButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.SAVE_AS); +} + +export function PrintButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.PRINT); +} + +export function CreatePortfolioButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.CREATE_PORTFOLIO); +} + +export function SettingsButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.SETTINGS); +} + +export function FormFieldEditButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.FORM_FIELD_EDIT); +} + +export function ContentEditButton() { + return prepareButtonStory(PRESET_BUTTON_TYPES.CONTENT_EDIT); +} \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/buttons/FullScreen.js b/src/components/ModularComponents/PresetButton/buttons/FullScreen.js new file mode 100644 index 0000000000..031f514502 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/buttons/FullScreen.js @@ -0,0 +1,43 @@ +import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import selectors from 'selectors'; +import { useTranslation } from 'react-i18next'; +import toggleFullscreen from 'helpers/toggleFullscreen'; +import { innerItemToFlyoutItem } from 'src/helpers/itemToFlyoutHelper'; +import { getPresetButtonDOM } from '../../Helpers/menuItems'; +import { PRESET_BUTTON_TYPES } from 'src/constants/customizationVariables'; + +/** + * A button that toggles fullscreen mode. + * @name fullscreenButton + * @memberof UI.Components.PresetButton + */ +const FullScreenButton = (props) => { + const { isFlyoutItem, iconDOMElement } = props; + const { t } = useTranslation(); + const [ + isFullScreen, + ] = useSelector( + (state) => [ + selectors.isFullScreen(state), + ], + ); + + return ( + isFlyoutItem ? + innerItemToFlyoutItem({ + isDisabled: false, + icon: iconDOMElement, + label: isFullScreen ? t('action.exitFullscreen') : t('action.enterFullscreen'), + }, toggleFullscreen) + : + getPresetButtonDOM(PRESET_BUTTON_TYPES.FULLSCREEN, false, toggleFullscreen, isFullScreen) + ); +}; + +FullScreenButton.propTypes = { + isFlyoutItem: PropTypes.bool, + iconDOMElement: PropTypes.object, +}; + +export default FullScreenButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/buttons/Print.js b/src/components/ModularComponents/PresetButton/buttons/Print.js new file mode 100644 index 0000000000..17530ff243 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/buttons/Print.js @@ -0,0 +1,62 @@ +import { useDispatch, shallowEqual, useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import { getPresetButtonDOM, menuItems } from '../../Helpers/menuItems'; +import { print } from 'helpers/print'; +import selectors from 'selectors'; +import core from 'core'; +import { innerItemToFlyoutItem } from 'helpers/itemToFlyoutHelper'; +import { useTranslation } from 'react-i18next'; +import { PRESET_BUTTON_TYPES } from 'constants/customizationVariables'; + +/** + * A button that prints the document. + * @name printButton + * @memberof UI.Components.PresetButton + */ +const PrintButton = (props) => { + const { isFlyoutItem, iconDOMElement } = props; + const { label } = menuItems.printButton; + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const [ + isEmbedPrintSupported, + sortStrategy, + colorMap, + timezone, + ] = useSelector( + (state) => [ + selectors.isEmbedPrintSupported(state), + selectors.getSortStrategy(state), + selectors.getColorMap(state), + selectors.getTimezone(state), + ], + shallowEqual, + ); + + const handlePrintButtonClick = () => { + print(dispatch, isEmbedPrintSupported, sortStrategy, colorMap, { isGrayscale: core.getDocumentViewer().isGrayscaleModeEnabled(), timezone }); + }; + + return ( + isFlyoutItem ? + innerItemToFlyoutItem({ + isDisabled: false, + icon: iconDOMElement, + label: t(label), + }, handlePrintButtonClick) + : + getPresetButtonDOM( + PRESET_BUTTON_TYPES.PRINT, + false, + handlePrintButtonClick + ) + ); +}; + +PrintButton.propTypes = { + isFlyoutItem: PropTypes.bool, + iconDOMElement: PropTypes.object, +}; + +export default PrintButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/buttons/Redo.js b/src/components/ModularComponents/PresetButton/buttons/Redo.js new file mode 100644 index 0000000000..5c4b2b2103 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/buttons/Redo.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import selectors from 'selectors'; +import PropTypes from 'prop-types'; +import ActionButton from 'components/ActionButton'; +import { menuItems } from '../../Helpers/menuItems'; +import core from 'core'; + +/** + * A button that performs the redo action. + * @name redoButton + * @memberof UI.Components.PresetButton + */ +const RedoButton = (props) => { + const { isFlyoutItem, iconDOMElement } = props; + const { label, presetDataElement, icon, title } = menuItems.redoButton; + const activeDocumentViewerKey = useSelector((state) => selectors.getActiveDocumentViewerKey(state)); + const { t } = useTranslation(); + + const handleClick = () => { + core.redo(activeDocumentViewerKey); + }; + + const onKeyDown = (e) => { + if (e.key === 'Enter') { + handleClick(); + } + }; + + return ( + isFlyoutItem ? + ( +
+
+ {iconDOMElement} + {label &&
{t(label)}
} +
+
+ ) + : ( + !state.viewer.canRedo[state.viewer.activeDocumentViewerKey]} + /> + ) + ); +}; + +RedoButton.propTypes = { + isFlyoutItem: PropTypes.bool, + iconDOMElement: PropTypes.object, +}; + +export default RedoButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/buttons/Settings.js b/src/components/ModularComponents/PresetButton/buttons/Settings.js new file mode 100644 index 0000000000..005196fcb2 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/buttons/Settings.js @@ -0,0 +1,42 @@ +import { useDispatch } from 'react-redux'; +import actions from 'actions'; +import PropTypes from 'prop-types'; +import { getPresetButtonDOM, menuItems } from '../../Helpers/menuItems'; +import DataElements from 'constants/dataElement'; +import { PRESET_BUTTON_TYPES } from 'constants/customizationVariables'; +import { innerItemToFlyoutItem } from 'helpers/itemToFlyoutHelper'; +import { useTranslation } from 'react-i18next'; + +/** + * A button that opens the settings modal. + * @name settingsButton + * @memberof UI.Components.PresetButton + */ +const SettingsButton = (props) => { + const { isFlyoutItem, iconDOMElement } = props; + const { label } = menuItems.settingsButton; + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const handleSettingsButtonClick = () => { + dispatch(actions.openElement(DataElements.SETTINGS_MODAL)); + }; + + return ( + isFlyoutItem ? + innerItemToFlyoutItem({ + isDisabled: false, + icon: iconDOMElement, + label: t(label), + }, handleSettingsButtonClick) + : + getPresetButtonDOM(PRESET_BUTTON_TYPES.SETTINGS, false, handleSettingsButtonClick) + ); +}; + +SettingsButton.propTypes = { + isFlyoutItem: PropTypes.bool, + iconDOMElement: PropTypes.object, +}; + +export default SettingsButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/buttons/Undo.js b/src/components/ModularComponents/PresetButton/buttons/Undo.js new file mode 100644 index 0000000000..07ca5b8200 --- /dev/null +++ b/src/components/ModularComponents/PresetButton/buttons/Undo.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import selectors from 'selectors'; +import PropTypes from 'prop-types'; +import ActionButton from 'components/ActionButton'; +import { menuItems } from '../../Helpers/menuItems'; +import core from 'core'; + +/** + * A button that performs the undo action. + * @name undoButton + * @memberof UI.Components.PresetButton + */ +const UndoButton = (props) => { + const { isFlyoutItem, iconDOMElement } = props; + const { label, presetDataElement, icon, title } = menuItems.undoButton; + const activeDocumentViewerKey = useSelector((state) => selectors.getActiveDocumentViewerKey(state)); + const { t } = useTranslation(); + + const handleClick = () => { + core.undo(activeDocumentViewerKey); + }; + + const onKeyDown = (e) => { + if (e.key === 'Enter') { + handleClick(); + } + }; + + return ( + isFlyoutItem ? + ( +
+
+ {iconDOMElement} + {label &&
{t(label)}
} +
+
+ ) + : ( + !state.viewer.canUndo[state.viewer.activeDocumentViewerKey]} + /> + ) + ); +}; + +UndoButton.propTypes = { + isFlyoutItem: PropTypes.bool, + iconDOMElement: PropTypes.object, +}; + +export default UndoButton; \ No newline at end of file diff --git a/src/components/ModularComponents/PresetButton/index.js b/src/components/ModularComponents/PresetButton/index.js new file mode 100644 index 0000000000..204e7d4a6d --- /dev/null +++ b/src/components/ModularComponents/PresetButton/index.js @@ -0,0 +1,3 @@ +import PresetButton from './PresetButton'; + +export default PresetButton; \ No newline at end of file diff --git a/src/components/ModularComponents/RibbonGroup/index.js b/src/components/ModularComponents/RibbonGroup/index.js new file mode 100644 index 0000000000..7301b1f53d --- /dev/null +++ b/src/components/ModularComponents/RibbonGroup/index.js @@ -0,0 +1,3 @@ +import RibbonGroup from './RibbonGroup'; + +export default RibbonGroup; \ No newline at end of file diff --git a/src/components/ModularComponents/RibbonItem/RibbonItem.scss b/src/components/ModularComponents/RibbonItem/RibbonItem.scss new file mode 100644 index 0000000000..98e531dd7c --- /dev/null +++ b/src/components/ModularComponents/RibbonItem/RibbonItem.scss @@ -0,0 +1,57 @@ +.RibbonItem { + padding: 0; + border: none; + background-color: transparent; + cursor: pointer; + color: var(--faded-text); + white-space: nowrap; + height: auto; + + .Button { + width: 100%; + padding: 8px; + column-gap: 8px; + row-gap: 4px; + color: var(--faded-text); + &:hover { + background-color: var(--blue-1); + } + &.active { + color: var(--ribbon-active-color); + background-color: transparent; + .Icon { + color: var(--ribbon-active-color); + } + } + } + + &:not(.vertical) .Button { + &.active { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-bottom: 2px solid var(--ribbon-active-color); + } + } + + &.vertical { + white-space: wrap; + .Button { + flex-direction: column; + height: 100%; + &.active { + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-left: 2px solid var(--ribbon-active-color); + } + } + } + + &.vertical:not(.left) .Button { + &.active { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-left: none; + border-right: 2px solid var(--ribbon-active-color); + } + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/RibbonItem/index.js b/src/components/ModularComponents/RibbonItem/index.js new file mode 100644 index 0000000000..a44f180ef1 --- /dev/null +++ b/src/components/ModularComponents/RibbonItem/index.js @@ -0,0 +1,3 @@ +import RibbonItem from './RibbonItem'; + +export default RibbonItem; \ No newline at end of file diff --git a/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.js b/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.js new file mode 100644 index 0000000000..c21fce7d1f --- /dev/null +++ b/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.js @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; +import { useDispatch } from 'react-redux'; +import actions from 'actions'; +import './RibbonOverflowFlyout.scss'; + +const RibbonOverflowFlyout = (props) => { + const dispatch = useDispatch(); + const { items } = props; + + useEffect(() => { + const RibbonOverflowFlyout = { + dataElement: 'RibbonOverflowFlyout', + className: 'RibbonOverflowFlyout', + items: items || [], + }; + dispatch(actions.addFlyout(RibbonOverflowFlyout)); + }, []); + + return null; +}; + +export default RibbonOverflowFlyout; diff --git a/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.scss b/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.scss new file mode 100644 index 0000000000..58f7019b2a --- /dev/null +++ b/src/components/ModularComponents/RibbonOverflowFlyout/RibbonOverflowFlyout.scss @@ -0,0 +1,11 @@ +.Flyout .RibbonOverflowFlyout { + .RibbonItem { + width: 100%; + .Button { + justify-content: left; + &:hover { + background-color: transparent; + } + } + } +} \ No newline at end of file diff --git a/src/components/ModularComponents/RibbonOverflowFlyout/index.js b/src/components/ModularComponents/RibbonOverflowFlyout/index.js new file mode 100644 index 0000000000..cade412da3 --- /dev/null +++ b/src/components/ModularComponents/RibbonOverflowFlyout/index.js @@ -0,0 +1,3 @@ +import RibbonOverflowFlyout from './RibbonOverflowFlyout'; + +export default RibbonOverflowFlyout; \ No newline at end of file diff --git a/src/components/ModularComponents/RightHeader/RightHeader.scss b/src/components/ModularComponents/RightHeader/RightHeader.scss new file mode 100644 index 0000000000..ff7d1e55df --- /dev/null +++ b/src/components/ModularComponents/RightHeader/RightHeader.scss @@ -0,0 +1,19 @@ +@import '../../../constants/styles'; + +.RightHeader { + height: 100%; + width: $right-header-width; + min-width: fit-content; + z-index: 63; + padding: 12px 8px; +} + +.RightHeader.closed { + display: none; +} + +.RightHeader.stroke { + border-left-width: 1px; + border-left-style: solid; + border-left-color: var(--gray-5); +} \ No newline at end of file diff --git a/src/components/ModularComponents/RightHeader/index.js b/src/components/ModularComponents/RightHeader/index.js new file mode 100644 index 0000000000..48ec4457e1 --- /dev/null +++ b/src/components/ModularComponents/RightHeader/index.js @@ -0,0 +1,3 @@ +import RightHeader from './RightHeaderContainer'; + +export default RightHeader; \ No newline at end of file diff --git a/src/components/ModularComponents/StatefulButton/StatefulButton.js b/src/components/ModularComponents/StatefulButton/StatefulButton.js new file mode 100644 index 0000000000..0333abe22f --- /dev/null +++ b/src/components/ModularComponents/StatefulButton/StatefulButton.js @@ -0,0 +1,91 @@ +import React, { useEffect } from 'react'; +import '../../Button/Button.scss'; +import './StatefulButton.scss'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import Button from 'components/Button'; + +const StatefulButton = (props) => { + const { dataElement, disabled, mount, unmount, states } = props; + const [, updateState] = React.useState(); + const forceUpdate = React.useCallback(() => updateState({}), []); + + const [activeState, setActiveState] = React.useState(props.initialState); + + useEffect(() => { + if (mount) { + mount(update); + } + return function() { + if (unmount) { + unmount(); + } + }; + }); + + const update = (newState) => { + if (newState) { + setActiveState(newState); + } else { + forceUpdate(); + } + }; + + const onClick = () => { + const { dispatch } = props; + + states[activeState].onClick( + update, + states[activeState], + dispatch, + ); + }; + + const { title, img, getContent, isActive } = states[activeState]; + const content = getContent ? getContent(states[activeState]) : ''; + const className = [ + 'StatefulButton', + states[activeState].className ? states[activeState].className : '', + ].join(' ').trim(); + + return ( + + ); +}; + +StatefulButton.propTypes = { + initialState: PropTypes.string.isRequired, + mount: PropTypes.func.isRequired, + unmount: PropTypes.func, + states: PropTypes.shape({ + activeState: PropTypes.shape({ + img: PropTypes.string, + label: PropTypes.string, + onClick: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + getContent: PropTypes.func.isRequired, + }), + AnotherState: PropTypes.shape({ + img: PropTypes.string, + label: PropTypes.string, + onClick: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + getContent: PropTypes.func.isRequired, + }), + }), +}; + +export default React.memo(StatefulButton); diff --git a/src/components/ModularComponents/StatefulButton/StatefulButton.scss b/src/components/ModularComponents/StatefulButton/StatefulButton.scss new file mode 100644 index 0000000000..1700e53217 --- /dev/null +++ b/src/components/ModularComponents/StatefulButton/StatefulButton.scss @@ -0,0 +1,7 @@ +@import '../../../constants/styles'; + +.StatefulButton { + padding: 5px; + width: fit-content; + background-color: var(--gray-4); +} \ No newline at end of file diff --git a/src/components/ModularComponents/StatefulButton/StatefulButton.stories.js b/src/components/ModularComponents/StatefulButton/StatefulButton.stories.js new file mode 100644 index 0000000000..b628d65961 --- /dev/null +++ b/src/components/ModularComponents/StatefulButton/StatefulButton.stories.js @@ -0,0 +1,75 @@ +import React from 'react'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; +import StatefulButtonComponent from './StatefulButton'; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + openElements: [], + } +}; +function rootReducer(state = initialState) { + return state; +} + +const store = createStore(rootReducer); + +const BasicComponent = (props) => { + return ( + + + + ); +}; + +export default { + title: 'ModularComponents/StatefulButton', + component: StatefulButtonComponent, +}; + + +export const StatefulButtonCounter = BasicComponent.bind({}); +StatefulButtonCounter.args = { + type: 'statefulButton', + dataElement: 'countButton', + initialState: 'Count', + states: { + Count: { + number: 3, + getContent: (activeState) => { + return activeState.number; + }, + onClick: (update, activeState) => { + activeState.number += 1; + update(); + } + } + }, + mount: () => {}, +}; + +export const StatefulButtonStates = BasicComponent.bind({}); +StatefulButtonStates.args = { + type: 'statefulButton', + dataElement: 'singlePageBtn', + initialState: 'SinglePage', + states: { + SinglePage: { + img: 'icon-header-page-manipulation-page-layout-single-page-line', + onClick: (update) => { + update('DoublePage'); + }, + title: 'Single Page', + }, + DoublePage: { + img: 'icon-header-page-manipulation-page-layout-double-page-line', + onClick: (update) => { + update('SinglePage'); + }, + title: 'Single Page', + }, + }, + mount: () => {}, +}; diff --git a/src/components/ModularComponents/StatefulButton/index.js b/src/components/ModularComponents/StatefulButton/index.js new file mode 100644 index 0000000000..a340a837d2 --- /dev/null +++ b/src/components/ModularComponents/StatefulButton/index.js @@ -0,0 +1,3 @@ +import StatefulButton from './StatefulButton'; + +export default StatefulButton; \ No newline at end of file diff --git a/src/components/ModularComponents/TabPanel/index.js b/src/components/ModularComponents/TabPanel/index.js new file mode 100644 index 0000000000..b5620b9e57 --- /dev/null +++ b/src/components/ModularComponents/TabPanel/index.js @@ -0,0 +1,3 @@ +import TabPanel from './TabPanel'; + +export default TabPanel; \ No newline at end of file diff --git a/src/components/ModularComponents/TopHeader/index.js b/src/components/ModularComponents/TopHeader/index.js new file mode 100644 index 0000000000..b3e5f5206d --- /dev/null +++ b/src/components/ModularComponents/TopHeader/index.js @@ -0,0 +1,3 @@ +import TopHeader from './TopHeaderContainer'; + +export default TopHeader; \ No newline at end of file diff --git a/src/components/ModularComponents/ViewControls/ViewControlsFlyout.js b/src/components/ModularComponents/ViewControls/ViewControlsFlyout.js new file mode 100644 index 0000000000..97252b4007 --- /dev/null +++ b/src/components/ModularComponents/ViewControls/ViewControlsFlyout.js @@ -0,0 +1,227 @@ +import displayModeObjects from 'constants/displayModeObjects'; +import core from 'core'; +import { useLayoutEffect } from 'react'; +import { useSelector, useStore, useDispatch } from 'react-redux'; +import selectors from 'selectors'; +import { enterReaderMode, exitReaderMode } from 'helpers/readerMode'; +import actions from 'actions'; +import toggleFullscreen from 'helpers/toggleFullscreen'; +import { isIE11, isIOS, isIOSFullScreenSupported } from 'helpers/device'; +import DataElements from 'constants/dataElement'; + +const ViewControlsFlyout = () => { + const store = useStore(); + const dispatch = useDispatch(); + const [ + totalPages, + displayMode, + isDisabled, + isReaderMode, + isMultiViewerMode, + isFullScreen, + activeDocumentViewerKey, + isMultiTab, + isMultiViewerModeAvailable, + currentFlyout + ] = useSelector((state) => [ + selectors.getTotalPages(state), + selectors.getDisplayMode(state), + selectors.isElementDisabled(state, 'viewControlsFlyout'), + selectors.isReaderMode(state), + selectors.isMultiViewerMode(state), + selectors.isFullScreen(state), + selectors.getActiveDocumentViewerKey(state), + selectors.getIsMultiTab(state), + selectors.getIsMultiViewerModeAvailable(state), + selectors.getFlyout(state, 'viewControlsFlyout') + ]); + + const totalPageThreshold = 1000; + let isPageTransitionEnabled = totalPages < totalPageThreshold; + + useLayoutEffect(() => { + const viewControlsFlyout = { + dataElement: 'viewControlsFlyout', + className: 'ViewControlsFlyout', + items: getViewControlsFlyoutItems() + }; + + if (!currentFlyout) { + dispatch(actions.addFlyout(viewControlsFlyout)); + } else { + dispatch(actions.updateFlyout(viewControlsFlyout.dataElement, viewControlsFlyout)); + } + }, [isFullScreen, isMultiViewerModeAvailable, isMultiViewerMode, displayMode]); + + if (isDisabled) { + return; + } + + const documentViewer = core.getDocumentViewer(); + const displayModeManager = documentViewer?.getDisplayModeManager(); + if (displayModeManager?.isVirtualDisplayEnabled()) { + isPageTransitionEnabled = true; + } + + const showReaderButton = core.isFullPDFEnabled() && core.getDocument()?.getType() === 'pdf'; + const showCompareButton = !isIE11 && !isMultiTab && isMultiViewerModeAvailable; + const toggleCompareMode = () => { + store.dispatch(actions.setIsMultiViewerMode(!isMultiViewerMode)); + }; + + const handleClick = (pageTransition, layout) => { + const setDisplayMode = () => { + const displayModeObject = displayModeObjects.find( + (obj) => obj.pageTransition === pageTransition && obj.layout === layout, + ); + core.setDisplayMode(displayModeObject.displayMode); + }; + + if (isReaderMode) { + exitReaderMode(store); + setTimeout(() => { + setDisplayMode(); + }); + } else { + setDisplayMode(); + } + }; + + const handleReaderModeClick = () => { + if (isReaderMode) { + return; + } + enterReaderMode(store); + }; + + let pageTransition; + let layout; + + const displayModeObject = displayModeObjects.find((obj) => obj.displayMode === displayMode); + if (displayModeObject) { + pageTransition = displayModeObject.pageTransition; + layout = displayModeObject.layout; + } + + const getViewControlsFlyoutItems = () => { + let viewControlsFlyoutItems = []; + + const continuousPageTransitionButton = { + icon: 'icon-header-page-manipulation-page-transition-continuous-page-line', + label: 'option.pageTransition.continuous', + title: 'option.pageTransition.continuous', + onClick: () => handleClick('continuous', layout), + dataElement: 'continuousPageTransitionButton', + isActive: pageTransition === 'continuous' && !isReaderMode + }; + const defaultPageTransitionButton = { + icon: 'icon-header-page-manipulation-page-transition-page-by-page-line', + label: 'option.pageTransition.default', + title: 'option.pageTransition.default', + onClick: () => handleClick('default', layout), + dataElement: 'defaultPageTransitionButton', + isActive: pageTransition === 'default' && !isReaderMode + }; + const readerPageTransitionButton = { + icon: 'icon-header-page-manipulation-page-transition-reader', + label: 'option.pageTransition.reader', + title: 'option.pageTransition.reader', + onClick: () => handleReaderModeClick(), + dataElement: 'readerPageTransitionButton', + isActive: isReaderMode + }; + const rotateClockwiseButton = { + icon: 'icon-header-page-manipulation-page-rotation-clockwise-line', + label: 'action.rotateClockwise', + title: 'action.rotateClockwise', + onClick: () => core.rotateClockwise(activeDocumentViewerKey), + dataElement: 'rotateClockwiseButton' + }; + const rotateCounterClockwiseButton = { + icon: 'icon-header-page-manipulation-page-rotation-clockwise-line', + label: 'action.rotateCounterClockwise', + title: 'action.rotateCounterClockwise', + onClick: () => core.rotateCounterClockwise(activeDocumentViewerKey), + dataElement: 'rotateCounterClockwiseButton' + }; + const singleLayoutButton = { + icon: 'icon-header-page-manipulation-page-layout-single-page-line', + label: 'option.layout.single', + title: 'option.layout.single', + onClick: () => handleClick(pageTransition, 'single'), + dataElement: 'singleLayoutButton', + isActive: layout === 'single' + }; + const doubleLayoutButton = { + icon: 'icon-header-page-manipulation-page-layout-double-page-line', + label: 'option.layout.double', + title: 'option.layout.double', + onClick: () => handleClick(pageTransition, 'double'), + dataElement: 'doubleLayoutButton', + isActive: layout === 'double' + }; + const coverLayoutButton = { + icon: 'icon-header-page-manipulation-page-layout-cover-line', + label: 'option.layout.cover', + title: 'option.layout.cover', + onClick: () => handleClick(pageTransition, 'cover'), + dataElement: 'coverLayoutButton', + isActive: layout === 'cover' + }; + + const divider = 'divider'; + + if (isPageTransitionEnabled) { + viewControlsFlyoutItems.push('option.displayMode.pageTransition'); + viewControlsFlyoutItems = [...viewControlsFlyoutItems, continuousPageTransitionButton, defaultPageTransitionButton]; + + if (showReaderButton) { + viewControlsFlyoutItems.push(readerPageTransitionButton); + } + if (!isReaderMode) { + viewControlsFlyoutItems.push(divider); + } + } + if (!isReaderMode) { + viewControlsFlyoutItems = [...viewControlsFlyoutItems, + 'action.rotate', + rotateClockwiseButton, + rotateCounterClockwiseButton, + divider, + 'option.displayMode.layout', + singleLayoutButton, + doubleLayoutButton, + coverLayoutButton + ]; + } + if (showCompareButton) { + const toggleCompareModeButton = { + icon: 'icon-header-compare', + label: 'action.comparePages', + title: 'action.comparePages', + onClick: toggleCompareMode, + dataElement: 'toggleCompareModeButton', + isActive: isMultiViewerMode + }; + viewControlsFlyoutItems.push(toggleCompareModeButton); + } + + if (!isIOS || isIOSFullScreenSupported) { + const fullScreenButton = { + icon: isFullScreen ? 'icon-header-full-screen-exit' : 'icon-header-full-screen', + label: isFullScreen ? 'action.exitFullscreen' : 'action.enterFullscreen', + title: isFullScreen ? 'action.exitFullscreen' : 'action.enterFullscreen', + onClick: toggleFullscreen, + dataElement: DataElements.FULLSCREEN_BUTTON + }; + viewControlsFlyoutItems.push(divider); + viewControlsFlyoutItems.push(fullScreenButton); + } + + return viewControlsFlyoutItems; + }; + + return null; +}; + +export default ViewControlsFlyout; diff --git a/src/components/ModularComponents/ViewControls/ViewControlsToggleButton.js b/src/components/ModularComponents/ViewControls/ViewControlsToggleButton.js new file mode 100644 index 0000000000..3142668655 --- /dev/null +++ b/src/components/ModularComponents/ViewControls/ViewControlsToggleButton.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ToggleElementButton from '../ToggleElementButton'; + +const ViewControlsToggleButton = () => { + return ( +
+ +
+ ); +}; + +export default ViewControlsToggleButton; diff --git a/src/components/ModularComponents/ViewControls/index.js b/src/components/ModularComponents/ViewControls/index.js new file mode 100644 index 0000000000..59927601c3 --- /dev/null +++ b/src/components/ModularComponents/ViewControls/index.js @@ -0,0 +1,3 @@ +import ViewControls from './ViewControlsToggleButton'; + +export default ViewControls; \ No newline at end of file diff --git a/src/components/ModularComponents/ZoomControls/ZoomControls.spec.js b/src/components/ModularComponents/ZoomControls/ZoomControls.spec.js new file mode 100644 index 0000000000..db6e5590ca --- /dev/null +++ b/src/components/ModularComponents/ZoomControls/ZoomControls.spec.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ZoomControls from './ZoomControls'; +import core from 'core'; + +const ZoomControlWithRedux = withProviders(ZoomControls); + +const props = { + setZoomHandler: jest.fn(), + zoomValue: '100', + zoomTo: jest.fn(), + onZoomInClicked: jest.fn(), + onZoomOutClicked: jest.fn(), + isZoomFlyoutMenuActive: false, + isActive: true, + dataElement: 'zoom-container', + size: 0, +}; + +describe('Zoom Container component', () => { + beforeEach(() => { + const documentViewer = core.setDocumentViewer(1, new window.Core.DocumentViewer()); + documentViewer.doc = new window.Core.Document('dummy', 'pdf'); + }); + + it('it renders the zoomvalue correctly', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input.value).toEqual(props.zoomValue); + }); + + it('it ignores invalid values that you input', () => { + render(); + const input = screen.getByRole('textbox'); + userEvent.type(input, 'zoom'); + expect(input.value).toEqual(props.zoomValue); + }); + + it('Should execute zoomIn/zoomOut when zoom in/out button is clicked', async () => { + render(); + const zoomInButton = screen.getByRole('button', { name: 'Zoom in' }); + const zoomOutButton = screen.getByRole('button', { name: 'Zoom out' }); + expect(zoomInButton).toBeInTheDocument(); + fireEvent.click(zoomInButton); + expect(props.onZoomInClicked).toHaveBeenCalledTimes(1); + fireEvent.click(zoomOutButton); + expect(props.onZoomOutClicked).toHaveBeenCalledTimes(1); + }); + + it('it renders the zoomvalue correctly', () => { + render(); + const input = screen.getByRole('textbox'); + userEvent.type(input, '66'); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' }); + expect(props.zoomTo).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/ModularComponents/ZoomControls/index.js b/src/components/ModularComponents/ZoomControls/index.js new file mode 100644 index 0000000000..96f7b1a7b6 --- /dev/null +++ b/src/components/ModularComponents/ZoomControls/index.js @@ -0,0 +1,3 @@ +import ZoomControls from './ZoomControlsContainer'; + +export default ZoomControls; diff --git a/src/components/ModularHeaderItems/index.js b/src/components/ModularHeaderItems/index.js new file mode 100644 index 0000000000..178f651f00 --- /dev/null +++ b/src/components/ModularHeaderItems/index.js @@ -0,0 +1,3 @@ +import ModularHeaderItems from './ModularHeaderItems'; + +export default ModularHeaderItems; \ No newline at end of file diff --git a/src/components/MoreOptionsContextMenuPopup/MoreOptionsContextMenuPopup.js b/src/components/MoreOptionsContextMenuPopup/MoreOptionsContextMenuPopup.js new file mode 100644 index 0000000000..262be70a3d --- /dev/null +++ b/src/components/MoreOptionsContextMenuPopup/MoreOptionsContextMenuPopup.js @@ -0,0 +1,143 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import PropTypes from 'prop-types'; + +import DataElementWrapper from 'components/DataElementWrapper'; + +import useOnClickOutside from 'hooks/useOnClickOutside'; +import getOverlayPositionBasedOn from 'helpers/getOverlayPositionBasedOn'; + +import './MoreOptionsContextMenuPopup.scss'; +import Button from '../Button'; +import getRootNode from 'helpers/getRootNode'; + +const propTypes = { + type: PropTypes.oneOf(['bookmark', 'outline', 'portfolio']).isRequired, + anchorButton: PropTypes.string.isRequired, + shouldDisplayDeleteButton: PropTypes.bool, + onClosePopup: PropTypes.func.isRequired, + onRenameClick: PropTypes.func, + onSetDestinationClick: PropTypes.func, + onDownloadClick: PropTypes.func, + onDeleteClick: PropTypes.func, + onOpenClick: PropTypes.func, +}; + +const MoreOptionsContextMenuPopup = ({ + type, + anchorButton, + shouldDisplayDeleteButton, + onClosePopup, + onRenameClick, + onSetDestinationClick, + onDownloadClick, + onDeleteClick, + onOpenClick, +}) => { + const [t] = useTranslation(); + const containerRef = useRef(null); + const [position, setPosition] = useState({ left: -100, right: 'auto', top: 'auto' }); + + const Portal = ({ children, position }) => { + const mount = getRootNode().querySelector('#outline-edit-popup-portal'); + mount.style.position = 'absolute'; + mount.style.top = position.top === 'auto' ? position.top : `${position.top}px`; + mount.style.left = position.left === 'auto' ? position.left : `${position.left}px`; + mount.style.right = position.right === 'auto' ? position.right : `${position.right}px`; + mount.style.zIndex = 999; + + return createPortal(children, mount); + }; + + useEffect(() => { + const position = getOverlayPositionBasedOn(anchorButton, containerRef); + setPosition(position); + }, [anchorButton]); + + const onClickOutside = useCallback((e) => { + if (!containerRef?.current.contains(e.target)) { + onClosePopup(); + } + }); + + useOnClickOutside(containerRef, onClickOutside); + + return ( + + + {type === 'portfolio' && onOpenClick && + + : + + } +
+ ); +}; + +export default ComparisonButton; diff --git a/src/components/MultiViewer/ComparisonButton/ComparisonButton.scss b/src/components/MultiViewer/ComparisonButton/ComparisonButton.scss new file mode 100644 index 0000000000..123009c8a4 --- /dev/null +++ b/src/components/MultiViewer/ComparisonButton/ComparisonButton.scss @@ -0,0 +1,36 @@ +@import '../../../constants/styles'; + +.ComparisonButton { + display: flex; + align-items: center; + font-size: var(--font-size-medium); + + button { + @include button-reset; + background: var(--primary-button); + border-radius: 4px; + display: flex; + align-items: center; + padding: 8px 16px; + justify-content: center; + position: relative; + color: var(--primary-button-text); + cursor: pointer; + + @include mobile { + font-size: 13px; + } + + &:hover { + background: var(--primary-button-hover); + &:disabled { + background: var(--primary-button); + } + } + + &:disabled { + opacity: 0.8; + cursor: not-allowed; + } + } +} diff --git a/src/components/MultiViewer/ComparisonButton/index.js b/src/components/MultiViewer/ComparisonButton/index.js new file mode 100644 index 0000000000..8dd89c8e24 --- /dev/null +++ b/src/components/MultiViewer/ComparisonButton/index.js @@ -0,0 +1,3 @@ +import ComparisonButton from './ComparisonButton'; + +export default ComparisonButton; \ No newline at end of file diff --git a/src/components/MultiViewer/DocumentContainer/DocumentContainer.scss b/src/components/MultiViewer/DocumentContainer/DocumentContainer.scss new file mode 100644 index 0000000000..4b25ab223b --- /dev/null +++ b/src/components/MultiViewer/DocumentContainer/DocumentContainer.scss @@ -0,0 +1,58 @@ +@import '../../../constants/styles'; + +.MultiViewer { + .DocumentContainer { + display: flex; + flex-direction: column; + align-items: center; + width: calc(100% - 4px); + height: calc(100% - 32px); + overflow: overlay; + user-select: none; + + @include ie11 { + margin-left: 0 !important; + width: 100% !important + } + + .document { + overflow-x: visible; + overflow-y: visible; + margin: auto; // vertical centering when content is smaller than document container + // can't use 'justify-content: center;' due to losing access to content when overflowing + // see: https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container + outline: none; + -webkit-tap-highlight-color: transparent; + + &.hidden { + display: none; + } + + .pageSection { + &[id*=pageSectionb] { + box-shadow: none; + } + + .pageContainer { + background-color: $document-bg-color; + position: relative; + box-shadow: $md-shadow1; + + span.link { + cursor: pointer; + } + } + } + + textarea.freetext { + position: absolute; + z-index: 20; + border: 0; + padding: 0; + box-sizing: border-box; + resize: none; + outline: 1px solid transparent; + } + } + } +} diff --git a/src/components/MultiViewer/DocumentContainer/index.js b/src/components/MultiViewer/DocumentContainer/index.js new file mode 100644 index 0000000000..57c02d0d56 --- /dev/null +++ b/src/components/MultiViewer/DocumentContainer/index.js @@ -0,0 +1,3 @@ +import DocumentContainer from './DocumentContainer'; + +export default DocumentContainer; \ No newline at end of file diff --git a/src/components/MultiViewer/DocumentHeader/DocumentHeader.js b/src/components/MultiViewer/DocumentHeader/DocumentHeader.js new file mode 100644 index 0000000000..736328d4b1 --- /dev/null +++ b/src/components/MultiViewer/DocumentHeader/DocumentHeader.js @@ -0,0 +1,72 @@ +import React, { useEffect, useState } from 'react'; +import ToggleZoomOverlay from 'components/ToggleZoomOverlay'; +import PropTypes from 'prop-types'; +import Button from 'components/Button'; +import classNames from 'classnames'; +import core from 'core'; +import { useTranslation } from 'react-i18next'; +import downloadPdf from 'helpers/downloadPdf'; +import { useDispatch, useSelector } from 'react-redux'; +import selectors from 'selectors'; +import DataElements from 'constants/dataElement'; +import actions from 'actions'; + +import './DocumentHeader.scss'; + +const propTypes = { + documentViewerKey: PropTypes.number.isRequired, + docLoaded: PropTypes.bool.isRequired, + isSyncing: PropTypes.bool.isRequired, +}; + +// Todo Compare: Make stories for this component +const DocumentHeader = ({ + documentViewerKey, + docLoaded, + isSyncing, +}) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const [filename, setFileName] = useState('Untitled'); + const [saveButtonDisabled] = useSelector((state) => [selectors.isElementDisabled(state, DataElements.MULTI_VIEWER_SAVE_DOCUMENT_BUTTON)]); + + useEffect(() => { + const stopSyncing = () => dispatch(actions.setSyncViewer(null)); + const onLoaded = () => setFileName(core.getDocument(documentViewerKey)?.getFilename()); + const unLoaded = () => setFileName('Untitled'); + core.addEventListener('documentLoaded', onLoaded, undefined, documentViewerKey); + core.addEventListener('documentUnloaded', unLoaded, undefined, documentViewerKey); + core.addEventListener('displayModeUpdated', stopSyncing, undefined, documentViewerKey); + setFileName(core.getDocument(1)?.getFilename() || 'Untitled'); + return () => { + core.removeEventListener('documentLoaded', onLoaded, documentViewerKey); + core.removeEventListener('documentUnloaded', unLoaded, documentViewerKey); + core.removeEventListener('displayModeUpdated', stopSyncing, documentViewerKey); + }; + }, [documentViewerKey]); + + const closeDocument = () => core.closeDocument(documentViewerKey); + const onClickSync = () => dispatch(actions.setSyncViewer(isSyncing ? null : documentViewerKey)); + const onSaveDocument = () => downloadPdf(dispatch, undefined, documentViewerKey); + + return ( +
+ +
{filename}
+
+ {!saveButtonDisabled && +
+
+ ); +}; + +DocumentHeader.propTypes = propTypes; + +export default DocumentHeader; diff --git a/src/components/MultiViewer/DocumentHeader/DocumentHeader.scss b/src/components/MultiViewer/DocumentHeader/DocumentHeader.scss new file mode 100644 index 0000000000..cdc8ccc387 --- /dev/null +++ b/src/components/MultiViewer/DocumentHeader/DocumentHeader.scss @@ -0,0 +1,48 @@ +@import '../../../constants/styles'; + +.DocumentHeader { + width: 100%; + height: fit-content; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 4px 0; + + &.hidden { + visibility: hidden; + } + + .Button { + width: 28px; + height: 28px; + + &:hover { + background-color: var(--popup-button-hover); + } + + &.active{ + background-color: var(--view-header-button-active); + .Icon { + color: var(--view-header-icon-active-fill); + } + } + } + + .zoom-overlay { + //align-self: flex-start; + } + + .file-name { + font-family: var(--font-family); + font-size: var(--font-size-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .control-buttons { + display: flex; + gap: 8px; + } +} diff --git a/src/components/MultiViewer/DocumentHeader/index.js b/src/components/MultiViewer/DocumentHeader/index.js new file mode 100644 index 0000000000..8429f904c7 --- /dev/null +++ b/src/components/MultiViewer/DocumentHeader/index.js @@ -0,0 +1,3 @@ +import DocumentHeader from './DocumentHeader'; + +export default DocumentHeader; \ No newline at end of file diff --git a/src/components/MultiViewer/DropArea/DropArea.js b/src/components/MultiViewer/DropArea/DropArea.js new file mode 100644 index 0000000000..e56b4c6fa0 --- /dev/null +++ b/src/components/MultiViewer/DropArea/DropArea.js @@ -0,0 +1,63 @@ +import React, { useRef } from 'react'; +import selectors from 'selectors'; +import './DropArea.scss'; +import { useTranslation } from 'react-i18next'; +import Icon from 'components/Icon'; +import PropTypes from 'prop-types'; +import loadDocument from 'helpers/loadDocument'; +import { useDispatch, useSelector } from 'react-redux'; +import getHashParameters from 'helpers/getHashParameters'; + +const propTypes = { + documentViewerKey: PropTypes.number.isRequired, +}; + +// Todo Compare: Make stories for this component +const DropArea = ({ documentViewerKey }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const fileInput = useRef(); + const [ + customMultiViewerAcceptedFileFormats, + ] = useSelector((state) => [ + selectors.getCustomMultiViewerAcceptedFileFormats(state), + ]); + + const browseFiles = () => fileInput.current.click(); + + const onDrop = (e) => { + e.preventDefault(); + const { files } = e.dataTransfer; + if (files.length) { + loadDocument(dispatch, files[0], {}, documentViewerKey); + } + }; + const loadFile = (e) => { + e.preventDefault(); + const { files } = e.target; + if (files.length) { + loadDocument(dispatch, files[0], {}, documentViewerKey); + } + }; + + const wvServer = !!getHashParameters('webviewerServerURL', null); + const acceptFormats = wvServer ? window.Core.SupportedFileFormats.SERVER : window.Core.SupportedFileFormats.CLIENT; + + return ( +
e.preventDefault()} onDrop={onDrop}> + +
{t('multiViewer.dragAndDrop')}
+
{t('multiViewer.or')}
+ + `.${format}`, + )).join(', ')} + /> +
+ ); +}; + +DropArea.propTypes = propTypes; + +export default DropArea; diff --git a/src/components/MultiViewer/DropArea/DropArea.scss b/src/components/MultiViewer/DropArea/DropArea.scss new file mode 100644 index 0000000000..8353ce4412 --- /dev/null +++ b/src/components/MultiViewer/DropArea/DropArea.scss @@ -0,0 +1,44 @@ +@import '../../../constants/styles'; + +.DropArea { + gap: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + z-index: 10; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; + background-color: var(--document-background-color); + + border: 4px dashed #E1E1E3; + box-sizing: border-box; + + .Icon { + color: var(--gray-7); + height: 65px; + width: 65px; + } + .hidden { + display: none; + } + + button { + width: 105px; + height: 32px; + border-radius: 4px; + inset: 1px; + color: var(--blue-5); + background-color: var(--document-background-color); + border: 1px solid var(--blue-5); + pointer-events: auto; + + &:hover { + background-color: var(--tools-button-hover); + } + } +} \ No newline at end of file diff --git a/src/components/MultiViewer/DropArea/index.js b/src/components/MultiViewer/DropArea/index.js new file mode 100644 index 0000000000..c19e0dc81c --- /dev/null +++ b/src/components/MultiViewer/DropArea/index.js @@ -0,0 +1,3 @@ +import DropArea from './DropArea'; + +export default DropArea; \ No newline at end of file diff --git a/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.js b/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.js new file mode 100644 index 0000000000..b431a3491a --- /dev/null +++ b/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.js @@ -0,0 +1,11 @@ +import React from 'react'; +import './MultiViewerWrapper.scss'; +import { useSelector } from 'react-redux'; +import selectors from 'selectors'; + +const MultiViewerWrapper = ({ children }) => { + const isMultiViewerReady = useSelector((state) => selectors.isMultiViewerReady(state)); + return isMultiViewerReady ? <>{children} : null; +}; + +export default MultiViewerWrapper; \ No newline at end of file diff --git a/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.scss b/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.scss new file mode 100644 index 0000000000..7820f5b31d --- /dev/null +++ b/src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper.scss @@ -0,0 +1,3 @@ +.MultiViewerWrapper { + +} \ No newline at end of file diff --git a/src/components/MultiViewer/MultiViewerWrapper/index.js b/src/components/MultiViewer/MultiViewerWrapper/index.js new file mode 100644 index 0000000000..c5717b7f47 --- /dev/null +++ b/src/components/MultiViewer/MultiViewerWrapper/index.js @@ -0,0 +1,3 @@ +import MultiViewerWrapper from 'src/components/MultiViewer/MultiViewerWrapper/MultiViewerWrapper'; + +export default MultiViewerWrapper; \ No newline at end of file diff --git a/src/components/MultiViewer/index.js b/src/components/MultiViewer/index.js new file mode 100644 index 0000000000..27038c49a7 --- /dev/null +++ b/src/components/MultiViewer/index.js @@ -0,0 +1,3 @@ +import MultiViewer from 'src/components/MultiViewer/MultiViewer'; + +export default MultiViewer; \ No newline at end of file diff --git a/src/components/NoteTextarea/NoteTextarea.stories.js b/src/components/NoteTextarea/NoteTextarea.stories.js new file mode 100644 index 0000000000..93de470a49 --- /dev/null +++ b/src/components/NoteTextarea/NoteTextarea.stories.js @@ -0,0 +1,77 @@ +import React, { useRef } from 'react'; +import NoteContext from '../Note/Context'; +import NoteTextarea from './NoteTextarea'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; + +export default { + title: 'Components/NotesPanel/NoteTextarea', + component: NoteTextarea, +}; + +function handleStateChange(newValue) { + // eslint-disable-next-line no-console +} + +const context = { + pendingEditTextMap: {}, + pendingReplyMap: {}, +}; + +const initialState = { + viewer: { + disabledElements: {}, + openElements: { audioPlaybackPopup: true }, + customElementOverrides: {}, + userData: [ + { + value: 'John Doe', + id: 'johndoe@gmail.com', + email: 'johndoe@gmail.com', + }, + { + value: 'Jane Doe', + id: 'janedoe@gmail.com', + email: 'janedoe@gmail.com' + }, + { + value: 'Jane Doe', + id: 'janedoejanedoejanedoejanedoe@gmail.com', + email: 'janedoejanedoejanedoejanedoe@gmail.com' + }, + ] + } +}; + +function rootReducer(state = initialState, action) { + return state; +} + +const store = createStore(rootReducer); + +const props = { + value: 'test', + onChange: handleStateChange, + onSubmit: () => console.log('onSubmit'), + onBlur: () => console.log('onBlur'), + onFocus: () => console.log('onFocus') +}; + +export const Basic = () => { + const textareaRef = useRef(null); + + return ( + + + { + textareaRef.current = el; + } + } + /> + + + ); +}; \ No newline at end of file diff --git a/src/components/NotesPanel/ReplyAttachmentPicker.js b/src/components/NotesPanel/ReplyAttachmentPicker.js new file mode 100644 index 0000000000..ee3bf62f12 --- /dev/null +++ b/src/components/NotesPanel/ReplyAttachmentPicker.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import selectors from 'selectors'; + +const ReplyAttachmentPicker = ({ annotationId, addAttachments }) => { + const replyAttachmentHandler = useSelector((state) => selectors.getReplyAttachmentHandler(state)); + + const onChange = async (e) => { + const file = e.target.files[0]; + if (file) { + let attachment = file; + if (replyAttachmentHandler) { + const url = await replyAttachmentHandler(file); + attachment = { + url, + name: file.name, + size: file.size, + type: file.type + }; + } + addAttachments(annotationId, [attachment]); + } + }; + + return ( + { + e.target.value = ''; + }} + /> + ); +}; + +export default ReplyAttachmentPicker; diff --git a/src/components/NotesPanelHeader/index.js b/src/components/NotesPanelHeader/index.js new file mode 100644 index 0000000000..8a772a9429 --- /dev/null +++ b/src/components/NotesPanelHeader/index.js @@ -0,0 +1,3 @@ +import NotesPanelHeader from './NotesPanelHeader'; + +export default NotesPanelHeader; \ No newline at end of file diff --git a/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.scss b/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.scss new file mode 100644 index 0000000000..8c2c4b6a13 --- /dev/null +++ b/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.scss @@ -0,0 +1,26 @@ +.office-editor-create-table { + margin: 8px; + width: 120px; + height: 150px; + cursor: pointer; + + table, td { + border: 1px solid var(--gray-8); + border-collapse: collapse; + } + + td { + width: 12px; + height: 12px; + + &.selected-cell { + background-color: var(--oe-table-dropdown-highlight); + } + } + + .create-table-rows-columns { + display: block; + text-align: center; + margin: 10px auto; + } +} diff --git a/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.stories.js b/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.stories.js new file mode 100644 index 0000000000..ab70942ea3 --- /dev/null +++ b/src/components/OfficeEditorCreateTablePopup/OfficeEditorCreateTablePopup.stories.js @@ -0,0 +1,13 @@ +import React from 'react'; +import OfficeEditorCreateTablePopup from './OfficeEditorCreateTablePopup'; + +export default { + title: 'Components/OfficeEditorCreateTablePopup', + component: OfficeEditorCreateTablePopup +}; + +export function Basic() { + return ( + + ); +} diff --git a/src/components/OfficeEditorCreateTablePopup/index.js b/src/components/OfficeEditorCreateTablePopup/index.js new file mode 100644 index 0000000000..3bd366ecdf --- /dev/null +++ b/src/components/OfficeEditorCreateTablePopup/index.js @@ -0,0 +1,3 @@ +import OfficeEditorCreateTablePopup from './OfficeEditorCreateTablePopup'; + +export default OfficeEditorCreateTablePopup; diff --git a/src/components/OfficeEditorImageFilePickerHandler/OfficeEditorImageFilePickerHandler.js b/src/components/OfficeEditorImageFilePickerHandler/OfficeEditorImageFilePickerHandler.js new file mode 100644 index 0000000000..2204df4ccd --- /dev/null +++ b/src/components/OfficeEditorImageFilePickerHandler/OfficeEditorImageFilePickerHandler.js @@ -0,0 +1,59 @@ +import React from 'react'; +import actions from 'actions'; +import { useDispatch } from 'react-redux'; +import getRootNode from 'helpers/getRootNode'; +import core from 'core'; +import DataElements from 'constants/dataElement'; + +import '../FilePickerHandler/FilePickerHandler.scss'; + +// TODO: Can we accept any other image formats? +const ACCEPTED_FORMATS = ['jpg', 'jpeg', 'png', 'bmp'].map( + (format) => `.${format}`, +).join(', '); + +const toBase64 = (file) => new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; +}); + +const FilePickerHandler = () => { + const dispatch = useDispatch(); + + const openDocument = async (e) => { + const file = e.target.files[0]; + if (file) { + try { + dispatch(actions.openElement(DataElements.LOADING_MODAL)); + const base64 = await toBase64(file); + await core.getOfficeEditor().insertImageAtCursor(base64); + dispatch(actions.closeElement(DataElements.LOADING_MODAL)); + } catch (error) { + dispatch(actions.closeElement(DataElements.LOADING_MODAL)); + dispatch(actions.showWarningMessage({ + title: 'Error', + message: error.message, + })); + } + const picker = getRootNode().querySelector('#office-editor-file-picker'); + if (picker) { + picker.value = ''; + } + } + }; + + return ( +
+ +
+ ); +}; + +export default FilePickerHandler; diff --git a/src/components/OfficeEditorImageFilePickerHandler/index.js b/src/components/OfficeEditorImageFilePickerHandler/index.js new file mode 100644 index 0000000000..9babab9332 --- /dev/null +++ b/src/components/OfficeEditorImageFilePickerHandler/index.js @@ -0,0 +1,3 @@ +import OfficeEditorImageFilePickerHandler from './OfficeEditorImageFilePickerHandler'; + +export default OfficeEditorImageFilePickerHandler; diff --git a/src/components/OutlineContent/OutlineContent.js b/src/components/OutlineContent/OutlineContent.js new file mode 100644 index 0000000000..de85a11ef3 --- /dev/null +++ b/src/components/OutlineContent/OutlineContent.js @@ -0,0 +1,267 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import PropTypes from 'prop-types'; +import core from 'core'; + +import Button from '../Button'; +import MoreOptionsContextMenuPopup from '../MoreOptionsContextMenuPopup'; +import OutlineContext from '../Outline/Context'; +import './OutlineContent.scss'; + +const propTypes = { + text: PropTypes.string.isRequired, + outlinePath: PropTypes.string, + isAdding: PropTypes.bool, + isOutlineRenaming: PropTypes.bool, + setOutlineRenaming: PropTypes.func, + isOutlineChangingDest: PropTypes.bool, + setOutlineChangingDest: PropTypes.func, + setIsHovered: PropTypes.func, + onCancel: PropTypes.func, + textColor: PropTypes.string, +}; + +const OutlineContent = ({ + text, + outlinePath, + isAdding, + isOutlineRenaming, + setOutlineRenaming, + isOutlineChangingDest, + setOutlineChangingDest, + setIsHovered, + onCancel, + textColor, +}) => { + const { + currentDestPage, + currentDestText, + editingOutlines, + setEditingOutlines, + isMultiSelectMode, + isOutlineEditable, + addNewOutline, + renameOutline, + updateOutlineDest, + updateOutlines, + removeOutlines, + } = useContext(OutlineContext); + + const [t] = useTranslation(); + const TOOL_NAME = 'OutlineDestinationCreateTool'; + + const [isDefault, setIsDefault] = useState(false); + const [outlineText, setOutlineText] = useState(text); + const [isContextMenuOpen, setContextMenuOpen] = useState(false); + const inputRef = useRef(); + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.stopPropagation(); + if (isAdding) { + onAddOutline(); + } + if (isOutlineRenaming && !isRenameButtonDisabled()) { + onRenameOutline(); + } + } + if (e.key === 'Escape') { + onCancelOutline(); + } + }; + + const onAddOutline = () => { + addNewOutline(outlineText.trim() === '' ? '' : outlineText); + }; + + const onRenameOutline = () => { + setOutlineRenaming(false); + renameOutline(outlinePath, outlineText); + }; + + const onCancelOutline = () => { + updateOutlines(); + if (isOutlineRenaming) { + setOutlineRenaming(false); + setOutlineText(text); + } + if (isOutlineChangingDest) { + setOutlineChangingDest(false); + } + if (isAdding) { + onCancel(); + } + }; + + const isRenameButtonDisabled = () => { + return !outlineText || text === outlineText; + }; + + useEffect(() => { + if (outlineText !== text) { + setOutlineText(text); + } + }, [text]); + + useEffect(() => { + if (isAdding || isOutlineRenaming) { + inputRef.current.focus(); + inputRef.current.select(); + } + + setIsDefault(!isAdding && !isOutlineRenaming && !isOutlineChangingDest); + }, [isOutlineRenaming, isOutlineChangingDest]); + + useEffect(() => { + const editingOutlinesClone = { ...editingOutlines }; + const isOutlineEditing = isOutlineRenaming || isOutlineChangingDest; + if (isOutlineEditing) { + editingOutlinesClone[outlinePath] = (isOutlineEditing); + } else { + delete editingOutlinesClone[outlinePath]; + } + setEditingOutlines({ ...editingOutlinesClone }); + }, [isOutlineRenaming, isOutlineChangingDest]); + + useEffect(() => { + if (!isAdding) { + setIsHovered(isContextMenuOpen); + } + }, [isContextMenuOpen]); + + const textStyle = { + color: textColor || 'auto' + }; + + return ( +
+ {isAdding && +
+ {t('component.newOutlineTitle')} +
+ } + + {isDefault && + <> +
{ + if (isOutlineEditable) { + setOutlineRenaming(true); + } + }} + style={textStyle} + > + {text} +
+ + {isOutlineEditable && +
+ } +
+ ); +}; + +OutlineContent.propTypes = propTypes; + +export default OutlineContent; diff --git a/src/components/OutlineContent/OutlineContent.scss b/src/components/OutlineContent/OutlineContent.scss new file mode 100644 index 0000000000..88577c5f59 --- /dev/null +++ b/src/components/OutlineContent/OutlineContent.scss @@ -0,0 +1,13 @@ +.outline-text, +.outline-destination { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.outline-destination { + flex-basis: 100%; + font-size: 10px; + color: var(--faded-text); + margin-top: var(--padding-small); +} \ No newline at end of file diff --git a/src/components/OutlineContent/OutlineContent.spec.js b/src/components/OutlineContent/OutlineContent.spec.js new file mode 100644 index 0000000000..b6093a0fbb --- /dev/null +++ b/src/components/OutlineContent/OutlineContent.spec.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import { Basic, Renaming, ColoredOutline } from './OutlineContent.stories'; + +const BasicOutline = withProviders(Basic); +const RenamingOutline = withProviders(Renaming); + +describe('Outline', () => { + it('Story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('Save button in renaming outline is disabled if text is empty or text is the same as current name', () => { + const { container } = render(); + + const saveButton = container.querySelector('.bookmark-outline-save-button'); + expect(saveButton.disabled).toBe(true); + + const textInput = container.querySelector('.bookmark-outline-input'); + fireEvent.change(textInput, { target: { value: 'new outline' } }); + expect(saveButton.disabled).toBe(false); + + fireEvent.change(textInput, { target: { value: '' } }); + expect(saveButton.disabled).toBe(true); + }); + + it('should set font color if textColor is passed to OutlineContent', () => { + const { container } = render(); + + const outline = container.querySelector('.bookmark-outline-text'); + expect(outline.style.color).toBe('rgb(255, 0, 0)'); + }); +}); diff --git a/src/components/OutlineContent/OutlineContent.stories.js b/src/components/OutlineContent/OutlineContent.stories.js new file mode 100644 index 0000000000..e92329339b --- /dev/null +++ b/src/components/OutlineContent/OutlineContent.stories.js @@ -0,0 +1,190 @@ +import React from 'react'; +import { legacy_createStore as createStore } from 'redux'; +import { Provider as ReduxProvider } from 'react-redux'; +import OutlineContent from './OutlineContent'; +import OutlineContext from '../Outline/Context'; +import '../LeftPanel/LeftPanel.scss'; + +const NOOP = () => { }; + +export default { + title: 'Components/OutlineContent', + component: OutlineContent, +}; + +const reducer = () => { + return { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + isOutlineEditingEnabled: true, + }, + document: { + outlines: {}, + }, + }; +}; + +export const Basic = () => { + return ( + +
+
+
+ + + +
+
+
+
+ ); +}; + +export const Adding = () => { + return ( + +
+
+
+ + + +
+
+
+
+ ); +}; + +export const Renaming = () => { + return ( + +
+
+
+ + + +
+
+
+
+ ); +}; + +export const ChangingDestination = () => { + return ( + +
+
+
+ + + +
+
+
+
+ ); +}; + +export const ColoredOutline = () => { + return ( + +
+
+
+ + + +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/components/OutlineContent/index.js b/src/components/OutlineContent/index.js new file mode 100644 index 0000000000..a1e905f50e --- /dev/null +++ b/src/components/OutlineContent/index.js @@ -0,0 +1,3 @@ +import OutlineContent from './OutlineContent'; + +export default OutlineContent; diff --git a/src/components/OutlinesPanel/OutlinesDragLayer.js b/src/components/OutlinesPanel/OutlinesDragLayer.js new file mode 100644 index 0000000000..6eff6e115e --- /dev/null +++ b/src/components/OutlinesPanel/OutlinesDragLayer.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { useDragLayer } from 'react-dnd'; +import { ItemTypes } from 'constants/dnd'; + +const layerStyles = { + position: 'fixed', + pointerEvents: 'none', + zIndex: 99999, + left: 0, + top: 0, + width: '100%', + height: '100%' +}; + +const getItemStyles = (initialOffset, currentOffset) => { + if (!initialOffset || !currentOffset) { + return { + display: 'none' + }; + } + const { x, y } = currentOffset; + const transform = `translate(calc(${x}px - 50%), calc(${y}px - 100%))`; + return { + transform, + WebkitTransform: transform, + }; +}; + +export const OutlinesDragLayer = () => { + const { + itemType, + item, + isDragging, + initialOffset, + currentOffset + } = useDragLayer((dragLayerState) => ({ + itemType: dragLayerState.getItemType(), + item: dragLayerState.getItem(), + isDragging: dragLayerState.isDragging(), + initialOffset: dragLayerState.getInitialSourceClientOffset(), + currentOffset: dragLayerState.getClientOffset(), + })); + + const renderDragItem = () => { + if (!item) { + return null; + } + + const { dragOutline } = item; + + switch (itemType) { + case ItemTypes.OUTLINE: + return ( + <> + {dragOutline.getName()} + + ); + default: + return null; + } + }; + + if (!isDragging) { + return null; + } + + return ( +
+
+ {renderDragItem()} +
+
+ ); +}; diff --git a/src/components/Panel/Panel.stories.js b/src/components/Panel/Panel.stories.js index df6c061d8c..ac207f171e 100644 --- a/src/components/Panel/Panel.stories.js +++ b/src/components/Panel/Panel.stories.js @@ -27,7 +27,7 @@ const initialState = { panelWidths: { panel: DEFAULT_NOTES_PANEL_WIDTH }, sortStrategy: 'position', isInDesktopOnlyMode: true, - modularHeaders: [] + modularHeaders: {} } }; diff --git a/src/components/PortfolioItem/PortfolioItem.scss b/src/components/PortfolioItem/PortfolioItem.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/PortfolioItem/index.js b/src/components/PortfolioItem/index.js new file mode 100644 index 0000000000..8711af14dc --- /dev/null +++ b/src/components/PortfolioItem/index.js @@ -0,0 +1,3 @@ +import PortfolioItem from './PortfolioItem'; + +export default PortfolioItem; diff --git a/src/components/PortfolioItemContent/PortfolioItemContent.scss b/src/components/PortfolioItemContent/PortfolioItemContent.scss index 65fbbe5495..3a2afe8993 100644 --- a/src/components/PortfolioItemContent/PortfolioItemContent.scss +++ b/src/components/PortfolioItemContent/PortfolioItemContent.scss @@ -1,5 +1,7 @@ .PortfolioPanel { .bookmark-outline-single-container { + margin-top: 6px; + margin-bottom: 6px; .bookmark-outline-label-row { align-items: center; diff --git a/src/components/PortfolioItemContent/index.js b/src/components/PortfolioItemContent/index.js new file mode 100644 index 0000000000..0765d3b00b --- /dev/null +++ b/src/components/PortfolioItemContent/index.js @@ -0,0 +1,3 @@ +import PortfolioItemContent from './PortfolioItemContent'; + +export default PortfolioItemContent; diff --git a/src/components/PortfolioPanel/PortfolioContext.js b/src/components/PortfolioPanel/PortfolioContext.js new file mode 100644 index 0000000000..484ecdd0b5 --- /dev/null +++ b/src/components/PortfolioPanel/PortfolioContext.js @@ -0,0 +1,5 @@ +import React from 'react'; + +const PortfolioContext = React.createContext(); + +export default PortfolioContext; \ No newline at end of file diff --git a/src/components/PortfolioPanel/PortfolioDragLayer.js b/src/components/PortfolioPanel/PortfolioDragLayer.js new file mode 100644 index 0000000000..be3ff191b0 --- /dev/null +++ b/src/components/PortfolioPanel/PortfolioDragLayer.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { useDragLayer } from 'react-dnd'; +import { ItemTypes } from 'constants/dnd'; + +const layerStyles = { + position: 'fixed', + pointerEvents: 'none', + zIndex: 99999, + left: 0, + top: 0, + width: '100%', + height: '100%' +}; + +const getItemStyles = (initialOffset, currentOffset) => { + if (!initialOffset || !currentOffset) { + return { + display: 'none' + }; + } + const { x, y } = currentOffset; + const transform = `translate(calc(${x}px - 50%), calc(${y}px - 100%))`; + return { + transform, + WebkitTransform: transform, + }; +}; + +export const PortfolioDragLayer = () => { + const { + itemType, + item, + isDragging, + initialOffset, + currentOffset + } = useDragLayer((dragLayerState) => ({ + itemType: dragLayerState.getItemType(), + item: dragLayerState.getItem(), + isDragging: dragLayerState.isDragging(), + initialOffset: dragLayerState.getInitialSourceClientOffset(), + currentOffset: dragLayerState.getClientOffset(), + })); + + const renderDragItemPreview = () => { + if (!item) { + return null; + } + + const { dragPortfolioItem } = item; + + if (itemType === ItemTypes.PORTFOLIO) { + return ( + <> + {dragPortfolioItem.name} + + ); + } + + return null; + }; + + if (!isDragging) { + return null; + } + + return ( +
+
+ {renderDragItemPreview()} +
+
+ ); +}; diff --git a/src/components/PortfolioPanel/PortfolioPanel.scss b/src/components/PortfolioPanel/PortfolioPanel.scss new file mode 100644 index 0000000000..a4a0d22370 --- /dev/null +++ b/src/components/PortfolioPanel/PortfolioPanel.scss @@ -0,0 +1,12 @@ +@import '../../constants/styles'; +@import '../../constants/panel'; + +.PortfolioPanel { + .portfolio-panel-control { + display: flex; + } + + .bookmark-outline-row { + padding-top: 6px; + } +} \ No newline at end of file diff --git a/src/components/PortfolioPanel/index.js b/src/components/PortfolioPanel/index.js new file mode 100644 index 0000000000..33783b3238 --- /dev/null +++ b/src/components/PortfolioPanel/index.js @@ -0,0 +1,3 @@ +import PortfolioPanel from './PortfolioPanel'; + +export default PortfolioPanel; diff --git a/src/components/ReactSelectCustomArrowIndicator/ReactSelectCustomArrowIndicator.js b/src/components/ReactSelectCustomArrowIndicator/ReactSelectCustomArrowIndicator.js new file mode 100644 index 0000000000..db31b603bb --- /dev/null +++ b/src/components/ReactSelectCustomArrowIndicator/ReactSelectCustomArrowIndicator.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { components } from 'react-select'; +import Icon from 'components/Icon'; + +const ReactSelectCustomArrowIndicator = (props) => { + const { selectProps } = props; + const { menuIsOpen } = selectProps; + return ( + + + + ); +}; + +export default ReactSelectCustomArrowIndicator; \ No newline at end of file diff --git a/src/components/ReactSelectCustomArrowIndicator/index.js b/src/components/ReactSelectCustomArrowIndicator/index.js new file mode 100644 index 0000000000..1a2e698e7f --- /dev/null +++ b/src/components/ReactSelectCustomArrowIndicator/index.js @@ -0,0 +1,3 @@ +import ReactSelectCustomArrowIndicator from './ReactSelectCustomArrowIndicator'; + +export default ReactSelectCustomArrowIndicator; \ No newline at end of file diff --git a/src/components/ReactSelectWebComponentProvider/index.js b/src/components/ReactSelectWebComponentProvider/index.js new file mode 100644 index 0000000000..3a1f0e7dc1 --- /dev/null +++ b/src/components/ReactSelectWebComponentProvider/index.js @@ -0,0 +1,3 @@ +import ReactSelectWebComponentProvider from './ReactSelectWebComponentProvider'; + +export default ReactSelectWebComponentProvider; \ No newline at end of file diff --git a/src/components/RedactionPanel/RedactionPanel.stories.js b/src/components/RedactionPanel/RedactionPanel.stories.js index b1f2574537..b5af2f69fb 100644 --- a/src/components/RedactionPanel/RedactionPanel.stories.js +++ b/src/components/RedactionPanel/RedactionPanel.stories.js @@ -6,13 +6,20 @@ import RedactionPanelContainerWithProvider from './RedactionPanelContainer'; import RightPanel from 'components/RightPanel'; import { RedactionPanelContext } from './RedactionPanelContext'; import { defaultRedactionTypes, redactionTypeMap } from 'constants/redactionTypes'; +import Panel from 'components/Panel'; const noop = () => { }; export default { title: 'Components/RedactionPanel', component: RedactionPanel, - includeStories: ['EmptyList', 'PanelWithRedactionItems', 'RedactionPanelWithSearch'] + includeStories: [ + 'EmptyList', 'PanelWithRedactionItems', 'RedactionPanelWithSearch', + 'RedactionLeftGenericPanel', + 'RedactionRightGenericPanel', + 'RightPanelWithRedactionItems', + 'LeftPanelWithRedactionItems', + ] }; export const RedactionContextMock = ({ children, mockContext }) => { @@ -50,12 +57,14 @@ const initialState = { openElements: { header: true, redactionPanel: true, + panel: true, }, currentLanguage: 'en', panelWidths: { redactionPanel: 330, + panel: 300, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 @@ -200,7 +209,65 @@ export function PanelWithRedactionItems() { export function RedactionPanelWithSearch() { return ( - + ); } + + +export function RedactionLeftGenericPanel() { + return ( + + + +
+ +
+
+
+
+ ); +} + +export function RedactionRightGenericPanel() { + return ( + + + +
+ +
+
+
+
+ ); +} + + +export function RightPanelWithRedactionItems() { + return ( + + + +
+ +
+
+
+
+ ); +} + +export function LeftPanelWithRedactionItems() { + return ( + + + +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/RedactionSearchResultGroup/RedactionSearchResultGroup.scss b/src/components/RedactionSearchResultGroup/RedactionSearchResultGroup.scss index 7c35f384a6..aa3e037d00 100644 --- a/src/components/RedactionSearchResultGroup/RedactionSearchResultGroup.scss +++ b/src/components/RedactionSearchResultGroup/RedactionSearchResultGroup.scss @@ -1,7 +1,7 @@ .redaction-search-results-page-number { display: flex; align-items: center; - margin-left: 11px; + margin-left: 11px !important; label { font-size: 13px; diff --git a/src/components/ReplyAttachmentList/ReplyAttachmentList.scss b/src/components/ReplyAttachmentList/ReplyAttachmentList.scss new file mode 100644 index 0000000000..910cd5b46b --- /dev/null +++ b/src/components/ReplyAttachmentList/ReplyAttachmentList.scss @@ -0,0 +1,84 @@ +.reply-attachment-list { + display: flex; + flex-direction: column; + cursor: default; + + .reply-attachment { + display: flex; + flex-direction: column; + background-color: var(--gray-1); + border-radius: 4px; + cursor: pointer; + overflow: hidden; + + &:not(:last-child) { + margin-bottom: 8px; + } + + .reply-attachment-preview { + width: 100%; + max-height: 80px; + display: flex; + justify-content: center; + + &.dirty { + position: relative; + margin-bottom: 15px; + } + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + .reply-attachment-preview-message { + font-size: 11px; + color: var(--error-text-color); + position: absolute; + bottom: -15px; + left: 10px; + } + } + + .reply-attachment-info { + display: flex; + align-items: center; + height: 40px; + padding: 8px; + + .reply-attachment-icon { + height: 24px; + min-height: 24px; + width: 24px; + min-width: 24px; + } + + .reply-file-name { + height: 16px; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 8px; + margin-right: 8px; + } + + .attachment-button { + height: 24px; + min-height: 24px; + width: 24px; + min-width: 24px; + + &:hover { + background-color: var(--blue-1); + } + + .Icon { + height: 16px; + width: 16px; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/ReplyAttachmentList/ReplyAttachmentList.stories.js b/src/components/ReplyAttachmentList/ReplyAttachmentList.stories.js new file mode 100644 index 0000000000..f994ca3e33 --- /dev/null +++ b/src/components/ReplyAttachmentList/ReplyAttachmentList.stories.js @@ -0,0 +1,95 @@ +import React from 'react'; +import ReplyAttachmentList from './ReplyAttachmentList'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; + +export default { + title: 'Components/ReplyAttachmentList', + component: ReplyAttachmentList +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + replyAttachmentPreviewEnabled: true, + } +}; +function rootReducer(state = initialState, action) { + return state; +} +const store = createStore(rootReducer); + +const files = [ + { + name: 'file_1.pdf' + }, + { + name: 'file_2.doc' + }, + { + name: 'file_3_extra_long_file_name.cad' + } +]; + +// State 1 +export function DisplayMode() { + const props = { + files, + isEditing: false + }; + + return ( + +
+ +
+
+ ); +} + +// State 2 +export function EditMode() { + const props = { + files, + isEditing: true + }; + + return ( + +
+ +
+
+ ); +} + +const SVG_MIME_TYPE = 'image/svg+xml'; +const svgString = ` + + + + + + +`; +const svgBlob = new Blob([svgString], { type: SVG_MIME_TYPE }); +const svgFile = new File([svgBlob], 'redirect.svg', { type: SVG_MIME_TYPE }); + +// State 3 +export function UnsafeSVGAttachment() { + const props = { + files: [...files, svgFile], + isEditing: false + }; + + return ( + +
+ +
+
+ ); +} diff --git a/src/components/ReplyAttachmentList/index.js b/src/components/ReplyAttachmentList/index.js new file mode 100644 index 0000000000..4487c18bf9 --- /dev/null +++ b/src/components/ReplyAttachmentList/index.js @@ -0,0 +1,3 @@ +import ReplyAttachmentList from './ReplyAttachmentList'; + +export default ReplyAttachmentList; \ No newline at end of file diff --git a/src/components/RichTextStyleEditor/RichTextStyleEditor.stories.js b/src/components/RichTextStyleEditor/RichTextStyleEditor.stories.js new file mode 100644 index 0000000000..6c095e8388 --- /dev/null +++ b/src/components/RichTextStyleEditor/RichTextStyleEditor.stories.js @@ -0,0 +1,117 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import initialState from 'src/redux/initialState'; +import { Provider } from 'react-redux'; +import RichTextStyleEditor from './RichTextStyleEditor'; +import Panel from '../Panel'; +import '../StylePicker/StylePicker.scss'; +import { initialTextColors } from 'helpers/initialColorStates'; + +export default { + title: 'Components/RichTextStyleEditor', + component: RichTextStyleEditor, +}; + +// Mock some state to show the style popups +const state = { + ...initialState, + viewer: { + openElements: { + watermarkPanel: true, + stylePopup: true, + stylePanel: true, + stylePopupTextStyleContainer: true, + stylePopupColorsContainer: true, + stylePopupLabelTextContainer: true, + panel: true, + header: true, + }, + selectedScale: undefined, + colorMap: { + textField: { + currentStyleTab: 'StrokeColor', + iconColor: 'StrokeColor', + } + }, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + customElementOverrides: {}, + panelWidths: { panel: 264 }, + colors: [ + '#fdac0f', '#fa9933', '#f34747', '#21905b', '#c531a4', + '#e5631a', '#3e5ece', '#dc9814', '#c27727', '#b11c1c', + '#13558c', '#76287b', '#347842', '#318f29', '#ffffff', + '#cdcdcd', '#9c9c9c', '#696969', '#272727', '#000000' + ], + textColors: initialTextColors, + toolColorOverrides: {}, + disabledElements: { + logoBar: { disabled: true }, + }, + sortStrategy: 'position', + isInDesktopOnlyMode: true, + modularHeaders: {} + } +}; + +const noop = () => {}; + +const store = configureStore({ + reducer: () => state +}); + +const BasicComponent = (props) => { + return ( + + +
+ +
+
+
+ ); +}; + +export const Basic = BasicComponent.bind({ + annotation: '', + editor: {}, + style: {}, + isFreeTextAutoSize: false, + onFreeTextSizeToggle: () => {}, + onPropertyChange: () => {}, + onRichTextStyleChange: () => {}, +}); +Basic.args = { + currentStyleTab: 'StrokeColor', + isInFormBuilderAndNotFreeText: true, + style: { + 'FillColor': new window.Core.Annotations.Color(212, 211, 211), + 'StrokeColor': new window.Core.Annotations.Color(0, 0, 0), + 'TextColor': new window.Core.Annotations.Color(0, 0, 0), + 'Opacity': null, + 'StrokeThickness': 1, + 'FontSize': '12pt', + 'Style': 'solid' + }, + colorMapKey: 'textField', + colorPalette: 'StrokeColor', + disableSeparator: true, + hideSnapModeCheckbox: true, + isFreeText: false, + isEllipse: false, + isTextStyleContainerActive: true, + isLabelTextContainerActive: true, + properties: { + 'StrokeStyle': 'solid', + }, + isRedaction: false, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + onSliderChange: noop, + onStyleChange: noop, + closeElement: noop, + openElement: noop, + onPropertyChange: noop, + onRichTextStyleChange: noop, + onLineStyleChange: noop, +}; diff --git a/src/components/RichTextStyleEditor/index.js b/src/components/RichTextStyleEditor/index.js new file mode 100644 index 0000000000..54d5259661 --- /dev/null +++ b/src/components/RichTextStyleEditor/index.js @@ -0,0 +1,3 @@ +import RichTextStyleEditor from './RichTextStyleEditor'; + +export default RichTextStyleEditor; \ No newline at end of file diff --git a/src/components/RightPanel/RightPanel.js b/src/components/RightPanel/RightPanel.js index bafca271cf..5b965d5b61 100644 --- a/src/components/RightPanel/RightPanel.js +++ b/src/components/RightPanel/RightPanel.js @@ -50,7 +50,10 @@ const RightPanel = ({ children, dataElement, onResize }) => { const legacyToolsHeaderOpen = isToolsHeaderOpen && currentToolbarGroup !== 'toolbarGroup-View'; const legacyAllHeadersHidden = !isHeaderOpen && !legacyToolsHeaderOpen; const { customizableUI } = featureFlags; - const style = {}; + const style = { + // prevent panel from appearing until scss is loaded + display: 'none', + }; // Calculating its height according to the existing horizontal modular headers if (customizableUI) { const horizontalHeadersHeight = topHeadersHeight + bottomHeadersHeight; diff --git a/src/components/ScaleModal/ScaleCustom.js b/src/components/ScaleModal/ScaleCustom.js new file mode 100644 index 0000000000..ab1198e133 --- /dev/null +++ b/src/components/ScaleModal/ScaleCustom.js @@ -0,0 +1,318 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector, shallowEqual } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import selectors from 'selectors'; +import Tooltip from '../Tooltip'; +import Dropdown from '../Dropdown'; +import { + ifFractionalPrecision, + hintValues, + convertUnit, + fractionalUnits, + floatRegex, + inFractionalRegex, + ftInFractionalRegex, + ftInDecimalRegex, + parseFtInDecimal, + parseInFractional, + parseFtInFractional +} from 'constants/measurementScale'; +import classNames from 'classnames'; + +const Scale = window.Core.Scale; + +const ScaleCustomProps = { + scale: PropTypes.array, + onScaleChange: PropTypes.func, + precision: PropTypes.number +}; + +function ScaleCustom({ scale, onScaleChange, precision }) { + const [measurementUnits] = useSelector((state) => [selectors.getMeasurementUnits(state)], shallowEqual); + const [pageValueDisplay, setPageValueDisplay] = useState(''); + const [worldValueDisplay, setWorldValueDisplay] = useState(''); + const [isFractionalPrecision, setIsFractionalPrecision] = useState(false); + const [pageWarningMessage, setPageWarningMessage] = useState(''); + const [worldWarningMessage, setWorldWarningMessage] = useState(''); + const [scaleValueBlurFlag, setScaleValueBlurFlag] = useState(false); + + const pageValueInput = useRef(null); + const worldValueInput = useRef(null); + + const [t] = useTranslation(); + + const filterFractionalUnits = (units) => units.filter((unit) => fractionalUnits.includes(unit)); + const unitFromOptions = isFractionalPrecision ? filterFractionalUnits(measurementUnits.from) : measurementUnits.from; + const unitToOptions = isFractionalPrecision ? filterFractionalUnits(measurementUnits.to) : measurementUnits.to; + + // If our scale has a unit that is not in the current 'from' measurement units, change it + // to the first unit in the list. + useEffect(() => { + if (!unitFromOptions.includes(scale[0][1])) { + onScaleUnitChange(unitFromOptions[0], true); + } + }, [scale[0][1]]); + + // If our scale has a unit that is not in the current 'to' measurement units, change it + // to the first unit in the list. We want to wait until the 'from' unit is valid before + // setting the 'to' unit. Otherwise, we will reset the 'from' unit. + useEffect(() => { + if (unitFromOptions.includes(scale[0][1]) && !unitToOptions.includes(scale[1][1])) { + onScaleUnitChange(unitToOptions[0], false); + } + }, [scale[0][1], scale[1][1]]); + + useEffect(() => { + const formatDecimal = (value) => { + return value?.toFixed((1 / precision).toString().length - 1); + }; + + if (scale[0][0] && pageValueInput?.current !== document.activeElement) { + if (!isFractionalPrecision) { + setPageValueDisplay(formatDecimal(scale[0][0]) || ''); + } else { + setPageValueDisplay(Scale.getFormattedValue(scale[0][0], scale[0][1], precision, false, true) || ''); + } + } + if (scale[1][0] && !(worldValueInput && worldValueInput.current === document.activeElement)) { + if (!isFractionalPrecision && scale[1][1] !== 'ft-in') { + setWorldValueDisplay(formatDecimal(scale[1][0]) || ''); + } else { + setWorldValueDisplay(Scale.getFormattedValue(scale[1][0], scale[1][1], precision, false, true) || ''); + } + } + }, [scale, precision, worldValueInput, pageValueInput, isFractionalPrecision, scaleValueBlurFlag]); + + useEffect(() => { + setIsFractionalPrecision(ifFractionalPrecision(precision)); + }, [precision]); + + useEffect(() => { + if (isFractionalPrecision) { + setPageWarningMessage(hintValues[scale[0][1]]); + setWorldWarningMessage(hintValues[scale[1][1]]); + } else if (scale[1][1] === 'ft-in') { + setPageWarningMessage(''); + setWorldWarningMessage(hintValues['ft-in decimal']); + } else { + setPageWarningMessage(''); + setWorldWarningMessage(''); + } + }, [scale, isFractionalPrecision]); + + // Re-validate invalid world value input when world unit changes + useEffect(() => { + !isWorldValueValid && onInputValueChange(worldValueInput.current.value, false); + }, [scale[1][1]]); + + // Re-validate invalid scale value input when isFractionalPrecision value changes + useEffect(() => { + if (!isPageValueValid && !isWorldValueValid) { + let pageScale = { + value: scale[0][0], + unit: scale[0][1] + }; + onInputValueChange(pageValueInput.current.value, true, (newScale) => { + pageScale = newScale.pageScale; + }); + let worldScale = { + value: scale[1][0], + unit: scale[1][1] + }; + onInputValueChange(worldValueInput.current.value, false, (newScale) => { + worldScale = newScale.worldScale; + }); + + _onScaleChange(new Scale({ pageScale, worldScale })); + } else { + !isPageValueValid && onInputValueChange(pageValueInput.current.value, true); + !isWorldValueValid && onInputValueChange(worldValueInput.current.value, false); + } + }, [isFractionalPrecision]); + + const isPageValueValid = !!scale[0][0]; + const isWorldValueValid = !!scale[1][0]; + + const pageValueClass = classNames('scale-input', { + 'invalid-value': !isPageValueValid + }); + const worldValueClass = classNames('scale-input', { + 'invalid-value': !isWorldValueValid + }); + + // If scale value is smaller than the current precision, replace it with precision value to prevent 0 value. + const _onScaleChange = (newScale) => { + const getPrecision = (unit) => (unit === 'ft-in' ? precision / 12 : precision); + + if (newScale.pageScale.value && newScale.pageScale.value < precision) { + newScale.pageScale.value = getPrecision(newScale.pageScale.unit); + } + if (newScale.worldScale.value && newScale.worldScale.value < precision) { + newScale.worldScale.value = getPrecision(newScale.worldScale.unit); + } + onScaleChange(newScale); + }; + + const onInputValueChange = (value, isPageValue, getNewScale) => { + const updateScaleValue = (scaleValue) => { + if ((isPageValue && scaleValue !== scale[0][0]) || (!isPageValue && scaleValue !== scale[1][0])) { + const newScale = new Scale({ + pageScale: { value: isPageValue ? scaleValue : scale[0][0], unit: scale[0][1] }, + worldScale: { value: !isPageValue ? scaleValue : scale[1][0], unit: scale[1][1] } + }); + if (getNewScale) { + getNewScale(newScale); + } else { + _onScaleChange(newScale); + } + } + }; + + if (isPageValue) { + setPageValueDisplay(value); + } else { + setWorldValueDisplay(value); + } + const inputValue = value.trim(); + if (!isFractionalPrecision) { + if (!isPageValue && scale[1][1] === 'ft-in') { + if (ftInDecimalRegex.test(inputValue)) { + const result = parseFtInDecimal(inputValue); + if (result > 0) { + updateScaleValue(result); + return; + } + } + } else if (floatRegex.test(inputValue)) { + const scaleValue = parseFloat(inputValue) || 0; + updateScaleValue(scaleValue); + return; + } + } else { + const scaleUnit = isPageValue ? scale[0][1] : scale[1][1]; + if (scaleUnit === 'in') { + if (inFractionalRegex.test(inputValue)) { + const result = parseInFractional(inputValue); + if (result > 0) { + updateScaleValue(result); + return; + } + } + } else if (scaleUnit === 'ft-in') { + if (ftInFractionalRegex.test(inputValue)) { + const result = parseFtInFractional(inputValue); + if (result > 0) { + updateScaleValue(result); + return; + } + } + } + } + updateScaleValue(undefined); + }; + + const onScaleUnitChange = (newUnit, isPageUnit) => { + let newPageScale; + if (isPageUnit && newUnit !== scale[0][1]) { + newPageScale = { + value: scale[0][0] ? convertUnit(scale[0][0], scale[0][1], newUnit) : scale[0][0], + unit: newUnit + }; + } else { + newPageScale = { value: scale[0][0], unit: scale[0][1] }; + } + let newWorldScale; + if (!isPageUnit && newUnit !== scale[1][1]) { + newWorldScale = { + value: scale[1][0] ? convertUnit(scale[1][0], scale[1][1], newUnit) : scale[1][0], + unit: newUnit + }; + } else { + newWorldScale = { value: scale[1][0], unit: scale[1][1] }; + } + + _onScaleChange(new Scale({ pageScale: newPageScale, worldScale: newWorldScale })); + }; + + const getInputPlaceholder = (isPageValue) => { + const unit = isPageValue ? scale[0][1] : scale[1][1]; + return isFractionalPrecision ? hintValues[unit] : (unit === 'ft-in' ? hintValues['ft-in decimal'] : ''); + }; + + const onInputBlur = () => { + setScaleValueBlurFlag((flag) => !flag); + }; + + return ( +
+
+
+
+ onInputValueChange(e.target.value, true)} + placeholder={getInputPlaceholder(true)} + ref={pageValueInput} + onBlur={onInputBlur} + /> + +
+ onScaleUnitChange(value, true)} + currentSelectionKey={scale[0][1]} + /> +
+
+
+ {' = '} +
+ onInputValueChange(e.target.value, false)} + placeholder={getInputPlaceholder(false)} + ref={worldValueInput} + onBlur={onInputBlur} + /> + +
+ onScaleUnitChange(value, false)} + currentSelectionKey={scale[1][1]} + /> +
+
+
+
+
+
+ {!isPageValueValid && ( +
+ {`${t('option.measurement.scaleModal.incorrectSyntax')} ${pageWarningMessage}`} +
+ )} + {!isWorldValueValid && ( +
+ {`${t('option.measurement.scaleModal.incorrectSyntax')} ${worldWarningMessage}`} +
+ )} +
+
+ ); +} + +ScaleCustom.propTypes = ScaleCustomProps; + +export default ScaleCustom; diff --git a/src/components/ScaleModal/index.js b/src/components/ScaleModal/index.js new file mode 100644 index 0000000000..73f40ff610 --- /dev/null +++ b/src/components/ScaleModal/index.js @@ -0,0 +1,3 @@ +import ScaleModal from './ScaleModal'; + +export default ScaleModal; \ No newline at end of file diff --git a/src/components/ScaleOverlay/MeasurementDetail.spec.js b/src/components/ScaleOverlay/MeasurementDetail.spec.js new file mode 100644 index 0000000000..1aaee4fa38 --- /dev/null +++ b/src/components/ScaleOverlay/MeasurementDetail.spec.js @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { EllipseScaleOverlay } from './MeasurementDetail.stories'; + +const noop = () => { }; + +jest.mock('core', () => ({ + addEventListener: noop, + removeEventListener: noop, + jumpToAnnotation: noop, + getDocumentViewer: () => ({ + getAnnotationManager: () => ({ + deselectAllAnnotations: noop, + selectAnnotation: noop, + }) + }), + getAnnotationManager: () => ({ + selectAnnotation: noop, + redrawAnnotation: noop, + trigger: noop + }), + getTool: () => ({ + finish: noop + }) +})); + + +describe('MeasurementDetail', () => { + it('renders the EllipseScaleOverlay storybook component', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('increases the area when the radius increases', () => { + render(); + + const radiusInput = screen.getByDisplayValue('1.74'); + const areaTextElement = screen.getByText('10.00 sq in'); + + const beforeText = areaTextElement.textContent; + fireEvent.change(radiusInput, { target: { value: '2' } }); + const afterText = areaTextElement.textContent; + const beforeValue = parseFloat(beforeText.substring(0, beforeText.indexOf(' '))); + const afterValue = parseFloat(afterText.substring(0, afterText.indexOf(' '))); + + expect(beforeValue).toBeLessThan(afterValue); + }); + + it('sets area to zero when radius is zero', () => { + render(); + + const radiusInput = screen.getByDisplayValue('1.74'); + const areaTextElement = screen.getByText('10.00 sq in'); + + fireEvent.change(radiusInput, { target: { value: '0' } }); + const afterText = areaTextElement.textContent; + const afterValue = parseFloat(afterText.substring(0, afterText.indexOf(' '))); + + expect(afterValue).toEqual(0); + }); +}); \ No newline at end of file diff --git a/src/components/ScaleOverlay/ScaleHeader.js b/src/components/ScaleOverlay/ScaleHeader.js new file mode 100644 index 0000000000..aaffcb4eed --- /dev/null +++ b/src/components/ScaleOverlay/ScaleHeader.js @@ -0,0 +1,36 @@ +import React from 'react'; +import Icon from 'components/Icon'; +import ScaleSelector from './ScaleSelector'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; + +const propTypes = { + scales: PropTypes.arrayOf(PropTypes.object).isRequired, + selectedScales: PropTypes.arrayOf(PropTypes.string).isRequired, + onScaleSelected: PropTypes.func.isRequired, + onAddingNewScale: PropTypes.func.isRequired, +}; + +const ScaleHeader = ({ scales, selectedScales, onScaleSelected, onAddingNewScale }) => { + const [t] = useTranslation(); + + return ( +
+ +
{t('option.measurementOption.scale')}
+ {scales.length ? ( + + ) : ( + + )} +
+ ); +}; + +ScaleHeader.propTypes = propTypes; +export default ScaleHeader; diff --git a/src/components/SearchPanel/SearchPanel.js b/src/components/SearchPanel/SearchPanel.js index a9f73d2d66..94c7b0a094 100644 --- a/src/components/SearchPanel/SearchPanel.js +++ b/src/components/SearchPanel/SearchPanel.js @@ -20,7 +20,8 @@ const propTypes = { setActiveResult: PropTypes.func, isInDesktopOnlyMode: PropTypes.bool, isProcessingSearchResults: PropTypes.bool, - activeDocumentViewerKey: PropTypes.number + activeDocumentViewerKey: PropTypes.number, + isCustomPanel: PropTypes.bool, }; function noop() { } @@ -36,7 +37,9 @@ function SearchPanel(props) { isMobile = false, isInDesktopOnlyMode, isProcessingSearchResults, - activeDocumentViewerKey + activeDocumentViewerKey, + dataElement = 'searchPanel', + isCustomPanel = false, } = props; const { t } = useTranslation(); @@ -76,12 +79,15 @@ function SearchPanel(props) { }, []); const className = getClassName('Panel SearchPanel', { isOpen }); - const style = !isInDesktopOnlyMode && isMobile ? {} : { width: `${currentWidth}px`, minWidth: `${currentWidth}px` }; + let style = {}; + if (!isCustomPanel && (isInDesktopOnlyMode || !isMobile)) { + style = { width: `${currentWidth}px`, minWidth: `${currentWidth}px` }; + } return ( {!isInDesktopOnlyMode && isMobile && diff --git a/src/components/SearchPanel/SearchPanel.stories.js b/src/components/SearchPanel/SearchPanel.stories.js new file mode 100644 index 0000000000..c5c9dcece5 --- /dev/null +++ b/src/components/SearchPanel/SearchPanel.stories.js @@ -0,0 +1,46 @@ +import React from 'react'; +import SearchPanel from './SearchPanelContainer'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import Panel from 'components/Panel'; + +export default { + title: 'ModularComponents/SearchPanel', + component: SearchPanel +}; + +const initialState = { + viewer: { + openElements: { + panel: true, + }, + disabledElements: {}, + customElementOverrides: {}, + tab: {}, + panelWidths: { panel: 300 }, + modularHeaders: {}, + }, + search: {}, +}; + +const store = configureStore({ reducer: () => initialState }); + +export function SearchPanelLeft() { + return ( + + + + + + ); +} + +export function SearchPanelRight() { + return ( + initialState })}> + + + + + ); +} diff --git a/src/components/Selector/Selector.js b/src/components/Selector/Selector.js new file mode 100644 index 0000000000..323102e77d --- /dev/null +++ b/src/components/Selector/Selector.js @@ -0,0 +1,51 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'components/Icon'; +import './Selector.scss'; + +const propTypes = { + className: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.string).isRequired, + selectedItem: PropTypes.string, + onItemSelected: PropTypes.func.isRequired, + placeHolder: PropTypes.string, + selectedItemStyle: PropTypes.object, +}; + +const Selector = ({ className, items = [], selectedItem = '', onItemSelected, placeHolder, selectedItemStyle }) => { + return ( +
+ +
    +
  • +
    {!selectedItem && placeHolder ? placeHolder : selectedItem}
    + +
  • + {items.map((value, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +Selector.propTypes = propTypes; + +export default Selector; diff --git a/src/components/Selector/Selector.scss b/src/components/Selector/Selector.scss new file mode 100644 index 0000000000..157cc5fa68 --- /dev/null +++ b/src/components/Selector/Selector.scss @@ -0,0 +1,93 @@ +.customSelector { + position: relative; + .customSelector__selectedItem { + height: 2rem; + width: 8.5rem; + position: relative; + background-color: transparent; + border: solid 1px; + border-color: var(--border); + padding: 0 4px 0 8px; + color: var(--text-color); + font-family: Lato; + font-style: normal; + font-weight: normal; + font-size: 0.8rem; + text-align: left; + border-radius: 0.3rem; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + & ul { + margin: 0; + list-style-type: none; + position: absolute; + width: 10rem; + left: 0; + top: 0; + text-align: left; + letter-spacing: 0px; + display: none; + border-radius: 4px; + pointer-events: all; + z-index: 1000; + background-color: var(--component-background); + box-shadow: 0 0 3px 0 var(--document-box-shadow); + padding-left: 0px; + } + + & li { + display: block; + height: 2rem; + position: relative; + font-family: Lato; + font-style: normal; + font-weight: normal; + font-size: 0.8rem; + padding-left: 0.5rem; + :hover { + cursor: pointer; + } + } + + & li:first-child { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0 4px 0 8px; + } + + & li:not(:first-child) .options:hover { + background-color: var(--blue-1); + } + + & li .optionSelected { + color: var(--blue-5); + } + + & li .options { + border: none; + background-color: transparent; + padding-right: 0.65rem; + padding-left: 0.5rem; + width: calc(100% + 0.5rem); + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + margin-left: -0.5rem; + z-index: 1; + } + + .customSelector__selectedItem:focus + ul { + display: block; + pointer-events: all; + transform: translateY(0px); + } + +} diff --git a/src/components/Selector/Selector.spec.js b/src/components/Selector/Selector.spec.js new file mode 100644 index 0000000000..9f4f11044b --- /dev/null +++ b/src/components/Selector/Selector.spec.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Basic as BasicStory, Placeholder as PlaceholderStory } from './Selector.stories'; + +const BasicSelectStory = withI18n(BasicStory); + +describe('Selector', () => { + it('Basic story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); +}); + +const PlaceHolderSelectStory = withI18n(PlaceholderStory); + +describe('Placeholder version', () => { + it('Should have placeholder text as default item', () => { + const { container } = render(); + + const selectedItem = container.querySelector('.customSelector__selectedItem'); + expect(selectedItem).toHaveTextContent('PLACEHOLDER'); + }); +}); \ No newline at end of file diff --git a/src/components/Selector/Selector.stories.js b/src/components/Selector/Selector.stories.js new file mode 100644 index 0000000000..4d72f16456 --- /dev/null +++ b/src/components/Selector/Selector.stories.js @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import Selector from './Selector'; + +export default { + title: 'Components/Selector', + component: Selector, +}; + +export function Basic() { + const items = ['ITEM 1', 'ITEM 2']; + const [selectedItem, setSelectedItem] = useState(items[0]); + const placeHolder = 'PLACEHOLDER'; + const onItemSelected = (item) => { + setSelectedItem(item); + }; + + return ( +
+ +
+ ); +} + +export function Placeholder() { + const items = ['ITEM 1', 'ITEM 2']; + const [selectedItem, setSelectedItem] = useState(); + const placeHolder = 'PLACEHOLDER'; + const onItemSelected = (item) => { + setSelectedItem(item); + }; + + return ( +
+ +
+ ); +} diff --git a/src/components/Selector/index.js b/src/components/Selector/index.js new file mode 100644 index 0000000000..837acf7044 --- /dev/null +++ b/src/components/Selector/index.js @@ -0,0 +1,3 @@ +import Selector from './Selector'; + +export default Selector; \ No newline at end of file diff --git a/src/components/SettingsModal/AdvancedTab.scss b/src/components/SettingsModal/AdvancedTab.scss new file mode 100644 index 0000000000..51ec77df99 --- /dev/null +++ b/src/components/SettingsModal/AdvancedTab.scss @@ -0,0 +1,22 @@ +.setting-item { + border: 1px var(--border) solid; + padding: 16px; + display: flex; + align-items: flex-start; + justify-content: space-between; + + &:not(:last-child) { + border-bottom: 0px + } + + .setting-item-info { + display: flex; + flex-direction: column; + margin-right: 18px; + + .setting-item-label { + font-weight: 700; + margin-bottom: 10px; + } + } +} diff --git a/src/components/SettingsModal/GeneralTab.js b/src/components/SettingsModal/GeneralTab.js new file mode 100644 index 0000000000..a69b139c49 --- /dev/null +++ b/src/components/SettingsModal/GeneralTab.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { useSelector, useDispatch, useStore } from 'react-redux'; +import selectors from 'selectors'; +import actions from 'actions'; +import { useTranslation } from 'react-i18next'; +import { isIE } from 'helpers/device'; +import Languages from 'constants/languages'; +import Theme from 'constants/theme'; +import DataElements from 'constants/dataElement'; +import setLanguage from '../../apis/setLanguage'; +import Dropdown from 'components/Dropdown'; +import Icon from 'components/Icon'; +import Choice from 'components/Choice'; +import DataElementWrapper from 'components/DataElementWrapper'; +import { SearchWrapper } from './SearchWrapper'; + +import './GeneralTab.scss'; + +const GeneralTab = () => { + const [ + currentLanguage, + activeTheme + ] = useSelector((state) => [ + selectors.getCurrentLanguage(state), + selectors.getActiveTheme(state) + ]); + const [t] = useTranslation(); + const dispatch = useDispatch(); + const store = useStore(); + + const changeLanguage = (value) => { + if (value !== currentLanguage) { + setLanguage(store)(value); + } + }; + + const isLightMode = activeTheme === Theme.LIGHT; + + const setTheme = (theme) => { + dispatch(actions.setActiveTheme(theme)); + }; + + return ( + <> + + +
{t('option.settings.language')}
+ item[0]} + getDisplayValue={(item) => item[1]} + onClickItem={changeLanguage} + maxHeight={200} + width={336} + getCustomItemStyle={() => ({ textAlign: 'left', width: '326px' })} + className="language-dropdown" + /> +
+
+ + {!isIE && ( + +
{t('option.settings.theme')}
+
+
+ +
+ setTheme(Theme.LIGHT)} + label={t('option.settings.lightMode')} + name="theme_choice" + /> +
+
+
+ +
+ setTheme(Theme.DARK)} + label={t('option.settings.darkMode')} + name="theme_choice" + /> +
+
+
+
+ )} +
+ + ); +}; + +export default GeneralTab; diff --git a/src/components/SettingsModal/GeneralTab.scss b/src/components/SettingsModal/GeneralTab.scss new file mode 100644 index 0000000000..9d85ad9fe0 --- /dev/null +++ b/src/components/SettingsModal/GeneralTab.scss @@ -0,0 +1,61 @@ +.language-dropdown { + .Dropdown__items { + left: 0; + width: 336px; + } +} + +.theme-options { + width: 336px; + height: 160px; + display: flex; + justify-content: space-between; + + .theme-option { + width: 160px; + height: 160px; + display: flex; + flex-direction: column; + + .Icon { + width: 160px; + height: 120px; + + &.light-mode-icon { + color: white; + } + + &.dark-mode-icon { + color: black; + } + + svg { + border: 1px solid; + border-color: var(--border); + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + } + + .theme-choice { + height: 100%; + border: 1px solid; + border-color: var(--border); + border-top: 0px; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + display: flex; + padding-left: 12px; + } + + &.active-theme { + .Icon svg { + border-color: var(--focus-border); + } + + .theme-choice { + border-color: var(--focus-border); + } + } + } +} diff --git a/src/components/SettingsModal/SearchWrapper.js b/src/components/SettingsModal/SearchWrapper.js new file mode 100644 index 0000000000..a50fb0179d --- /dev/null +++ b/src/components/SettingsModal/SearchWrapper.js @@ -0,0 +1,13 @@ +import React, { createContext, useContext } from 'react'; + +export const SearchContext = createContext(); + +export const SearchWrapper = ({ children, keywords = [] }) => { + const searchTerm = useContext(SearchContext).trim(); + + return (!searchTerm || keywords.some((keyword) => keyword.toLowerCase().includes(searchTerm.toLowerCase()))) ? ( + <> + {children} + + ) : null; +}; diff --git a/src/components/SettingsModal/index.js b/src/components/SettingsModal/index.js new file mode 100644 index 0000000000..09a83d8fe7 --- /dev/null +++ b/src/components/SettingsModal/index.js @@ -0,0 +1,3 @@ +import SettingsModal from './SettingsModal'; + +export default SettingsModal; \ No newline at end of file diff --git a/src/components/SignatureListPanel/index.js b/src/components/SignatureListPanel/index.js new file mode 100644 index 0000000000..bdc6bf76e5 --- /dev/null +++ b/src/components/SignatureListPanel/index.js @@ -0,0 +1,3 @@ +import SignatureListPanel from './SignatureListPanel'; + +export default SignatureListPanel; \ No newline at end of file diff --git a/src/components/SignatureModal/SavedSignatures/index.js b/src/components/SignatureModal/SavedSignatures/index.js new file mode 100644 index 0000000000..91ec23ef95 --- /dev/null +++ b/src/components/SignatureModal/SavedSignatures/index.js @@ -0,0 +1,3 @@ +import SavedSignatures from './SavedSignatures'; + +export default SavedSignatures; \ No newline at end of file diff --git a/src/components/SignatureModal/SignatureModal.js b/src/components/SignatureModal/SignatureModal.js index fb12f41490..1978ca1388 100644 --- a/src/components/SignatureModal/SignatureModal.js +++ b/src/components/SignatureModal/SignatureModal.js @@ -82,6 +82,7 @@ const SignatureModal = () => { for (const signatureTool of signatureToolArray) { signatureTool.clearLocation(); signatureTool.setSignature(null); + signatureTool.setInitials(null); } dispatch(actions.closeElement(DataElements.SIGNATURE_MODAL)); }; diff --git a/src/components/SignatureStylePopup/SignatureStylePopup.stories.js b/src/components/SignatureStylePopup/SignatureStylePopup.stories.js index be4bcc81af..617444ca02 100644 --- a/src/components/SignatureStylePopup/SignatureStylePopup.stories.js +++ b/src/components/SignatureStylePopup/SignatureStylePopup.stories.js @@ -4,30 +4,13 @@ import SelectedSignatureRow from './SelectedSignatureRow'; import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import SignatureModes from 'constants/signatureModes'; +import { mockSavedSignatures, mockSavedInitials } from './mockedSignatures'; export default { title: 'Components/SavedSignaturesOverlay', component: SignatureStylePopup, }; -const mockSavedSignatures = [ - { - imgSrc: 'data:image/png;base64,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' - }, - { - imgSrc: 'data:image/png;base64,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' - } -]; - -const mockSavedInitials = [ - { - imgSrc: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABsCAYAAAAv1f1mAAAAAXNSR0IArs4c6QAADThJREFUeF7tXU1eG7kSL3UznuUkm5isXuYEAycYcoA4cIIHJ4jZ4xknZh84QeAEMeYAJCcYcoLHrCbOJmY5/OLW+6m7TRzTUklqqT+s8hLUaqlU/67vEgP6EQWIAlIKMKINUYAoIKcAAYS4gyigoAABhNiDKEAAIR4gCthRgCSIHd3oqUAoQAAJ5KBpm98p0Bt8eQvA94HDDQd4fXncHcvoQwAhzgmKAi8H03ccYH9505zDngwkBJCg2CPsze4e/bOTsOiqgAqzyaj7uIg6BJCweSaY3e8Ovz5K5nd/AcCz1U1zgL8vR90HfxfjCCDBsEi4G03B8e3uChhsFVKBwevJm+6QJEi4PBLszjFwCOkRx52t8fDxjAASLJuEuXEMHIIqKgOdVKww+SaIXe8Ov2wl3/g7qVqVcb9UtVoQiWyQINglrE2m4Jhz4a16JN05h/PJcfcHdy+pWB74RBzGfM5+EVPHMb8dD59ce3gNTalJgdxb9T8X4CAVS5Poq8Ny3fYVMCj0fHDgh5ejzRPL6emxEhTo/fH5BDh7JZ1CQ61afpZULMPD0NJtASCK2TZJE0Pilhyeq1Yi1lH448AOLkdPzkxeQwAxoJaWbpvPh3lHDF5LQzUp8GLw+YoB2ykabgMOUrE0CS+GmYBDjI948nx8/PSDwStoaAkKKNJIABg/nbzZ7NtMTxJEg2qm4ACA2yjuPJMFnzReSUMMKaCQHqXOggCCHIQeOPjH79OwWRQn/fHw6Y3hGdNwSwqopEdZhwkBRHEoLwZf9hnwd6pzs9VtLXmBHiugQO9o+pckIFhKepANImE34cbl8ztRN7BL4Gg2Jl8MPvcZsLeFqzR06RbNQRJkhSq5uH6vDDSJHB4Ll2GzWa19q1MFBbEkRN3dEkCWKNU7mv4pC/4tDbuNeLJLHipdFvM3ThUUdPUBI4CkLtyvj+bzf9/LfOg/gCNmOxQA9Mf0ujOrg4L842S0WRgP0Z1/MS54gOikROfE+hTFbJ/AYcpifsargoIuY1DBA6SoiP/BkTow9vywSZizKg1zzSxdXcoFDRAMHKmhx5N9sjd02cn/OCRbt7Rbd3UHwQJEI8ZxEcWdfYqG+2d6kzf0jqZnwOC/Rc+UDQqSmzengDJvR4xxLKZNGKDqscLYBZjP2hD5V5+bO8N8+QyCkyAaBTUXk1FXGSCsmol9vO/lYPp+JRA6YwBjFncOmyo1FRFzb+UFwQFERWQA+BTFnZ2mMogroCiNXIAZcDiJNpLzJkkVdcTcPlsXo2lQAEGM8tsoTraaxBTY4dn+X6XHr6gXY87YOIp+uqjzo7E7/OdZMo9EIVRRjblzwzxIFQvzWLn0ndsyblXPoWWpxRbwNQA/izb4RdUfkQJ18H6FriLmMtoHIUEwcPjwflTF7DbvQZ0U2KQcRGOKcbTBLnwHTuswzIOSIBg4QvJYLR985uZO8rY3TLTkTDuzWPxuANg4iuHcB1h6g6noUFLYNzeKk199SzOlBMla2iRvGWePhHidHG+eWhCwtkdQVYLDebTR6depX9dGnKUXpzr+t2goiy8YrNEpWHp/TIfA4c/C91eU3SAFSFHad+4GPGgDQ6FqRECxDl0GT4GSRPucwz4D+I/uc4XjUjVM2Cw/n9vwi8owd5XKrrM/KUCkyWAcrqMNduBDnOosWGeMqtV99ryfoJLO2toyJs2WTZJ9ztluWbAwgDMWs1MTnlF2KFFceOOavlKA9AZTrnjZjAM7NO0x5HrxsvkQN2YQsQ6XtHYFFg78A2PR2eTNk3PV+l4cTXcZA1G0VvCr9uMmB4gi52Wx6uzL0KzIq7qAX93q3iVTretcQvWZz4VUYcLA/81ynzfA2LAIKIj0rzxWJbdBsht5RF8nNREapnIp0xGoV5UlPxc/5gAsN8DhLNronC7slCYY5su7ReMgmlHXGXA+rNvLVVc6glOua+lkC7AAsL6FzZKnt7CLZM4LW4eqrknzSTIUIOLleWq4aMas9JXX6eWqMx3B5wG1ce68f3Hfgdv4fvt1ZTpoAUSsMq8BFo1/Mb3zJorZnonHwgUT1JmO4GL96zhHak8kd/3SbuNUjU/2fAcFi85AGyAZSNLLEE+0vgyc96tSudTZqdV6PdaR0V3sKddCRH9c7AMrfV2qofDktMoKTyOALFbeJJVL7RIU1xD4T0dwwUChzJF5GdkQgP1uvWchUSA5rAIoVgDJpEmagjzW+CJ4U7nQvrkVpSNYH3TAD7oASmbzJoc+VS9rgCzOFs13Wgx0rHLhlYGkWrUBf9r8I9+MVw9qaYCkXq4s8ikM+Mq8XEhlYOUBpTYwY9PWiH/k9FfsK2jtBCBVq1x4ZSB1P9RnrfpGqpwrohAKOJ+xiO9o54Ol3q7Oc5vkSBkVnAGkKpULqaemptL18bvxm+W1Hg/VY+EYAuBDjSDkLIrZc1dhBucA8aly5Y4BUUBT+AutMtCYIxv0gKovmSwouIirAAfhLlaq865Kcb0AxEjlMsjlUqa9UH1Hg9hfvRS17YE7V/Q9qOxkMnpyWIYw3gBiqHKh6fNIARSlsJfhgoqfVSUk6qaU6Aaty6Y/eQeIicoFIEe8sps3BQMrZnH716nVZFx6rL4Zs0nT8QZayur8lQDEROUSRTVx/PPesifC9zVb9sdNT5pSwMe1BZphhlnEkz3T6HtlALlXuTQKsQDgPvqORMu9Ng0zPXwaj9geaZpJdCXxsJxPjrt5lxVzSuom05oa75UDJFW5sttjsfT5GTDWB56OK+qoB0CpJOacVNMTVVQKZu/4d4zleZmApBaAZCqXaCnExxp+bdmRfpqMuqKfE/1aQIEqKwU1ivxmk1H3sQ7ZagNIBhI9xBds5DaiuwJ1zrcRY1SGua8WPtj9L5NRV4v3tQb5prJpwpqJiPS9dpofp0BdLXxyu0T0VfgxqMj0u8E3AiAGdklQl9vgrNf8EUi9jve7WDLpxc7u7RLDbpqNAYg4alXZ7DIrkARpPjC+q9B3oglDUW/dSjOus5u0AExztBoDEExnfMgS5dMI2sFm7V1llYa5Lyo1AiBoH13J7sumEfgiKs17X3FaeOlNXS18bM6ldoDgfXSRbdXY8cKG4KE8o3K16uZbNYFWtQMEsTs+cWAnOkFFlzUATTiYNq8BKUvwbpi7pF2tAEHsjnsjTjeoSMa7S9awn0v10Wtbl5naAIJ1JFkVw7pBRQKJPWO7eFJpT7awZqcWgOS5/FfAoDhVRBHI0UgjAFHAfzHqHrg4cJrDjAKKoGArE0trAYgysgr4FQU6LmECiRljuxitlB4tTSytHCCuOpLkhyEa18lrkw1SClwwSOhzrJv0EOdZKUCwnCtT+0Gaa7PEqaZzhs7ktvtfR+lRKUCwHrq21zHncRTlRT+8wjvtbBms7c+to/SoFCCq+64BoJRvXAMkTnsltZ2ZXa9ffe0dP7wcbYqit1b+KlGxkMCRk44keiDp/Oqy614rT9zDomXSo00pJTKyeAdIrlq9KyqbdV0skwPxWmq4e2hN6YHfWjWlWnqwg6behKxLZO8A6Q2mX6U15R4CR7nhXnjPXUoUDteT4+62LoFonJoCirjU7WTULe4l0CKiegUIyqyefONYnIRiJG44VKk6ezpbNyvXn8UbQHRa2/vMy1HWImTuideTN92hPqlo5CoF1tVztbxPbwDpDaYiiPdSwla3HFjft36KpaVQjMQe9EopvUYfHy8AyaWHsD2KfpWWWmIgiWK2bVqGac9W6/Ekojq3MueqMi8WlohYNUNquH9vorizTe5fPfBiqvO6XUHhXIKoEhGhptwoFCQ1rUuPJZszCvv4AcDaNfNzChCs03adpZZY3lada2sOBNQrUX78ACpVnauimTOAYAVQTTCIlflgFB9R8hyShQ3r+oFxApAcHO8l/Y8a1exNZbSvm/7s6iuLxZXWmW6lAZKnGghwSKKm5peiuDrYonlye+RGko5CSY0rRMuDgYXte9KhHrIhfJ6/6dylAIJ9WaCheimybgLJEhch99GXysI2ZdY6xlsDRAMcjb6SuTf4/EF1j0QTbKY6GGL5nYjd4SQLu+49Yu+3AggSCMwlb7MzOdHM31R7aPYesMMt83/EIxnM9RNeANIWxtKQgrMoTrbHw6fCZgnil3044lcAXNxFXvhbZ6N8dcNWABGTSFSU24gnu6YXJdbJeShI1iivCKOzZo/ktbc7lulkDZCH0Wn+MYr5fhu/tkiHFPQOd4zx2vJ/zC4TkfIo7uyElJZjDRBx6AIk8293OzEkszZJDQv3L4TQ+AEDSBR3HocEDsEnpQDSli+j7jqRzN+1Vy3UJQrNimfpnmnZcQSQJQqmEnF+d1188+76M4gsXy3rHZDstFF9JoCUpcDK8zImWddco1XyLdRmFsEW53wWc37ddvW5DIuQBCmgXg6SMwD4Tfy7LW7rMoxAzxZTgACi4Izs4sefbkIzTAks3ylAACFuIAooKEAAIfYgChBAiAeIAnYUIAliRzd6KhAKEEACOWjaph0F/g+hw+/W4d7l1QAAAABJRU5ErkJggg==' - }, - { - imgSrc: 'data:image/png;base64,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' - } -]; - const initialState = { viewer: { isInitialsModeEnabled: true, @@ -76,14 +59,14 @@ const savedInitialsStore = configureStore({ export const SavedInitialsTab = () => (
- +
); export const SelectedSignature = () => ( - + ); @@ -100,6 +83,6 @@ const initialsModeStore = configureStore({ export const SelectedInitials = () => ( - + ); diff --git a/src/components/SignatureStylePopup/mockedSignatures.js b/src/components/SignatureStylePopup/mockedSignatures.js new file mode 100644 index 0000000000..b943b99509 --- /dev/null +++ b/src/components/SignatureStylePopup/mockedSignatures.js @@ -0,0 +1,17 @@ +export const mockSavedSignatures = [ + { + imgSrc: 'data:image/png;base64,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' + }, + { + imgSrc: 'data:image/png;base64,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' + } +]; + +export const mockSavedInitials = [ + { + imgSrc: 'data:image/png;base64,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' + }, + { + imgSrc: 'data:image/png;base64,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' + } +]; diff --git a/src/components/SignatureValidationModal/SignatureValidationModal.scss b/src/components/SignatureValidationModal/SignatureValidationModal.scss index 566593902b..5a6190250a 100644 --- a/src/components/SignatureValidationModal/SignatureValidationModal.scss +++ b/src/components/SignatureValidationModal/SignatureValidationModal.scss @@ -75,7 +75,7 @@ div.body > div.section { margin: 16px 16px; padding-bottom: 16px; - border-bottom: 1px solid var(--gray-4); + border-bottom: 1px solid var(--gray-5); } div.body > div.section:last-child { diff --git a/src/components/SnippingToolPopup/SnippingToolPopup.js b/src/components/SnippingToolPopup/SnippingToolPopup.js new file mode 100644 index 0000000000..1cc68936d1 --- /dev/null +++ b/src/components/SnippingToolPopup/SnippingToolPopup.js @@ -0,0 +1,155 @@ +import React from 'react'; +import classNames from 'classnames'; +import Icon from 'components/Icon'; +import { useTranslation } from 'react-i18next'; +import { Choice } from '@pdftron/webviewer-react-toolkit'; +import Dropdown from 'components/Dropdown'; +import actions from 'actions'; +import { useDispatch } from 'react-redux'; +import DataElements from 'constants/dataElement'; + +import './SnippingToolPopup.scss'; + +const SnippingToolPopup = ({ + snippingMode, + onSnippingModeChange, + closeSnippingPopup, + applySnipping, + isSnipping, + isInDesktopOnlyMode, + isMobile, + shouldShowApplySnippingWarning, +}) => { + const { t } = useTranslation(); + + const snippingNames = { + 'CLIPBOARD': t('snippingPopUp.clipboard'), + 'DOWNLOAD': t('snippingPopUp.download'), + 'CROP_AND_REMOVE': t('snippingPopUp.cropAndRemove'), + }; + + const className = classNames({ + Popup: true, + SnippingToolPopup: true, + mobile: isMobile, + }); + + const handleButtonPressed = (button) => { + switch (button) { + case 'apply': + shouldShowApplySnippingWarning && snippingMode === 'CROP_AND_REMOVE' ? openSnippingConfirmationWarning() : applySnipping(); + break; + case 'cancel': + isSnipping ? openSnippingCancellationWarning() : closeSnippingPopup(); + break; + } + }; + + const dispatch = useDispatch(); + + const openSnippingConfirmationWarning = () => { + const title = t('snippingPopUp.snippingModal.applyTitle'); + const message = t('snippingPopUp.snippingModal.applyMessage'); + const confirmationWarning = { + message, + title, + onConfirm: () => { + applySnipping(); + }, + }; + dispatch(actions.showWarningMessage(confirmationWarning)); + }; + + const openSnippingCancellationWarning = () => { + const title = t('snippingPopUp.snippingModal.cancelTitle'); + const message = t('snippingPopUp.snippingModal.cancelMessage'); + const cancellationWarning = { + message, + title, + onConfirm: () => { + closeSnippingPopup(); + }, + }; + dispatch(actions.showWarningMessage(cancellationWarning)); + }; + + if (isMobile && !isInDesktopOnlyMode) { + return ( +
+
+
+
+ onSnippingModeChange(Object.keys(snippingNames).find((key) => snippingNames[key] === e))} + currentSelectionKey={snippingNames[snippingMode]} + /> +
+ +
+ +
+
+ ); + } + return ( +
+
+ {t('snippingPopUp.title')} + onSnippingModeChange('CLIPBOARD')} + checked={snippingMode === 'CLIPBOARD'} + radio + /> + onSnippingModeChange('DOWNLOAD')} + checked={snippingMode === 'DOWNLOAD'} + radio + /> + onSnippingModeChange('CROP_AND_REMOVE')} + checked={snippingMode === 'CROP_AND_REMOVE'} + radio + /> +
+
+
+ + +
+
+ ); +}; + +export default SnippingToolPopup; diff --git a/src/components/SnippingToolPopup/SnippingToolPopup.scss b/src/components/SnippingToolPopup/SnippingToolPopup.scss new file mode 100644 index 0000000000..7e9acbfaaa --- /dev/null +++ b/src/components/SnippingToolPopup/SnippingToolPopup.scss @@ -0,0 +1,204 @@ +@import '../../constants/styles'; +@import '../../constants/modal'; +@import '../../constants/popup.scss'; + +.SnippingToolPopup { + width: 250px; + + .snipping-section { + padding: 16px; + display: flex; + flex-direction: column; + + .ui__choice { + margin: 0; + } + + .ui__choice:not(:last-of-type) { + padding-bottom: 12px; + } + } + + .menu-title { + padding-bottom: 16px; + font-weight: bold; + } + + .crop-inactive { + color: var(--gray-6); + } + + .Icon { + height: 18px; + width: 18px; + } + + .divider { + border-top: 1px solid var(--divider); + width: 100%; + } + + .buttons { + padding: 12px; + text-align: right; + font-size: 13px; + display: flex; + justify-content: space-between; + } + + .save-button { + color: var(--primary-button-text); + padding: 6px 16px; + background: var(--primary-button); + border-radius: 4px; + border: 0; + height: 32px; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: auto; + } + } + + .cancel-button { + cursor: pointer; + background: none; + border: 0; + color: var(--secondary-button-text); + padding: 6px 16px; + margin-right: 4px; + height: 32px; + &:hover { + color: var(--secondary-button-hover); + } + &:focus { + outline: none; + } + &:disabled { + opacity: 0.5; + cursor: auto; + color: var(--secondary-button-text); + } + } +} + +.custom-select { + flex-grow: 2; + max-width: 100%; + margin: 4px; + + .customSelector { + margin-left: 0; + height: 28px; + width: 100% !important; + + .customSelector__selectedItem { + width: 100%; + } + + ul { + width: 100%; + } + + .customSelector__arrow { + height: 18px; + width: 18px; + } + } + + select { + height: 28px; + width: 100%; + } +} + +.SnippingPopupContainer { + @extend %popup; + border-radius: 4px; + box-shadow: 0 0 3px 0 var(--document-box-shadow); + background: var(--component-background); + top: 0; + + @include mobile { + width: 100%; + position: fixed; + bottom: 0 !important; + border-radius: 0; + justify-content: start; + top: auto; + + .snipping-mobile-section { + padding-top: 8px; + padding-bottom: 8px; + padding-left: 12px; + padding-right: 12px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } + + .SnippingToolPopup { + width: 100%; + } + + .snipping-mobile-container { + display: flex; + align-items: center; + + .customSelector { + width: 100%; + } + + .Dropdown { + height: 32px; + min-width: 150px; + width: 100% !important; + + .arrow { + flex: 0 1 auto; + } + + .picked-option .picked-option-text { + width: 150px; + text-align: left; + } + } + + .Dropdown__items { + top: -52px; + z-index: 80; + width: 100%; + } + + .wrapper { + z-index: 79; + } + + .save-button { + margin-left: 6px; + min-width: 75px; + } + } + .cancel-button { + padding: 0; + + .Icon { + width: 24px; + height: 24px; + } + } + + .snipping-selector { + width: 100%; + display: flex; + } + + @media (max-width: 430px) { + .snipping-selector { + display: block; + } + } + } +} diff --git a/src/components/SnippingToolPopup/SnippingToolPopup.spec.js b/src/components/SnippingToolPopup/SnippingToolPopup.spec.js new file mode 100644 index 0000000000..01edeabec2 --- /dev/null +++ b/src/components/SnippingToolPopup/SnippingToolPopup.spec.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Basic } from './SnippingToolPopup.stories'; + +const BasicSnippingToolPopupStory = withI18n(Basic); + +jest.mock('core'); + +describe('SnippingToolPopup', () => { + describe('Component', () => { + it('Story should not throw any errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + }); +}); diff --git a/src/components/SnippingToolPopup/SnippingToolPopup.stories.js b/src/components/SnippingToolPopup/SnippingToolPopup.stories.js new file mode 100644 index 0000000000..0cf959cec2 --- /dev/null +++ b/src/components/SnippingToolPopup/SnippingToolPopup.stories.js @@ -0,0 +1,42 @@ +import React from 'react'; +import SnippingToolPopup from './SnippingToolPopup'; +import { createStore } from 'redux'; +import { Provider } from 'react-redux'; + +export default { + title: 'Components/SnippingToolPopup', + component: SnippingToolPopup, +}; + +const initialState = { + viewer: { + disabledElements: {}, + customElementOverrides: {}, + }, +}; + +function rootReducer(state = initialState, action) { + return state; +} + +const store = createStore(rootReducer); + +const noop = () => {}; + +const popupProps = { + closeSnippingPopup: noop, + applySnipping: noop, + isSnipping: true, + isInDesktopOnlyMode: false, + isMobile: false, +}; + +export function Basic() { + return ( + +
+ +
+
+ ); +} diff --git a/src/components/SnippingToolPopup/SnippingToolPopupContainer.js b/src/components/SnippingToolPopup/SnippingToolPopupContainer.js new file mode 100644 index 0000000000..56295559a0 --- /dev/null +++ b/src/components/SnippingToolPopup/SnippingToolPopupContainer.js @@ -0,0 +1,200 @@ +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import actions from 'actions'; +import selectors from 'selectors'; +import core from 'core'; +import SnippingToolPopup from './SnippingToolPopup'; +import './SnippingToolPopup.scss'; +import Draggable from 'react-draggable'; +import useOnSnippingAnnotationChangedOrSelected from '../../hooks/useOnSnippingAnnotationChangedOrSelected'; +import { isMobileSize } from 'helpers/getDeviceSize'; +import getRootNode from 'helpers/getRootNode'; +import DataElements from 'constants/dataElement'; + +function SnippingToolPopupContainer() { + const snippingToolName = window.Core.Tools.ToolNames['SNIPPING']; + const snippingCreateTool = core.getTool(snippingToolName); + const [ + isOpen, + isInDesktopOnlyMode, + shouldShowApplySnippingWarning, + ] = useSelector((state) => [ + selectors.getActiveToolName(state) === snippingToolName && selectors.isElementOpen(state, DataElements.SNIPPING_TOOL_POPUP), + selectors.isInDesktopOnlyMode(state), + selectors.shouldShowApplySnippingWarning(state), + ]); + const dispatch = useDispatch(); + const [isSnipping, setIsSnipping] = useState(snippingCreateTool.getIsSnipping()); + + const elementsToClose = ['leftPanel', 'searchPanel', 'notesPanel', 'redactionPanel', 'textEditingPanel']; + + const openSnippingPopup = () => { + dispatch(actions.openElement(DataElements.SNIPPING_TOOL_POPUP)); + // eslint-disable-next-line no-undef + dispatch(actions.closeElements(elementsToClose)); + setIsSnipping(snippingCreateTool.getIsSnipping()); + }; + + useEffect(() => { + const handleSnippingCancellation = () => setIsSnipping(false); + + const handleToolModeChange = (newTool, oldTool) => { + if (newTool instanceof Core.Tools.SnippingCreateTool) { // eslint-disable-line no-undef + newTool.addEventListener(window.Core.Tools.SnippingCreateTool.Events['SNIPPING_CANCELLED'], handleSnippingCancellation); + openSnippingPopup(); + } else if (oldTool instanceof Core.Tools.SnippingCreateTool) { // eslint-disable-line no-undef + newTool.removeEventListener(window.Core.Tools.SnippingCreateTool.Events['SNIPPING_CANCELLED'], handleSnippingCancellation); + setIsSnipping(false); + snippingCreateTool.reset(); + reenableHeader(); + } + }; + + core.addEventListener('toolModeUpdated', handleToolModeChange); + + return () => { + core.removeEventListener('toolModeUpdated', handleToolModeChange); + }; + }); + + const disableHeader = () => { + const header = getRootNode().querySelector('[data-element=header]'); + if (header) { + header.style.pointerEvents = 'none'; + header.style.opacity = '0.5'; + } + + const toolsHeader = getRootNode().querySelector('[data-element=toolsHeader]'); + if (toolsHeader) { + toolsHeader.style.pointerEvents = 'none'; + toolsHeader.style.opacity = '0.5'; + } + }; + + const reenableHeader = () => { + const header = getRootNode().querySelector('[data-element=header]'); + if (header) { + header.style.pointerEvents = ''; + header.style.opacity = '1'; + } + + const toolsHeader = getRootNode().querySelector('[data-element=toolsHeader]'); + if (toolsHeader) { + toolsHeader.style.pointerEvents = ''; + toolsHeader.style.opacity = '1'; + } + }; + + const snippingAnnotation = useOnSnippingAnnotationChangedOrSelected(openSnippingPopup); + + // re-enable other tools and panels while not snipping + useEffect(() => { + if (!isSnipping) { + reenableHeader(); + } else { + disableHeader(); + } + }, [isSnipping]); + + const [snippingMode, setSnippingMode] = useState(null); + + useEffect(() => { + snippingCreateTool.setSnippingMode('CLIPBOARD'); + setSnippingMode('CLIPBOARD'); + }, []); + + const onSnippingModeChange = (option) => { + snippingCreateTool.setSnippingMode(option); + setSnippingMode(option); + }; + + const snippingPopupRef = useRef(); + const DEFAULT_POPUP_WIDTH = 250; + const DEFAULT_POPUP_HEIGHT = 200; + const documentContainerElement = core.getScrollViewElement(); + const popupWidth = snippingPopupRef.current?.getBoundingClientRect().width || DEFAULT_POPUP_WIDTH; + const popupHeight = snippingPopupRef.current?.getBoundingClientRect().height || DEFAULT_POPUP_HEIGHT; + const documentViewer = core.getDocumentViewer(1); + // eslint-disable-next-line no-undef + const xOffset = documentViewer.getViewerElement()?.getBoundingClientRect().right || 0; + + const getSnippingPopupOffset = () => { + const offset = { + x: xOffset + 35, + y: documentContainerElement?.offsetTop + 10, + }; + if (snippingAnnotation && snippingPopupRef?.current) { + offset.x = Math.min(offset.x, documentContainerElement.offsetWidth - popupWidth); + } + return offset; + }; + + const getSnippingPopupBounds = () => { + const bounds = { + top: 0, + bottom: documentContainerElement.offsetHeight - popupHeight, + left: 0 - getSnippingPopupOffset()['x'], + right: documentContainerElement.offsetWidth - getSnippingPopupOffset()['x'] - popupWidth, + }; + return bounds; + }; + + const closeAndReset = () => { + snippingCreateTool.reset(); + dispatch(actions.closeElement(DataElements.SNIPPING_TOOL_POPUP)); + reenableHeader(); + core.setToolMode(window.Core.Tools.ToolNames.SNIPPING); + }; + + const closeSnippingPopup = useCallback(() => { + closeAndReset(); + }, []); + + // disable/enable the 'apply' button when snipping + useEffect(() => { + setIsSnipping(snippingCreateTool.getIsSnipping()); + }, [snippingAnnotation]); + + const applySnipping = async () => { + await snippingCreateTool.applySnipping(); + snippingCreateTool.reset(); + reenableHeader(); + }; + + const props = { + snippingMode, + onSnippingModeChange, + closeSnippingPopup, + applySnipping, + isSnipping, + isInDesktopOnlyMode, + shouldShowApplySnippingWarning, + }; + + const isMobile = isMobileSize(); + + if (isOpen && core.getDocument()) { + if (isMobile && !isInDesktopOnlyMode) { + // disable draggable on mobile devices + return ( +
+ +
+ ); + } + return ( + +
+ +
+
+ ); + } + return null; +} + +export default SnippingToolPopupContainer; diff --git a/src/components/SnippingToolPopup/index.js b/src/components/SnippingToolPopup/index.js new file mode 100644 index 0000000000..c95f3125e3 --- /dev/null +++ b/src/components/SnippingToolPopup/index.js @@ -0,0 +1,3 @@ +import SnippingToolPopup from './SnippingToolPopupContainer'; + +export default SnippingToolPopup; \ No newline at end of file diff --git a/src/components/StylePicker/ColorPicker/index.js b/src/components/StylePicker/ColorPicker/index.js new file mode 100644 index 0000000000..60951c677e --- /dev/null +++ b/src/components/StylePicker/ColorPicker/index.js @@ -0,0 +1,3 @@ +import ColorPicker from './ColorPicker'; + +export default ColorPicker; \ No newline at end of file diff --git a/src/components/StylePicker/index.js b/src/components/StylePicker/index.js new file mode 100644 index 0000000000..8114839de3 --- /dev/null +++ b/src/components/StylePicker/index.js @@ -0,0 +1,3 @@ +import StylePicker from './StylePicker'; + +export default StylePicker; \ No newline at end of file diff --git a/src/components/StylePopup/StylePopup.stories.js b/src/components/StylePopup/StylePopup.stories.js new file mode 100644 index 0000000000..ce01b333a5 --- /dev/null +++ b/src/components/StylePopup/StylePopup.stories.js @@ -0,0 +1,213 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import initialState from 'src/redux/initialState'; +import { Provider } from 'react-redux'; +import StylePopup from './StylePopup'; +import core from 'core'; + +export default { + title: 'Components/StylePopup', + component: StylePopup, +}; + +// Mock some state to show the style popups +const state = { + ...initialState, + viewer: { + openElements: { + watermarkPanel: true, + stylePopup: true, + stylePopupTextStyleContainer: true, + stylePopupColorsContainer: true, + stylePopupLabelTextContainer: true + }, + disabledElements: {}, + selectedScale: undefined, + colorMap: { + textField: { + currentStyleTab: 'StrokeColor', + iconColor: 'StrokeColor', + } + }, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + customElementOverrides: {} + } +}; + +const noop = () => {}; + +const store = configureStore({ + reducer: () => state +}); + +const BasicComponent = (props) => { + core.getFormFieldCreationManager = () => ({ + isInFormFieldCreationMode: () => true, + }); + + return ( + + + + ); +}; + +export const StylePopupInFormBuilder = BasicComponent.bind({}); +StylePopupInFormBuilder.args = { + currentStyleTab: 'StrokeColor', + isInFormBuilderAndNotFreeText: true, + style: { + 'FillColor': new window.Core.Annotations.Color(212, 211, 211), + 'StrokeColor': new window.Core.Annotations.Color(0, 0, 0), + 'TextColor': new window.Core.Annotations.Color(0, 0, 0), + 'Opacity': null, + 'StrokeThickness': 1, + 'FontSize': '12pt', + 'Style': 'solid' + }, + colorMapKey: 'textField', + colorPalette: 'StrokeColor', + disableSeparator: true, + hideSnapModeCheckbox: true, + isFreeText: false, + isEllipse: false, + isTextStyleContainerActive: true, + isLabelTextContainerActive: true, + properties: { + 'StrokeStyle': 'solid', + }, + isRedaction: false, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + onSliderChange: noop, + onStyleChange: noop, + closeElement: noop, + openElement: noop, + onPropertyChange: noop, + onRichTextStyleChange: noop, + onLineStyleChange: noop, +}; + +export const StylePopupForRedactionToolInHeaderItem = () => { + const props = { + currentStyleTab: 'TextColor', + isInFormBuilderAndNotFreeText: false, + style: { + 'FillColor': new window.Core.Annotations.Color(212, 211, 211), + 'StrokeColor': new window.Core.Annotations.Color(0, 0, 0), + 'TextColor': new window.Core.Annotations.Color(0, 0, 0), + 'Opacity': null, + 'StrokeThickness': 1, + 'FontSize': '12pt', + 'Style': 'solid' + }, + colorMapKey: 'textField', + colorPalette: 'TextColor', + disableSeparator: true, + hideSnapModeCheckbox: true, + isFreeText: false, + isEllipse: false, + isTextStyleContainerActive: true, + isLabelTextContainerActive: true, + properties: { + 'StrokeStyle': 'solid', + }, + isRedaction: true, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + onSliderChange: noop, + onStyleChange: noop, + closeElement: noop, + openElement: noop, + onPropertyChange: noop, + onRichTextStyleChange: noop, + onLineStyleChange: noop, + }; + + const stateForTextTab = { + ...state, + viewer: { + ...state.viewer, + colorMap: { + textField: { + currentStyleTab: 'TextColor', + iconColor: 'TextColor', + } + }, + } + }; + + const store = configureStore({ + reducer: () => stateForTextTab + }); + + return ( + +
+ +
+
+ ); +}; + +export const StylePopupForFreeTextToolInHeaderItem = () => { + const props = { + currentStyleTab: 'TextColor', + isInFormBuilderAndNotFreeText: false, + style: { + 'FillColor': new window.Core.Annotations.Color(212, 211, 211), + 'StrokeColor': new window.Core.Annotations.Color(0, 0, 0), + 'TextColor': new window.Core.Annotations.Color(0, 0, 0), + 'Opacity': 1, + 'StrokeThickness': 1, + 'FontSize': '12pt', + 'Style': 'solid' + }, + colorMapKey: 'textField', + colorPalette: 'TextColor', + disableSeparator: true, + hideSnapModeCheckbox: true, + isFreeText: true, + isEllipse: false, + isTextStyleContainerActive: true, + isLabelTextContainerActive: false, + properties: { + 'StrokeStyle': 'solid', + }, + fonts: ['Helvetica', 'Times New Roman', 'Arimo'], + isSnapModeEnabled: false, + onSliderChange: noop, + onStyleChange: noop, + closeElement: noop, + openElement: noop, + onPropertyChange: noop, + onRichTextStyleChange: noop, + onLineStyleChange: noop, + }; + + const stateForTextTab = { + ...state, + viewer: { + ...state.viewer, + colorMap: { + textField: { + currentStyleTab: 'TextColor', + iconColor: 'TextColor', + } + }, + } + }; + + const store = configureStore({ + reducer: () => stateForTextTab + }); + + return ( + +
+ +
+
+ ); +}; diff --git a/src/components/TextEditingPanel/TextEditingPanel.scss b/src/components/TextEditingPanel/TextEditingPanel.scss new file mode 100644 index 0000000000..ae3ed0c28f --- /dev/null +++ b/src/components/TextEditingPanel/TextEditingPanel.scss @@ -0,0 +1,152 @@ +@import '../../constants/styles'; +@import '../../constants/panel'; + +.TextEditingPanel { + padding: 16px 16px 0px 16px; + display: flex; + flex-direction: column; + + .text-editing-panel-text-style-picker { + margin-top: 16px; + } + + .text-editing-panel-section { + .text-editing-panel-heading { + font-size: var(--font-size-default); + font-weight: 700; + } + + .text-editing-panel-menu-items { + margin-top: 16px; + margin-bottom: 16px; + + .text-editing-panel-menu-items-buttons { + display: flex; + gap: 8px; + } + + .text-editing-panel-menu-items-buttons.undo-redo { + gap: 12px; + } + + // align font family dropdown items right + .Dropdown__items { + right: auto; + } + + .FontSizeDropdown .Dropdown__items { + right: 0; + } + + .Dropdown__items .Dropdown__item { + font-size: var(--font-size-default); + font-family: var(--font-family); + } + + .Dropdown .picked-option .picked-option-text { + font-family: var(--font-family); + } + + .inactive { + opacity: 0.5; + pointer-Events: none; + } + } + + .top-panel { + margin-top: 0px; + } + + .icon-grid .row { + padding-top: 12px; + } + + .link-section { + margin-top: 12px; + } + + .text-editing-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + gap: 8px; + + width: 100%; + margin-top: 8px; + margin-bottom: 8px; + .Button .Icon { + width: 32px; + height: 32px; + } + + .color-picker-container { + width: 100%; + } + + .ColorPalette { + display: flex; + flex-wrap: wrap; + justify-content: left; + align-content: flex-start; + gap: 8px; + } + } + + .custom-colors-pallete { + .cell-container { + flex: none; + } + } + + .custom-colors-section { + margin-top: 12px; + } + + .addToCustomButton { + width: 24px; + height: 24px; + padding-top: 8px; + align-self: center; + } + + .text-editing-panel-color-palette { + display: flex; + margin-bottom: 16px; + } + + .opacity-slider { + @include ie11 { + align-items: stretch; + } + } + } + + @include mobile { + width: 100%; + min-width: 100%; + padding-top: 0px; + + .icon-grid .text-horizontal-alignment { + float: none; + } + + .close-container { + display: flex; + align-items: center; + justify-content: flex-end; + height: 64px; + + width: 100%; + padding-right: 12px; + + .close-icon-container { + cursor: pointer; + .close-icon { + width: 24px; + height: 24px; + } + } + } + } +} + diff --git a/src/components/TextEditingPanel/TextEditingPanel.spec.js b/src/components/TextEditingPanel/TextEditingPanel.spec.js new file mode 100644 index 0000000000..5d6bf69fba --- /dev/null +++ b/src/components/TextEditingPanel/TextEditingPanel.spec.js @@ -0,0 +1,121 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { TextEditingUndoRedo as TextEditUndoRedoStory } from './TextEditingPanel.stories'; +import TextEditingPanel from './TextEditingPanel'; +import core from 'core'; + +const TestTextEditingPanel = withProviders(TextEditingPanel); + +const noop = () => { }; + +const mockProps = { + handlePropertyChange: noop, + handleTextFormatChange: noop, + handleColorChange: noop, + format: { + bold: false, + italic: false, + underline: false, + }, + undoRedoProperties: undefined, +}; + +core.getContentEditManager = () => ({ + isInContentEditMode: () => true, +}); + +describe('TextEditingPanel', () => { + it('Undo/redo story should render without errors', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('should render without undo/redo buttons', () => { + render(); + const undoButton = screen.queryByRole('Undo', { name: 'Undo' }); + const redoButton = screen.queryByRole('Redo', { name: 'Redo' }); + expect(undoButton).not.toBeInTheDocument(); + expect(redoButton).not.toBeInTheDocument(); + }); + + it('should render with undo/redo buttons disabled', () => { + mockProps.undoRedoProperties = { + canUndo: false, + canRedo: false + }; + + render(); + const undoButton = screen.getByRole('button', { name: 'Undo' }); + const redoButton = screen.getByRole('button', { name: 'Redo' }); + expect(undoButton.disabled).toBe(true); + expect(redoButton.disabled).toBe(true); + }); + + it('should render with undo/redo buttons enabled', () => { + mockProps.undoRedoProperties = { + canUndo: true, + canRedo: true + }; + + render(); + const undoButton = screen.getByRole('button', { name: 'Undo' }); + const redoButton = screen.getByRole('button', { name: 'Redo' }); + expect(undoButton.disabled).toBe(false); + expect(redoButton.disabled).toBe(false); + }); + + it('should render with undo enabled but redo disabled', () => { + mockProps.undoRedoProperties = { + canUndo: true, + canRedo: false + }; + + render(); + const undoButton = screen.getByRole('button', { name: 'Undo' }); + const redoButton = screen.getByRole('button', { name: 'Redo' }); + expect(undoButton.disabled).toBe(false); + expect(redoButton.disabled).toBe(true); + }); + + it('should render with undo disabled but redo enabled', () => { + mockProps.undoRedoProperties = { + canUndo: false, + canRedo: true + }; + + render(); + const undoButton = screen.getByRole('button', { name: 'Undo' }); + const redoButton = screen.getByRole('button', { name: 'Redo' }); + expect(undoButton.disabled).toBe(true); + expect(redoButton.disabled).toBe(false); + }); + + it('should fire undo handler when button is clicked', () => { + mockProps.undoRedoProperties = { + canUndo: true, + handleUndo: jest.fn() + }; + + render(); + const undoButton = screen.getByRole('button', { name: 'Undo' }); + expect(undoButton.disabled).toBe(false); + + undoButton.click(); + expect(mockProps.undoRedoProperties.handleUndo).toHaveBeenCalled(); + }); + + it('should fire redo handler when button is clicked', () => { + mockProps.undoRedoProperties = { + canRedo: true, + handleRedo: jest.fn() + }; + + render(); + const redoButton = screen.getByRole('button', { name: 'Redo' }); + expect(redoButton.disabled).toBe(false); + + redoButton.click(); + expect(mockProps.undoRedoProperties.handleRedo).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/components/TextEditingPanel/index.js b/src/components/TextEditingPanel/index.js new file mode 100644 index 0000000000..0547944cee --- /dev/null +++ b/src/components/TextEditingPanel/index.js @@ -0,0 +1,3 @@ +import TextEditingPanel from './TextEditingPanelContainer'; + +export default TextEditingPanel; diff --git a/src/components/TextStylePicker/TextStylePicker.spec.js b/src/components/TextStylePicker/TextStylePicker.spec.js new file mode 100644 index 0000000000..2af910b86e --- /dev/null +++ b/src/components/TextStylePicker/TextStylePicker.spec.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TextStylePicker from './TextStylePicker'; +import { DEBOUNCE_TIME } from '../FontSizeDropdown/FontSizeDropdown'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import userEvent from '@testing-library/user-event'; + +jest.mock('core', () => ({ + getContentEditManager: () => ({ + isInContentEditMode: () => false, + }), +})); + +// mock initial state. +// UI Buttons are redux connected, and they need a state or the +const initialState = { + viewer: { + openElements: { + }, + disabledElements: {}, + customElementOverrides: {}, + } +}; + +const store = configureStore({ + reducer: () => initialState +}); + +const TextStylePickerWithRedux = (props) => ( + + + +); + + +const noop = () => { }; + +describe.only('TextStylePicker Component', () => { + it('should render without errors', () => { + const props = { + onPropertyChange: noop + }; + render(); + }); + + it('should render a warning if you enter an invalid font size', async () => { + const props = { + onPropertyChange: noop + }; + render(); + const fontSizeInput = screen.getByRole('textbox'); + userEvent.type(fontSizeInput, '12345'); + // Assert that a warning exists + await new Promise((r) => setTimeout(r, DEBOUNCE_TIME + 5)); + expect(screen.getByText('Font size must be in the following range: 1 - 512')).toBeInTheDocument(); + }); +}); diff --git a/src/components/TextStylePicker/TextStylePicker.stories.js b/src/components/TextStylePicker/TextStylePicker.stories.js new file mode 100644 index 0000000000..b47ce1abeb --- /dev/null +++ b/src/components/TextStylePicker/TextStylePicker.stories.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { configureStore } from '@reduxjs/toolkit'; +import initialState from 'src/redux/initialState'; +import i18n from 'i18next'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import TextStylePicker from './TextStylePicker'; +import core from 'core'; + +export default { + title: 'Components/TextStylePicker', + component: TextStylePicker, +}; + +const noop = () => {}; + +const state = { + ...initialState, + viewer: { + currentLanguage: 'ja', + disabledElements: {}, + customElementOverrides: {} + } +}; +const store = configureStore({ + reducer: () => state +}); + +const BasicComponent = (props) => { + return ( + + +
+ +
+
+
+ ); +}; + +const DisabledFontSelectorComponent = (props) => { + return ( + + +
+ +
+
+
+ ); +}; + +export const TextStylePickerSection = BasicComponent.bind({}); +TextStylePickerSection.args = { + properties: { + FontSize: '128' + }, + isRedaction: false, + onPropertyChange: noop +}; + +export const TextStylePickerFreeTextDisabled = DisabledFontSelectorComponent.bind({}); +TextStylePickerFreeTextDisabled.args = { + properties: { + FontSize: '128' + }, + isFreeText: true, + isFreeTextAutoSize: true, + isRedaction: false, + onPropertyChange: noop +}; + +export const TextStylePickerFreeTextEnabled = DisabledFontSelectorComponent.bind({}); +TextStylePickerFreeTextEnabled.args = { + properties: { + FontSize: '128' + }, + isFreeText: true, + isFreeTextAutoSize: false, + isRedaction: false, + onPropertyChange: noop +}; \ No newline at end of file diff --git a/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.js b/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.js new file mode 100644 index 0000000000..b262ba0922 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropertyKeyValuePair from '../PropertyKeyValuePair/PropertyKeyValuePair'; + +const GeneralValuesSection = (props) => { + const { entities } = props; + + const elements = []; + + for (const entity in entities) { + elements.push(); + } + + return
{elements}
; +}; + +export default GeneralValuesSection; diff --git a/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.stories.js b/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.stories.js index ca0f98d39b..23ca96ae95 100644 --- a/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.stories.js +++ b/src/components/Wv3dPropertiesPanel/GeneralValuesSection/GeneralValuesSection.stories.js @@ -24,7 +24,7 @@ const initialState = { panelWidths: { wv3dPropertiesPanel: 330, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 diff --git a/src/components/Wv3dPropertiesPanel/Group/Group.js b/src/components/Wv3dPropertiesPanel/Group/Group.js new file mode 100644 index 0000000000..c3e4b9d825 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Group/Group.js @@ -0,0 +1,37 @@ +import React, { useState, useMemo } from 'react'; +import Icon from 'components/Icon'; +import PropertyKeyValuePair from '../PropertyKeyValuePair/PropertyKeyValuePair'; +import './Group.scss'; + +const Group = (props) => { + const { name, data, open } = props; + + const [isActive, setIsActive] = useState(open); + const downArrow = 'ic_chevron_down_black_24px'; + const rightArrow = 'ic_chevron_right_black_24px'; + + const onClick = () => { + setIsActive(!isActive); + }; + + const elements = useMemo(() => { + return Object.entries(data).map((entity) => ( + + )); + }, [data]); + + return ( +
+
+
+ +
+ {name} +
+ +
{elements}
+
+ ); +}; + +export default React.memo(Group); diff --git a/src/components/Wv3dPropertiesPanel/Group/Group.scss b/src/components/Wv3dPropertiesPanel/Group/Group.scss new file mode 100644 index 0000000000..d9c1a61bf7 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Group/Group.scss @@ -0,0 +1,15 @@ +.group-title { + display: flex; + align-items: center; + margin-bottom: 5px; + user-select: all !important; +} + +.dropdown.active { + visibility: visible; +} + +.dropdown.inactive { + visibility: hidden; + display: none; +} diff --git a/src/components/Wv3dPropertiesPanel/Group/Group.stories.js b/src/components/Wv3dPropertiesPanel/Group/Group.stories.js index 948ac7dfd7..20b590332c 100644 --- a/src/components/Wv3dPropertiesPanel/Group/Group.stories.js +++ b/src/components/Wv3dPropertiesPanel/Group/Group.stories.js @@ -24,7 +24,7 @@ const initialState = { panelWidths: { wv3dPropertiesPanel: 330, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 diff --git a/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.js b/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.js new file mode 100644 index 0000000000..521d13ec1a --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.js @@ -0,0 +1,39 @@ +import React from 'react'; +import Group from '../Group/Group'; + +function addOrderedGroups(orderedGroup, groups) { + const orderArray = []; + for (const group in orderedGroup) { + const groupName = orderedGroup[group]; + + if (groupName in groups) { + orderArray.push(); + } + } + + return orderArray; +} + +const GroupsContainer = (props) => { + const { groups, groupOrder } = props; + + let combinedGroups = []; + + if (groupOrder && groupOrder.length > 0) { + combinedGroups = addOrderedGroups(groupOrder, groups); + + for (const group in groups) { + if (!groupOrder.includes(group)) { + combinedGroups.push(); + } + } + } else { + for (const group in groups) { + combinedGroups.push(); + } + } + + return
{combinedGroups}
; +}; + +export default GroupsContainer; diff --git a/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.stories.js b/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.stories.js index 0fd8b3db72..cf6b73fa5f 100644 --- a/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.stories.js +++ b/src/components/Wv3dPropertiesPanel/GroupsContainer/GroupsContainer.stories.js @@ -24,7 +24,7 @@ const initialState = { panelWidths: { wv3dPropertiesPanel: 330, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 diff --git a/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.js b/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.js new file mode 100644 index 0000000000..8b664b6215 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import './HeaderTitle.scss'; + +const HeaderTitle = (attributes) => { + const { title } = attributes; + const { t } = useTranslation(); + + return ( +

+ {t('wv3dPropertiesPanel.propertiesHeader')} + + {title} +

+ ); +}; + +export default HeaderTitle; diff --git a/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.scss b/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.scss new file mode 100644 index 0000000000..9a7c4c74f2 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/HeaderTitle/HeaderTitle.scss @@ -0,0 +1,7 @@ +.header-value { + color: var(--gray-7); +} + +.header-title { + font-size: 16px; +} diff --git a/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.js b/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.js new file mode 100644 index 0000000000..cb2f466444 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.js @@ -0,0 +1,91 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import GeneralValuesSection from '../GeneralValuesSection/GeneralValuesSection'; +import GroupsContainer from '../GroupsContainer/GroupsContainer'; +import Group from '../Group/Group'; +import HeaderTitle from '../HeaderTitle/HeaderTitle'; + +function createDataSet(dataMap, propertySet, removeEmptyRows) { + const combinedMap = {}; + + if (removeEmptyRows) { + for (const item in dataMap) { + const dataPoint = propertySet[dataMap[item]]; + if (dataPoint !== undefined && dataPoint !== '') { + combinedMap[item] = dataPoint; + } + } + } else { + for (const item in dataMap) { + combinedMap[item] = propertySet[dataMap[item]]; + } + } + + return combinedMap; +} + +function checkForEmptyKeys(data) { + for (const key in data) { + const value = data[key]; + if (value !== undefined && value !== '') { + return false; + } + } + + return true; +} + +function generateGroupDataSet(dataMap, propertySet, removeEmptyRows, removeEmptyGroups) { + const combinedGroupMap = {}; + + if (removeEmptyGroups) { + for (const group in dataMap) { + const dataset = createDataSet(dataMap[group], propertySet, removeEmptyRows); + if (Object.keys(dataset).length > 0) { + if (!checkForEmptyKeys(dataset)) { + combinedGroupMap[group] = dataset; + } + } + } + } else { + for (const group in dataMap) { + combinedGroupMap[group] = createDataSet(dataMap[group], propertySet, removeEmptyRows); + } + } + + return combinedGroupMap; +} + +const PropertiesElement = (props) => { + const { element, schema } = props; + + const { + headerName, + defaultValues, + groups, + groupOrder, + removeEmptyRows, + removeEmptyGroups, + createRawValueGroup, + } = schema; + + const { t } = useTranslation(); + + const defaultItems = createDataSet(defaultValues, element, removeEmptyRows); + const groupsItems = generateGroupDataSet(groups, element, removeEmptyRows, removeEmptyGroups); + const name = element[headerName]; + + return ( +
+ + + + {createRawValueGroup ? ( + + ) : null} +
+ ); +}; + +export default PropertiesElement; diff --git a/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.stories.js b/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.stories.js index df09df1d00..bd4e731d8a 100644 --- a/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.stories.js +++ b/src/components/Wv3dPropertiesPanel/PropertiesElement/PropertiesElement.stories.js @@ -106,7 +106,7 @@ const initialState = { panelWidths: { wv3dPropertiesPanel: 330, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 diff --git a/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.js b/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.js new file mode 100644 index 0000000000..c585d70bf4 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.js @@ -0,0 +1,15 @@ +import React from 'react'; +import './PropertyKeyValuePair.scss'; + +const PropertyKeyValuePair = (props) => { + const { name, value } = props; + + return ( +
+ {name} + {value} +
+ ); +}; + +export default PropertyKeyValuePair; diff --git a/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.scss b/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.scss new file mode 100644 index 0000000000..2780e41b17 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/PropertyKeyValuePair/PropertyKeyValuePair.scss @@ -0,0 +1,25 @@ +.property-pair { + margin-left: 24px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; + padding-bottom: 10px; + user-select: all !important; +} + +.property-key { + flex-basis: 140px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.property-value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-basis: 300px; + padding-left: 20px; +} diff --git a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.js b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.js new file mode 100644 index 0000000000..b4bf9da7a2 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.js @@ -0,0 +1,53 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import Icon from 'components/Icon'; +import DataElementWrapper from '../DataElementWrapper'; +import { v4 as uuidv4 } from 'uuid'; +import './Wv3dPropertiesPanel.scss'; + +import PropertiesElement from './PropertiesElement/PropertiesElement'; + +const Wv3dPropertiesPanel = (props) => { + const { currentWidth, isInDesktopOnlyMode, isMobile = false, closeWv3dPropertiesPanel, schema, modelData } = props; + + const { t } = useTranslation(); + const style = !isInDesktopOnlyMode && isMobile ? {} : { width: `${currentWidth}px`, minWidth: `${currentWidth}px` }; + + const renderMobileCloseButton = () => { + return ( +
+
+ +
+
+ ); + }; + + let propertiesCollection = modelData.map((element) => ( + + )); + + const emptyPanelPlaceholder = () => { + return ( +
+
+ +
+
{t('wv3dPropertiesPanel.emptyPanelMessage')}
+
+ ); + }; + + if (modelData.length < 1) { + propertiesCollection = emptyPanelPlaceholder(); + } + + return ( + + {!isInDesktopOnlyMode && isMobile && renderMobileCloseButton()} + {propertiesCollection} + + ); +}; + +export default Wv3dPropertiesPanel; diff --git a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.scss b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.scss new file mode 100644 index 0000000000..5488f9b3b1 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.scss @@ -0,0 +1,64 @@ +@import '../../constants/styles'; +@import '../../constants/panel'; + +.wv3d-properties-panel { + padding: 16px 0px 0px 16px; + display: flex; + flex-direction: column; + overflow-y: auto; + user-select: all !important; + + .no-selections { + flex-direction: column; + display: flex; + align-items: center; + justify-content: center; + + .empty-icon { + width: 100px; + height: 100px; + + svg { + width: 100px; + height: 100px; + } + + * { + fill: var(--gray-5); + color: var(--gray-5); + } + } + + .empty-text { + margin-top: 4px; + padding-left: 4px; + padding-right: 4px; + width: 68%; + text-align: center; + } + } + + @include mobile { + width: 100%; + min-width: 100%; + padding-top: 0px; + + .close-container { + display: flex; + align-items: center; + justify-content: flex-end; + height: 64px; + + width: 100%; + padding-right: 12px; + + .close-icon-container { + cursor: pointer; + .close-icon { + width: 24px; + height: 24px; + } + } + } + } +} diff --git a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.spec.js b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.spec.js new file mode 100644 index 0000000000..337fa72f85 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.spec.js @@ -0,0 +1,322 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Wv3dPropertiesPanel from './Wv3dPropertiesPanel'; + +import { + DefaultStandard, + GroupOrderSpecified, + MultiplePropertiesElements, + EmptyPanel, + RemoveEmptyRows, + RemoveEmptyGroups, + RemoveRawValues, +} from './Wv3dPropertiesPanel.stories'; + +const sampleData = [ + { + ConnectedFrom: '2274843', + ConnectedTo: '2274847', + ContainedInStructure: '2258156', + Declares: '', + Decomposes: '', + Description: 'ÿ', + ExtendToStructure: 'T', + FillsVoids: '', + GlobalId: '3_YR89Qiz6UgyQsBcI$FJz', + GrossFootprintArea: '317.638889', + GrossSideArea: '4879.727431', + GrossVolume: '8132.879051', + HasAssignments: '', + HasAssociations: '2260414', + HasContext: '', + HasCoverings: '', + HasOpenings: '', + HasProjections: '', + Height: '25.604167', + InterferesElements: '', + IsConnectionRealization: '', + IsDeclaredBy: '', + IsDefinedBy: '29092,29099', + IsExternal: 'T', + IsInterferedByElements: '', + IsNestedBy: '', + IsTypedBy: '2266845', + Length: '190.583333', + LoadBearing: 'F', + Name: 'Basic Wall:Reinforced Concrete - 1\'-8":117463', + Nests: '', + ObjectPlacement: '29040', + ObjectType: 'Basic Wall:Reinforced Concrete - 1\'-8":118691', + OwnerHistory: '42', + PredefinedType: 'NOTDEFINED', + ProvidesBoundaries: '', + Reference: 'Basic Wall:Reinforced Concrete - 1\'-8"', + ReferencedBy: '', + ReferencedInStructures: '', + Representation: '29077', + Tag: '117463', + Width: '1.666667', + handle: '29081', + }, +]; + +const sampleSchema = { + headerName: 'Name', + defaultValues: {}, + groups: {}, + groupOrder: [], + removeEmptyRows: false, + removeEmptyGroups: false, + createRawValueGroup: false, +}; + +describe('Wv3dPropertiesPanel', () => { + it('The Header is populated with a title', () => { + render( + , + ); + + const name = sampleSchema['headerName']; + const expectedHeader = sampleData[0][name]; + const res = document.body.getElementsByClassName('header-value'); + + expect(res[0].innerHTML).toContain(expectedHeader); + }); + + it('Default Values are generated', () => { + sampleSchema.defaultValues = { + 'GrossVolume': 'GrossVolume', + 'OwnerHistory': 'OwnerHistory', + }; + + const { getByText } = render( + , + ); + + expect(getByText('GrossVolume')).toBeInTheDocument(); + expect(getByText(sampleData[0]['GrossVolume'])).toBeInTheDocument(); + + expect(getByText('OwnerHistory')).toBeInTheDocument(); + expect(getByText(sampleData[0]['OwnerHistory'])).toBeInTheDocument(); + }); + + it('Groups are created succesfully', async () => { + sampleSchema.defaultValues = {}; + sampleSchema.groups = { + TestGroup: { + 'GrossVolume': 'GrossVolume', + 'OwnerHistory': 'OwnerHistory', + }, + }; + + const { getByText } = render( + , + ); + + expect(getByText('GrossVolume')).toBeInTheDocument(); + expect(getByText(sampleData[0]['GrossVolume'])).toBeInTheDocument(); + + expect(getByText('OwnerHistory')).toBeInTheDocument(); + expect(getByText(sampleData[0]['OwnerHistory'])).toBeInTheDocument(); + + expect(getByText('TestGroup')).toBeInTheDocument(); + }); + + it('Empty Values are removed when removeEmptyRows is true', () => { + sampleSchema.defaultValues = { 'GrossVolume': 'GrossVolume', 'OwnerHistory': 'OwnerHistory', 'EmptyTest': '' }; + sampleSchema.removeEmptyRows = true; + sampleSchema.groups = {}; + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('GrossVolume')).toBeInTheDocument(); + expect(getByText(sampleData[0]['GrossVolume'])).toBeInTheDocument(); + + expect(getByText('OwnerHistory')).toBeInTheDocument(); + expect(getByText(sampleData[0]['OwnerHistory'])).toBeInTheDocument(); + + expect(queryByText('EmptyTest')).toBe(null); + }); + + it('Empty Groups are removed when removeEmptyGroups is true', () => { + sampleSchema.defaultValues = {}; + sampleSchema.groups = { + TestGroup: { + 'GrossVolume': 'GrossVolume', + 'OwnerHistory': 'OwnerHistory', + 'EmptyTest': '', + }, + TestGroup2: { + 'EmptyTest': '', + 'EmptyTest2': '', + 'EmptyTest3': '', + }, + }; + sampleSchema.removeEmptyRows = true; + sampleSchema.removeEmptyGroups = true; + + const { getByText, queryByText } = render( + , + ); + + expect(getByText('TestGroup')).toBeInTheDocument(); + expect(queryByText('TestGroup2')).toBe(null); + }); + + it('Groups are ordered correctly', () => { + sampleSchema.defaultValues = {}; + sampleSchema.groups = { + TestGroup: { + 'GrossVolume': 'GrossVolume', + 'OwnerHistory': 'OwnerHistory', + 'EmptyTest': '', + }, + TestGroup2: { + 'EmptyTest': '', + 'EmptyTest2': '', + 'EmptyTest3': '', + }, + TestGroup3: { + 'EmptyTest': '', + 'EmptyTest2': '', + 'EmptyTest3': '', + }, + }; + + sampleSchema.groupOrder = ['TestGroup2', 'TestGroup3']; + sampleSchema.removeEmptyRows = false; + sampleSchema.removeEmptyGroups = false; + + const { asFragment } = render( + , + ); + + const fragment = asFragment(); + const groupContainer = fragment.querySelector('[data-element="groupsContainer"]'); + + expect(groupContainer.children.length).toBe(3); + expect(groupContainer.children[0]).toHaveTextContent('TestGroup2'); + expect(groupContainer.children[1]).toHaveTextContent('TestGroup3'); + expect(groupContainer.children[2]).toHaveTextContent('TestGroup'); + }); + + it('Raw Value Group was created successfully', () => { + sampleSchema.createRawValueGroup = true; + + const { getByText } = render( + , + ); + + expect(getByText('All')).toBeInTheDocument(); + }); + + it('Raw Value Group should not be in the document', () => { + sampleSchema.createRawValueGroup = false; + + const { queryByText } = render( + , + ); + + expect(queryByText('All')).toBe(null); + }); + + it('renders the storybook component with defaults correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with ordered groups correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with empty rows removed correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with empty groups removed correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with the raw values section removed correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with multiple elements correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('renders the storybook component with zero elements correctly', () => { + expect(() => { + render(); + }).not.toThrow(); + }); +}); diff --git a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.stories.js b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.stories.js index 4484ba570e..3a897e67e3 100644 --- a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.stories.js +++ b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanel.stories.js @@ -201,7 +201,7 @@ const initialState = { panelWidths: { wv3dPropertiesPanel: 330, }, - modularHeaders: [], + modularHeaders: {}, modularHeadersHeight: { topHeaders: 40, bottomHeaders: 40 diff --git a/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanelContainer.js b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanelContainer.js new file mode 100644 index 0000000000..af740bdf12 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/Wv3dPropertiesPanelContainer.js @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; +import Wv3dPropertiesPanel from './Wv3dPropertiesPanel'; +import { useSelector, shallowEqual, useDispatch } from 'react-redux'; +import selectors from 'selectors'; +import actions from 'actions'; + +import { isMobileSize } from 'helpers/getDeviceSize'; + +const Wv3dPropertiesPanelContainer = () => { + const [isOpen, isDisabled, wv3dPropertiesPanelWidth, isInDesktopOnlyMode, modelData, schema] = useSelector( + (state) => [ + selectors.isElementOpen(state, 'wv3dPropertiesPanel'), + selectors.isElementDisabled(state, 'wv3dPropertiesPanel'), + selectors.getWv3dPropertiesPanelWidth(state), + selectors.isInDesktopOnlyMode(state), + selectors.getWv3dPropertiesPanelModelData(state), + selectors.getWv3dPropertiesPanelSchema(state), + ], + shallowEqual, + ); + + const isMobile = isMobileSize(); + + const dispatch = useDispatch(); + + const closeWv3dPropertiesPanel = () => { + dispatch(actions.closeElement('wv3dPropertiesPanel')); + }; + + const [renderNull, setRenderNull] = useState(false); + + useEffect(() => { + const timeout = setTimeout(() => { + setRenderNull(!isOpen); + }, 500); + return () => { + clearTimeout(timeout); + }; + }, [isOpen]); + + if (isDisabled || (!isOpen && renderNull)) { + return null; + } + + return ( + + ); +}; + +export default Wv3dPropertiesPanelContainer; diff --git a/src/components/Wv3dPropertiesPanel/index.js b/src/components/Wv3dPropertiesPanel/index.js new file mode 100644 index 0000000000..c0e4c60388 --- /dev/null +++ b/src/components/Wv3dPropertiesPanel/index.js @@ -0,0 +1,3 @@ +import Wv3dPropertiesPanel from './Wv3dPropertiesPanelContainer'; + +export default Wv3dPropertiesPanel; diff --git a/src/constants/defaultToolsWithInlineCommentOnAnnotationSelected.js b/src/constants/defaultToolsWithInlineCommentOnAnnotationSelected.js new file mode 100644 index 0000000000..516ca89263 --- /dev/null +++ b/src/constants/defaultToolsWithInlineCommentOnAnnotationSelected.js @@ -0,0 +1,10 @@ +// default annotations that have inline comment enabled on select +export default [ + window.Core.Annotations.TextUnderlineAnnotation, + window.Core.Annotations.TextHighlightAnnotation, + window.Core.Annotations.FreeTextAnnotation, + window.Core.Annotations.CaretAnnotation, + window.Core.Annotations.StickyAnnotation, + window.Core.Annotations.TextSquigglyAnnotation, + window.Core.Annotations.TextStrikeoutAnnotation, +]; \ No newline at end of file diff --git a/src/constants/featureFlags.js b/src/constants/featureFlags.js new file mode 100644 index 0000000000..d49647551d --- /dev/null +++ b/src/constants/featureFlags.js @@ -0,0 +1,11 @@ +/** + * Contains string enums for WebViewer feature flags + * @name UI.FeatureFlags + * @property {string} CUSTOMIZABLE_UI Feature flag for the new customizable UI + * @ignore + */ +const FEATURE_FLAGS = { + CUSTOMIZABLE_UI: 'customizableUI', +}; + +export default FEATURE_FLAGS; \ No newline at end of file diff --git a/src/constants/highContrastDark.scss b/src/constants/highContrastDark.scss index 9ff92de340..297aa3aadd 100644 --- a/src/constants/highContrastDark.scss +++ b/src/constants/highContrastDark.scss @@ -100,4 +100,6 @@ --outline-color: var(--blue-7); --outline-hover: var(--blue-3); + + --oe-table-dropdown-highlight: var(--blue-5); } diff --git a/src/constants/highContrastLight.scss b/src/constants/highContrastLight.scss index be1b459121..5bcf4c3b76 100644 --- a/src/constants/highContrastLight.scss +++ b/src/constants/highContrastLight.scss @@ -103,4 +103,6 @@ --outline-hover: var(--blue-1); --preset-background: var(--gray-1); + + --oe-table-dropdown-highlight: var(--blue-4); } diff --git a/src/constants/languages.js b/src/constants/languages.js new file mode 100644 index 0000000000..e4681ca970 --- /dev/null +++ b/src/constants/languages.js @@ -0,0 +1,72 @@ +// The values in this array should match the language codes of the json files inside the i18n folder +const Languages = [ + ['en', 'English'], + ['el', 'Ελληνικά'], + ['de', 'Deutsch'], + ['es', 'Español'], + ['fr', 'Français'], + ['hu', 'Magyar'], + ['it', 'Italiano'], + ['ja', '日本語'], + ['ko', '한국어'], + ['nl', 'Nederlands'], + ['pt_br', 'Português'], + ['pl', 'Polski'], + ['uk', 'українська'], + ['ru', 'Pусский'], + ['ro', 'Romanian'], + ['sv', 'Svenska'], + ['tr', 'Türk'], + ['th', 'ไทย'], + ['vi', 'Tiếng Việt'], + ['ms', 'Melayu'], + ['hi', 'हिन्दी'], + ['bn', 'বাংলা'], + ['zh_cn', '简体中文'], + ['zh_tw', '繁體中文'], + ['cs', 'česky, čeština'], + ['id', 'Bahasa Indonesia'] +]; + +/** + * Contains string enums for the default languages found in WebViewer. + * @name UI.Languages + * @property {string} EN English (en) + * @property {string} CS česky, čeština (cs) + * @property {string} EL Ελληνικά (el) + * @property {string} DE Deutsch (de) + * @property {string} ES Español (es) + * @property {string} FR Français (fr) + * @property {string} HU Magyar (hu) + * @property {string} IT Italiano (it) + * @property {string} JA 日本語 (ja) + * @property {string} KO 한국어 (ko) + * @property {string} NL Nederlands (nl) + * @property {string} PT_BR Português (pt_br) + * @property {string} PL Polski (pl) + * @property {string} UK українська (uk) + * @property {string} RU Pусский (ru) + * @property {string} RO Romanian (ro) + * @property {string} SV Svenska (sv) + * @property {string} TR Türk (tr) + * @property {string} TH ไทย (th) + * @property {string} VI Tiếng Việt (vi) + * @property {string} ID Bahasa Indonesia (id) + * @property {string} MS Melayu (ms) + * @property {string} HI हिन्दी (hi) + * @property {string} BN বাংলা (bn) + * @property {string} ZH_CN 简体中文 (zh_cn) + * @property {string} ZH_TW 繁體中文 (zh_tw) + * @example + WebViewer(...).then(function(instance) { + instance.UI.setLanguage(instance.UI.Languages.FR); + }); + */ + +export const languageEnum = Languages.reduce((acc, pair) => { + const code = pair[0]; + acc[code.toUpperCase()] = code; + return acc; +}, {}); + +export default Languages; diff --git a/src/constants/measurementScale.js b/src/constants/measurementScale.js new file mode 100644 index 0000000000..bef86c3b5c --- /dev/null +++ b/src/constants/measurementScale.js @@ -0,0 +1,150 @@ +const Scale = window.Core.Scale; + +export const PresetMeasurementSystems = { + METRIC: 'metric', + IMPERIAL: 'imperial' +}; + +const metricPreset = [ + ['1:10', new Scale([[1, 'mm'], [10, 'mm']])], + ['1:20', new Scale([[1, 'mm'], [20, 'mm']])], + ['1:50', new Scale([[1, 'mm'], [50, 'mm']])], + ['1:100', new Scale([[1, 'mm'], [100, 'mm']])], + ['1:200', new Scale([[1, 'mm'], [200, 'mm']])], + ['1:500', new Scale([[1, 'mm'], [500, 'mm']])], + ['1:1000', new Scale([[1, 'mm'], [1000, 'mm']])] +]; +const imperialPreset = [ + ['1/16"=1\'-0"', new Scale([[1 / 16, 'in'], [1, 'ft-in']])], + ['3/32"=1\'-0"', new Scale([[3 / 32, 'in'], [1, 'ft-in']])], + ['1/8"=1\'-0"', new Scale([[1 / 8, 'in'], [1, 'ft-in']])], + ['3/16"=1\'-0"', new Scale([[3 / 16, 'in'], [1, 'ft-in']])], + ['1/4"=1\'-0"', new Scale([[1 / 4, 'in'], [1, 'ft-in']])], + ['3/8"=1\'-0"', new Scale([[3 / 8, 'in'], [1, 'ft-in']])], + ['1/2"=1\'-0"', new Scale([[1 / 2, 'in'], [1, 'ft-in']])], + ['3/4"=1\'-0"', new Scale([[3 / 4, 'in'], [1, 'ft-in']])], + ['1"=1\'-0"', new Scale([[1, 'in'], [1, 'ft-in']])] +]; + +export const getMeasurementScalePreset = () => ({ + [PresetMeasurementSystems.METRIC]: metricPreset, + [PresetMeasurementSystems.IMPERIAL]: imperialPreset +}); + +const decimalPrecisions = [ + ['0.1', 0.1], + ['0.01', 0.01], + ['0.001', 0.001], + ['0.0001', 0.0001] +]; +const fractionalPrecisions = [ + ['1/8', 0.125], + ['1/16', 0.0625], + ['1/32', 0.03125], + ['1/64', 0.015625] +]; +export const PrecisionType = { + DECIMAL: 'decimal', + FRACTIONAL: 'fractional' +}; +export const precisionOptions = { + [PrecisionType.DECIMAL]: decimalPrecisions, + [PrecisionType.FRACTIONAL]: fractionalPrecisions +}; + +export const precisionFractions = { + 0.125: '1/8', + 0.0625: '1/16', + 0.03125: '1/32', + 0.015625: '1/64' +}; + +export const numberRegex = /^\d*(\.\d*)?$/; +export const fractionRegex = /^\d*(\s\d\/\d*)$/; +export const pureFractionRegex = /^(\d\/\d*)*$/; +export const floatRegex = /^(\d+)?(\.)?(\d+)?$/; +export const inFractionalRegex = /^((\d+) )?((\d+)\/)?(\d+)"$/; +export const ftInFractionalRegex = /^((\d+)'-)?((\d+) )?((\d+)\/)?(\d+)"$/; +export const ftInDecimalRegex = /^((\d+)ft-)?(((\d+).)?(\d+))in$/; + +export const parseFtInDecimal = (valueStr) => { + const matches = valueStr.match(ftInDecimalRegex); + let sum = 0; + sum += matches[2] ? Number(matches[2]) : 0; + if (matches[3] && Number(matches[3])) { + sum += (Number(matches[3]) / 12); + } + return sum; +}; +export const parseInFractional = (valueStr) => { + const matches = valueStr.match(inFractionalRegex); + let sum = 0; + sum += matches[2] ? Number(matches[2]) : 0; + if (matches[5] && Number(matches[5])) { + if (matches[4] && Number(matches[4])) { + sum += (Number(matches[4]) / Number(matches[5])); + } else { + sum += Number(matches[5]); + } + } + return sum; +}; +export const parseFtInFractional = (valueStr) => { + const matches = valueStr.match(ftInFractionalRegex); + let sum = 0; + sum += matches[2] ? Number(matches[2]) : 0; + sum += matches[4] ? Number(matches[4]) / 12 : 0; + if (matches[7] && Number(matches[7])) { + if (matches[6] && Number(matches[6])) { + sum += (Number(matches[6]) / Number(matches[7])) / 12; + } else { + sum += Number(matches[7]) / 12; + } + } + return sum; +}; + +export const fractionalUnits = ['in', 'ft-in']; +export const metricUnits = ['mm', 'cm', 'm', 'km']; + +export const ifFractionalPrecision = (precision) => fractionalPrecisions.map((item) => item[0]).includes(precision) || fractionalPrecisions.map((item) => item[1]).includes(precision); + +export const hintValues = { + 'in': 'eg. 1 1/2"', + 'ft-in': 'eg. 1\'-1 1/2"', + 'ft-in decimal': 'eg. 1ft-10.5in' +}; + +// the base unit is cm +const unitConversion = { + 'mm': 0.1, + 'cm': 1, + 'm': 100, + 'km': 100000, + 'mi': 160394, + 'yd': 91.44, + 'ft': 30.48, + 'in': 2.54, + 'ft\'': 30.48, + 'in"': 2.54, + 'pt': 0.0352778, + 'ft-in': 30.48 +}; + +export const convertUnit = (value, unit, newUnit) => { + return value * unitConversion[unit] / unitConversion[newUnit]; +}; + +export const scalePresetPrecision = { + [imperialPreset[0][0]]: fractionalPrecisions[1], + [imperialPreset[1][0]]: fractionalPrecisions[2], + [imperialPreset[2][0]]: fractionalPrecisions[0], + [imperialPreset[3][0]]: fractionalPrecisions[1], + [imperialPreset[4][0]]: fractionalPrecisions[0], + [imperialPreset[5][0]]: fractionalPrecisions[0], + [imperialPreset[6][0]]: fractionalPrecisions[0], + [imperialPreset[7][0]]: fractionalPrecisions[0], + [imperialPreset[8][0]]: fractionalPrecisions[0] +}; + +export const initialScale = new Scale({ pageScale: { value: 1, unit: 'in' }, worldScale: { value: 1, unit: 'in' } }); diff --git a/src/constants/measurementTypes.js b/src/constants/measurementTypes.js new file mode 100644 index 0000000000..2ef30b46ff --- /dev/null +++ b/src/constants/measurementTypes.js @@ -0,0 +1,9 @@ +export const measurementTypeTranslationMap = { + distanceMeasurement: 'option.measurementOverlay.distanceMeasurement', + perimeterMeasurement: 'option.measurementOverlay.perimeterMeasurement', + areaMeasurement: 'option.measurementOverlay.areaMeasurement', + rectangularAreaMeasurement: 'option.measurementOverlay.areaMeasurement', + cloudyRectangularAreaMeasurement: 'option.measurementOverlay.areaMeasurement', + ellipseMeasurement: 'option.measurementOverlay.areaMeasurement', + arcMeasurement: 'option.measurementOverlay.arcMeasurement', +}; \ No newline at end of file diff --git a/src/constants/multiViewerContants.js b/src/constants/multiViewerContants.js new file mode 100644 index 0000000000..c0c1865cb2 --- /dev/null +++ b/src/constants/multiViewerContants.js @@ -0,0 +1,7 @@ +export const DISABLED_TOOL_GROUPS = ['toolbarGroup-Edit', 'toolbarGroup-Forms', 'toolbarGroup-EditText']; +export const DISABLED_TOOLS_KEYWORDS = ['Content', 'AddParagraphTool', 'FormField', 'Crop']; + +export const SYNC_MODES = { + 'SYNC': 'SYNC', + 'SKIP_UNMATCHED': 'SKIP_UNMATCHED', +}; diff --git a/src/constants/officeEditorFonts.js b/src/constants/officeEditorFonts.js new file mode 100644 index 0000000000..d53ebdfe48 --- /dev/null +++ b/src/constants/officeEditorFonts.js @@ -0,0 +1,170 @@ +export const availableFontFaces = [ + 'Arial', + 'Arial Black', + 'Arial Narrow', + 'Arial Rounded MT Bold', + 'Baskerville Old Face', + 'Bookman Old Style', + 'Bookshelf Symbol 7', + 'Brush Script MT', + 'Calibri', + 'Calibri Light', + 'Cambria', + 'Cambria Math', + 'Century', + 'Century Schoolbook', + 'Comic Sans MS', + 'Consolas', + 'Cooper Black', + 'Copperplate Gothic Light', + 'Courier', + 'Courier New', + 'Garamond', + 'Georgia', + 'Gill Sans MT', + 'Gill Sans MT Condensed', + 'Helvetica', + 'Lucida Console', + 'MS Outlook', + 'Malgun Gothic', + 'Meiryo', + 'monospace', + 'Myriad Pro', + 'sans-serif', + 'serif', + 'SimSun', + 'Symbol', + 'Tahoma', + 'Tahoma Bold', + 'Times New Roman', + 'Trebuchet MS', + 'Verdana', +]; + +export const cssFontValues = { + 'Arial': { + fontFamily: 'Arial, Helvetica, sans-serif', + }, + 'Arial Black': { + fontFamily: '"Arial Black", Gadget, sans-serif', + }, + 'Arial Italic': { + fontFamily: 'Arial, Helvetica, sans-serif', + fontStyle: 'italic', + }, + 'Arial Narrow': { + fontFamily: '"Arial Narrow", sans-serif', + }, + 'Arial Rounded MT Bold': { + fontFamily: '"Arial Rounded MT Bold", sans-serif', + }, + 'Baskerville Old Face': { + fontFamily: '"Baskerville Old Face", "Book Antiqua", Palatino, serif', + }, + 'Bookman Old Style': { + fontFamily: '"Bookman Old Style", serif', + }, + 'Bookshelf Symbol 7': { + fontFamily: '"Bookshelf Symbol 7", sans-serif', + }, + 'Brush Script MT': { + fontFamily: '"Brush Script MT", cursive', + }, + 'Calibri': { + fontFamily: 'Calibri, Candara, Segoe, "Segoe UI", Optima, Arial, sans-serif', + }, + 'Calibri Light': { + fontFamily: 'Calibri, Candara, Segoe, "Segoe UI", Optima, Arial, sans-serif', + }, + 'Cambria': { + fontFamily: 'Cambria, Georgia, serif', + }, + 'Cambria Math': { + fontFamily: '"Cambria Math", serif', + }, + 'Century': { + fontFamily: 'Century, sans-serif', + }, + 'Century Schoolbook': { + fontFamily: '"Century Schoolbook", serif', + }, + 'Comic Sans MS': { + fontFamily: '"Comic Sans MS", cursive, sans-serif', + }, + 'Consolas': { + fontFamily: 'Consolas, monaco, monospace', + }, + 'Consolas Italic': { + fontFamily: 'Consolas, monaco, monospace', + fontStyle: 'italic', + }, + 'Cooper Black': { + fontFamily: '"Cooper Black", sans-serif', + }, + 'Copperplate Gothic Light': { + fontFamily: '"Copperplate Gothic Light", sans-serif', + }, + 'Courier': { + fontFamily: 'Courier, monospace', + }, + 'Courier New': { + fontFamily: '"Courier New", Courier, monospace', + }, + 'Garamond': { + fontFamily: 'Garamond, serif', + }, + 'Georgia': { + fontFamily: 'Georgia, serif', + }, + 'Gill Sans MT': { + fontFamily: '"Gill Sans MT", sans-serif', + }, + 'Gill Sans MT Condensed': { + fontFamily: '"Gill Sans MT Condensed", sans-serif', + }, + 'Helvetica': { + fontFamily: 'Helvetica, Arial, sans-serif', + }, + 'Lucida Console': { + fontFamily: '"Lucida Console", Monaco, monospace', + }, + 'MS Outlook': { + fontFamily: '"MS Outlook", sans-serif', + }, + 'Malgun Gothic': { + fontFamily: '"Malgun Gothic", sans-serif', + }, + 'Meiryo': { + fontFamily: 'Meiryo, sans-serif', + }, + 'monospace': { + fontFamily: 'monospace', + }, + 'Myriad Pro': { + fontFamily: '"Myriad Pro", Myriad, sans-serif', + }, + 'sans-serif': { + fontFamily: 'sans-serif', + }, + 'serif': { + fontFamily: 'serif', + }, + 'Symbol': { + fontFamily: 'Symbol, sans-serif', + }, + 'SimSun': { + fontFamily: '"SimSun", sans-serif', + }, + 'Tahoma': { + fontFamily: 'Tahoma, Geneva, sans-serif', + }, + 'Times New Roman': { + fontFamily: '"Times New Roman", Times, serif', + }, + 'Trebuchet MS': { + fontFamily: '"Trebuchet MS", Helvetica, sans-serif', + }, + 'Verdana': { + fontFamily: 'Verdana, Geneva, sans-serif', + }, +}; diff --git a/src/constants/pageNumberPlaceholder.js b/src/constants/pageNumberPlaceholder.js new file mode 100644 index 0000000000..c597b8f901 --- /dev/null +++ b/src/constants/pageNumberPlaceholder.js @@ -0,0 +1 @@ +export default '1, 3, 5-10'; \ No newline at end of file diff --git a/src/constants/presetNewPageDimensions.js b/src/constants/presetNewPageDimensions.js new file mode 100644 index 0000000000..5535946921 --- /dev/null +++ b/src/constants/presetNewPageDimensions.js @@ -0,0 +1,14 @@ +export default { + 'Letter': { + 'height': 11, + 'width': 8.5, + }, + 'Half letter': { + 'height': 5.5, + 'width': 8.5, + }, + 'Junior legal': { + 'height': 5, + 'width': 8, + }, +}; \ No newline at end of file diff --git a/src/constants/signatureModes.js b/src/constants/signatureModes.js new file mode 100644 index 0000000000..a28b91c554 --- /dev/null +++ b/src/constants/signatureModes.js @@ -0,0 +1,6 @@ +const SignatureModes = { + FULL_SIGNATURE: window.Core.Tools.SignatureCreateTool.SignatureTypes.FULL_SIGNATURE, + INITIALS: window.Core.Tools.SignatureCreateTool.SignatureTypes.INITIALS, +}; + +export default SignatureModes; \ No newline at end of file diff --git a/src/constants/webFonts.js b/src/constants/webFonts.js new file mode 100644 index 0000000000..09788bd9a5 --- /dev/null +++ b/src/constants/webFonts.js @@ -0,0 +1,14 @@ +// web fonts from https://www.pdftron.com/webfonts/v2/fonts.json +// that support bold, italic, and bold-italic +// and can be used in content editing +export default [ + 'Arimo', + 'Caladea', + 'Carlito', + 'Cousine', + 'Liberation Serif', + 'Open Sans', + 'Roboto', + 'Roboto Mono', + 'Tinos', +]; diff --git a/src/core/createAndApplyScale.js b/src/core/createAndApplyScale.js new file mode 100644 index 0000000000..800947737b --- /dev/null +++ b/src/core/createAndApplyScale.js @@ -0,0 +1,8 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.MeasurementManager.html#createScale__anchor + */ +export default (scale, applyTo, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).getMeasurementManager().createAndApplyScale({ scale, applyTo }); +}; diff --git a/src/core/deleteScale.js b/src/core/deleteScale.js new file mode 100644 index 0000000000..df4f9cc2d3 --- /dev/null +++ b/src/core/deleteScale.js @@ -0,0 +1,8 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.MeasurementManager.html#deleteScale__anchor + */ +export default (scale, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).getMeasurementManager().deleteScale(scale); +}; diff --git a/src/core/deselectAnnotations.js b/src/core/deselectAnnotations.js new file mode 100644 index 0000000000..7bf77bbf79 --- /dev/null +++ b/src/core/deselectAnnotations.js @@ -0,0 +1,10 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.AnnotationManager.html#deselectAnnotations__anchor + * @fires annotationSelected on AnnotationManager + * @see https://docs.apryse.com/api/web/Core.AnnotationManager.html#event:annotationSelected__anchor + */ +export default (annotations, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).getAnnotationManager().deselectAnnotations(annotations); +}; diff --git a/src/core/documentViewers.js b/src/core/documentViewers.js new file mode 100644 index 0000000000..10ee9e51a4 --- /dev/null +++ b/src/core/documentViewers.js @@ -0,0 +1,18 @@ +const documentViewerMap = new Map(); + +export const setDocumentViewer = (number, documentViewer) => { + documentViewerMap.set(number, documentViewer); + return documentViewer; +}; + +export const deleteDocumentViewer = (number) => { + documentViewerMap.delete(number); +}; + +export const getDocumentViewer = (number = 1) => { + return documentViewerMap.get(number); +}; + +export const getDocumentViewers = () => { + return Array.from(documentViewerMap.values()); +}; \ No newline at end of file diff --git a/src/core/enableAnnotationNumbering.js b/src/core/enableAnnotationNumbering.js new file mode 100644 index 0000000000..e05e8dc68c --- /dev/null +++ b/src/core/enableAnnotationNumbering.js @@ -0,0 +1,8 @@ +import getAnnotationManager from './getAnnotationManager'; + +/** + * https://docs.apryse.com/api/web/Core.AnnotationManager.html#enableAnnotationNumbering__anchor + */ +export default (documentViewerKey = 1) => { + getAnnotationManager(documentViewerKey).enableAnnotationNumbering(); +}; \ No newline at end of file diff --git a/src/core/getAllowedFileExtensions.js b/src/core/getAllowedFileExtensions.js new file mode 100644 index 0000000000..f87656ffeb --- /dev/null +++ b/src/core/getAllowedFileExtensions.js @@ -0,0 +1,8 @@ +/** + * https://docs.apryse.com/api/web/Core.html#.getAllowedFileExtensions__anchor + */ +export default () => { + return window.Core.getAllowedFileExtensions().length > 0 ? + window.Core.getAllowedFileExtensions().map((format) => `.${format}`,).join(', ') : + window.Core.SupportedFileFormats.CLIENT.map((format) => `.${format}`,).join(', '); +}; diff --git a/src/core/getContentEditManager.js b/src/core/getContentEditManager.js new file mode 100644 index 0000000000..9a7e2438a0 --- /dev/null +++ b/src/core/getContentEditManager.js @@ -0,0 +1,3 @@ +import core from 'core'; + +export default (pageNumber, documentViewerKey = 1) => core.getDocumentViewer(documentViewerKey).getContentEditManager(); diff --git a/src/core/getOfficeEditor.js b/src/core/getOfficeEditor.js new file mode 100644 index 0000000000..1d53fcdc17 --- /dev/null +++ b/src/core/getOfficeEditor.js @@ -0,0 +1,3 @@ +import core from 'core'; + +export default (documentViewerKey = 1) => core.getDocumentViewer(documentViewerKey).getDocument().getOfficeEditor(); diff --git a/src/core/getResultCode.js b/src/core/getResultCode.js new file mode 100644 index 0000000000..8a6dd62c4a --- /dev/null +++ b/src/core/getResultCode.js @@ -0,0 +1 @@ +export default () => window.Core.Search.ResultCode; diff --git a/src/core/getScalePrecision.js b/src/core/getScalePrecision.js new file mode 100644 index 0000000000..713cf553f7 --- /dev/null +++ b/src/core/getScalePrecision.js @@ -0,0 +1,8 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.MeasurementManager.html#getScalePrecision__anchor + */ +export default (scale, documentViewerKey = 1) => { + return core.getDocumentViewer(documentViewerKey).getMeasurementManager().getScalePrecision(scale); +}; diff --git a/src/core/getScales.js b/src/core/getScales.js new file mode 100644 index 0000000000..61fe650045 --- /dev/null +++ b/src/core/getScales.js @@ -0,0 +1,8 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.MeasurementManager.html#getScales__anchor + */ +export default (documentViewerKey = 1) => { + return core.getDocumentViewer(documentViewerKey).getMeasurementManager().getScales(); +}; diff --git a/src/core/getSemanticDiffAnnotations.js b/src/core/getSemanticDiffAnnotations.js new file mode 100644 index 0000000000..6a3e498b78 --- /dev/null +++ b/src/core/getSemanticDiffAnnotations.js @@ -0,0 +1,6 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.AnnotationManager.html#getSemanticDiffAnnotations__anchor + */ +export default (documentViewerKey = 1) => core.getDocumentViewer(documentViewerKey).getAnnotationManager().getSemanticDiffAnnotations(); \ No newline at end of file diff --git a/src/core/getToolsFromAllDocumentViewers.js b/src/core/getToolsFromAllDocumentViewers.js new file mode 100644 index 0000000000..8087d59535 --- /dev/null +++ b/src/core/getToolsFromAllDocumentViewers.js @@ -0,0 +1,6 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#getTool__anchor + */ +export default (toolName) => core.getDocumentViewers().map((documentViewer) => documentViewer.getTool(toolName)); diff --git a/src/core/isSearchResultEqual.js b/src/core/isSearchResultEqual.js new file mode 100644 index 0000000000..e3b5c1e2d5 --- /dev/null +++ b/src/core/isSearchResultEqual.js @@ -0,0 +1 @@ +export default (resultA, resultB) => window.Core.Search.isSearchResultEqual(resultA, resultB); diff --git a/src/core/loadBlankOfficeEditorDocument.js b/src/core/loadBlankOfficeEditorDocument.js new file mode 100644 index 0000000000..2ad4f2ad33 --- /dev/null +++ b/src/core/loadBlankOfficeEditorDocument.js @@ -0,0 +1,6 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#loadBlankOfficeEditorDocument + */ +export default (options, documentViewerKey = 1) => core.getDocumentViewer(documentViewerKey).loadBlankOfficeEditorDocument(options); diff --git a/src/core/replaceScales.js b/src/core/replaceScales.js new file mode 100644 index 0000000000..83422b43bd --- /dev/null +++ b/src/core/replaceScales.js @@ -0,0 +1,8 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.MeasurementManager.html#replaceScales__anchor + */ +export default (originalScales, scale, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).getMeasurementManager().replaceScales(originalScales, scale); +}; diff --git a/src/core/setBookmarkIconShortcutVisibility.js b/src/core/setBookmarkIconShortcutVisibility.js new file mode 100644 index 0000000000..6aa7f2c17c --- /dev/null +++ b/src/core/setBookmarkIconShortcutVisibility.js @@ -0,0 +1,9 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkIconShortcutVisibility__anchor + * @see https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkIconShortcutVisibility__anchor + */ +export default (isEnabled, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).setBookmarkIconShortcutVisibility(isEnabled); +}; diff --git a/src/core/setBookmarkShortcutToggleOffFunction.js b/src/core/setBookmarkShortcutToggleOffFunction.js new file mode 100644 index 0000000000..257d402db9 --- /dev/null +++ b/src/core/setBookmarkShortcutToggleOffFunction.js @@ -0,0 +1,9 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkShortcutToggleOffFunction__anchor + * @see https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkShortcutToggleOffFunction__anchor + */ +export default (callback, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).setBookmarkShortcutToggleOffFunction(callback); +}; diff --git a/src/core/setBookmarkShortcutToggleOnFunction.js b/src/core/setBookmarkShortcutToggleOnFunction.js new file mode 100644 index 0000000000..173af37655 --- /dev/null +++ b/src/core/setBookmarkShortcutToggleOnFunction.js @@ -0,0 +1,9 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkShortcutToggleOnFunction__anchor + * @see https://docs.apryse.com/api/web/Core.DocumentViewer.html#setBookmarkShortcutToggleOnFunction__anchor + */ +export default (callback, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).setBookmarkShortcutToggleOnFunction(callback); +}; diff --git a/src/core/setUserBookmarks.js b/src/core/setUserBookmarks.js new file mode 100644 index 0000000000..24dd049e21 --- /dev/null +++ b/src/core/setUserBookmarks.js @@ -0,0 +1,9 @@ +import core from 'core'; + +/** + * https://docs.apryse.com/api/web/Core.DocumentViewer.html#setUserBookmarks__anchor + * @see https://docs.apryse.com/api/web/Core.DocumentViewer.html#setUserBookmarks__anchor + */ +export default (bookmarks, documentViewerKey = 1) => { + core.getDocumentViewer(documentViewerKey).setUserBookmarks(bookmarks); +}; diff --git a/src/event-listeners/onCaretAnnotationAdded.js b/src/event-listeners/onCaretAnnotationAdded.js new file mode 100644 index 0000000000..836f7204d7 --- /dev/null +++ b/src/event-listeners/onCaretAnnotationAdded.js @@ -0,0 +1,29 @@ +import core from 'core'; +import actions from 'actions'; +import selectors from 'selectors'; +import DataElements from 'src/constants/dataElement'; + +export default ({ dispatch, getState }) => (annotation) => { + const state = getState(); + const isNotesPanelDisabled = selectors.isElementDisabled(state, DataElements.NOTES_PANEL); + const isNotesPanelOpen = selectors.isElementOpen(state, DataElements.NOTES_PANEL); + const isInlineCommentDisabled = selectors.isElementDisabled(state, DataElements.INLINE_COMMENT_POPUP); + + if (isNotesPanelDisabled) { + return; + } + + dispatch(actions.closeElement('searchPanel')); + dispatch(actions.closeElement(DataElements.REDACTION_PANEL)); + if (!isInlineCommentDisabled || isNotesPanelOpen) { + core.selectAnnotation(annotation); + dispatch(actions.triggerNoteEditing()); + } else { + dispatch(actions.openElement(DataElements.NOTES_PANEL)); + // wait for the notes panel to be fully opened before focusing + setTimeout(() => { + core.selectAnnotation(annotation); + dispatch(actions.triggerNoteEditing()); + }, 400); + } +}; diff --git a/src/event-listeners/onContentEditModeEnded.js b/src/event-listeners/onContentEditModeEnded.js index 99814b480c..213007167a 100644 --- a/src/event-listeners/onContentEditModeEnded.js +++ b/src/event-listeners/onContentEditModeEnded.js @@ -1,5 +1,13 @@ import actions from 'actions'; +import selectors from 'selectors'; +import core from 'core'; -export default (dispatch) => () => { +export default (dispatch, store) => () => { dispatch(actions.enableElements(['thumbnailControl', 'documentControl'])); + const featureFlags = selectors.getFeatureFlags(store.getState()); + const { customizableUI } = featureFlags; + + if (customizableUI) { + core.setToolMode('AnnotationEdit'); + } }; diff --git a/src/event-listeners/onFormFieldCreationModeStarted.js b/src/event-listeners/onFormFieldCreationModeStarted.js index 929d3dc8e3..19545b7f5a 100644 --- a/src/event-listeners/onFormFieldCreationModeStarted.js +++ b/src/event-listeners/onFormFieldCreationModeStarted.js @@ -15,7 +15,6 @@ const formBuilderDefaultDisabledKeys = { PRINT: 'print', BOOKMARK: 'bookmark', SWITCH_PAN: 'switchPan', - SELECT: 'select', PAN: 'pan', ARROW: 'arrow', CALLOUT: 'callout', diff --git a/src/event-listeners/onImageContentAdded.js b/src/event-listeners/onImageContentAdded.js new file mode 100644 index 0000000000..02cc392960 --- /dev/null +++ b/src/event-listeners/onImageContentAdded.js @@ -0,0 +1,9 @@ +import core from 'core'; +import actions from 'actions'; +import defaultTool from 'constants/defaultTool'; + +export default (dispatch) => (annotation) => { + core.setToolMode(defaultTool); + dispatch(actions.setActiveToolGroup('')); + core.selectAnnotation(annotation); +}; \ No newline at end of file diff --git a/src/event-listeners/onInitialDeleted.js b/src/event-listeners/onInitialDeleted.js new file mode 100644 index 0000000000..b6fe9a6c44 --- /dev/null +++ b/src/event-listeners/onInitialDeleted.js @@ -0,0 +1,10 @@ +import core from 'core'; +import actions from 'actions'; +import getSignatureDataToStore from 'helpers/getSignatureDataToStore'; + +export default (dispatch) => async () => { + const signatureTool = core.getTool('AnnotationCreateSignature'); + const savedInitials = signatureTool.getSavedInitials(); + const newSavedInitials = await getSignatureDataToStore(savedInitials); + dispatch(actions.setSavedInitials(newSavedInitials)); +}; diff --git a/src/event-listeners/onInitialSaved.js b/src/event-listeners/onInitialSaved.js new file mode 100644 index 0000000000..a1296c41a3 --- /dev/null +++ b/src/event-listeners/onInitialSaved.js @@ -0,0 +1,22 @@ +import core from 'core'; +import selectors from 'selectors'; +import actions from 'actions'; +import getSignatureDataToStore from 'helpers/getSignatureDataToStore'; + +export default (dispatch, store) => async () => { + const signatureTool = core.getTool('AnnotationCreateSignature'); + let savedInitials = signatureTool.getSavedInitials(); + const maxSignaturesCount = selectors.getMaxSignaturesCount(store.getState()); + const numberOfInitialsToRemove = savedInitials.length - maxSignaturesCount; + + if (numberOfInitialsToRemove > 0) { + // to keep the UI sync with the signatures saved in the tool + for (let i = 0; i < numberOfInitialsToRemove; i++) { + signatureTool.deleteSavedInitials(0); + } + } + + savedInitials = signatureTool.getSavedInitials(); + const initialsToStore = await getSignatureDataToStore(savedInitials); + dispatch(actions.setSavedInitials(initialsToStore)); +}; \ No newline at end of file diff --git a/src/event-listeners/onLayersUpdated.js b/src/event-listeners/onLayersUpdated.js index da98b2f6d2..edc3c4d549 100644 --- a/src/event-listeners/onLayersUpdated.js +++ b/src/event-listeners/onLayersUpdated.js @@ -1,10 +1,10 @@ import actions from 'actions'; -import equal from 'fast-deep-equal'; +import _isEqual from 'lodash/isEqual'; import setUIPropertiesForLayers from 'helpers/setUIPropertiesForLayers'; export default (newOCGLayers, currentOCGLayers, dispatch) => { - const isEqual = equal(newOCGLayers, currentOCGLayers); - if (!isEqual) { + const layersEqual = _isEqual(newOCGLayers, currentOCGLayers); + if (!layersEqual) { const layersToSet = setUIPropertiesForLayers(newOCGLayers); dispatch(actions.setLayers(layersToSet)); } diff --git a/src/event-listeners/onSignatureSaved.js b/src/event-listeners/onSignatureSaved.js index cbe46d890f..20275a6026 100644 --- a/src/event-listeners/onSignatureSaved.js +++ b/src/event-listeners/onSignatureSaved.js @@ -18,5 +18,7 @@ export default (dispatch, store) => async () => { savedSignatures = signatureTool.getSavedSignatures(); const signaturesToStore = await getSignatureDataToStore(savedSignatures); + // get the last element of the array (LIFO) and set it as active so it can be shown in the new signature list panel + dispatch(actions.setSelectedDisplayedSignatureIndex(signaturesToStore.length - 1)); dispatch(actions.setSavedSignatures(signaturesToStore)); }; \ No newline at end of file diff --git a/src/helpers/checkFeaturesToEnable.js b/src/helpers/checkFeaturesToEnable.js new file mode 100644 index 0000000000..277981c571 --- /dev/null +++ b/src/helpers/checkFeaturesToEnable.js @@ -0,0 +1,38 @@ +import { mapKeyToToolNames, annotationMapKeys } from 'constants/map'; +import Feature from 'constants/feature'; +import { getInstanceNode } from './getRootNode'; +import DataElements from 'constants/dataElement'; + +const checkFeaturesToEnable = (componentsMap) => { + const keys = Object.keys(componentsMap); + const measurementTools = [ + mapKeyToToolNames(annotationMapKeys.DISTANCE_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.PERIMETER_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.ARC_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.RECTANGULAR_AREA_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.CLOUDY_RECTANGULAR_AREA_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.AREA_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.ELLIPSE_MEASUREMENT), + mapKeyToToolNames(annotationMapKeys.COUNT_MEASUREMENT), + ].flat(); + const contentEditTools = mapKeyToToolNames(annotationMapKeys.CONTENT_EDIT_TOOL); + const redactTools = mapKeyToToolNames(annotationMapKeys.REDACTION); + const instance = getInstanceNode().instance; + + for (let index = 0, len = keys.length; index < len; index++) { + const element = componentsMap[keys[index]]; + if (element.dataElement === DataElements.FILE_PICKER_BUTTON) { + instance.UI.enableFilePicker(); + } else if (element.dataElement === DataElements.CREATE_PORTFOLIO) { + instance.UI.enableFeatures(Feature.Portfolio); + } else if (redactTools.indexOf(element.toolName) > -1) { + instance.UI.enableRedaction(); + } else if (measurementTools.indexOf(element.toolName) > -1) { + instance.UI.enableMeasurement(); + } else if (contentEditTools.indexOf(element.toolName) > -1) { + instance.UI.enableFeatures(Feature.ContentEdit); + } + } +}; + +export default checkFeaturesToEnable; \ No newline at end of file diff --git a/src/helpers/checkFeaturesToEnable.spec.js b/src/helpers/checkFeaturesToEnable.spec.js new file mode 100644 index 0000000000..31e58825e7 --- /dev/null +++ b/src/helpers/checkFeaturesToEnable.spec.js @@ -0,0 +1,48 @@ +import checkFeaturesToEnable from './checkFeaturesToEnable'; +import { getInstanceNode } from './getRootNode'; + +jest.mock('./getRootNode', () => { + const original = jest.requireActual('./getRootNode'); // Step 2. + + return { + ...original, + getInstanceNode: jest.fn(() => {}) + }; +}); + +describe('checkFeaturesToEnable', () => { + it('Should call checkFeaturesToEnable functions', () => { + const mockMethod = jest.fn(); + const mockMethod2 = jest.fn(); + const mockMethod3 = jest.fn(); + const mockMethod4 = jest.fn(); + getInstanceNode.mockImplementation(() => { + return { + instance: { + UI: { + enableFeatures: mockMethod, + enableFilePicker: mockMethod2, + enableRedaction: mockMethod3, + enableMeasurement: mockMethod4, + } + } + }; + }); + const instance = getInstanceNode().instance; + checkFeaturesToEnable({ + arcMeasurementToolButton: { + toolName: 'AnnotationCreateArcMeasurement', + dataElement: 'arcMeasurementToolButton', + }, + contentEditButton: { + toolName: 'ContentEditTool', + dataElement: 'contentEditButton', + } + }); + + expect(instance.UI.enableFeatures).toHaveBeenCalledTimes(1); + expect(instance.UI.enableFilePicker).not.toBeCalled(); + expect(instance.UI.enableMeasurement).toHaveBeenCalledTimes(1); + expect(instance.UI.enableRedaction).not.toBeCalled(); + }); +}); \ No newline at end of file diff --git a/src/helpers/clickTracker.js b/src/helpers/clickTracker.js new file mode 100644 index 0000000000..ef215d72db --- /dev/null +++ b/src/helpers/clickTracker.js @@ -0,0 +1,11 @@ +export const ClickedItemTypes = { + BUTTON: 'button', +}; + +let clickMiddleWare; + +export const setClickMiddleWare = (middleware) => { + clickMiddleWare = middleware; +}; + +export const getClickMiddleWare = () => clickMiddleWare; diff --git a/src/helpers/getAngleInRadians.js b/src/helpers/getAngleInRadians.js new file mode 100644 index 0000000000..1a20add5b5 --- /dev/null +++ b/src/helpers/getAngleInRadians.js @@ -0,0 +1,21 @@ +export default (pt1, pt2, pt3) => { + let angle; + + if (pt1 && pt2) { + if (pt3) { + // calculate the angle using Law of cosines + const AB = Math.sqrt(Math.pow(pt2.x - pt1.x, 2) + Math.pow(pt2.y - pt1.y, 2)); + const BC = Math.sqrt(Math.pow(pt2.x - pt3.x, 2) + Math.pow(pt2.y - pt3.y, 2)); + const AC = Math.sqrt(Math.pow(pt3.x - pt1.x, 2) + Math.pow(pt3.y - pt1.y, 2)); + angle = Math.acos((BC * BC + AB * AB - AC * AC) / (2 * BC * AB)); + } else { + // if there are only two points returns the angle in the plane (in radians) between the positive x-axis and the ray from (0,0) to the point (x,y) + angle = Math.atan2(pt2.y - pt1.y, pt2.x - pt1.x); + // keep the angle range between 0 and Math.PI / 2 + angle = Math.abs(angle); + angle = angle > Math.PI / 2 ? Math.PI - angle : angle; + } + } + + return angle; +}; diff --git a/src/helpers/getDeviceSize.js b/src/helpers/getDeviceSize.js new file mode 100644 index 0000000000..9e1fbc502b --- /dev/null +++ b/src/helpers/getDeviceSize.js @@ -0,0 +1,55 @@ +import getRootNode from './getRootNode'; +import useMedia from 'hooks/useMedia'; +import { MOBILE_SIZE, TABLET_SIZE } from 'constants/deviceSizes'; + +export const isMobileSize = () => { + if (window.isApryseWebViewerWebComponent) { + return getRootNode()?.host.clientWidth <= MOBILE_SIZE; + } + return useMedia( + // Media queries + [`(max-width: ${MOBILE_SIZE}px)`], + [true], + // Default value + false, + ); +}; + +export const isTabletSize = () => { + if (window.isApryseWebViewerWebComponent) { + return getRootNode()?.host.clientWidth > MOBILE_SIZE && getRootNode()?.host.clientWidth <= TABLET_SIZE; + } + return useMedia( + // Media queries + [`(min-width: ${MOBILE_SIZE + 1}px) and (max-width: ${TABLET_SIZE}px)`], + [true], + // Default value + false, + ); +}; + +export const isTabletAndMobileSize = () => { + if (window.isApryseWebViewerWebComponent) { + return getRootNode()?.host.clientWidth <= TABLET_SIZE; + } + return useMedia( + // Media queries + [`(max-width: ${TABLET_SIZE}px)`], + [true], + // Default value + false, + ); +}; + +export const isDesktopSize = () => { + if (window.isApryseWebViewerWebComponent) { + return getRootNode()?.host.clientWidth > TABLET_SIZE; + } + return useMedia( + // Media queries + [`(min-width: ${TABLET_SIZE + 1}px)`], + [true], + // Default value + false, + ); +}; \ No newline at end of file diff --git a/src/helpers/getElements.js b/src/helpers/getElements.js index c42cc6ea55..5d9f558b6d 100644 --- a/src/helpers/getElements.js +++ b/src/helpers/getElements.js @@ -12,8 +12,4 @@ export function getAllOpenedModals() { export function getDatePicker() { return document.querySelector('[data-element="datePickerContainer"]'); -} - -export function getAllPanels(location) { - return document.querySelectorAll(`.flx-Panel.${location}`); -} +} \ No newline at end of file diff --git a/src/helpers/getNumberOfDecimalPlaces.js b/src/helpers/getNumberOfDecimalPlaces.js new file mode 100644 index 0000000000..7469c00e83 --- /dev/null +++ b/src/helpers/getNumberOfDecimalPlaces.js @@ -0,0 +1 @@ +export default (precision) => (precision === 1 ? 0 : precision?.toString().split('.')[1].length); diff --git a/src/helpers/getRootNode.js b/src/helpers/getRootNode.js index ae37eb3f8e..7b900be470 100644 --- a/src/helpers/getRootNode.js +++ b/src/helpers/getRootNode.js @@ -1,5 +1,21 @@ let rootNode; +function findNestedWebComponents(tagName, root = document) { + const elements = []; + + // Check direct children + root.querySelectorAll(tagName).forEach((el) => elements.push(el)); + + // Check shadow DOMs + root.querySelectorAll('*').forEach((el) => { + if (el.shadowRoot) { + elements.push(...findNestedWebComponents(tagName, el.shadowRoot)); + } + }); + + return elements; +} + const getRootNode = () => { if (!window.isApryseWebViewerWebComponent) { return document; @@ -7,7 +23,13 @@ const getRootNode = () => { if (rootNode) { return rootNode; } - const elementList = document.getElementsByTagName('apryse-webviewer'); + + let elementList; + elementList = document.getElementsByTagName('apryse-webviewer'); + if (elementList.length === 0) { + elementList = findNestedWebComponents('apryse-webviewer'); + } + if (elementList?.length) { for (const element of elementList) { const foundNode = element.shadowRoot; diff --git a/src/helpers/handleFreeTextAutoSizeToggle.js b/src/helpers/handleFreeTextAutoSizeToggle.js new file mode 100644 index 0000000000..9fd6420a32 --- /dev/null +++ b/src/helpers/handleFreeTextAutoSizeToggle.js @@ -0,0 +1,22 @@ +import core from 'core'; + +/** + * @ignore + * handler for auto size font toggle + * @param {FreeTextAnnotation} annotation annotation to toggle auto size font + * @param {function} setAutoSizeFont function to set auto size font + * @param {boolean} isAutoSizeFont current auto size font value + */ +export default (annotation, setAutoSizeFont, isAutoSizeFont) => { + const freeTextAnnot = annotation; + const calculatedFontSize = freeTextAnnot.getCalculatedFontSize(); + if (isAutoSizeFont) { + freeTextAnnot.FontSize = calculatedFontSize; + } else { + freeTextAnnot.switchToAutoFontSize(); + } + + setAutoSizeFont(!isAutoSizeFont); + core.getAnnotationManager().redrawAnnotation(freeTextAnnot); +}; + diff --git a/src/helpers/initialColorStates.js b/src/helpers/initialColorStates.js new file mode 100644 index 0000000000..fb754c8a4c --- /dev/null +++ b/src/helpers/initialColorStates.js @@ -0,0 +1,50 @@ +const initialColors = [ + '#e44234', + '#ff8d00', + '#ffcd45', + '#5cc96e', + '#25d2d1', + '#597ce2', + '#c544ce', + '#7d2e25', + '#a84f1d', + '#e99e38', + '#347842', + '#167e7d', + '#354a87', + '#76287b', + '#ffffff', + '#cdcdcd', + '#9c9c9c', + '#696969', + '#272727', + '#000000' +]; + +const initialTextColors = [ + '#000000', + '#272727', + '#696969', + '#9c9c9c', + '#cdcdcd', + '#ffffff', + '#7d2e25', + '#a84f1d', + '#e99e38', + '#347842', + '#167e7d', + '#354a87', + '#76287b', + '#e44234', + '#ff8d00', + '#ffcd45', + '#5cc96e', + '#25d2d1', + '#597ce2', + '#c544ce' +]; + +export { + initialColors, + initialTextColors, +}; \ No newline at end of file diff --git a/src/helpers/multiViewerHelper.js b/src/helpers/multiViewerHelper.js new file mode 100644 index 0000000000..34503633be --- /dev/null +++ b/src/helpers/multiViewerHelper.js @@ -0,0 +1,14 @@ +const multiViewerHelper = { + matchedPages: null, + isScrolledByClickingChangeItem: false, +}; + +export const setIsScrolledByClickingChangeItem = (value) => { + multiViewerHelper.isScrolledByClickingChangeItem = value; +}; + +export const getIsScrolledByClickingChangeItem = () => { + return multiViewerHelper.isScrolledByClickingChangeItem; +}; + +export default multiViewerHelper; diff --git a/src/helpers/officeEditor.js b/src/helpers/officeEditor.js new file mode 100644 index 0000000000..b94a760de3 --- /dev/null +++ b/src/helpers/officeEditor.js @@ -0,0 +1,6 @@ +import core from 'core'; +import { workerTypes } from 'constants/types'; + +export function isOfficeEditorMode() { + return core.getDocument()?.getType() === workerTypes.OFFICE_EDITOR; +} diff --git a/src/helpers/openOfficeEditorFilePicker.js b/src/helpers/openOfficeEditorFilePicker.js new file mode 100644 index 0000000000..9d3e3e8c1b --- /dev/null +++ b/src/helpers/openOfficeEditorFilePicker.js @@ -0,0 +1,5 @@ +import getRootNode from 'helpers/getRootNode'; + +export default () => { + getRootNode().querySelector('#office-editor-file-picker')?.click(); +}; diff --git a/src/helpers/sanitizeSVG.js b/src/helpers/sanitizeSVG.js new file mode 100644 index 0000000000..06208e4d7a --- /dev/null +++ b/src/helpers/sanitizeSVG.js @@ -0,0 +1,43 @@ +import DOMPurify from 'dompurify'; + +const SVG_MIME_TYPE = 'image/svg+xml'; + +const hasFileSize = (file) => { + return file.size !== undefined; +}; + +// Taken from https://github.com/mattkrick/sanitize-svg/blob/master/src/sanitizeSVG.ts#L31 +const readAsText = (svg) => { + return new Promise((resolve) => { + if (!hasFileSize(svg)) { + resolve(svg.toString('utf-8')); + } else { + const fileReader = new FileReader(); + fileReader.onload = () => resolve(fileReader.result); + fileReader.readAsText(svg); + } + }); +}; + +export const isSVG = (file) => { + return file.type === SVG_MIME_TYPE; +}; + +export const sanitizeSVG = async (file) => { + const svgText = await readAsText(file); + if (!svgText) { + return { svg: file }; + } + + const forbiddenTags = []; + DOMPurify.addHook('uponSanitizeElement', (_, hookEvent) => { + const { tagName, allowedTags } = hookEvent; + if (!allowedTags[tagName]) { + forbiddenTags.push(tagName); + } + }); + + const clean = DOMPurify.sanitize(svgText); + const svg = new Blob([clean], { type: SVG_MIME_TYPE }); + return { svg, isDirty: forbiddenTags.length > 0 }; +}; \ No newline at end of file diff --git a/src/helpers/setEnableAnnotationNumbering.js b/src/helpers/setEnableAnnotationNumbering.js new file mode 100644 index 0000000000..1233663334 --- /dev/null +++ b/src/helpers/setEnableAnnotationNumbering.js @@ -0,0 +1,7 @@ +import core from 'core'; + +export default (state) => { + if (state.viewer.isAnnotationNumberingEnabled) { + core.enableAnnotationNumbering(); + } +}; \ No newline at end of file diff --git a/src/helpers/useWindowsDimensions.js b/src/helpers/useWindowsDimensions.js new file mode 100644 index 0000000000..89f0f7bd84 --- /dev/null +++ b/src/helpers/useWindowsDimensions.js @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; + +function getWindowDimensions() { + const { innerWidth: width, innerHeight: height } = window; + return { + width, + height + }; +} + +export default function useWindowDimensions() { + const [windowDimensions, setWindowDimensions] = useState(getWindowDimensions()); + + useEffect(() => { + function handleResize() { + setWindowDimensions(getWindowDimensions()); + } + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + return windowDimensions; +} diff --git a/src/hooks/useFloatingHeaderSelectors/index.js b/src/hooks/useFloatingHeaderSelectors/index.js new file mode 100644 index 0000000000..d197bf916d --- /dev/null +++ b/src/hooks/useFloatingHeaderSelectors/index.js @@ -0,0 +1,3 @@ +import useFloatingHeaderSelectors from './useFloatingHeaderSelectors'; + +export default useFloatingHeaderSelectors; \ No newline at end of file diff --git a/src/hooks/useFloatingHeaderSelectors/useFloatingHeaderSelectors.spec.js b/src/hooks/useFloatingHeaderSelectors/useFloatingHeaderSelectors.spec.js new file mode 100644 index 0000000000..43f557219c --- /dev/null +++ b/src/hooks/useFloatingHeaderSelectors/useFloatingHeaderSelectors.spec.js @@ -0,0 +1,102 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector, Provider } from 'react-redux'; +import useFloatingHeaderSelectors from './useFloatingHeaderSelectors'; +import rootReducer from 'reducers/rootReducer'; +import initialState from 'src/redux/initialState'; +import { configureStore } from '@reduxjs/toolkit'; +import React from 'react'; +import { RESIZE_BAR_WIDTH } from 'src/constants/panel'; +// Mocking useSelector +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); + +const floatEndBottomHeader = { + dataElement: 'floatEndBottomHeader', + placement: 'bottom', + float: true, + position: 'end', + items: [], +}; + +const floatStartBottomHeader = { + dataElement: 'floatStartBottomHeader', + placement: 'bottom', + float: true, + position: 'start', + items: [], +}; + +const floatStartTopHeader = { + dataElement: 'topStartFloatingHeader', + placement: 'top', + float: true, + position: 'start', + items: [], +}; + +const floatEndTopHeader = { + dataElement: 'topEndFloatingHeader', + placement: 'top', + float: true, + position: 'end', + items: [], +}; + +describe('useFloatingHeaderSelectors hook', () => { + it('should return the correct values from the state', () => { + const mockState = { + ...initialState, + viewer: { + ...initialState.viewer, + openElements: { + ...initialState.viewer.openElements, + leftPanel: false, + notesPanel: false, + redactionPanel: true, + }, + modularHeadersWidth: { + rightHeader: 23, + leftHeader: 48, + }, + floatingContainersDimensions: { + topFloatingContainerHeight: 36, + bottomFloatingContainerHeight: 28, + }, + modularHeaders: [ + floatEndBottomHeader, + floatStartTopHeader, + floatEndTopHeader, + floatStartBottomHeader, + ], + } + }; + + const store = configureStore({ + reducer: rootReducer, + preloadedState: mockState, + }); + + useSelector.mockImplementation((callback) => callback(mockState)); + + const { result } = renderHook(() => useFloatingHeaderSelectors(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current.isLeftPanelOpen).toBeFalsy(); + expect(result.current.isRightPanelOpen).toBe(true); + expect(result.current.leftPanelWidth).toBe(mockState.viewer.panelWidths.leftPanel + RESIZE_BAR_WIDTH); + expect(result.current.rightPanelWidth).toBe(mockState.viewer.panelWidths.redactionPanel); + expect(result.current.leftHeaderWidth).toBe(0); + expect(result.current.rightHeaderWidth).toBe(0); + expect(result.current.topHeadersHeight).toBe(0); + expect(result.current.bottomHeadersHeight).toBe(0); + expect(result.current.topFloatingContainerHeight).toBe(mockState.viewer.floatingContainersDimensions.topFloatingContainerHeight); + expect(result.current.bottomFloatingContainerHeight).toBe(mockState.viewer.floatingContainersDimensions.bottomFloatingContainerHeight); + expect(result.current.topStartFloatingHeaders).toEqual([floatStartTopHeader]); + expect(result.current.bottomStartFloatingHeaders).toEqual([floatStartBottomHeader]); + expect(result.current.bottomEndFloatingHeaders).toEqual([floatEndBottomHeader]); + expect(result.current.topEndFloatingHeaders).toEqual([floatEndTopHeader]); + }); +}); diff --git a/src/hooks/useOnAnnotationContentOverlayOpen/index.js b/src/hooks/useOnAnnotationContentOverlayOpen/index.js new file mode 100644 index 0000000000..8d414fb75f --- /dev/null +++ b/src/hooks/useOnAnnotationContentOverlayOpen/index.js @@ -0,0 +1,3 @@ +import useOnAnnotationContentOverlayOpen from './useOnAnnotationContentOverlayOpen'; + +export default useOnAnnotationContentOverlayOpen; \ No newline at end of file diff --git a/src/hooks/useOnAnnotationContentOverlayOpen/useOnAnnotationContentOverlayOpen.js b/src/hooks/useOnAnnotationContentOverlayOpen/useOnAnnotationContentOverlayOpen.js new file mode 100644 index 0000000000..4accc4344b --- /dev/null +++ b/src/hooks/useOnAnnotationContentOverlayOpen/useOnAnnotationContentOverlayOpen.js @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import actions from 'actions'; +import selectors from 'selectors'; +import core from 'core'; +import DataElements from 'constants/dataElement'; + +export default function useOnAnnotationContentOverlayOpen() { + // Clients have the option to customize how the tooltip is rendered by passing a handler + const customHandler = useSelector((state) => selectors.getAnnotationContentOverlayHandler(state)); + + const dispatch = useDispatch(); + const [annotation, setAnnotation] = useState(null); + const [clientXY, setClientXY] = useState({ clientX: 0, clientY: 0 }); + const isUsingCustomHandler = customHandler !== null; + + useEffect(() => { + const viewElement = core.getViewerElement(); + + const onMouseHover = (e) => { + if (e.buttons !== 0) { + return; + } + let annotation = core.getAnnotationManager().getAnnotationByMouseEvent(e); + + if (annotation && viewElement.contains(e.target)) { + // if hovered annot is grouped, pick the "primary" annot to match Adobe's behavior + const groupedAnnots = core.getAnnotationManager().getGroupAnnotations(annotation); + const ungroupedAnnots = groupedAnnots.filter((annot) => !annot.isGrouped()); + annotation = ungroupedAnnots.length > 0 ? ungroupedAnnots[0] : annotation; + + const isFreeTextAnnotation = annotation instanceof window.Core.Annotations.FreeTextAnnotation; + if (isUsingCustomHandler || !isFreeTextAnnotation) { + setClientXY({ clientX: e.clientX, clientY: e.clientY }); + setAnnotation(annotation); + dispatch(actions.openElement(DataElements.ANNOTATION_CONTENT_OVERLAY)); + } + } else { + setAnnotation(null); + dispatch(actions.closeElement(DataElements.ANNOTATION_CONTENT_OVERLAY)); + } + }; + + core.addEventListener('mouseMove', onMouseHover); + return () => core.removeEventListener('mouseMove', onMouseHover); + }, [annotation, isUsingCustomHandler]); + + return { annotation, clientXY }; +} \ No newline at end of file diff --git a/src/hooks/useOnAnnotationCreateSignatureToolMode/index.js b/src/hooks/useOnAnnotationCreateSignatureToolMode/index.js new file mode 100644 index 0000000000..2264e9c3af --- /dev/null +++ b/src/hooks/useOnAnnotationCreateSignatureToolMode/index.js @@ -0,0 +1,3 @@ +import useOnAnnotationCreateSignatureToolMode from './useOnAnnotationCreateSignatureToolMode'; + +export default useOnAnnotationCreateSignatureToolMode; \ No newline at end of file diff --git a/src/hooks/useOnAnnotationPopupOpen/index.js b/src/hooks/useOnAnnotationPopupOpen/index.js new file mode 100644 index 0000000000..62fb3ba75c --- /dev/null +++ b/src/hooks/useOnAnnotationPopupOpen/index.js @@ -0,0 +1,3 @@ +import useOnAnnotationPopupOpen from './useOnAnnotationPopupOpen'; + +export default useOnAnnotationPopupOpen; \ No newline at end of file diff --git a/src/hooks/useOnCountMeasurementAnnotationSelected/useOnCountMeasurementAnnotationSelected.js b/src/hooks/useOnCountMeasurementAnnotationSelected/useOnCountMeasurementAnnotationSelected.js index 1223246222..80e6de8fab 100644 --- a/src/hooks/useOnCountMeasurementAnnotationSelected/useOnCountMeasurementAnnotationSelected.js +++ b/src/hooks/useOnCountMeasurementAnnotationSelected/useOnCountMeasurementAnnotationSelected.js @@ -59,10 +59,7 @@ export default function useOnCountMeasurementAnnotationSelected() { ) { setAnnotation(annotations[0]); dispatch(actions.openElement(DataElements.MEASUREMENT_OVERLAY)); - } else if ( - action === 'deselected' && - !core.isAnnotationSelected(annotation) - ) { + } else if (action === 'deselected' && !core.isAnnotationSelected(annotation)) { dispatch(actions.closeElement(DataElements.MEASUREMENT_OVERLAY)); } }; diff --git a/src/hooks/useOnCropAnnotationAdded/index.js b/src/hooks/useOnCropAnnotationAdded/index.js new file mode 100644 index 0000000000..66ebd9d402 --- /dev/null +++ b/src/hooks/useOnCropAnnotationAdded/index.js @@ -0,0 +1,3 @@ +import useOnCropAnnotationAdded from './useOnCropAnnotationAdded'; + +export default useOnCropAnnotationAdded; \ No newline at end of file diff --git a/src/hooks/useOnCropAnnotationAdded/useOnCropAnnotationAdded.js b/src/hooks/useOnCropAnnotationAdded/useOnCropAnnotationAdded.js new file mode 100644 index 0000000000..ceb8edb931 --- /dev/null +++ b/src/hooks/useOnCropAnnotationAdded/useOnCropAnnotationAdded.js @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import core from 'core'; + +export default function useOnCropAnnotationAdded(openDocumentCropPopup) { + const [cropAnnotation, setCropAnnotation] = useState(null); + + useEffect(() => { + const onAnnotationChanged = (annotations, action) => { + const annotation = annotations[0]; + if (action === 'add' && annotation.Subject === 'Rectangle' && annotation.ToolName === 'CropPage') { + setCropAnnotation(annotation); + openDocumentCropPopup(); + } + }; + + core.addEventListener('annotationChanged', onAnnotationChanged); + + return () => { + core.removeEventListener('annotationChanged', onAnnotationChanged); + }; + }, []); + + return cropAnnotation; +} \ No newline at end of file diff --git a/src/hooks/useOnCropAnnotationChangedOrSelected/index.js b/src/hooks/useOnCropAnnotationChangedOrSelected/index.js new file mode 100644 index 0000000000..91ececbb66 --- /dev/null +++ b/src/hooks/useOnCropAnnotationChangedOrSelected/index.js @@ -0,0 +1,3 @@ +import useOnCropAnnotationChangedOrSelected from './useOnCropAnnotationChangedOrSelected'; + +export default useOnCropAnnotationChangedOrSelected; \ No newline at end of file diff --git a/src/hooks/useOnCropAnnotationChangedOrSelected/useOnCropAnnotationChangedOrSelected.js b/src/hooks/useOnCropAnnotationChangedOrSelected/useOnCropAnnotationChangedOrSelected.js new file mode 100644 index 0000000000..387599b8df --- /dev/null +++ b/src/hooks/useOnCropAnnotationChangedOrSelected/useOnCropAnnotationChangedOrSelected.js @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import core from 'core'; + +export default function useOnCropAnnotationChangedOrSelected(openDocumentCropPopup) { + const [cropAnnotation, setCropAnnotation] = useState(null); + + useEffect(() => { + const onAnnotationChanged = (annotations, action) => { + const annotation = annotations[0]; + if (action === 'add' && annotation.ToolName === window.Core.Tools.ToolNames['CROP'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setCropAnnotation(annotation); + openDocumentCropPopup(); + } + if (action === 'delete' && annotation.ToolName === window.Core.Tools.ToolNames['CROP'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setCropAnnotation(null); + } + }; + + const onAnnotationSelected = (annotations, action) => { + const annotation = annotations[0]; + if (action === 'selected' && annotation.ToolName === window.Core.Tools.ToolNames['CROP'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setCropAnnotation(annotation); + openDocumentCropPopup(); + } + }; + + core.addEventListener('annotationChanged', onAnnotationChanged); + core.addEventListener('annotationSelected', onAnnotationSelected); + + return () => { + core.removeEventListener('annotationChanged', onAnnotationChanged); + core.removeEventListener('annotationSelected', onAnnotationSelected); + }; + }, []); + + return cropAnnotation; +} diff --git a/src/hooks/useOnFormFieldsChanged/index.js b/src/hooks/useOnFormFieldsChanged/index.js new file mode 100644 index 0000000000..db652dca01 --- /dev/null +++ b/src/hooks/useOnFormFieldsChanged/index.js @@ -0,0 +1,3 @@ +import useOnFormFieldsChanged from './useOnFormFieldsChanged'; + +export default useOnFormFieldsChanged; \ No newline at end of file diff --git a/src/hooks/useOnFormFieldsChanged/useOnFormFieldsChanged.js b/src/hooks/useOnFormFieldsChanged/useOnFormFieldsChanged.js new file mode 100644 index 0000000000..08a24daf40 --- /dev/null +++ b/src/hooks/useOnFormFieldsChanged/useOnFormFieldsChanged.js @@ -0,0 +1,64 @@ +import { useState, useEffect } from 'react'; +import core from 'core'; + +export default function useOnFormFieldsChanged() { + const [formFieldAnnotationsList, setFormFieldAnnotationsList] = useState([]); + + useEffect(() => { + const setFormFieldIndicators = () => { + let formFieldIndicators = []; + const annotations = core.getAnnotationsList(); + const formFieldCreationManager = core.getFormFieldCreationManager(); + if (formFieldCreationManager.isInFormFieldCreationMode()) { + const formFieldPlaceholders = annotations.filter((annotation) => annotation.isFormFieldPlaceholder()); + formFieldIndicators = [ + ...formFieldPlaceholders + .reduce( + (fieldNameMap, field) => { + if (!fieldNameMap.has(field.getCustomData(formFieldCreationManager.getFieldLabels().FIELD_NAME))) { + fieldNameMap.set(field.getCustomData(formFieldCreationManager.getFieldLabels().FIELD_NAME), field); + } + return fieldNameMap; + }, + new Map() + ).values(), + ]; + } else { + const widgets = annotations.filter( + (annotation) => formFieldCreationManager.getShowIndicator(annotation) + ); + formFieldIndicators = [ + ...widgets + .reduce( + (fieldNameMap, field) => { + if (!fieldNameMap.has(field['fieldName'])) { + fieldNameMap.set(field['fieldName'], field); + } + return fieldNameMap; + }, + new Map() + ).values(), + ]; + } + setFormFieldAnnotationsList(formFieldIndicators); + }; + + const onDocumentLoaded = () => { + setFormFieldAnnotationsList([]); + }; + + core.addEventListener('documentLoaded', onDocumentLoaded); + core.addEventListener('zoomUpdated', setFormFieldIndicators); + core.addEventListener('annotationChanged', setFormFieldIndicators); + core.addEventListener('pageNumberUpdated', setFormFieldIndicators); + + return () => { + core.removeEventListener('documentLoaded', onDocumentLoaded); + core.removeEventListener('zoomUpdated', setFormFieldIndicators); + core.removeEventListener('annotationChanged', setFormFieldIndicators); + core.removeEventListener('pageNumberUpdated', setFormFieldIndicators); + }; + }); + + return formFieldAnnotationsList; +} diff --git a/src/hooks/useOnInlineCommentPopupOpen/index.js b/src/hooks/useOnInlineCommentPopupOpen/index.js new file mode 100644 index 0000000000..664fdf586b --- /dev/null +++ b/src/hooks/useOnInlineCommentPopupOpen/index.js @@ -0,0 +1,3 @@ +import useOnInlineCommentPopupOpen from './useOnInlineCommentPopupOpen'; + +export default useOnInlineCommentPopupOpen; \ No newline at end of file diff --git a/src/hooks/useOnInlineCommentPopupOpen/useOnInlineCommentPopupOpen.js b/src/hooks/useOnInlineCommentPopupOpen/useOnInlineCommentPopupOpen.js new file mode 100644 index 0000000000..ca8a8b5282 --- /dev/null +++ b/src/hooks/useOnInlineCommentPopupOpen/useOnInlineCommentPopupOpen.js @@ -0,0 +1,125 @@ +import { useEffect, useState } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import actions from 'actions'; +import selectors from 'selectors'; +import core from 'core'; +import DataElements from 'constants/dataElement'; + +export default function useOnInlineCommentPopupOpen() { + const [ + isNotesPanelOpen, + notesInLeftPanel, + leftPanelOpen, + activeLeftPanel, + inlineCommentFilter, + activeDocumentViewerKey, + ] = useSelector( + (state) => [ + selectors.isElementOpen(state, DataElements.NOTES_PANEL), + selectors.getNotesInLeftPanel(state), + selectors.isElementOpen(state, DataElements.LEFT_PANEL), + selectors.getActiveLeftPanel(state), + selectors.getInlineCommentFilter(state), + selectors.getActiveDocumentViewerKey(state), + ], + shallowEqual, + ); + const dispatch = useDispatch(); + + const [annotation, setAnnotation] = useState(null); + const [isFreeTextAnnotationAdded, setFreeTextAnnotationAdded] = useState(false); + const { ToolNames } = window.Core.Tools; + + const isNotesPanelOpenOrActive = isNotesPanelOpen || (notesInLeftPanel && leftPanelOpen && activeLeftPanel === 'notesPanel'); + + const closeAndReset = () => { + dispatch(actions.closeElement(DataElements.INLINE_COMMENT_POPUP)); + setAnnotation(null); + setFreeTextAnnotationAdded(false); + }; + + const isFreeTextAnnotation = (annot) => { + return annot instanceof window.Core.Annotations.FreeTextAnnotation; + }; + + useEffect(() => { + const onAnnotationDoubleClicked = (annot) => { + if (isFreeTextAnnotation(annot)) { + closeAndReset(); + } + }; + + core.addEventListener('annotationDoubleClicked', onAnnotationDoubleClicked, null, activeDocumentViewerKey); + return () => core.removeEventListener('annotationDoubleClicked', onAnnotationDoubleClicked, null, activeDocumentViewerKey); + }, [activeDocumentViewerKey]); + + useEffect(() => { + const onAnnotationSelected = (annotations, action) => { + const selectedAnnotationTool = annotations[0].ToolName; + const shouldSetCommentingAnnotation = + action === 'selected' + && annotations.length + && !isFreeTextAnnotationAdded + && selectedAnnotationTool !== ToolNames.CROP; + if (shouldSetCommentingAnnotation) { + setAnnotation(annotations[0]); + } + + if (action === 'deselected' && annotations.length) { + setFreeTextAnnotationAdded(false); + if (annotations.some((annot) => annot === annotation)) { + closeAndReset(); + } + } + }; + + core.addEventListener('annotationSelected', onAnnotationSelected, null, activeDocumentViewerKey); + return () => { + core.removeEventListener('annotationSelected', onAnnotationSelected, null, activeDocumentViewerKey); + }; + }, [annotation, isFreeTextAnnotationAdded, activeDocumentViewerKey]); + + useEffect(() => { + setFreeTextAnnotationAdded(false); + const onMouseLeftUp = (e) => { + // WILL BE TRIGGERED ON MOBILE: happens before annotationSelected + // clicking on the selected annotation is considered clicking outside of this component + // so this component will close due to useOnClickOutside + // this handler is used to make sure that if we click on the selected annotation, this component will show up again + const annotUnderMouse = core.getAnnotationByMouseEvent(e, activeDocumentViewerKey); + + if (annotation) { + if (!annotUnderMouse) { + closeAndReset(); + } + + if (core.isAnnotationSelected(annotUnderMouse) && annotUnderMouse !== annotation) { + setAnnotation(annotUnderMouse); + } + } + }; + + const onAnnotationChanged = (annotations, action) => { + setFreeTextAnnotationAdded(action === 'add' && isFreeTextAnnotation(annotations[0])); + const isCommentingAnnotationSelected = core.isAnnotationSelected(annotation); + if (annotation && !isCommentingAnnotationSelected) { + closeAndReset(); + } + }; + + core.addEventListener('mouseLeftUp', onMouseLeftUp, null, activeDocumentViewerKey); + core.addEventListener('annotationChanged', onAnnotationChanged, null, activeDocumentViewerKey); + return () => { + core.removeEventListener('mouseLeftUp', onMouseLeftUp, null, activeDocumentViewerKey); + core.removeEventListener('annotationChanged', onAnnotationChanged, null, activeDocumentViewerKey); + }; + }, [annotation, activeDocumentViewerKey]); + + useEffect(() => { + if (!isNotesPanelOpenOrActive && annotation && inlineCommentFilter(annotation)) { + dispatch(actions.openElement(DataElements.INLINE_COMMENT_POPUP)); + } + }, [annotation, inlineCommentFilter]); + + return { annotation, closeAndReset }; +} \ No newline at end of file diff --git a/src/hooks/useOnLinkAnnotationPopupOpen/index.js b/src/hooks/useOnLinkAnnotationPopupOpen/index.js new file mode 100644 index 0000000000..3dcf1661e8 --- /dev/null +++ b/src/hooks/useOnLinkAnnotationPopupOpen/index.js @@ -0,0 +1,3 @@ +import useOnLinkAnnotationPopupOpen from './useOnLinkAnnotationPopupOpen'; + +export default useOnLinkAnnotationPopupOpen; \ No newline at end of file diff --git a/src/hooks/useOnLinkAnnotationPopupOpen/useOnLinkAnnotationPopupOpen.spec.js b/src/hooks/useOnLinkAnnotationPopupOpen/useOnLinkAnnotationPopupOpen.spec.js new file mode 100644 index 0000000000..7b8237f80e --- /dev/null +++ b/src/hooks/useOnLinkAnnotationPopupOpen/useOnLinkAnnotationPopupOpen.spec.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useOnLinkAnnotationPopupOpen from './useOnLinkAnnotationPopupOpen'; +import core from 'core'; + +jest.mock('core'); + +const MockComponent = ({ children }) => (
{children}
); +const wrapper = withProviders(MockComponent); + +const scrollContainer = { + scrollLeft: 100, + scrollTop: 100, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), +}; + +describe('useOnLinkAnnotationPopupOpen hook', () => { + beforeAll(() => { + core.addEventListener = jest.fn(); + core.getScrollViewElement = jest.fn(); + core.getScrollViewElement.mockReturnValue(scrollContainer); + core.getAnnotationManager = jest.fn().mockReturnValue({ + getContentEditHistoryManager: jest.fn().mockReturnValue({ + getAnnotationsByMouseEvent: jest.fn(), + }), + }); + }); + + it('adds event listeners on mouse move', () => { + const { result } = renderHook(() => { + return useOnLinkAnnotationPopupOpen(); + }, { wrapper }); + + expect(result.error).toBeUndefined(); + expect(core.addEventListener).toBeCalledWith('mouseMove', expect.any(Function)); + }); + + it('removes event listeners to mouse move when component is unmounted', () => { + const { result, unmount } = renderHook(() => useOnLinkAnnotationPopupOpen(), { wrapper }); + + expect(result.error).toBeUndefined(); + unmount(); + + expect(core.removeEventListener).toBeCalledWith('mouseMove', expect.any(Function)); + }); +}); diff --git a/src/hooks/useOnMeasurementToolOrAnnotationSelected/index.js b/src/hooks/useOnMeasurementToolOrAnnotationSelected/index.js new file mode 100644 index 0000000000..ec3414a8f9 --- /dev/null +++ b/src/hooks/useOnMeasurementToolOrAnnotationSelected/index.js @@ -0,0 +1,3 @@ +import useOnMeasurementToolOrAnnotationSelected from './useOnMeasurementToolOrAnnotationSelected'; + +export default useOnMeasurementToolOrAnnotationSelected; \ No newline at end of file diff --git a/src/hooks/useOnRightClick/index.js b/src/hooks/useOnRightClick/index.js new file mode 100644 index 0000000000..ce45aa251c --- /dev/null +++ b/src/hooks/useOnRightClick/index.js @@ -0,0 +1,3 @@ +import useOnRightClick from './useOnRightClick'; + +export default useOnRightClick; \ No newline at end of file diff --git a/src/hooks/useOnRightClick/useOnRightClick.js b/src/hooks/useOnRightClick/useOnRightClick.js new file mode 100644 index 0000000000..f3329aac40 --- /dev/null +++ b/src/hooks/useOnRightClick/useOnRightClick.js @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import core from 'core'; +import getRootNode from 'helpers/getRootNode'; +import { shallowEqual, useSelector } from 'react-redux'; +import selectors from 'selectors'; + +export default (handler) => { + const [ + activeDocumentViewerKey, + isMultiViewerMode, + ] = useSelector( + (state) => [ + selectors.getActiveDocumentViewerKey(state), + selectors.isMultiViewerMode(state), + ], + shallowEqual, + ); + + useEffect(() => { + const listener = (e) => { + const { tagName } = e.target; + const clickedOnInput = tagName === 'INPUT'; + const clickedOnTextarea = tagName === 'TEXTAREA'; + const clickedOnFreeTextarea = !!(( + e.target.className === 'ql-editor' + || e.target.parentNode.className === 'ql-editor' + || e.target.parentNode.parentNode.className === 'ql-editor' + )); + + const documentContainer = + isMultiViewerMode + ? getRootNode().querySelector(`#DocumentContainer${activeDocumentViewerKey}`) + : getRootNode().querySelector('.DocumentContainer'); + const clickedOnDocumentContainer = documentContainer.contains(e.target); + + if ( + clickedOnDocumentContainer && + // when clicking on these two elements we want to display the default context menu so that users can use auto-correction, look up dictionary, etc... + !(clickedOnInput || clickedOnTextarea || clickedOnFreeTextarea) + ) { + e.preventDefault(); + handler(e); + } + }; + + getRootNode().addEventListener('contextmenu', listener); + core.addEventListener('longTap', listener, null, activeDocumentViewerKey); + return () => { + getRootNode().removeEventListener('contextmenu', listener); + core.removeEventListener('longTap', listener, null, activeDocumentViewerKey); + }; + }, [handler, activeDocumentViewerKey, isMultiViewerMode]); +}; diff --git a/src/hooks/useOnRightClick/useOnRightClick.spec.js b/src/hooks/useOnRightClick/useOnRightClick.spec.js new file mode 100644 index 0000000000..d1597e94ec --- /dev/null +++ b/src/hooks/useOnRightClick/useOnRightClick.spec.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import useOnRightClick from './useOnRightClick'; +import core from 'core'; + +jest.mock('core'); + +const MockComponent = ({ children }) => (
{children}
); +const wrapper = withProviders(MockComponent); + +describe('useOnRightClick hook', () => { + it('adds event listeners to longTap on mobile', () => { + core.addEventListener = jest.fn(); + + const { result } = renderHook(() => useOnRightClick(), { wrapper }); + + expect(result.error).toBeUndefined(); + + expect(core.addEventListener).toBeCalledWith('longTap', expect.any(Function), null, 1); + }); + + it('removes event listeners to longTap when component is unmounted', () => { + core.removeEventListener = jest.fn(); + + const { result, unmount } = renderHook(() => useOnRightClick(), { wrapper }); + + expect(result.error).toBeUndefined(); + unmount(); + + expect(core.removeEventListener).toBeCalledWith('longTap', expect.any(Function), null, 1); + }); +}); \ No newline at end of file diff --git a/src/hooks/useOnRightClickAnnotation/index.js b/src/hooks/useOnRightClickAnnotation/index.js new file mode 100644 index 0000000000..98132ed95f --- /dev/null +++ b/src/hooks/useOnRightClickAnnotation/index.js @@ -0,0 +1,3 @@ +import useOnRightClickAnnotation from './useOnRightClickAnnotation'; + +export default useOnRightClickAnnotation; \ No newline at end of file diff --git a/src/hooks/useOnRightClickAnnotation/useOnRightClickAnnotation.js b/src/hooks/useOnRightClickAnnotation/useOnRightClickAnnotation.js new file mode 100644 index 0000000000..13b143ca35 --- /dev/null +++ b/src/hooks/useOnRightClickAnnotation/useOnRightClickAnnotation.js @@ -0,0 +1,20 @@ +import { useState, useCallback } from 'react'; +import core from 'core'; +import useOnRightClick from 'hooks/useOnRightClick'; + +export default () => { + const [rightClickedAnnotation, setRightClickedAnnotation] = useState(null); + + useOnRightClick( + useCallback((e) => { + const annotUnderMouse = core.getAnnotationByMouseEvent(e); + if (annotUnderMouse && annotUnderMouse.ToolName !== window.Core.Tools.ToolNames.CROP) { + if (annotUnderMouse !== rightClickedAnnotation) { + setRightClickedAnnotation(annotUnderMouse); + } + } + }, [rightClickedAnnotation]) + ); + + return { rightClickedAnnotation, setRightClickedAnnotation }; +}; diff --git a/src/hooks/useOnSnippingAnnotationChangedOrSelected/index.js b/src/hooks/useOnSnippingAnnotationChangedOrSelected/index.js new file mode 100644 index 0000000000..30a2f600cb --- /dev/null +++ b/src/hooks/useOnSnippingAnnotationChangedOrSelected/index.js @@ -0,0 +1,3 @@ +import useOnSnippingAnnotationChangedOrSelected from './useOnSnippingAnnotationChangedOrSelected'; + +export default useOnSnippingAnnotationChangedOrSelected; \ No newline at end of file diff --git a/src/hooks/useOnSnippingAnnotationChangedOrSelected/useOnSnippingAnnotationChangedOrSelected.js b/src/hooks/useOnSnippingAnnotationChangedOrSelected/useOnSnippingAnnotationChangedOrSelected.js new file mode 100644 index 0000000000..cca8767535 --- /dev/null +++ b/src/hooks/useOnSnippingAnnotationChangedOrSelected/useOnSnippingAnnotationChangedOrSelected.js @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import core from 'core'; + +export default function useOnCropAnnotationChangedOrSelected(openSnippingPopup) { + const [snippingAnnotation, setSnippingAnnotation] = useState(null); + + useEffect(() => { + const onAnnotationChanged = (annotations, action) => { + const annotation = annotations[0]; + if (action === 'add' && annotation.ToolName === window.Core.Tools.ToolNames['SNIPPING'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setSnippingAnnotation(annotation); + openSnippingPopup(); + } + if (action === 'delete' && annotation.ToolName === window.Core.Tools.ToolNames['SNIPPING'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setSnippingAnnotation(null); + } + }; + + const onAnnotationSelected = (annotations, action) => { + const annotation = annotations[0]; + if (action === 'selected' && annotation.ToolName === window.Core.Tools.ToolNames['SNIPPING'] && annotation instanceof window.Core.Annotations.RectangleAnnotation) { + setSnippingAnnotation(annotation); + openSnippingPopup(); + } + }; + + core.addEventListener('annotationChanged', onAnnotationChanged); + core.addEventListener('annotationSelected', onAnnotationSelected); + + return () => { + core.removeEventListener('annotationChanged', onAnnotationChanged); + core.removeEventListener('annotationSelected', onAnnotationSelected); + }; + }, []); + + return snippingAnnotation; +} diff --git a/src/hooks/useResizeObserver/index.js b/src/hooks/useResizeObserver/index.js new file mode 100644 index 0000000000..4fa6b2d3ef --- /dev/null +++ b/src/hooks/useResizeObserver/index.js @@ -0,0 +1,3 @@ +import useResizeObserver from './useResizeObserver'; + +export default useResizeObserver; \ No newline at end of file diff --git a/src/hooks/useResizeObserver/useResizeObserver.js b/src/hooks/useResizeObserver/useResizeObserver.js new file mode 100644 index 0000000000..ba446181da --- /dev/null +++ b/src/hooks/useResizeObserver/useResizeObserver.js @@ -0,0 +1,34 @@ +import { useEffect, useRef, useState } from 'react'; + +const useResizeObserver = () => { + const [dimensions, setDimensions] = useState({ width: null, height: null }); + const elementRef = useRef(null); + + useEffect(() => { + const node = elementRef.current; + + if (node) { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const observedWidth = entry.borderBoxSize[0].inlineSize; + const observedHeight = entry.borderBoxSize[0].blockSize; + setDimensions({ + width: observedWidth, + height: observedHeight + }); + } + }); + + observer.observe(node); + + // Cleanup: stop observing when the component unmounts + return () => { + observer.unobserve(node); + }; + } + }, [elementRef.current]); + + return [elementRef, dimensions]; +}; + +export default useResizeObserver; diff --git a/src/hooks/useResizeObserver/useResizeObserver.spec.js b/src/hooks/useResizeObserver/useResizeObserver.spec.js new file mode 100644 index 0000000000..5f52f85886 --- /dev/null +++ b/src/hooks/useResizeObserver/useResizeObserver.spec.js @@ -0,0 +1,32 @@ +import { act } from '@testing-library/react-hooks'; +import { render } from '@testing-library/react'; +import React from 'react'; +import useResizeObserver from './useResizeObserver'; + +// Mocking ResizeObserver as it's not available in Jest's JSDOM +global.ResizeObserver = class ResizeObserver { + constructor(callback) { + this.callback = callback; + } + observe() { + this.callback([{ borderBoxSize: [{ inlineSize: 100, blockSize: 200 }] }]); + } + unobserve() { } +}; + +describe('useResizeObserver', () => { + it('returns correct dimensions after observing', () => { + let dimensions; + function DummyComponent() { + const [ref, size] = useResizeObserver(); + dimensions = size; + return
; + } + + act(() => { + render(); + }); + + expect(dimensions).toEqual({ width: 100, height: 200 }); + }); +}); \ No newline at end of file diff --git a/src/redux/reducers/digitalSignatureValidationReducer.js b/src/redux/reducers/digitalSignatureValidationReducer.js new file mode 100644 index 0000000000..08369bc8d7 --- /dev/null +++ b/src/redux/reducers/digitalSignatureValidationReducer.js @@ -0,0 +1,46 @@ +export default (initialState) => (state = initialState, action) => { + const { type, payload } = action; + + switch (type) { + case 'SET_VALIDATION_MODAL_WIDGET_NAME': + return { + ...state, + validationModalWidgetName: payload.validationModalWidgetName, + }; + case 'SET_VERIFICATION_RESULT': + return { ...state, verificationResult: payload.result }; + case 'ADD_TRUSTED_CERTIFICATES': + /** + * To mimic the behavior of the Core implementation, where certificates + * can only be added but not removed, only allow this action to append + * to the existing array + */ + return { + ...state, + certificates: [...state.certificates, ...payload.certificates], + }; + case 'ADD_TRUST_LIST': + /** + * The Core implementation only allows a single Trust List to be passed + * as a parameter, but in order to allow flexibility of future potential + * requirements where a developer may want to add multiple Trust Lists, + * we are storing an Array of Trust Lists + */ + return { + ...state, + trustLists: [...state.trustLists, payload.trustList], + }; + case 'SET_IS_REVOCATION_CHECKING_ENABLED': + return { + ...state, + isRevocationCheckingEnabled: payload.isRevocationCheckingEnabled, + }; + case 'SET_REVOCATION_PROXY_PREFIX': + return { + ...state, + revocationProxyPrefix: payload.revocationProxyPrefix, + }; + default: + return state; + } +}; diff --git a/src/redux/reducers/featureFlagsReducer.js b/src/redux/reducers/featureFlagsReducer.js new file mode 100644 index 0000000000..af3ad53f08 --- /dev/null +++ b/src/redux/reducers/featureFlagsReducer.js @@ -0,0 +1,12 @@ +export default (initialState) => (state = initialState, action) => { + const { type, payload } = action; + + switch (type) { + case 'ENABLE_FEATURE_FLAG': + return { ...state, [payload.featureFlag]: true }; + case 'DISABLE_FEATURE_FLAG': + return { ...state, [payload.featureFlag]: false }; + default: + return state; + } +}; \ No newline at end of file diff --git a/src/redux/reducers/wv3dPropertiesPanelReducer.js b/src/redux/reducers/wv3dPropertiesPanelReducer.js new file mode 100644 index 0000000000..2ae898d19d --- /dev/null +++ b/src/redux/reducers/wv3dPropertiesPanelReducer.js @@ -0,0 +1,26 @@ +export default (initialState) => (state = initialState, action) => { + const { type, payload } = action; + + switch (type) { + case 'SET_WV3D_PROPERTIES_PANEL_MODEL_DATA': { + const { modelData } = payload; + + return { + ...state, + modelData, + }; + } + + case 'SET_WV3D_PROPERTIES_PANEL_SCHEMA': { + const { schema } = payload; + + return { + ...state, + schema, + }; + } + + default: + return state; + } +}; diff --git a/webpack.config.dev.js b/webpack.config.dev.js index aca52ad8b1..faff432e65 100644 --- a/webpack.config.dev.js +++ b/webpack.config.dev.js @@ -6,7 +6,7 @@ module.exports = { mode: 'development', devtool: 'cheap-module-eval-source-map', entry: [ - 'webpack-hot-middleware/client?name=ui&path=/__webpack_hmr', + 'webpack-hot-middleware/client?name=ui&path=/__webpack_hmr&noInfo=true', path.resolve(__dirname, 'src'), ], output: { @@ -57,29 +57,53 @@ module.exports = { loader: 'style-loader', options: { insert: function (styleTag) { - const webComponents = document.getElementsByTagName('apryse-webviewer'); - if (webComponents.length > 0) { - const clonedStyleTags = []; - for (let i = 0; i < webComponents.length; i++) { - const webComponent = webComponents[i]; - if (i === 0) { - webComponent.shadowRoot.appendChild(styleTag); - styleTag.onload = function () { - if (clonedStyleTags.length > 0) { - clonedStyleTags.forEach((styleNode) => { - // eslint-disable-next-line no-unsanitized/property - styleNode.innerHTML = styleTag.innerHTML; - }); - } - }; - } else { - const styleNode = styleTag.cloneNode(true); - webComponent.shadowRoot.appendChild(styleNode); - clonedStyleTags.push(styleNode); + function findNestedWebComponents(tagName, root = document) { + const elements = []; + + // Check direct children + root.querySelectorAll(tagName).forEach(el => elements.push(el)); + + // Check shadow DOMs + root.querySelectorAll('*').forEach(el => { + if (el.shadowRoot) { + elements.push(...findNestedWebComponents(tagName, el.shadowRoot)); } - } - } else { + }); + + return elements; + } + // If its the iframe we just append to the document head + if (!window.isApryseWebViewerWebComponent) { document.head.appendChild(styleTag); + return; + } + + let webComponents; + // First we see if the webcomponent is at the document level + webComponents = document.getElementsByTagName('apryse-webviewer'); + // If not, we check have to check if it is nested in another webcomponent + if (!webComponents.length) { + webComponents = findNestedWebComponents('apryse-webviewer'); + } + // Now we append the style tag to each webcomponent + const clonedStyleTags = []; + for (let i = 0; i < webComponents.length; i++) { + const webComponent = webComponents[i]; + if (i === 0) { + webComponent.shadowRoot.appendChild(styleTag); + styleTag.onload = function () { + if (clonedStyleTags.length > 0) { + clonedStyleTags.forEach((styleNode) => { + // eslint-disable-next-line no-unsanitized/property + styleNode.innerHTML = styleTag.innerHTML; + }); + } + }; + } else { + const styleNode = styleTag.cloneNode(true); + webComponent.shadowRoot.appendChild(styleNode); + clonedStyleTags.push(styleNode); + } } }, }, diff --git a/webpack.config.prod.js b/webpack.config.prod.js index 8c112a59c9..96784a33e6 100644 --- a/webpack.config.prod.js +++ b/webpack.config.prod.js @@ -1,7 +1,7 @@ const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); // const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; -const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +// const MiniCssExtractPlugin = require('mini-css-extract-plugin'); module.exports = { mode: 'production', @@ -32,10 +32,10 @@ module.exports = { to: '../build/configorigin.txt', }, ]), - new MiniCssExtractPlugin({ - filename: 'style.css', - chunkFilename: 'chunks/[name].chunk.css' - }), + // new MiniCssExtractPlugin({ + // filename: 'style.css', + // chunkFilename: 'chunks/[name].chunk.css' + // }), // new BundleAnalyzerPlugin() ], module: { @@ -45,12 +45,16 @@ module.exports = { use: { loader: 'babel-loader', options: { + ignore: [ + /\/core-js/, + ], + sourceType: "unambiguous", presets: [ '@babel/preset-react', [ '@babel/preset-env', { - useBuiltIns: 'entry', + useBuiltIns: 'usage', corejs: 3, }, ], @@ -66,14 +70,67 @@ module.exports = { }, }, include: [path.resolve(__dirname, 'src'), path.resolve(__dirname, 'node_modules')], - exclude: function(modulePath) { + exclude: function (modulePath) { return /node_modules/.test(modulePath) && !/node_modules.+react-dnd/.test(modulePath); } }, { test: /\.scss$/, use: [ - MiniCssExtractPlugin.loader, + { + loader: 'style-loader', + options: { + insert: function (styleTag) { + function findNestedWebComponents(tagName, root = document) { + const elements = []; + + // Check direct children + root.querySelectorAll(tagName).forEach(el => elements.push(el)); + + // Check shadow DOMs + root.querySelectorAll('*').forEach(el => { + if (el.shadowRoot) { + elements.push(...findNestedWebComponents(tagName, el.shadowRoot)); + } + }); + + return elements; + } + if (!window.isApryseWebViewerWebComponent) { + document.head.appendChild(styleTag); + return; + } + + let webComponents; + // First we see if the webcomponent is at the document level + webComponents = document.getElementsByTagName('apryse-webviewer'); + // If not, we check have to check if it is nested in another webcomponent + if (!webComponents.length) { + webComponents = findNestedWebComponents('apryse-webviewer'); + } + // Now we append the style tag to each webcomponent + const clonedStyleTags = []; + for (let i = 0; i < webComponents.length; i++) { + const webComponent = webComponents[i]; + if (i === 0) { + webComponent.shadowRoot.appendChild(styleTag); + styleTag.onload = function () { + if (clonedStyleTags.length > 0) { + clonedStyleTags.forEach((styleNode) => { + // eslint-disable-next-line no-unsanitized/property + styleNode.innerHTML = styleTag.innerHTML; + }); + } + }; + } else { + const styleNode = styleTag.cloneNode(true); + webComponent.shadowRoot.appendChild(styleNode); + clonedStyleTags.push(styleNode); + } + } + }, + }, + }, 'css-loader', { loader: 'postcss-loader',