├── .appcast.xml ├── .babelrc ├── .circleci └── config.yml ├── .env.example ├── .eslintrc ├── .github ├── CODEOWNERS └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .releaserc.json ├── .sketchpacks.json ├── .vscode └── settings.json ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── assets └── icon.png ├── constants.ts ├── enums ├── color-type.enum.ts └── layer-type.enum.ts ├── jest.config.js ├── package-lock.json ├── package.json ├── prepareRelease.sh ├── resources ├── app │ ├── Global.styles.ts │ ├── ListContext.tsx │ ├── assets │ │ ├── Futura-Bold.otf │ │ ├── Rectangle.svg │ │ ├── SFProDisplay-Bold.otf │ │ ├── SFProDisplay-Heavy.otf │ │ ├── SFProDisplay-Regular.otf │ │ ├── arrowActive.svg │ │ ├── arrowGrey.svg │ │ ├── arrowInactive.svg │ │ ├── artboard.svg │ │ ├── checkered_small.svg │ │ ├── colormateLogo.svg │ │ ├── minimilistBubbles.svg │ │ ├── replaceBtn.svg │ │ ├── replaceBtnHover.svg │ │ ├── text1Loader.svg │ │ ├── text2Loader.svg │ │ ├── textIcon.svg │ │ └── washingTransparent.gif │ ├── components │ │ ├── App.tsx │ │ ├── Banner.tsx │ │ ├── Button.test.tsx │ │ ├── Button.tsx │ │ ├── ColorPicker.tsx │ │ ├── Footer.test.tsx │ │ ├── Footer.tsx │ │ ├── Header.test.tsx │ │ ├── Header.tsx │ │ ├── List │ │ │ ├── List.tsx │ │ │ ├── ListItem.styles.tsx │ │ │ ├── ListItem.tsx │ │ │ ├── ListItemTree.tsx │ │ │ └── Tree │ │ │ │ ├── LayerNode.styles.tsx │ │ │ │ └── LayerNode.tsx │ │ ├── Loader.test.tsx │ │ ├── Loader.tsx │ │ ├── NoColorsFound.test.tsx │ │ ├── NoColorsFound.tsx │ │ └── __snapshots__ │ │ │ ├── Footer.test.tsx.snap │ │ │ ├── Header.test.tsx.snap │ │ │ ├── Loader.test.tsx.snap │ │ │ └── NoColorsFound.test.tsx.snap │ ├── helpers │ │ ├── calculations.test.js │ │ ├── calculations.ts │ │ ├── replace-color.test.ts │ │ ├── replace-color.ts │ │ ├── transform-sketch-colormap.test.ts │ │ ├── transform-sketch-colormap.ts │ │ ├── window.test.ts │ │ └── window.ts │ ├── hooks │ │ ├── useHover.test.tsx │ │ └── useHover.ts │ ├── index.tsx │ └── models │ │ ├── color-with-layers.model.ts │ │ └── sketch-color-map.model.ts ├── style.css ├── webview.html └── webview.js ├── setupTest.ts ├── src ├── __mocks__ │ ├── MockLayer.json │ ├── MockSketchDocument.json │ ├── MockTextLayer.json │ └── getColorsResult.json ├── get-colors.test.ts ├── get-colors.ts ├── helpers │ ├── environment.ts │ ├── get-colors.test.ts │ ├── get-colors.ts │ ├── get-page.test.ts │ ├── get-page.ts │ ├── replace-color-in-layers.test.ts │ ├── replace-color-in-layers.ts │ ├── traverse.test.ts │ └── traverse.ts ├── manifest.json ├── models │ └── layer.model.ts └── my-command.js ├── tsconfig.json ├── tsconfig.test.json ├── typings ├── assets.d.ts ├── sketch.d.ts └── window.d.ts ├── updateAppcast.sh └── webpack.skpm.config.js /.appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Colormate 5 | http://sparkle-project.org/files/sparkletestcast.xml 6 | Colormate is a kickass sketch plugin that will help you figure out how in the hell you ended up with 457 different greys, instead of the 1 grey Mandy gave you in the handover 7 | en 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Version v1.2.0 16 | 17 | 18 | 19 | Version v1.3.0 20 | 21 | 22 | 23 | Version v1.3.1 24 | 25 | 26 | 27 | Version v1.4.4 28 | 29 | 30 | 31 | Version v1.4.5 32 | 33 | 34 | 35 | Version v1.4.6 36 | 37 | 38 | 39 | Version v1.4.6 40 | 41 | 42 | 43 | Version v1.4.7 44 | 45 | 46 | 47 | Version v1.4.8 48 | 49 | 50 | 51 | Version v1.4.9 52 | 53 | 54 | 55 | Version v1.4.9 56 | 57 | 58 | 59 | Version v1.4.10 60 | 61 | 62 | 63 | Version v1.4.11 64 | 65 | 66 | 67 | Version vv1.4.12 68 | 69 | 70 | 71 | Version v1.4.13 72 | 73 | 74 | 75 | Version v1.4.14 76 | 77 | 78 | 79 | Version v1.4.14 80 | 81 | 82 | 83 | Version v1.4.16 84 | 85 | 86 | 87 | Version v1.4.17 88 | 89 | 90 | 91 | Version v1.4.18 92 | 93 | 94 | 95 | Version v1.4.22 96 | 97 | 98 | 99 | Version v1.4.24 100 | 101 | 102 | 103 | Version v1.4.24 104 | 105 | 106 | 107 | Version v1.4.24 108 | 109 | 110 | 111 | Version v1.4.25 112 | 113 | 114 | 115 | Version v1.4.26 116 | 117 | 118 | 119 | Version v1.4.27 120 | 121 | 122 | 123 | Version v1.5.0 124 | 125 | 126 | 127 | Version v1.6.0 128 | 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/repo 3 | docker: 4 | - image: nikolaik/python-nodejs:python3.7-nodejs11 5 | 6 | version: 2 7 | jobs: 8 | install: 9 | <<: *defaults 10 | steps: 11 | - checkout 12 | - restore_cache: 13 | keys: 14 | - v1-dep-{{ checksum "package.json" }} 15 | - v1-dep- 16 | - run: 17 | name: Run install 18 | command: npm install 19 | - save_cache: 20 | paths: 21 | - node_modules 22 | key: v1-dep-{{ checksum "package.json" }} 23 | 24 | testing: 25 | <<: *defaults 26 | steps: 27 | - checkout 28 | - restore_cache: 29 | keys: 30 | - v1-dep-{{ checksum "package.json" }} 31 | - v1-dep- 32 | - run: 33 | name: Run tests 34 | command: npm run test 35 | 36 | linting: 37 | <<: *defaults 38 | steps: 39 | - checkout 40 | - restore_cache: 41 | keys: 42 | - v1-dep-{{ checksum "package.json" }} 43 | - v1-dep- 44 | - run: 45 | name: Run ESLint 46 | command: npm run lint 47 | 48 | pre-build-staging: 49 | <<: *defaults 50 | steps: 51 | - checkout 52 | - restore_cache: 53 | keys: 54 | - v1-dep-{{ checksum "package.json" }} 55 | - v1-dep- 56 | 57 | - run: apt-get update 58 | - run: apt-get -y install jq zip moreutils 59 | 60 | - run: 61 | name: Run semantic-release 62 | command: | 63 | npm i 64 | npm run semantic-release -- --branch ${CIRCLE_BRANCH} --dry-run 65 | 66 | - run: 67 | name: update env file 68 | command: | 69 | echo REACT_APP_TYPEFORM_URL=$TYPEFORM_URL >> .env 70 | 71 | - run: mkdir -p workspace 72 | 73 | - run: | 74 | cat .env > workspace/.env 75 | cat package.json > workspace/package.json 76 | cat src/manifest.json > workspace/manifest.json 77 | - persist_to_workspace: 78 | root: workspace 79 | paths: 80 | - .env 81 | - package.json 82 | - manifest.json 83 | 84 | build-staging: 85 | <<: *defaults 86 | steps: 87 | - checkout 88 | - attach_workspace: 89 | at: workspace 90 | - restore_cache: 91 | keys: 92 | - v1-dep-{{ checksum "package.json" }} 93 | - v1-dep- 94 | 95 | - run: apt-get update 96 | - run: apt-get -y install jq zip moreutils 97 | 98 | - run: cat workspace/.env >> $BASH_ENV 99 | - run: cat workspace/.env > .env 100 | - run: cat workspace/package.json > package.json 101 | - run: cat workspace/manifest.json > src/manifest.json 102 | 103 | - run: echo "REACT_APP_VERSION:" $REACT_APP_VERSION 104 | - run: 105 | name: Build project 106 | command: npm run build 107 | 108 | - run: mkdir -p workspace/build 109 | - run: cp -R colormate.sketchplugin workspace/build 110 | 111 | - persist_to_workspace: 112 | root: workspace 113 | paths: 114 | - .env 115 | - build/ 116 | 117 | zip-build: 118 | <<: *defaults 119 | steps: 120 | - attach_workspace: 121 | at: workspace 122 | - run: cat workspace/.env >> $BASH_ENV 123 | - run: apt-get update 124 | - run: apt-get -y install zip 125 | 126 | - run: 127 | name: Zipping project 128 | command: | 129 | echo "creating zip file:" colormate.zip 130 | cd workspace/build && zip -r ../../colormate.zip * 131 | - run: cp colormate.zip workspace/colormate.zip 132 | 133 | - persist_to_workspace: 134 | root: workspace 135 | paths: 136 | - . 137 | 138 | deploy-staging: 139 | <<: *defaults 140 | steps: 141 | - attach_workspace: 142 | at: workspace 143 | - run: cat workspace/.env >> $BASH_ENV 144 | 145 | - run: apt-get update 146 | - run: apt-get -y install jq zip moreutils 147 | - run: pip install awscli 148 | 149 | - run: 150 | name: Deploy to S3 151 | command: aws s3 cp workspace/colormate.zip s3://colormate-testing/staging/ 152 | 153 | - run: 154 | name: Notify slack channel 155 | command: | 156 | curl --header "Content-Type: application/json" --request POST --data \ 157 | '{"text": "<'"$CIRCLE_BUILD_URL"'|#'"$CIRCLE_BUILD_NUM"'> New testing version deployed you can download it here: <'"$AWS_S3_URL_STAGING"/colormate.zip'|'colormate_"$REACT_APP_VERSION".zip'> "}' \ 158 | $SLACK_WEBHOOK_URL 159 | pre-build-production: 160 | <<: *defaults 161 | steps: 162 | - checkout 163 | - restore_cache: 164 | keys: 165 | - v1-dep-{{ checksum "package.json" }} 166 | - v1-dep- 167 | 168 | - run: apt-get update 169 | - run: apt-get -y install jq moreutils 170 | 171 | - run: 172 | name: Set next version in .env by using prepareRelease.sh 173 | command: | 174 | npm run semantic-release -- --dry-run 175 | - run: 176 | name: update env file 177 | command: | 178 | echo REACT_APP_TYPEFORM_URL=$TYPEFORM_URL >> .env 179 | - run: 180 | name: Prepare workspace 181 | command: | 182 | mkdir -p workspace 183 | - run: 184 | name: Save files to workspace 185 | command: | 186 | cat .env > workspace/.env 187 | cat package.json > workspace/package.json 188 | cat src/manifest.json > workspace/manifest.json 189 | - persist_to_workspace: 190 | root: workspace 191 | paths: 192 | - . 193 | 194 | build-production: 195 | <<: *defaults 196 | steps: 197 | - checkout 198 | - attach_workspace: 199 | at: workspace 200 | - restore_cache: 201 | keys: 202 | - v1-dep-{{ checksum "package.json" }} 203 | - v1-dep- 204 | 205 | - run: apt-get update 206 | - run: apt-get -y install xmlstarlet 207 | 208 | - run: cat workspace/.env >> $BASH_ENV 209 | - run: cat workspace/.env > .env 210 | - run: cat workspace/package.json > package.json 211 | - run: cat workspace/manifest.json > src/manifest.json 212 | 213 | - run: 214 | name: Build project for version $REACT_APP_VERSION 215 | command: npm run build 216 | 217 | - run: 218 | name: Generate new appcast item 219 | command: bash ./updateAppcast.sh $REACT_APP_VERSION 220 | 221 | - run: mkdir -p workspace/build 222 | - run: 223 | name: Save build files to workspace 224 | command: | 225 | cat .appcast.xml > workspace/.appcast.xml 226 | cp -R colormate.sketchplugin workspace/build 227 | - persist_to_workspace: 228 | root: workspace 229 | paths: 230 | - . 231 | 232 | deploy-production: 233 | <<: *defaults 234 | steps: 235 | - checkout 236 | - attach_workspace: 237 | at: workspace 238 | - restore_cache: 239 | keys: 240 | - v1-dep-{{ checksum "package.json" }} 241 | - v1-dep- 242 | 243 | - run: apt-get update 244 | - run: apt-get -y install jq moreutils 245 | 246 | - run: 247 | name: Setup Environment Variables 248 | command: | 249 | cat workspace/.env >> $BASH_ENV 250 | source $BASH_ENV 251 | - add_ssh_keys: 252 | fingerprints: 253 | - "14:80:8a:23:90:81:f4:28:16:ee:47:ef:14:ec:72:27" 254 | 255 | - run: 256 | name: Update .appcast.xml 257 | command: cat workspace/.appcast.xml > .appcast.xml 258 | 259 | - run: 260 | name: Update package.json 261 | command: cat workspace/package.json > package.json 262 | 263 | - run: 264 | name: Update manifest.json 265 | command: cat workspace/manifest.json > src/manifest.json 266 | 267 | - run: 268 | name: Commit files 269 | command: | 270 | echo """Host * 271 | StrictHostKeyChecking no""" >> ~/.ssh/config 272 | git config --global user.email "ci@circleci.com" 273 | git config --global user.name "Ci Server" 274 | git add .appcast.xml package.json src/manifest.json 275 | git commit -m "New version released ${REACT_APP_VERSION} [ci skip]" 276 | git push 277 | 278 | - run: 279 | name: Tag and release 280 | command: | 281 | npm i 282 | mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config 283 | npm run semantic-release 284 | - run: 285 | name: Notify slack channel 286 | command: | 287 | curl --header "Content-Type: application/json" --request POST --data \ 288 | '{"text": "<'"$CIRCLE_BUILD_URL"'|#'"$CIRCLE_BUILD_NUM"'> :rocket: Colormate *v'"$REACT_APP_VERSION"'* deployed :rocket: \n Download it here <'https://github.com/themainingredient/colormate/releases/download/v"$REACT_APP_VERSION"/colormate.zip'> "}' \ 289 | $SLACK_WEBHOOK_URL 290 | workflows: 291 | version: 2 292 | untagged-build: 293 | jobs: 294 | - install 295 | - testing 296 | - linting 297 | tagged-build: 298 | jobs: 299 | - install: 300 | filters: 301 | branches: 302 | only: 303 | - master 304 | - testing: 305 | filters: 306 | branches: 307 | only: 308 | - master 309 | - linting: 310 | filters: 311 | branches: 312 | only: 313 | - master 314 | - pre-build-production: 315 | requires: 316 | - install 317 | - testing 318 | - linting 319 | filters: 320 | branches: 321 | only: 322 | - master 323 | - build-production: 324 | requires: 325 | - pre-build-production 326 | - zip-build: 327 | requires: 328 | - build-production 329 | - deploy-production: 330 | requires: 331 | - zip-build 332 | 333 | deploy-staging: 334 | jobs: 335 | - install: 336 | filters: 337 | branches: 338 | only: 339 | - /release/.*/ 340 | - testing: 341 | filters: 342 | branches: 343 | only: 344 | - /release/.*/ 345 | - linting: 346 | filters: 347 | branches: 348 | only: 349 | - /release/.*/ 350 | - pre-build-staging: 351 | requires: 352 | - install 353 | - testing 354 | - linting 355 | filters: 356 | branches: 357 | only: 358 | - /release/.*/ 359 | - build-staging: 360 | requires: 361 | - pre-build-staging 362 | - zip-build: 363 | requires: 364 | - build-staging 365 | - deploy-staging: 366 | requires: 367 | - zip-build 368 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Example env file: rename to .env and adjust to your needs 2 | # default values are for the develop environment 3 | 4 | REACT_APP_ENV=develop # develop || staging || production 5 | REACT_APP_VERSION=x.y.z # x.y.z for dev || 1.2.0-beta for staging || 1.2.0 for production 6 | REACT_APP_IS_BETA=true # true for dev/staging || false for production 7 | REACT_APP_TYPEFORM_URL= 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["jest"], 5 | "rules": { 6 | "arrow-body-style": "off", 7 | "class-methods-use-this": "off", 8 | "consistent-return": 1, 9 | "function-paren-newline": "off", 10 | "import/no-extraneous-dependencies": [ 11 | "error", 12 | { 13 | "devDependencies": true 14 | } 15 | ], 16 | "import/prefer-default-export": "off", 17 | "import/no-named-as-default": "off", 18 | "max-len": [ 19 | 1, 20 | 120 21 | ], 22 | "no-else-return": 1, 23 | "no-param-reassign": 0, 24 | "no-underscore-dangle": "off", 25 | "no-unused-expressions": "off", 26 | "no-unused-vars": "off", 27 | "jsx-quotes": [ 28 | "error", 29 | "prefer-single" 30 | ], 31 | "react/forbid-prop-types": "off", 32 | "react/jsx-curly-brace-presence": { 33 | "props": "always", 34 | "children": "off" 35 | }, 36 | "react/jsx-filename-extension": [1, { "extensions": [".tsx"] }], 37 | "react/no-array-index-key": "off", 38 | "react/prop-types": 1, 39 | "react/jsx-one-expression-per-line": "off", 40 | "react/no-unescaped-entities": "off", 41 | "react/destructuring-assignment": 1, 42 | "import/first": 1, 43 | "jsx-a11y/no-noninteractive-element-interactions": "off", 44 | "jsx-a11y/click-events-have-key-events": "off", 45 | "jsx-a11y/label-has-for": "off", 46 | "jsx-a11y/anchor-is-valid": [ 47 | "error", 48 | { 49 | "components": [ 50 | "Link" 51 | ], 52 | "specialLink": [ 53 | "hrefLeft", 54 | "hrefRight" 55 | ], 56 | "aspects": [ 57 | "invalidHref", 58 | "preferButton" 59 | ] 60 | } 61 | ], 62 | "jsx-a11y/accessible-emoji": "off" 63 | }, 64 | "overrides": [ 65 | { 66 | "files": [ 67 | "src/containers/**/*Container.jsx" 68 | ], 69 | "rules": { 70 | "react/prop-types": false 71 | } 72 | } 73 | ], 74 | "globals": { 75 | "Headers": true, 76 | "document": true, 77 | "window": true, 78 | "navigator": true, 79 | "fetch": true 80 | }, 81 | "env": { 82 | "jest/globals": true 83 | }, 84 | "parserOptions": { 85 | "sourceType": "module" 86 | }, 87 | "settings": { 88 | "import/resolver": { 89 | "node": { 90 | "extensions": [ 91 | ".js", 92 | ".jsx", 93 | ".ts", 94 | ".tsx" 95 | ], 96 | "paths": [ 97 | "typings" 98 | ] 99 | } 100 | } 101 | }, 102 | } 103 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | # The last matching pattern has the most precedence. 4 | 5 | * @DiogoBatista @jeroenknol @Iar0 6 | *.md @RomyHartendorp -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS version: [e.g. 10.14.6] 28 | - Sketch version [e.g. 55] 29 | - Colormate version [e.g. 1.2] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artefacts 2 | colormate.sketchplugin 3 | 4 | # npm 5 | node_modules 6 | .npm 7 | npm-debug.log 8 | 9 | # mac 10 | .DS_Store 11 | 12 | coverage 13 | 14 | .env 15 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branch": "master", 3 | "plugins": [ 4 | "@semantic-release/commit-analyzer", 5 | "@semantic-release/release-notes-generator", 6 | [ 7 | "@semantic-release/github", 8 | { 9 | "assets": [ 10 | {"path": "workspace/colormate.zip", "label": "To install: download this file, unzip and double click on the .sketchplugin"} 11 | ] 12 | } 13 | ], 14 | [ 15 | "@semantic-release/exec", 16 | { 17 | "verifyReleaseCmd": "bash ./prepareRelease.sh ${nextRelease.version} ${options.branch}" 18 | } 19 | ] 20 | ] 21 | } -------------------------------------------------------------------------------- /.sketchpacks.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema_version": "1.0.0", 3 | "manifest_path": "src/manifest.json", 4 | "appcast_path": ".appcast.xml" 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.eslintIntegration": true 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://sketchpacks.com/themainingredient/colormate/install) 2 | 3 | # Colormate Sketch Plugin 🌈 4 | 5 | This free plugin gives you an impression of _all the colours_ in your Sketch file and _how many times_ a certain colour is being used. 6 | 7 | ## Why this Plugin? 🤔 8 | 9 | During designing we often found ourselves ending up with fifty different shades of grey (and of course other colours). Sometimes we would just lose ourselves in designing and don't keep track of all the colours that we used. But this is not ideal when you have to hand it over to the development team. 10 | 11 | That's why we've created this plugin to help designers keep track of the colours in the file, _without_ having to add the colours to the document colours first! 12 | 13 | Did we peak your interest already? 🧐 14 | 15 | ## Installation ⚙️ 16 | 17 | 1. Download the latest [Colormate release](https://api.sketchpacks.com/v1/plugins/com.colormate.plugin/download) 18 | 2. Unzip the file 19 | 3. Double-click `colormate.sketchplugin` to install 20 | 21 | ## Getting started 💪 22 | 23 | Did you already download the plugin? Good. Let's get started then! 24 | 25 | Open your Sketch file and run the plugin by using the shortcut "CMD+Shift+8" on Mac or "CTRL+Shift+8" on Windows. You're also able to run the plugin by going to "Plugins" in the top nav bar and then select the Colormate plugin. 26 | 27 | The plugin starts with scanning every layer in the file and gathering all the colours that are being used in text, symbols & objects. This may take some seconds if you have a really big file (but we provided you with a nice gif to look at while waiting 😎). 28 | 29 | When the plugin is done scanning your file, you will get an overview of all the colours you've used in the document. You will be able to see the colour number, how many times this colour has been used (and where!) and if this colour has an opacity value. Now you're able to see that there are two types of red that you're using: #CC0000 has been used 117 times, but #C50909 has only been used 3 times. 30 | 31 | 32 | ## Features 33 | - Get colors overview on the entire Sketch file, seeing where each color is being used 34 | - Get colors overview on a specific selection 35 | - Replace all the occurences of a color 36 | - Replace colors of a single layer 37 | 38 | ### Future plans 🚀 39 | 40 | We're not going to sit back and let the plugin work its magic. We're continously working on developing new features. 41 | 42 | If you can think of any other functionalities, please let us know! 🤩 43 | 44 | Made with ❤️ by [The Main Ingredient](https://themainingredient.co) 45 | 46 | ## Join the community! 47 | 48 | Helps us improve by joining our community! 49 | 50 | Click below to join our Slack. 51 | 52 | [Join slack community](https://themainingredient.typeform.com/to/X65bqg) 53 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/assets/icon.png -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | export const tmiUrl = 'https://www.themainingredient.co'; 2 | export const browserWindowSize = { 3 | height: 674, 4 | width: 370, 5 | } 6 | -------------------------------------------------------------------------------- /enums/color-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum ColorType { 2 | fill = 'fill', 3 | border = 'border', 4 | text = 'text', 5 | } -------------------------------------------------------------------------------- /enums/layer-type.enum.ts: -------------------------------------------------------------------------------- 1 | export enum LayerType { 2 | page = 'Page', 3 | artboard = 'Artboard', 4 | group = 'Group', 5 | shapePath = 'ShapePath', 6 | text = 'Text', 7 | shape = 'Shape', 8 | document = 'Document', 9 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | // Stop running tests after `n` failures 8 | // bail: 0, 9 | 10 | // Respect "browser" field in package.json when resolving modules 11 | // browser: false, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/private/var/folders/p9/c21c_pbx1hz4gm173xmpkjjw0000gn/T/jest_dx", 15 | 16 | // Automatically clear mock calls and instances between every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | // collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: null, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | 30 | // A list of reporter names that Jest uses when writing coverage reports 31 | // coverageReporters: [ 32 | // "json", 33 | // "text", 34 | // "lcov", 35 | // "clover" 36 | // ], 37 | 38 | // An object that configures minimum threshold enforcement for coverage results 39 | // coverageThreshold: null, 40 | 41 | // A path to a custom dependency extractor 42 | // dependencyExtractor: null, 43 | 44 | // Make calling deprecated APIs throw helpful error messages 45 | // errorOnDeprecated: false, 46 | 47 | // Force coverage collection from ignored files using an array of glob patterns 48 | // forceCoverageMatch: [], 49 | 50 | // A path to a module which exports an async function that is triggered once before all test suites 51 | // globalSetup: null, 52 | 53 | // A path to a module which exports an async function that is triggered once after all test suites 54 | // globalTeardown: null, 55 | 56 | // A set of global variables that need to be available in all test environments 57 | globals: { 58 | 'ts-jest': { 59 | tsConfig: 'tsconfig.test.json', 60 | }, 61 | }, 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | moduleDirectories: [ 65 | 'node_modules', 66 | ], 67 | 68 | // An array of file extensions your modules use 69 | moduleFileExtensions: [ 70 | 'ts', 71 | 'tsx', 72 | 'js', 73 | 'jsx', 74 | 'json', 75 | 'node', 76 | ], 77 | 78 | // A map from regular expressions to module names that allow to stub out resources with a single module 79 | moduleNameMapper: { 80 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js', 81 | '\\.(css|less)$': '/__mocks__/styleMock.js', 82 | }, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | modulePathIgnorePatterns: [ 86 | 'colormate.sketchplugin', 87 | ], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | // preset: null, 97 | 98 | // Run tests from one or more projects 99 | // projects: null, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: null, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: null, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | setupFilesAfterEnv: [ 130 | './setupTest.ts', 131 | ], 132 | 133 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 134 | // snapshotSerializers: [], 135 | 136 | // The test environment that will be used for testing 137 | testEnvironment: 'jsdom', 138 | 139 | // Options that will be passed to the testEnvironment 140 | // testEnvironmentOptions: {}, 141 | 142 | // Adds a location field to test results 143 | // testLocationInResults: false, 144 | 145 | // The glob patterns Jest uses to detect test files 146 | // testMatch: [ 147 | // "**/__tests__/**/*.[jt]s?(x)", 148 | // "**/?(*.)+(spec|test).[tj]s?(x)" 149 | // ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | testPathIgnorePatterns: [ 153 | '/node_modules/', 154 | '/colormate.sketchplugin/', 155 | ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: null, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | transform: { 174 | '^.+\\.tsx?$': 'ts-jest', 175 | '^.+\\.jsx?$': 'babel-jest', 176 | '^.+\\.svg$': 'jest-svg-transformer', 177 | }, 178 | 179 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 180 | // transformIgnorePatterns: [ 181 | // "/node_modules/" 182 | // ], 183 | 184 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 185 | // unmockedModulePathPatterns: undefined, 186 | 187 | // Indicates whether each individual test should be reported during the run 188 | // verbose: null, 189 | 190 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 191 | // watchPathIgnorePatterns: [], 192 | 193 | // Whether to use watchman for file crawling 194 | // watchman: true, 195 | }; 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colormate", 3 | "version": "1.6.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/themainingredient/colormate.git" 7 | }, 8 | "description": "Colormate is a kickass sketch plugin that will help you figure out how in the hell you ended up with 457 different greys, instead of the 1 grey Mandy gave you in the handover", 9 | "engines": { 10 | "sketch": ">=3.0" 11 | }, 12 | "skpm": { 13 | "name": "Colormate", 14 | "manifest": "src/manifest.json", 15 | "identifier": "com.colormate.plugin", 16 | "main": "colormate.sketchplugin", 17 | "assets": [ 18 | "assets/**/*", 19 | "resources/app/assets/**/*" 20 | ] 21 | }, 22 | "scripts": { 23 | "publish": "skpm publish", 24 | "build": "skpm-build", 25 | "watch": "skpm-build --watch", 26 | "start": "skpm-build --watch", 27 | "postinstall": "npm run build && skpm-link", 28 | "test": "jest", 29 | "test:coverage": "jest --coverage && open coverage/lcov-report/index.html", 30 | "test:watch": "jest --watch", 31 | "lint": "./node_modules/.bin/eslint ./src/ ./resources/ --ext .ts --ext .tsx", 32 | "log": "skpm log -f", 33 | "semantic-release": "semantic-release" 34 | }, 35 | "devDependencies": { 36 | "@babel/preset-env": "^7.4.2", 37 | "@babel/preset-react": "^7.0.0", 38 | "@semantic-release/exec": "^3.3.3", 39 | "@semantic-release/github": "^5.3.2", 40 | "@skpm/builder": "^0.5.11", 41 | "@skpm/extract-loader": "^2.0.2", 42 | "@testing-library/jest-dom": "^4.1.0", 43 | "@types/jest": "^24.0.11", 44 | "@types/prop-types": "^15.7.1", 45 | "@types/react-color": "^3.0.0", 46 | "@typescript-eslint/parser": "^1.11.0", 47 | "babel-eslint": "^10.0.1", 48 | "babel-jest": "^24.7.1", 49 | "babel-loader": "^8.0.5", 50 | "css-loader": "^1.0.0", 51 | "cz-conventional-changelog": "^2.1.0", 52 | "dotenv": "^7.0.0", 53 | "eslint": "^5.15.3", 54 | "eslint-config-airbnb": "^17.1.0", 55 | "eslint-plugin-import": "^2.16.0", 56 | "eslint-plugin-jest": "^22.4.1", 57 | "eslint-plugin-jsx-a11y": "^6.2.1", 58 | "eslint-plugin-react": "^7.12.4", 59 | "file-loader": "^3.0.1", 60 | "html-loader": "^0.5.1", 61 | "jest": "^24.5.0", 62 | "jest-styled-components": "^6.3.1", 63 | "jest-svg-transformer": "^1.0.0", 64 | "react-hooks-testing-library": "^0.4.0", 65 | "react-svg-loader": "^3.0.3", 66 | "react-testing-library": "^6.1.2", 67 | "semantic-release": "^15.13.24", 68 | "source-map-loader": "^0.2.4", 69 | "ts-jest": "^24.0.2", 70 | "ts-loader": "^5.3.3", 71 | "typescript": "^3.4.3" 72 | }, 73 | "resources": [ 74 | "resources/**/*.js" 75 | ], 76 | "dependencies": { 77 | "@types/lodash": "^4.14.123", 78 | "@types/react": "^16.8.13", 79 | "@types/react-dom": "^16.8.4", 80 | "@types/styled-components": "^4.1.14", 81 | "lodash": "^4.17.15", 82 | "prop-types": "^15.7.2", 83 | "react": "^16.8.5", 84 | "react-color": "^2.17.3", 85 | "react-dom": "^16.8.5", 86 | "sketch-module-web-view": "^2.1.5", 87 | "styled-components": "^4.2.0" 88 | }, 89 | "author": "The Main Ingredient", 90 | "config": { 91 | "commitizen": { 92 | "path": "./node_modules/cz-conventional-changelog" 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /prepareRelease.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Building APP with version: ' $1 4 | echo 'Building app for branch: ' $2 5 | 6 | jq --arg h "$1" '.version=$h' package.json | sponge package.json 7 | jq --arg h "$1" '.version=$h' src/manifest.json | sponge src/manifest.json 8 | 9 | touch .env 10 | echo REACT_APP_VERSION=$1 >> .env 11 | 12 | if [[ "$2" =~ release/* ]] 13 | then 14 | echo REACT_APP_ENV=staging >> .env 15 | echo REACT_APP_IS_BETA=true >> .env 16 | fi 17 | -------------------------------------------------------------------------------- /resources/app/Global.styles.ts: -------------------------------------------------------------------------------- 1 | import { css, createGlobalStyle } from 'styled-components'; 2 | 3 | import SFProRegular from './assets/SFProDisplay-Regular.otf'; 4 | import SFProBold from './assets/SFProDisplay-Bold.otf'; 5 | import SFProHeavy from './assets/SFProDisplay-Heavy.otf'; 6 | import FuturaBold from './assets/Futura-Bold.otf'; 7 | 8 | export const GlobalFonts = createGlobalStyle` 9 | @font-face { 10 | font-family: 'SFProRegular'; 11 | src: url(${SFProRegular}); 12 | } 13 | 14 | @font-face { 15 | font-family: 'SFProBold'; 16 | src: url(${SFProBold}); 17 | } 18 | 19 | @font-face { 20 | font-family: 'SFProHeavy'; 21 | src: url(${SFProHeavy}); 22 | } 23 | 24 | @font-face { 25 | font-family: 'FuturaBold'; 26 | src: url(${FuturaBold}); 27 | } 28 | `; 29 | 30 | export default { 31 | colors: { 32 | White: '#FFFFFF', 33 | TMIBlueLight: '#EDECFF', 34 | TMIBlue: '#4E41FF', 35 | TMIBlueDark: '#3B2EEB', 36 | LightGrey: '#E8E9EF', 37 | MediumGrey: '#B5B8C6', 38 | DarkGrey: '#4d4f59', 39 | Black25: '#00000040', 40 | Cyan: '#00FFFF', 41 | CyanDark: '#00DDDD', 42 | Navy: '#0D0166', 43 | }, 44 | fonts: { 45 | SFPro: { 46 | reg: 'SFProRegular', 47 | bold: 'SFProBold', 48 | heavy: 'SFProHeavy', 49 | }, 50 | Futura: { 51 | bold: 'FuturaBold', 52 | }, 53 | }, 54 | }; 55 | 56 | export const flexCenter = css` 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | `; 61 | -------------------------------------------------------------------------------- /resources/app/ListContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, Dispatch } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ListContext = React.createContext({ 5 | selectedColor: '', 6 | setSelectedColor: '' as unknown as Dispatch, 7 | selectedLayer: '', 8 | setSelectedLayer: '' as unknown as Dispatch, 9 | colors: {}, 10 | setColors: '' as unknown as Dispatch, 11 | }); 12 | 13 | export const ListProvider = ({ children }) => { 14 | const [selectedColor, setSelectedColor] = useState(); 15 | const [selectedLayer, setSelectedLayer] = useState(); 16 | const [colors, setColors] = useState(); 17 | 18 | return ( 19 | 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | ListProvider.propTypes = { 35 | children: PropTypes.element.isRequired, 36 | }; 37 | 38 | export default ListContext; 39 | -------------------------------------------------------------------------------- /resources/app/assets/Futura-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/resources/app/assets/Futura-Bold.otf -------------------------------------------------------------------------------- /resources/app/assets/Rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Rectangle 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /resources/app/assets/SFProDisplay-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/resources/app/assets/SFProDisplay-Bold.otf -------------------------------------------------------------------------------- /resources/app/assets/SFProDisplay-Heavy.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/resources/app/assets/SFProDisplay-Heavy.otf -------------------------------------------------------------------------------- /resources/app/assets/SFProDisplay-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/resources/app/assets/SFProDisplay-Regular.otf -------------------------------------------------------------------------------- /resources/app/assets/arrowActive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/app/assets/arrowGrey.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /resources/app/assets/arrowInactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Path Copy 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /resources/app/assets/artboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tv 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /resources/app/assets/checkered_small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Custom Preset 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /resources/app/assets/minimilistBubbles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | BD05930F-448E-41CB-951B-8B3A476E31B4@1.00x 5 | Created with sketchtool. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /resources/app/assets/replaceBtn.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | replaceBtn 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /resources/app/assets/replaceBtnHover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | replaceBtnHover 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /resources/app/assets/text1Loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | E8D7D3A8-6192-44E0-950A-67C72503054D@1.00x 5 | Created with sketchtool. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /resources/app/assets/textIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | textIcon 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /resources/app/assets/washingTransparent.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themainingredient/colormate/bb64fe40b364121e329ce0203992d02eedf25fa1/resources/app/assets/washingTransparent.gif -------------------------------------------------------------------------------- /resources/app/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useContext } from 'react'; 2 | 3 | import styled from 'styled-components'; 4 | 5 | import { GlobalFonts } from '../Global.styles'; 6 | 7 | import ListContext from '../ListContext'; 8 | import Header from './Header'; 9 | import List from './List/List'; 10 | import Footer from './Footer'; 11 | import Loader from './Loader'; 12 | import NoColorsFound from './NoColorsFound'; 13 | import { browserWindowSize } from '../../../constants'; 14 | import { Banner } from './Banner'; 15 | 16 | const PluginWrapper = styled.div` 17 | height: ${browserWindowSize.height}px; 18 | display: flex; 19 | flex-direction: column; 20 | justify-content: space-between; 21 | `; 22 | 23 | export default function () { 24 | const [isLoading, setIsLoading] = useState(true); 25 | const { selectedLayer, colors = {}, setColors } = useContext(ListContext); 26 | 27 | useEffect(() => { 28 | window.sendUsedColors = (incomingColors) => { 29 | setIsLoading(false); 30 | setColors(incomingColors); 31 | }; 32 | 33 | // Call function to get all used colors 34 | window.postMessage('getColors', 'Loading all colors'); 35 | }, []); 36 | 37 | useEffect(() => { 38 | window.replaceColor = (args) => { 39 | const { colorToReplace, targetColor, layerIds } = args; 40 | const newState = Object.entries(colors).reduce((acc, keyValue) => { 41 | const colorKey = keyValue[0]; 42 | const instances = keyValue[1] as any[]; 43 | 44 | if (keyValue[0] !== colorToReplace) { 45 | acc[colorKey] = instances; 46 | return acc; 47 | } 48 | 49 | const changedLayers = instances.filter(layer => layerIds.includes(layer.id)); 50 | const untouchedLayers = instances.filter(layer => !layerIds.includes(layer.id)); 51 | 52 | acc[targetColor] = changedLayers; 53 | 54 | if (untouchedLayers.length !== 0) { 55 | acc[colorKey] = untouchedLayers; 56 | } 57 | return acc; 58 | }, {}); 59 | 60 | setColors(newState); 61 | }; 62 | }, [colors]); 63 | 64 | const content = Object.keys(colors).length !== 0 ? ( 65 | <> 66 | 67 | 68 | 69 | 70 | > 71 | ) : ( 72 | 73 | ); 74 | 75 | return ( 76 | 77 | 78 | {isLoading ? : content} 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /resources/app/components/Banner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import Globals from '../Global.styles'; 4 | import { openUrlInBrowser } from '../helpers/window'; 5 | 6 | const { colors, fonts } = Globals; 7 | 8 | const BannerWrapper = styled.div` 9 | background-color: ${colors.Navy}; 10 | height: 100px; 11 | width: 100%; 12 | padding: 16px 12px; 13 | `; 14 | 15 | const Text = styled.p` 16 | color: ${colors.White}; 17 | font-family: ${fonts.SFPro.bold}; 18 | font-size: 16px; 19 | letter-spacing: 0px; 20 | text-align: center; 21 | `; 22 | 23 | const ButtonsWrapper = styled.div` 24 | display: flex; 25 | justify-content: space-between; 26 | padding: 12px 16px; 27 | `; 28 | 29 | const Button = styled.button` 30 | padding: 10px 16px; 31 | background: ${colors.TMIBlue}; 32 | border-radius: 4.53px; 33 | color: ${colors.Cyan}; 34 | font-size: 14px; 35 | font-family: ${fonts.SFPro.heavy}; 36 | text-align: center; 37 | border: none; 38 | cursor: pointer; 39 | 40 | &:hover { 41 | background: ${colors.TMIBlueDark}; 42 | } 43 | 44 | &:active { 45 | color: ${colors.CyanDark}; 46 | } 47 | `; 48 | 49 | const Emoji = styled.span` 50 | font-size: 33px; 51 | height: 0px; /* remove default extra whitespace from emoji */ 52 | `; 53 | 54 | export const Banner = () => { 55 | const [isBannerVisible, showBanner] = useState(false); 56 | 57 | useEffect(() => { 58 | window.isBannerVisible = (isVisible: boolean) => { 59 | showBanner(isVisible); 60 | }; 61 | 62 | window.postMessage('isBannerVisible'); 63 | }, []); 64 | 65 | const handleYes = () => { 66 | openUrlInBrowser(`${process.env.REACT_APP_TYPEFORM_URL}`); 67 | window.postMessage('hideBanner'); 68 | showBanner(false); 69 | }; 70 | 71 | const handleNo = () => { 72 | window.postMessage('hideBanner'); 73 | showBanner(false); 74 | }; 75 | 76 | const handleAskAgain = () => { 77 | window.postMessage('postponeBanner'); 78 | showBanner(false); 79 | }; 80 | 81 | if (!isBannerVisible) { 82 | return null; 83 | } 84 | 85 | return ( 86 | 87 | Help us with a 2 minute questionnaire? 88 | 89 | 90 | 🎱 91 | Yes 92 | No 93 | Ask again later 94 | 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /resources/app/components/Button.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, fireEvent } from 'react-testing-library'; 3 | import Button from './Button'; 4 | import Globals from '../Global.styles'; 5 | 6 | const { colors } = Globals; 7 | 8 | afterEach(cleanup); 9 | 10 | describe('Button.jsx', () => { 11 | test('renders and displays correctly', () => { 12 | const clickMock = jest.fn(); 13 | const { getByText } = render( clickMock()}>TestButton); 14 | const renderedButton = getByText('TestButton'); 15 | 16 | fireEvent.click(renderedButton); 17 | 18 | expect(clickMock).toHaveBeenCalled(); 19 | expect(renderedButton).toHaveTextContent('TestButton'); 20 | expect(renderedButton).toHaveStyle(`background-color: ${colors.TMIBlue}`); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /resources/app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Globals from '../Global.styles'; 3 | 4 | const { colors, fonts } = Globals; 5 | 6 | const Button = styled.button` 7 | height: 37px; 8 | width: 92px; 9 | font-family: ${fonts.SFPro.bold}; 10 | font-size: 14px; 11 | color: ${colors.White}; 12 | border-radius: 23px; 13 | background-color: ${colors.TMIBlue}; 14 | border: none; 15 | 16 | &:hover { 17 | background-color: ${colors.TMIBlueDark}; 18 | cursor: pointer; 19 | } 20 | `; 21 | 22 | export default Button; 23 | -------------------------------------------------------------------------------- /resources/app/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SketchPicker, ColorResult } from 'react-color'; 3 | import styled from 'styled-components'; 4 | 5 | import { calculateColorWithAlpha } from '../helpers/calculations'; 6 | 7 | interface ColorPickerProps { 8 | color: string; 9 | ids: string[]; 10 | onBackgroundClick(): void; 11 | } 12 | 13 | const ColorPickerWrapper = styled.div` 14 | position: fixed; 15 | z-index: 2; 16 | top: 84px; 17 | right: 0px; 18 | `; 19 | 20 | const ColorPickerBackground = styled.div` 21 | position: fixed; 22 | top: 0px; 23 | right: 0px; 24 | bottom: 0px; 25 | left: 0px; 26 | `; 27 | 28 | const ColorPicker = ({ color, ids, onBackgroundClick }: ColorPickerProps) => { 29 | const replaceColor = (colorToReplace: string, targetColor: string, layerIds: string[]) => { 30 | window.postMessage('replaceColor', { 31 | message: 'Replacing the color', 32 | colorToReplace, 33 | targetColor, 34 | layerIds, 35 | }); 36 | }; 37 | 38 | const handleReplaceColorComplete = (targetColor: ColorResult) => { 39 | replaceColor(color, calculateColorWithAlpha(targetColor), ids); 40 | }; 41 | 42 | return ( 43 | 44 | 45 | handleReplaceColorComplete(newColor)} 50 | /> 51 | 52 | ); 53 | }; 54 | 55 | export default ColorPicker; 56 | -------------------------------------------------------------------------------- /resources/app/components/Footer.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, fireEvent } from 'react-testing-library'; 3 | import Footer from './Footer'; 4 | import { closeWindow, openUrlInBrowser } from '../helpers/window'; 5 | 6 | jest.mock('../helpers/window', () => { 7 | return { 8 | closeWindow: jest.fn(), 9 | openUrlInBrowser: jest.fn(), 10 | }; 11 | }); 12 | 13 | afterEach(cleanup); 14 | 15 | describe('Footer.jsx', () => { 16 | test('renders and displays correctly', () => { 17 | const { container } = render(); 18 | 19 | expect(container.firstChild).toMatchSnapshot(); 20 | }); 21 | 22 | test('calls the closeWindow function when the done button is clicked', () => { 23 | const { getByText } = render(); 24 | const doneButton = getByText('Done'); 25 | 26 | fireEvent.click(doneButton); 27 | 28 | expect(closeWindow).toHaveBeenCalled(); 29 | }); 30 | 31 | test('calls the openUrlInBrowser function with the TMI website when MadeBy is clicked', () => { 32 | const { getByText } = render(); 33 | const madeByButton = getByText('Made by'); 34 | 35 | fireEvent.click(madeByButton); 36 | 37 | expect(openUrlInBrowser).toHaveBeenCalled(); 38 | expect(openUrlInBrowser).toHaveBeenCalledWith('https://www.themainingredient.co'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /resources/app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Button from './Button'; 4 | import Globals from '../Global.styles'; 5 | import useHover from '../hooks/useHover'; 6 | import { closeWindow, openUrlInBrowser } from '../helpers/window'; 7 | import { tmiUrl } from '../../../constants'; 8 | 9 | const { colors, fonts } = Globals; 10 | 11 | const FooterWrapper = styled.div` 12 | display: flex; 13 | flex-direction: row; 14 | justify-content: space-between; 15 | align-items: center; 16 | padding-left: 24px; 17 | padding-right: 18px; 18 | height: 78px; 19 | width: 100%; 20 | background-color: ${colors.LightGrey}; 21 | `; 22 | 23 | const MadeBy = styled.a` 24 | color: ${colors.MediumGrey}; 25 | font-family: ${fonts.SFPro.reg}; 26 | font-size: 11px; 27 | text-decoration: none; 28 | 29 | &:hover { 30 | color: ${colors.TMIBlue}; 31 | cursor: pointer; 32 | } 33 | `; 34 | 35 | const Bold = styled.span` 36 | font-family: ${fonts.SFPro.bold}; 37 | `; 38 | 39 | const FeedbackMadeByWrapper = styled.div``; 40 | 41 | const Feedback = styled.a` 42 | font-family: ${fonts.SFPro.bold}; 43 | color: ${colors.DarkGrey}; 44 | font-size: 12px; 45 | text-decoration: none; 46 | 47 | &:hover { 48 | color: ${colors.TMIBlue}; 49 | } 50 | `; 51 | 52 | const Footer = () => { 53 | const [isHovered, hoverRef] = useHover(); 54 | 55 | return ( 56 | 57 | 58 | 59 | 60 | 💌 61 | {' '} 62 | Report & support here 63 | 64 | 65 | openUrlInBrowser(tmiUrl)}> 66 | Made by The Main Ingredient 67 | 68 | 69 | closeWindow()}> 70 | {isHovered ? '👍' : 'Done'} 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default Footer; 77 | -------------------------------------------------------------------------------- /resources/app/components/Header.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup } from 'react-testing-library'; 3 | import Header from './Header'; 4 | 5 | jest.mock('../assets/colormateLogo.svg', () => 'img'); 6 | 7 | afterEach(cleanup); 8 | 9 | describe('Header.jsx', () => { 10 | test('renders and displays correctly', () => { 11 | const { container } = render(); 12 | 13 | expect(container.firstChild).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /resources/app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Globals, { flexCenter } from '../Global.styles'; 4 | 5 | import ColormateLogo from '../assets/colormateLogo.svg'; 6 | 7 | const { colors, fonts } = Globals; 8 | const isBeta = process.env.REACT_APP_IS_BETA; 9 | const VERSION = isBeta ? `${process.env.REACT_APP_VERSION}-beta` : process.env.REACT_APP_VERSION; 10 | 11 | const HeaderWrapper = styled.div` 12 | ${flexCenter}; 13 | height: 87px; 14 | width: 100%; 15 | background-color: ${colors.LightGrey}; 16 | box-shadow: 0 5px 10px 2px ${colors.Black25}; 17 | `; 18 | 19 | const Tag = styled.div` 20 | height: 22px; 21 | width: ${isBeta ? '60px' : '39px'}; 22 | background-color: ${colors.TMIBlue}; 23 | border-radius: 11px; 24 | position: absolute; 25 | top: 8px; 26 | right: 8px; 27 | color: ${colors.White}; 28 | font-family: ${fonts.SFPro.reg}; 29 | font-size: 11px; 30 | line-height: 24px; 31 | text-align: center; 32 | `; 33 | 34 | const StyledColormateLogo = styled(ColormateLogo)` 35 | position: absolute; 36 | top: 50%; 37 | left: 47%; 38 | transform: translate(-50%, -50%); 39 | `; 40 | 41 | const Header = () => ( 42 | 43 | 44 | {VERSION} 45 | 46 | ); 47 | 48 | export default Header; 49 | -------------------------------------------------------------------------------- /resources/app/components/List/List.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | import ListItem from './ListItem'; 6 | 7 | const ListWrapper = styled.div` 8 | height: 509px; 9 | overflow-y: scroll; 10 | `; 11 | 12 | const List = ({ colorList }) => { 13 | return ( 14 | 15 | {Object.entries(colorList).map(([color, instances], index) => ( 16 | 22 | ))} 23 | 24 | ); 25 | }; 26 | 27 | List.propTypes = { 28 | colorList: PropTypes.shape({ 29 | layer: PropTypes.object, 30 | parents: PropTypes.array, 31 | }).isRequired, 32 | }; 33 | 34 | export default List; 35 | -------------------------------------------------------------------------------- /resources/app/components/List/ListItem.styles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { omit } from 'lodash'; 4 | import Globals from '../../Global.styles'; 5 | 6 | import CheckeredBackground from '../../assets/checkered_small.svg'; 7 | import ArrowActive from '../../assets/arrowActive.svg'; 8 | import ArrowInactive from '../../assets/arrowInactive.svg'; 9 | 10 | const { colors, fonts } = Globals; 11 | 12 | export const ListItemWrapper = styled.div<{isActive: boolean; onMouseEnter(): void; onMouseLeave(): void}>` 13 | border-bottom: 1px solid ${colors.LightGrey}; 14 | padding-left: 16px; 15 | padding-right: 16px; 16 | background-color: ${({ isActive }) => (isActive ? colors.TMIBlue : '')}; 17 | width: 100%; 18 | height: 72px; 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | flex-direction: row; 23 | 24 | &:hover { 25 | background-color: ${colors.TMIBlue}; 26 | } 27 | `; 28 | 29 | export const DotWrapper = styled.div` 30 | height: 37px; 31 | width: 37px; 32 | `; 33 | 34 | export const DotBG = styled(CheckeredBackground)` 35 | position: absolute; 36 | top: 1px; 37 | left: 1px; 38 | bottom: 1px; 39 | right: 1px; 40 | background-size: 50px; 41 | border-radius: 39px; 42 | `; 43 | 44 | export const DotColor = styled.div<{isBorderNeeded: boolean}>` 45 | position: absolute; 46 | top: 0px; 47 | left: 0px; 48 | bottom: 0px; 49 | right: 0px; 50 | padding: 1px; 51 | background-clip: content-box; 52 | border-radius: 39px; 53 | background-color: ${({ color }) => color}; 54 | box-shadow: ${({ isBorderNeeded }) => { 55 | if (isBorderNeeded) { 56 | return `0 0 0 2px ${colors.White} inset`; 57 | } 58 | return `0 0 0 1px ${colors.MediumGrey} inset`; 59 | }}; 60 | `; 61 | 62 | export const Title = styled.p<{isActive: boolean}>` 63 | margin-left: 8px; 64 | color: ${({ isActive }) => (isActive ? colors.White : colors.TMIBlue)}; 65 | font-size: 16px; 66 | font-family: ${fonts.SFPro.bold}; 67 | 68 | ${ListItemWrapper}:hover & { 69 | color: ${colors.White}; 70 | } 71 | `; 72 | 73 | export const Instances = styled.p<{isActive: boolean}>` 74 | color: ${({ isActive }) => (isActive ? colors.White : colors.DarkGrey)}; 75 | font-size: 14px; 76 | font-family: ${fonts.SFPro.reg}; 77 | font-weight: normal; 78 | 79 | ${ListItemWrapper}:hover & { 80 | color: ${colors.White}; 81 | } 82 | `; 83 | 84 | export const ColorDataWrapper = styled.div` 85 | display: flex; 86 | flex-direction: row; 87 | align-items: center; 88 | width: 50%; 89 | cursor: pointer; 90 | `; 91 | 92 | export const Spacer = styled.div` 93 | flex: 1; 94 | `; 95 | 96 | export const OpacityLabel = styled.p<{isActive: boolean}>` 97 | color: ${({ isActive }) => (isActive ? colors.TMIBlue : colors.DarkGrey)}; 98 | font-family: ${fonts.SFPro.bold}; 99 | font-size: 11px; 100 | line-height: 26px; 101 | text-align: center; 102 | background-color: ${({ isActive }) => (isActive ? colors.White : colors.LightGrey)}; 103 | border-radius: 2px; 104 | 105 | ${ListItemWrapper}:hover & { 106 | color: ${colors.TMIBlue}; 107 | background-color: ${colors.White}; 108 | } 109 | `; 110 | 111 | export const OpacityLabelWrapper = styled.div` 112 | width: 36px; 113 | height: 24px; 114 | `; 115 | 116 | export const IndicatorArrow = styled((props) => { 117 | const filteredProps = omit(props, ['isSelected', 'isHovered']); 118 | if (props.isSelected || props.isHovered) { 119 | return ; 120 | } 121 | 122 | return ; 123 | })` 124 | transform: ${({ isSelected }) => (isSelected ? 'rotate(90deg)' : 'rotate(0deg)')}; 125 | transition: transform 150ms ease-in-out; 126 | margin-left: 10px; 127 | `; 128 | 129 | export const LabelsWrapper = styled.div` 130 | justify-content: space-between; 131 | display: flex; 132 | width: 50%; 133 | align-items: center; 134 | `; 135 | 136 | export const InstancesWrapper = styled.div` 137 | width: 30px; 138 | text-align: right; 139 | `; 140 | -------------------------------------------------------------------------------- /resources/app/components/List/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | ListItemWrapper, 5 | ColorDataWrapper, 6 | DotWrapper, 7 | DotBG, 8 | DotColor, 9 | Title, 10 | Instances, 11 | OpacityLabel, 12 | Spacer, 13 | IndicatorArrow, 14 | LabelsWrapper, 15 | OpacityLabelWrapper, 16 | InstancesWrapper, 17 | } from './ListItem.styles'; 18 | import ListItemTree from './ListItemTree'; 19 | import ListContext from '../../ListContext'; 20 | import { transformSketchColorMap } from '../../helpers/transform-sketch-colormap'; 21 | 22 | import { calcOpacityPercentage, calculateContrast } from '../../helpers/calculations'; 23 | import ReplaceBtn from '../../assets/replaceBtn.svg'; 24 | import ReplaceBtnHover from '../../assets/replaceBtnHover.svg'; 25 | import ColorPicker from '../ColorPicker'; 26 | 27 | const isColorContrasting = (color: any) => calculateContrast(color) > 1.2; 28 | 29 | const ListItem = ({ color, instances, index }: { color: string, instances: any[], index: any }) => { 30 | const [isSelected, setSelected] = useState(); 31 | const [realLayers, setRealLayers] = useState(); 32 | const [isColorPickerVisible, setIsColorPickerVisible] = useState(false); 33 | const [isHovered, setIsHovered] = useState(false); 34 | const { selectedColor, setSelectedColor, colors } = useContext(ListContext); 35 | const opacityPercentage = calcOpacityPercentage(color); 36 | 37 | useEffect(() => { 38 | const layers = transformSketchColorMap({ [color]: instances }); 39 | setRealLayers(layers[0]); 40 | }, [colors]); 41 | 42 | useEffect(() => { 43 | setSelected(selectedColor === index); 44 | }, [selectedColor]); 45 | 46 | const updateSelectedColor = (itemIndex: any) => { 47 | setSelectedColor(itemIndex === selectedColor ? null : itemIndex); 48 | }; 49 | 50 | const toggleColorPicker = () => { 51 | setIsColorPickerVisible(!isColorPickerVisible); 52 | }; 53 | 54 | const OpacityIcon = ({ percentage, isActive }: { percentage: number, isActive: boolean }) => { 55 | return ( 56 | 57 | {percentage < 100 && {percentage}%} 58 | 59 | ); 60 | }; 61 | 62 | const ReplaceColorIcon = ({ isActive }: { isActive: boolean }) => { 63 | const style = { cursor: 'pointer' }; 64 | 65 | if (isActive) { 66 | return toggleColorPicker()} />; 67 | } 68 | 69 | return toggleColorPicker()} />; 70 | }; 71 | 72 | return ( 73 | <> 74 | setIsHovered(true)} 77 | onMouseLeave={() => setIsHovered(false)} 78 | > 79 | 80 | updateSelectedColor(index)}> 81 | 82 | 83 | 84 | 85 | {color.toUpperCase().slice(0, -2)} 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | {instances.length}x 97 | 98 | 99 | 100 | 101 | 102 | 103 | {isSelected && } 104 | {isColorPickerVisible && instance.id)} onBackgroundClick={toggleColorPicker} />} 105 | > 106 | ); 107 | }; 108 | 109 | ListItem.propTypes = { 110 | color: PropTypes.string.isRequired, 111 | instances: PropTypes.array.isRequired, 112 | index: PropTypes.number.isRequired, 113 | }; 114 | 115 | export default ListItem; 116 | -------------------------------------------------------------------------------- /resources/app/components/List/ListItemTree.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import LayerNode from './Tree/LayerNode'; 6 | import { LayerType } from '../../../../enums/layer-type.enum'; 7 | 8 | const ListItemTreeWrapper = styled.div` 9 | margin: 16px 0; 10 | `; 11 | 12 | const renderLayer = (tree, color, generation = 0) => { 13 | if ('layers' in tree) { 14 | return tree.layers.map(page => renderLayer(page, color, generation + 1)); 15 | } 16 | 17 | if (!('children' in tree)) { 18 | return ; 19 | } 20 | 21 | if ('children' in tree && tree.children.length) { 22 | if (tree.type !== LayerType.page && tree.type !== LayerType.artboard) { 23 | return tree.children.map(child => renderLayer(child, color, generation)); 24 | } 25 | return ( 26 | 27 | {tree.children.map(child => renderLayer(child, color, generation + 1))} 28 | 29 | ); 30 | } 31 | 32 | return null; 33 | }; 34 | 35 | const ListItemTree = ({ tree, color }) => { 36 | console.log(`ListItemTree.tsx - ${tree}`); 37 | return {renderLayer(tree, color)}; 38 | }; 39 | 40 | ListItemTree.propTypes = { 41 | tree: PropTypes.object.isRequired, 42 | }; 43 | 44 | export default ListItemTree; 45 | -------------------------------------------------------------------------------- /resources/app/components/List/Tree/LayerNode.styles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { omit } from 'lodash'; 4 | import GlobalStyles from '../../../Global.styles'; 5 | import Arrow from '../../../assets/arrowGrey.svg'; 6 | import { ColorType as ColorTypeEnum } from '../../../../../enums/color-type.enum'; 7 | 8 | const { colors, fonts } = GlobalStyles; 9 | 10 | interface NodeWrapperProps { 11 | generation: number, 12 | isSelected: boolean 13 | } 14 | 15 | interface NameProps { 16 | isHovered: boolean, 17 | isSelected: boolean 18 | } 19 | 20 | interface ColorTypeProps { 21 | colorType: ColorTypeEnum 22 | } 23 | 24 | export const NodeWrapper = styled.div` 25 | display: flex; 26 | flex-direction: row; 27 | align-items: center; 28 | padding-left: ${({ generation }: NodeWrapperProps) => 16 * generation}px; 29 | height: 33px; 30 | background-color: ${({ isSelected }: NodeWrapperProps) => (isSelected ? colors.TMIBlueLight : '')}; 31 | `; 32 | 33 | export const StyledArrow = styled(props => )` 34 | margin-right: 8px; 35 | transform: ${({ isOpen }) => (isOpen ? 'rotate(0deg)' : 'rotate(-90deg)')}; 36 | transition: transform 150ms ease-in-out; 37 | `; 38 | 39 | export const Name = styled.p` 40 | font-family: ${fonts.SFPro.reg}; 41 | font-size: 14px; 42 | color: ${({ isSelected }: NameProps) => (isSelected ? colors.TMIBlue : colors.DarkGrey)}; 43 | margin-left: 8px; 44 | border-bottom: ${({ isHovered }: NameProps) => (isHovered ? `1px solid ${colors.TMIBlue}` : '1px solid #00000000')}; 45 | `; 46 | 47 | export const Spacer = styled.div` 48 | flex-grow: 1; 49 | `; 50 | 51 | export const ColorType = styled.div` 52 | height: 8px; 53 | width: 8px; 54 | border-radius: 8px; 55 | border: 1px solid ${colors.DarkGrey}; 56 | background-color: ${({ colorType }: ColorTypeProps) => (colorType === ColorTypeEnum.fill || colorType === ColorTypeEnum.text ? colors.DarkGrey : '')}; 57 | margin-right: 16px; 58 | `; 59 | -------------------------------------------------------------------------------- /resources/app/components/List/Tree/LayerNode.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import useHover from '../../../hooks/useHover'; 4 | import ListContext from '../../../ListContext'; 5 | import ReplaceBtn from '../../../assets/replaceBtn.svg'; 6 | import ReplaceBtnHover from '../../../assets/replaceBtnHover.svg'; 7 | 8 | import { 9 | NodeWrapper, StyledArrow, Name, ColorType, Spacer, 10 | } from './LayerNode.styles'; 11 | 12 | import Artboard from '../../../assets/artboard.svg'; 13 | import ShapePath from '../../../assets/Rectangle.svg'; 14 | import Text from '../../../assets/textIcon.svg'; 15 | import ColorPicker from '../../ColorPicker'; 16 | import { LayerType } from '../../../../../enums/layer-type.enum'; 17 | 18 | const LayerNode = ({ 19 | layer, generation, children, color, 20 | }) => { 21 | const [isOpen, setOpen] = useState(true); 22 | const [isSelected, setSelected] = useState(); 23 | const [isLastNode, setLastNode] = useState(); 24 | const { selectedLayer, setSelectedLayer, colors } = useContext(ListContext); 25 | const [isHovered, hoverRef] = useHover(); 26 | const [isColorPickerVisible, setIsColorPickerVisible] = useState(false); 27 | 28 | const { 29 | name, id, type, colorType, 30 | } = layer; 31 | 32 | useEffect(() => { 33 | 'children' in layer && layer.children.length ? setLastNode(false) : setLastNode(true); 34 | }, []); 35 | 36 | useEffect(() => { 37 | setSelected(selectedLayer === id); 38 | }, [selectedLayer]); 39 | 40 | const handleClick = () => { 41 | if (type === LayerType.group) return; 42 | 43 | const shouldCenterOnSelf: boolean = type === LayerType.artboard || type === LayerType.page; 44 | setSelectedLayer(id); 45 | 46 | const idToCenterOn: string = shouldCenterOnSelf ? id : Object.entries(colors) 47 | .reduce((acc: any, keyValue: any) => ([...acc, ...keyValue[1]]), []) 48 | .find(innerLayer => innerLayer.id === id) 49 | .parents 50 | .find(parent => parent.type === LayerType.artboard) 51 | .id; 52 | 53 | window.postMessage('selectLayer', id, idToCenterOn); 54 | }; 55 | 56 | const toggleColorPicker = () => { 57 | setIsColorPickerVisible(!isColorPickerVisible); 58 | }; 59 | 60 | const ReplaceColorIcon = () => { 61 | const style = { 62 | cursor: 'pointer', 63 | height: 25, 64 | width: 25, 65 | marginRight: 8, 66 | }; 67 | 68 | if (isColorPickerVisible) { 69 | return toggleColorPicker()} />; 70 | } 71 | 72 | return toggleColorPicker()} />; 73 | }; 74 | 75 | return ( 76 | <> 77 | handleClick()} isSelected={isSelected}> 78 | {!isLastNode ? ( 79 | { 82 | e.stopPropagation(); 83 | setOpen(!isOpen); 84 | }} 85 | /> 86 | ) : ( 87 | 88 | )} 89 | {(() => { 90 | switch (type) { 91 | case LayerType.artboard: 92 | return ; 93 | case LayerType.shapePath: 94 | return ; 95 | case LayerType.text: 96 | return ; 97 | case LayerType.page: 98 | case LayerType.shape: 99 | default: 100 | return null; 101 | } 102 | })()} 103 | 104 | {name} 105 | 106 | 107 | 108 | 109 | {isLastNode && } 110 | 111 | {isColorPickerVisible && } 112 | 113 | 114 | {!isLastNode && isOpen && <>{children || null}>} 115 | > 116 | ); 117 | }; 118 | 119 | LayerNode.propTypes = { 120 | layer: PropTypes.object.isRequired, 121 | generation: PropTypes.number.isRequired, 122 | children: PropTypes.array, 123 | color: PropTypes.string, 124 | }; 125 | 126 | LayerNode.defaultProps = { 127 | children: [], 128 | color: '', 129 | }; 130 | 131 | export default LayerNode; 132 | -------------------------------------------------------------------------------- /resources/app/components/Loader.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup } from 'react-testing-library'; 3 | import Loader from './Loader'; 4 | 5 | jest.mock('../assets/text1Loader.svg', () => 'text-one-loader'); 6 | jest.mock('../assets/text2Loader.svg', () => 'text-two-loader'); 7 | 8 | afterEach(cleanup); 9 | 10 | describe('Loader.jsx', () => { 11 | test('renders and displays correctly', () => { 12 | const { container } = render(); 13 | 14 | expect(container.firstChild).toMatchSnapshot(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /resources/app/components/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Globals, { flexCenter } from '../Global.styles'; 4 | 5 | import LoaderTitle from '../assets/text1Loader.svg'; 6 | import LoaderContent from '../assets/text2Loader.svg'; 7 | import loader from '../assets/washingTransparent.gif'; 8 | 9 | const { colors } = Globals; 10 | 11 | const LoaderWrapper = styled.div` 12 | ${flexCenter}; 13 | flex-direction: column; 14 | background-color: ${colors.TMIBlue}; 15 | height: 100%; 16 | width: 100%; 17 | text-align: center; 18 | `; 19 | 20 | const LoaderImage = styled.img` 21 | height: 250px; 22 | margin: 40px 0; 23 | `; 24 | 25 | const Loader = () => ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | 33 | export default Loader; 34 | -------------------------------------------------------------------------------- /resources/app/components/NoColorsFound.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, cleanup, fireEvent } from 'react-testing-library'; 3 | import NoColorsFound from './NoColorsFound'; 4 | import { closeWindow } from '../helpers/window'; 5 | 6 | jest.mock('../assets/minimilistBubbles.svg', () => 'img'); 7 | jest.mock('../helpers/window', () => { 8 | return { 9 | closeWindow: jest.fn(), 10 | }; 11 | }); 12 | 13 | afterEach(cleanup); 14 | afterAll(() => (closeWindow as jest.Mock).mockReset()); 15 | 16 | describe('NoColorsFound.jsx', () => { 17 | test('renders and displays correctly', () => { 18 | const { container } = render(); 19 | 20 | expect(container.firstChild).toMatchSnapshot(); 21 | }); 22 | 23 | test('closes the window when the close button is clicked', () => { 24 | const { getByText } = render(); 25 | const closeButton = getByText('Close'); 26 | 27 | fireEvent.click(closeButton); 28 | 29 | expect(closeWindow).toHaveBeenCalled(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /resources/app/components/NoColorsFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import Globals from '../Global.styles'; 4 | import Button from './Button'; 5 | 6 | import MinimalistBubbles from '../assets/minimilistBubbles.svg'; 7 | import { closeWindow } from '../helpers/window'; 8 | 9 | const { colors, fonts } = Globals; 10 | 11 | const NoColorsFoundWrapper = styled.div` 12 | background-color: ${colors.LightGrey}; 13 | height: 100%; 14 | width: 100%; 15 | `; 16 | 17 | const Header = styled.div` 18 | margin-top: 37px; 19 | color: ${colors.TMIBlue}; 20 | font-family: ${fonts.Futura.bold}; 21 | font-size: 21.68px; 22 | letter-spacing: 0.13px; 23 | text-align: center; 24 | `; 25 | 26 | const StyledMinimalistBubbles = styled(MinimalistBubbles)` 27 | margin-top: 99px; 28 | margin-left: 76px; 29 | `; 30 | 31 | const ButtonWrapper = styled.div` 32 | position: absolute; 33 | bottom: 21px; 34 | right: 26px; 35 | `; 36 | 37 | const NoColorsFound = () => { 38 | return ( 39 | 40 | 41 | No colours found. 42 | Now that’s minimilist. 43 | 44 | 45 | 46 | closeWindow()}> 47 | Close 48 | 49 | 50 | 51 | 52 | ); 53 | }; 54 | 55 | export default NoColorsFound; 56 | -------------------------------------------------------------------------------- /resources/app/components/__snapshots__/Footer.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Footer.jsx renders and displays correctly 1`] = ` 4 | .c4 { 5 | height: 37px; 6 | width: 92px; 7 | font-family: SFProBold; 8 | font-size: 14px; 9 | color: #FFFFFF; 10 | border-radius: 23px; 11 | background-color: #4E41FF; 12 | border: none; 13 | } 14 | 15 | .c4:hover { 16 | background-color: #3B2EEB; 17 | cursor: pointer; 18 | } 19 | 20 | .c0 { 21 | display: -webkit-box; 22 | display: -webkit-flex; 23 | display: -ms-flexbox; 24 | display: flex; 25 | -webkit-flex-direction: row; 26 | -ms-flex-direction: row; 27 | flex-direction: row; 28 | -webkit-box-pack: justify; 29 | -webkit-justify-content: space-between; 30 | -ms-flex-pack: justify; 31 | justify-content: space-between; 32 | -webkit-align-items: center; 33 | -webkit-box-align: center; 34 | -ms-flex-align: center; 35 | align-items: center; 36 | padding-left: 24px; 37 | padding-right: 18px; 38 | height: 78px; 39 | width: 100%; 40 | background-color: #E8E9EF; 41 | } 42 | 43 | .c2 { 44 | color: #B5B8C6; 45 | font-family: SFProRegular; 46 | font-size: 11px; 47 | -webkit-text-decoration: none; 48 | text-decoration: none; 49 | } 50 | 51 | .c2:hover { 52 | color: #4E41FF; 53 | cursor: pointer; 54 | } 55 | 56 | .c3 { 57 | font-family: SFProBold; 58 | } 59 | 60 | .c1 { 61 | font-family: SFProBold; 62 | color: #4d4f59; 63 | font-size: 12px; 64 | -webkit-text-decoration: none; 65 | text-decoration: none; 66 | } 67 | 68 | .c1:hover { 69 | color: #4E41FF; 70 | } 71 | 72 | 75 | 78 | 82 | 86 | 💌 87 | 88 | 89 | Report & support here 90 | 91 | 92 | 95 | Made by 96 | 99 | The Main Ingredient 100 | 101 | 102 | 103 | 107 | Done 108 | 109 | 110 | `; 111 | -------------------------------------------------------------------------------- /resources/app/components/__snapshots__/Header.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Header.jsx renders and displays correctly 1`] = ` 4 | .c0 { 5 | display: -webkit-box; 6 | display: -webkit-flex; 7 | display: -ms-flexbox; 8 | display: flex; 9 | -webkit-box-pack: center; 10 | -webkit-justify-content: center; 11 | -ms-flex-pack: center; 12 | justify-content: center; 13 | -webkit-align-items: center; 14 | -webkit-box-align: center; 15 | -ms-flex-align: center; 16 | align-items: center; 17 | height: 87px; 18 | width: 100%; 19 | background-color: #E8E9EF; 20 | box-shadow: 0 5px 10px 2px #00000040; 21 | } 22 | 23 | .c2 { 24 | height: 22px; 25 | width: 39px; 26 | background-color: #4E41FF; 27 | border-radius: 11px; 28 | position: absolute; 29 | top: 8px; 30 | right: 8px; 31 | color: #FFFFFF; 32 | font-family: SFProRegular; 33 | font-size: 11px; 34 | line-height: 24px; 35 | text-align: center; 36 | } 37 | 38 | .c1 { 39 | position: absolute; 40 | top: 50%; 41 | left: 47%; 42 | -webkit-transform: translate(-50%,-50%); 43 | -ms-transform: translate(-50%,-50%); 44 | transform: translate(-50%,-50%); 45 | } 46 | 47 | 50 | 53 | 56 | 57 | `; 58 | -------------------------------------------------------------------------------- /resources/app/components/__snapshots__/Loader.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Loader.jsx renders and displays correctly 1`] = ` 4 | .c0 { 5 | display: -webkit-box; 6 | display: -webkit-flex; 7 | display: -ms-flexbox; 8 | display: flex; 9 | -webkit-box-pack: center; 10 | -webkit-justify-content: center; 11 | -ms-flex-pack: center; 12 | justify-content: center; 13 | -webkit-align-items: center; 14 | -webkit-box-align: center; 15 | -ms-flex-align: center; 16 | align-items: center; 17 | -webkit-flex-direction: column; 18 | -ms-flex-direction: column; 19 | flex-direction: column; 20 | background-color: #4E41FF; 21 | height: 100%; 22 | width: 100%; 23 | text-align: center; 24 | } 25 | 26 | .c1 { 27 | height: 250px; 28 | margin: 40px 0; 29 | } 30 | 31 | 34 | 35 | 40 | 41 | 42 | `; 43 | -------------------------------------------------------------------------------- /resources/app/components/__snapshots__/NoColorsFound.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`NoColorsFound.jsx renders and displays correctly 1`] = ` 4 | .c4 { 5 | height: 37px; 6 | width: 92px; 7 | font-family: SFProBold; 8 | font-size: 14px; 9 | color: #FFFFFF; 10 | border-radius: 23px; 11 | background-color: #4E41FF; 12 | border: none; 13 | } 14 | 15 | .c4:hover { 16 | background-color: #3B2EEB; 17 | cursor: pointer; 18 | } 19 | 20 | .c0 { 21 | background-color: #E8E9EF; 22 | height: 100%; 23 | width: 100%; 24 | } 25 | 26 | .c1 { 27 | margin-top: 37px; 28 | color: #4E41FF; 29 | font-family: FuturaBold; 30 | font-size: 21.68px; 31 | -webkit-letter-spacing: 0.13px; 32 | -moz-letter-spacing: 0.13px; 33 | -ms-letter-spacing: 0.13px; 34 | letter-spacing: 0.13px; 35 | text-align: center; 36 | } 37 | 38 | .c2 { 39 | margin-top: 99px; 40 | margin-left: 76px; 41 | } 42 | 43 | .c3 { 44 | position: absolute; 45 | bottom: 21px; 46 | right: 26px; 47 | } 48 | 49 | 52 | 55 | 56 | No colours found. 57 | 58 | 59 | Now that’s minimilist. 60 | 61 | 62 | 65 | 68 | 72 | Close 73 | 74 | 75 | 76 | `; 77 | -------------------------------------------------------------------------------- /resources/app/helpers/calculations.test.js: -------------------------------------------------------------------------------- 1 | 2 | import * as fromCalculations from './calculations.ts'; 3 | 4 | describe('React | Helpers', () => { 5 | describe('calcOpacityPercentage', () => { 6 | test('it returns the opacity percentage of a given hex color', () => { 7 | const color1 = '#ffffffff'; 8 | const color2 = '#C2C2C2C2'; 9 | 10 | expect(fromCalculations.calcOpacityPercentage(color1)).toEqual(100); 11 | expect(fromCalculations.calcOpacityPercentage(color2)).toEqual(76); 12 | }); 13 | }); 14 | 15 | describe('calculateCombinedLuminance', () => { 16 | test('it transforms a hex color to a relative luminance value', () => { 17 | const originalImplementation = fromCalculations.calculateLuminance; 18 | fromCalculations.calculateLuminance = jest.fn() 19 | .mockReturnValueOnce(0.730) 20 | .mockReturnValueOnce(0.056) 21 | .mockReturnValueOnce(0.246); 22 | 23 | expect(Number(fromCalculations.calculateCombinedLuminance('RANDOMVALUE').toFixed(4))).toEqual(0.213); 24 | fromCalculations.calculateLuminance = originalImplementation; 25 | }); 26 | }); 27 | 28 | describe('calculateLuminance', () => { 29 | test('it calculates the luminance of a single Hex', () => { 30 | expect(Number(fromCalculations.calculateLuminance('FF').toFixed(4))).toEqual(1); 31 | expect(Number(fromCalculations.calculateLuminance('AA').toFixed(4))).toEqual(0.4020); 32 | expect(Number(fromCalculations.calculateLuminance('11').toFixed(4))).toEqual(0.0056); 33 | expect(Number(fromCalculations.calculateLuminance('00').toFixed(4))).toEqual(0); 34 | }); 35 | }); 36 | 37 | describe('calculateContrast', () => { 38 | test('it returns the luminance of a color', () => { 39 | const color1 = '#EEEEEEFF'; 40 | const color2 = '#DE438FFF'; 41 | 42 | expect(fromCalculations.calculateContrast(color1)).toEqual(1.16); 43 | expect(fromCalculations.calculateContrast(color2)).toEqual(3.96); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /resources/app/helpers/calculations.ts: -------------------------------------------------------------------------------- 1 | import { ColorResult } from 'react-color'; 2 | import * as calculations from './calculations'; // eslint-disable-line import/no-self-import 3 | 4 | export const calculateColorWithAlpha = (targetColor: ColorResult): string => { 5 | const { rgb: { a: alpha = 100 } } = targetColor; 6 | const alphaBase16 = Math.round(alpha * 255).toString(16).padStart(2, '0'); 7 | return `${targetColor.hex}${alphaBase16}`; 8 | }; 9 | 10 | export const calcOpacityPercentage = (hexColor: string) => { 11 | if (hexColor.length > 7) { 12 | const hexOpacity = hexColor.substr(7, 9); 13 | return Math.round(parseInt(hexOpacity, 16) / 2.55); 14 | } 15 | return 100; 16 | }; 17 | 18 | export const calculateLuminance = (color: string) => { 19 | // Rebasing the incoming hex color to a value between 0 and 1, 0 being 0% intensity, and 1 being 100% intensity 20 | const rebasedColor = parseInt(color, 16) / 255; 21 | 22 | /** 23 | * There are 2 different ways to calculate the Luminance of a color, depending on the input value. 24 | * When the color intensity is lower than 3.928%, meaning black or really dark grey, we use the first method. 25 | * When the color intensity is higher we use the second method. 26 | */ 27 | 28 | if (rebasedColor <= 0.03928) { 29 | return rebasedColor / 12.92; 30 | } 31 | 32 | return Math.pow((rebasedColor + 0.055) / 1.055, 2.4); // eslint-disable-line no-restricted-properties 33 | }; 34 | 35 | export const calculateCombinedLuminance = (hexColor: string) => { 36 | const cleanedColor = hexColor.substr(1); // Remove the # of the incoming hex color 37 | const R = cleanedColor.substr(0, 2); // Grab the first and second chars in the color, representing the red value 38 | const G = cleanedColor.substr(2, 2); // Grab the third and fourth chars in the color, representing the green value 39 | const B = cleanedColor.substr(4, 2); // Grab the fifth and sixth chars in the color, representing the blue value 40 | 41 | /** 42 | * Here we calculate the Luminance of a color in the RGB space 43 | * The numbers that we use to multiply the calculateLuminance of R, G, and B is how much the light contributes 44 | * to the intensity perceived by humans. Green light contributes the most, and blue the least. 45 | * 46 | * More info here: https://en.wikipedia.org/wiki/Relative_luminance 47 | */ 48 | return ( 49 | 0.2126 * calculations.calculateLuminance(R) 50 | + 0.7152 * calculations.calculateLuminance(G) 51 | + 0.0722 * calculations.calculateLuminance(B) 52 | ); 53 | }; 54 | 55 | export const calculateContrast = (color: string) => { 56 | const colorLuminance = calculateCombinedLuminance(color); 57 | /** 58 | * The luminance of a color is represented by a color between 0 and 1. 59 | * Black has a luminosity of 0, meaning it reflects none of the light. 60 | * White has a luminosity of 1, meaning it reflects all the light the falls on it. 61 | */ 62 | const whiteLuminance = 1; 63 | 64 | if (colorLuminance === whiteLuminance) return 0; 65 | 66 | return Number(((whiteLuminance + 0.05) / (colorLuminance + 0.05)).toFixed(2)); 67 | }; 68 | -------------------------------------------------------------------------------- /resources/app/helpers/replace-color.test.ts: -------------------------------------------------------------------------------- 1 | import { replaceColor } from './replace-color'; 2 | 3 | describe('replaceColor', () => { 4 | const colorToReplace = '#ffffff'; 5 | const targetColor = '#000000'; 6 | 7 | test('should remove the colorToReplace from the colors', () => { 8 | const colors = { 9 | [colorToReplace]: ['whiteLayer'], 10 | [targetColor]: ['blackLayer'], 11 | }; 12 | 13 | expect(colors[colorToReplace]).toBeDefined(); 14 | 15 | const updatedColors = replaceColor(colors, colorToReplace, targetColor); 16 | 17 | expect(updatedColors[colorToReplace]).toBeUndefined(); 18 | }); 19 | 20 | test('if targetColor already exist, should append it the colorToReplace layers', () => { 21 | const colors = { 22 | [colorToReplace]: ['whiteLayer'], 23 | [targetColor]: ['blackLayer'], 24 | }; 25 | 26 | expect(replaceColor(colors, colorToReplace, targetColor)).toEqual({ 27 | [targetColor]: ['blackLayer', 'whiteLayer'], 28 | }); 29 | }); 30 | 31 | test('if targetColor does not exist, should add it with the colorToReplace layers', () => { 32 | const colors = { 33 | [colorToReplace]: ['whiteLayer'], 34 | }; 35 | 36 | expect(replaceColor(colors, colorToReplace, targetColor)).toEqual({ 37 | [targetColor]: ['whiteLayer'], 38 | }); 39 | }); 40 | 41 | test('should throw an exception if colorToReplace is not in colors', () => { 42 | const colors = {}; 43 | const error = `colorToReplace ${colorToReplace} not present in colors ${JSON.stringify(colors, null, 2)}`; 44 | 45 | expect(() => replaceColor({}, colorToReplace, targetColor)).toThrowError(error); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /resources/app/helpers/replace-color.ts: -------------------------------------------------------------------------------- 1 | import { omit } from 'lodash'; 2 | 3 | export const replaceColor = (colors: any, colorToReplace: string, targetColor: string) => { 4 | colorToReplace = colorToReplace.toLowerCase(); 5 | targetColor = targetColor.toLowerCase(); 6 | 7 | if (colorToReplace === targetColor) { 8 | return null; 9 | } 10 | const updatedColorLayers = colors[colorToReplace]; 11 | 12 | if (!updatedColorLayers) { 13 | throw new Error( 14 | `colorToReplace ${colorToReplace} not present in colors ${JSON.stringify(colors, null, 2)}`, 15 | ); 16 | } 17 | 18 | const targetLayers = !colors[targetColor] ? updatedColorLayers : [...colors[targetColor], ...updatedColorLayers]; 19 | 20 | return { 21 | ...omit(colors, colorToReplace), 22 | [targetColor]: targetLayers, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /resources/app/helpers/transform-sketch-colormap.test.ts: -------------------------------------------------------------------------------- 1 | import { ColorWithLayers } from '../models/color-with-layers.model'; 2 | import { SketchColorMap, SketchColorMapLayer, SketchColorMapLayerParent } from '../models/sketch-color-map.model'; 3 | import { transformSketchColorMap } from './transform-sketch-colormap'; 4 | import { LayerType } from '../../../enums/layer-type.enum'; 5 | import { ColorType } from '../../../enums/color-type.enum'; 6 | 7 | const createSketchColorMapLayer = ( 8 | name: string, 9 | type: string, 10 | parents: { name: string; type: string }[] = [], 11 | ): SketchColorMapLayer => { 12 | const inputLayer = { 13 | id: `id-${name}`, 14 | name, 15 | type, 16 | colorType: ColorType.fill, 17 | parents: [] as SketchColorMapLayerParent[], 18 | }; 19 | 20 | if (parents.length) { 21 | parents.forEach(({ name: parentName, type: parentType }) => { 22 | const parent = { id: `id-${parentName}`, name: parentName, type: parentType }; 23 | inputLayer.parents.push(parent); 24 | }); 25 | } 26 | 27 | return inputLayer; 28 | }; 29 | 30 | describe('createTreeStructure', () => { 31 | let input: SketchColorMap; 32 | let output: ColorWithLayers[]; 33 | 34 | afterEach(() => { 35 | expect(transformSketchColorMap(input)).toEqual(output); 36 | }); 37 | 38 | test('transform color map with no layers to an array of colors with no layers', () => { 39 | input = { red: [], yellow: [] }; 40 | 41 | output = [{ color: 'red', layers: [] }, { color: 'yellow', layers: [] }]; 42 | }); 43 | 44 | test('transform color map with no parents to an array of colors with a layer without children', () => { 45 | input = { 46 | red: [createSketchColorMapLayer('Rectangle1', 'ShapePath')], 47 | }; 48 | 49 | output = [ 50 | { 51 | color: 'red', 52 | layers: [{ 53 | id: 'id-Rectangle1', name: 'Rectangle1', type: 'ShapePath', colorType: ColorType.fill, 54 | }], 55 | }, 56 | ]; 57 | }); 58 | 59 | test('transform color map with parents to an array of colors with layers', () => { 60 | input = { 61 | red: [ 62 | createSketchColorMapLayer( 63 | 'Rectangle', 64 | 'ShapePath', 65 | [ 66 | { name: LayerType.page, type: LayerType.page }, 67 | { name: LayerType.group, type: LayerType.group }, 68 | ]), 69 | ], 70 | }; 71 | 72 | output = [ 73 | { 74 | color: 'red', 75 | layers: [ 76 | { 77 | id: 'id-Page', 78 | name: LayerType.page, 79 | type: LayerType.page, 80 | children: [ 81 | { 82 | id: 'id-Group', 83 | name: LayerType.group, 84 | type: LayerType.group, 85 | children: [{ 86 | id: 'id-Rectangle', name: 'Rectangle', type: 'ShapePath', colorType: ColorType.fill, 87 | }], 88 | }, 89 | ], 90 | }, 91 | ], 92 | }, 93 | ]; 94 | }); 95 | 96 | test('transform color map with different parents to an array of colors with layers', () => { 97 | input = { 98 | red: [ 99 | createSketchColorMapLayer('Rectangle1', 'ShapePath', [ 100 | { name: 'Page1', type: LayerType.page }, 101 | { name: 'Artboard1', type: LayerType.artboard }, 102 | ]), 103 | createSketchColorMapLayer('Rectangle2', 'ShapePath', [ 104 | { name: 'Page2', type: LayerType.page }, 105 | { name: 'Artboard2', type: LayerType.artboard }, 106 | ]), 107 | ], 108 | }; 109 | 110 | output = [ 111 | { 112 | color: 'red', 113 | layers: [ 114 | { 115 | name: 'Page1', 116 | id: 'id-Page1', 117 | type: LayerType.page, 118 | children: [ 119 | { 120 | name: 'Artboard1', 121 | id: 'id-Artboard1', 122 | type: LayerType.artboard, 123 | children: [{ 124 | id: 'id-Rectangle1', name: 'Rectangle1', type: 'ShapePath', colorType: ColorType.fill, 125 | }], 126 | }, 127 | ], 128 | }, 129 | { 130 | id: 'id-Page2', 131 | name: 'Page2', 132 | type: LayerType.page, 133 | children: [ 134 | { 135 | id: 'id-Artboard2', 136 | name: 'Artboard2', 137 | type: LayerType.artboard, 138 | children: [{ 139 | id: 'id-Rectangle2', name: 'Rectangle2', type: 'ShapePath', colorType: ColorType.fill, 140 | }], 141 | }, 142 | ], 143 | }, 144 | ], 145 | }, 146 | ]; 147 | }); 148 | 149 | test('transform color map with common parents to an array of colors with grouped layers', () => { 150 | input = { 151 | red: [ 152 | createSketchColorMapLayer('Rectangle1', 'ShapePath', [ 153 | { name: LayerType.page, type: LayerType.page }, 154 | { name: LayerType.artboard, type: LayerType.artboard }, 155 | ]), 156 | createSketchColorMapLayer('Rectangle2', 'ShapePath', [ 157 | { name: LayerType.page, type: LayerType.page }, 158 | { name: LayerType.artboard, type: LayerType.artboard }, 159 | ]), 160 | ], 161 | }; 162 | 163 | output = [ 164 | { 165 | color: 'red', 166 | layers: [ 167 | { 168 | name: LayerType.page, 169 | id: 'id-Page', 170 | type: LayerType.page, 171 | children: [ 172 | { 173 | name: LayerType.artboard, 174 | id: 'id-Artboard', 175 | type: LayerType.artboard, 176 | children: [ 177 | { 178 | id: 'id-Rectangle1', name: 'Rectangle1', type: 'ShapePath', colorType: ColorType.fill, 179 | }, 180 | { 181 | id: 'id-Rectangle2', name: 'Rectangle2', type: 'ShapePath', colorType: ColorType.fill, 182 | }, 183 | ], 184 | }, 185 | ], 186 | }, 187 | ], 188 | }, 189 | ]; 190 | }); 191 | 192 | test('transform color map with partially common parents to an array of colors with grouped layers', () => { 193 | input = { 194 | red: [ 195 | createSketchColorMapLayer('Rectangle2', 'ShapePath', [ 196 | { name: LayerType.page, type: LayerType.page }, 197 | { name: LayerType.artboard, type: LayerType.artboard }, 198 | ]), 199 | createSketchColorMapLayer('Rectangle1', 'ShapePath', [ 200 | { name: LayerType.page, type: LayerType.page }, 201 | { name: LayerType.artboard, type: LayerType.artboard }, 202 | { name: LayerType.group, type: LayerType.group }, 203 | ]), 204 | createSketchColorMapLayer('Rectangle3', 'ShapePath', [ 205 | { name: LayerType.page, type: LayerType.page }, 206 | { name: LayerType.artboard, type: LayerType.artboard }, 207 | { name: LayerType.group, type: LayerType.group }, 208 | ]), 209 | ], 210 | }; 211 | 212 | output = [ 213 | { 214 | color: 'red', 215 | layers: [ 216 | { 217 | name: LayerType.page, 218 | id: 'id-Page', 219 | type: LayerType.page, 220 | children: [ 221 | { 222 | name: LayerType.artboard, 223 | id: 'id-Artboard', 224 | type: LayerType.artboard, 225 | children: [ 226 | { 227 | id: 'id-Rectangle2', 228 | name: 'Rectangle2', 229 | type: 'ShapePath', 230 | colorType: ColorType.fill, 231 | }, 232 | { 233 | name: LayerType.group, 234 | id: 'id-Group', 235 | type: LayerType.group, 236 | children: [ 237 | { 238 | id: 'id-Rectangle1', 239 | name: 'Rectangle1', 240 | type: 'ShapePath', 241 | colorType: ColorType.fill, 242 | }, 243 | { 244 | id: 'id-Rectangle3', 245 | name: 'Rectangle3', 246 | type: 'ShapePath', 247 | colorType: ColorType.fill, 248 | }, 249 | ], 250 | }, 251 | ], 252 | }, 253 | ], 254 | }, 255 | ], 256 | }, 257 | ]; 258 | }); 259 | }); 260 | -------------------------------------------------------------------------------- /resources/app/helpers/transform-sketch-colormap.ts: -------------------------------------------------------------------------------- 1 | import { omit } from 'lodash'; 2 | import { Layer, ColorWithLayers } from '../models/color-with-layers.model'; 3 | 4 | import { SketchColorMap, SketchColorMapLayer } from '../models/sketch-color-map.model'; 5 | 6 | // there is only one child for each layer in the hierarchy 7 | const getChild = (hierarchy: Layer[]): Layer => { 8 | if (hierarchy.length === 1) { 9 | return hierarchy[0]; 10 | } 11 | 12 | return { 13 | ...hierarchy[0], 14 | children: [getChild(hierarchy.splice(1))], 15 | }; 16 | }; 17 | 18 | const getHierarchy = (inputLayer: SketchColorMapLayer): Layer[] => { 19 | if (inputLayer.parents.length) { 20 | return [...inputLayer.parents, omit(inputLayer, 'parents')]; 21 | } 22 | 23 | return [omit(inputLayer, 'parents')]; 24 | }; 25 | 26 | const mapInputLayerToLayer = (inputLayer: SketchColorMapLayer): Layer => { 27 | const hierarchy = getHierarchy(inputLayer); 28 | return getChild(hierarchy); 29 | }; 30 | 31 | const isSketchColorMapLayerType = (layer: SketchColorMapLayer | Layer): layer is SketchColorMapLayer => { 32 | return (layer).parents !== undefined; 33 | }; 34 | 35 | const addLayerWithGrouping = (groupedLayers: Layer[] = [], layerToAdd: SketchColorMapLayer | Layer): Layer[] => { 36 | const layer: Layer = isSketchColorMapLayerType(layerToAdd) ? mapInputLayerToLayer(layerToAdd) : layerToAdd; 37 | 38 | if (!groupedLayers.length) { 39 | return [layer]; 40 | } 41 | 42 | const filteredGroupedLayers = groupedLayers.filter(groupedLayer => groupedLayer.id === layer.id); 43 | if (filteredGroupedLayers.length) { 44 | return groupedLayers.map((groupedLayer) => { 45 | if (groupedLayer.name === layer.name && 'children' in layer) { 46 | // each child layer needs to be added with grouping to the children of the current layer 47 | const updatedLayers: Layer[] = layer.children!.reduce( 48 | (acc: Layer[], cur: Layer) => addLayerWithGrouping(acc, cur), 49 | groupedLayer.children!, 50 | ); 51 | return { 52 | ...groupedLayer, 53 | children: updatedLayers, 54 | }; 55 | } 56 | // layers have different name or layer to insert has no children: nothing to change for the current grouped layer 57 | return groupedLayer; 58 | }); 59 | } 60 | 61 | return groupedLayers.concat([layer]); 62 | }; 63 | 64 | 65 | export const transformSketchColorMap = (colorsObject: SketchColorMap): ColorWithLayers[] => { 66 | return Object.entries(colorsObject).map(([color, inputLayers]) => ({ 67 | color, 68 | layers: inputLayers.reduce((acc: Layer[], cur: SketchColorMapLayer) => addLayerWithGrouping(acc, cur), []), 69 | })); 70 | }; 71 | -------------------------------------------------------------------------------- /resources/app/helpers/window.test.ts: -------------------------------------------------------------------------------- 1 | import { closeWindow, openUrlInBrowser } from './window'; 2 | 3 | describe('closeWindow', () => { 4 | let spy: jest.SpyInstance; 5 | 6 | beforeEach(() => { 7 | spy = jest.spyOn(window, 'postMessage').mockImplementation(() => {}); 8 | }); 9 | 10 | test('it posts a closeWindow message', () => { 11 | closeWindow(); 12 | 13 | expect(spy).toHaveBeenCalledTimes(1); 14 | expect(spy).toHaveBeenCalledWith('closeWindow'); 15 | }); 16 | 17 | afterEach(() => { 18 | spy.mockRestore(); 19 | }); 20 | 21 | test('it posts a openUrlInBrowser message with URL', () => { 22 | const url = 'url'; 23 | openUrlInBrowser(url); 24 | 25 | expect(spy).toHaveBeenCalledTimes(1); 26 | expect(spy).toHaveBeenCalledWith('openUrlInBrowser', url); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /resources/app/helpers/window.ts: -------------------------------------------------------------------------------- 1 | export const closeWindow = () => { 2 | window.postMessage('closeWindow'); 3 | }; 4 | 5 | export const openUrlInBrowser = (url: string) => { 6 | window.postMessage('openUrlInBrowser', url); 7 | }; 8 | -------------------------------------------------------------------------------- /resources/app/hooks/useHover.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderHook, cleanup } from 'react-hooks-testing-library'; 3 | import { render, fireEvent } from 'react-testing-library'; 4 | import useHover from './useHover'; 5 | import Footer from '../components/Footer'; 6 | 7 | afterEach(cleanup); 8 | 9 | jest.mock('../Global.styles', () => ({ colors: [], fonts: { SFPro: { reg: '' } } })); 10 | 11 | test('returns false for no document passed', () => { 12 | const { result } = renderHook(() => useHover()); 13 | expect(result.current[0]).toBe(false); 14 | }); 15 | 16 | describe('Button', () => { 17 | let button: any; 18 | beforeEach(() => { 19 | const { container } = render(); 20 | 21 | button = container.querySelector('button'); 22 | }); 23 | 24 | it('expects the button to render done', () => { 25 | expect(button.textContent).toBe('Done'); 26 | }); 27 | 28 | it('it expects the button to render 👍 emoji on mouseOver', () => { 29 | fireEvent.mouseOver(button); 30 | 31 | expect(button.textContent).toBe('👍'); 32 | }); 33 | 34 | it('it expects the button to render done on mouseOut', () => { 35 | fireEvent.mouseOut(button); 36 | 37 | expect(button.textContent).toBe('Done'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /resources/app/hooks/useHover.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | export default (): any[] => { 4 | const [value, setValue] = useState(false); 5 | 6 | const ref = useRef(null); 7 | 8 | const handleMouseOver = () => setValue(true); 9 | const handleMouseOut = () => setValue(false); 10 | 11 | useEffect(() => { 12 | const node = ref.current as any; 13 | 14 | if (node) { 15 | node.addEventListener('mouseover', handleMouseOver); 16 | node.addEventListener('mouseout', handleMouseOut); 17 | 18 | return () => { 19 | node.removeEventListener('mouseover', handleMouseOver); 20 | node.removeEventListener('mouseout', handleMouseOut); 21 | }; 22 | } 23 | return () => null; 24 | }, [ref.current]); 25 | 26 | return [value, ref]; 27 | }; 28 | -------------------------------------------------------------------------------- /resources/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { ListProvider } from './ListContext'; 4 | import App from './components/App'; 5 | 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root'), 12 | ); 13 | -------------------------------------------------------------------------------- /resources/app/models/color-with-layers.model.ts: -------------------------------------------------------------------------------- 1 | export interface Layer { 2 | id: string; 3 | name: string; 4 | type: string; 5 | colorType?: string; 6 | children?: Layer[]; 7 | } 8 | 9 | export interface ColorWithLayers { 10 | color: string; 11 | layers: Layer[]; 12 | } 13 | -------------------------------------------------------------------------------- /resources/app/models/sketch-color-map.model.ts: -------------------------------------------------------------------------------- 1 | export interface SketchColorMap { 2 | [hexColor: string]: SketchColorMapLayer[] 3 | } 4 | 5 | export interface SketchColorMapLayer { 6 | id: string; 7 | name: string; 8 | type: string; 9 | colorType: string; 10 | parents: SketchColorMapLayerParent[]; 11 | } 12 | 13 | export interface SketchColorMapLayerParent { 14 | id: string; 15 | name: string; 16 | type: string; 17 | } 18 | -------------------------------------------------------------------------------- /resources/style.css: -------------------------------------------------------------------------------- 1 | /* some default styles to make the view more native like */ 2 | 3 | html { 4 | box-sizing: border-box; 5 | background: transparent; 6 | 7 | /* Prevent the page to be scrollable */ 8 | overflow: hidden; 9 | 10 | /* Force the default cursor, even on text */ 11 | cursor: default; 12 | } 13 | 14 | *, 15 | *:before, 16 | *:after { 17 | box-sizing: inherit; 18 | margin: 0; 19 | padding: 0; 20 | position: relative; 21 | 22 | /* Prevent the content from being selectionable */ 23 | -webkit-user-select: none; 24 | user-select: none; 25 | } 26 | 27 | input, 28 | textarea { 29 | -webkit-user-select: auto; 30 | user-select: auto; 31 | } 32 | 33 | html, 34 | body, 35 | #root { 36 | position: fixed; 37 | width: 100%; 38 | height: 100%; 39 | overflow: auto; 40 | display: flex; 41 | flex-direction: column; 42 | } 43 | 44 | -------------------------------------------------------------------------------- /resources/webview.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Colormate 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /resources/webview.js: -------------------------------------------------------------------------------- 1 | import './app/index.tsx'; 2 | import { isDev } from '../src/helpers/environment.ts'; 3 | 4 | // Disable the context menu to have a more native feel 5 | if (!isDev()) { 6 | document.addEventListener('contextmenu', (e) => { 7 | e.preventDefault(); 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /setupTest.ts: -------------------------------------------------------------------------------- 1 | import 'jest-styled-components'; 2 | import '@testing-library/jest-dom/extend-expect'; 3 | -------------------------------------------------------------------------------- /src/__mocks__/MockLayer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "ShapePath", 3 | "id": "C0CA5E54-4EF4-46B5-A142-7905EA7E4C37", 4 | "frame": { 5 | "x": 1032, 6 | "y": 108, 7 | "width": 237, 8 | "height": 216 9 | }, 10 | "name": "Rectangle Copy 5", 11 | "selected": false, 12 | "hidden": false, 13 | "locked": false, 14 | "exportFormats": [], 15 | "transform": { 16 | "rotation": 0, 17 | "flippedHorizontally": false, 18 | "flippedVertically": false 19 | }, 20 | "style": { 21 | "type": "Style", 22 | "id": "D23B17FF-5695-4710-BC1C-1A0944F2F794", 23 | "opacity": 1, 24 | "blendingMode": "Normal", 25 | "borderOptions": { 26 | "startArrowhead": "None", 27 | "endArrowhead": "None", 28 | "dashPattern": [], 29 | "lineEnd": "Butt", 30 | "lineJoin": "Miter" 31 | }, 32 | "blur": { 33 | "center": { 34 | "x": 0.5, 35 | "y": 0.5 36 | }, 37 | "motionAngle": 0, 38 | "radius": 10, 39 | "enabled": false, 40 | "blurType": "Gaussian" 41 | }, 42 | "fills": [ 43 | { 44 | "fill": "Color", 45 | "color": "#5f3e10ff", 46 | "gradient": { 47 | "gradientType": "Linear", 48 | "from": { 49 | "x": 0.5, 50 | "y": 0 51 | }, 52 | "to": { 53 | "x": 0.5, 54 | "y": 1 55 | }, 56 | "stops": [ 57 | { 58 | "position": 0, 59 | "color": "#ffffffff" 60 | }, 61 | { 62 | "position": 1, 63 | "color": "#000000ff" 64 | } 65 | ] 66 | }, 67 | "pattern": { 68 | "patternType": "Fill", 69 | "image": null, 70 | "tileScale": 1 71 | }, 72 | "noise": { 73 | "noiseType": "Original", 74 | "intensity": 0 75 | }, 76 | "enabled": true 77 | } 78 | ], 79 | "borders": [ 80 | { 81 | "fillType": "Color", 82 | "position": "Inside", 83 | "color": "#979797ff", 84 | "gradient": { 85 | "gradientType": "Linear", 86 | "from": { 87 | "x": 0.5, 88 | "y": 0 89 | }, 90 | "to": { 91 | "x": 0.5, 92 | "y": 1 93 | }, 94 | "stops": [ 95 | { 96 | "position": 0, 97 | "color": "#ffffffff" 98 | }, 99 | { 100 | "position": 1, 101 | "color": "#000000ff" 102 | } 103 | ] 104 | }, 105 | "thickness": 1, 106 | "enabled": true 107 | } 108 | ], 109 | "shadows": [], 110 | "innerShadows": [], 111 | "styleType": "Layer" 112 | }, 113 | "sharedStyleId": null, 114 | "shapeType": "Rectangle", 115 | "points": [ 116 | { 117 | "type": "CurvePoint", 118 | "cornerRadius": 0, 119 | "curveFrom": { 120 | "x": 0, 121 | "y": 0 122 | }, 123 | "curveTo": { 124 | "x": 0, 125 | "y": 0 126 | }, 127 | "point": { 128 | "x": 0, 129 | "y": 0 130 | }, 131 | "pointType": "Straight" 132 | }, 133 | { 134 | "type": "CurvePoint", 135 | "cornerRadius": 0, 136 | "curveFrom": { 137 | "x": 1, 138 | "y": 0 139 | }, 140 | "curveTo": { 141 | "x": 1, 142 | "y": 0 143 | }, 144 | "point": { 145 | "x": 1, 146 | "y": 0 147 | }, 148 | "pointType": "Straight" 149 | }, 150 | { 151 | "type": "CurvePoint", 152 | "cornerRadius": 0, 153 | "curveFrom": { 154 | "x": 1, 155 | "y": 1 156 | }, 157 | "curveTo": { 158 | "x": 1, 159 | "y": 1 160 | }, 161 | "point": { 162 | "x": 1, 163 | "y": 1 164 | }, 165 | "pointType": "Straight" 166 | }, 167 | { 168 | "type": "CurvePoint", 169 | "cornerRadius": 0, 170 | "curveFrom": { 171 | "x": 0, 172 | "y": 1 173 | }, 174 | "curveTo": { 175 | "x": 0, 176 | "y": 1 177 | }, 178 | "point": { 179 | "x": 0, 180 | "y": 1 181 | }, 182 | "pointType": "Straight" 183 | } 184 | ], 185 | "closed": true 186 | } 187 | -------------------------------------------------------------------------------- /src/__mocks__/MockTextLayer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "Text", 3 | "id": "B1927734-E157-49C6-AB17-CA5585AC5A17", 4 | "frame": { 5 | "x": 709, 6 | "y": 262, 7 | "width": 332, 8 | "height": 159 9 | }, 10 | "name": "TEST", 11 | "selected": false, 12 | "hidden": false, 13 | "locked": false, 14 | "exportFormats": [], 15 | "transform": { 16 | "rotation": 0, 17 | "flippedHorizontally": false, 18 | "flippedVertically": false 19 | }, 20 | "style": { 21 | "type": "Style", 22 | "id": "83407BF6-9184-45AA-9FDD-34D45B849979", 23 | "opacity": 1, 24 | "blendingMode": "Normal", 25 | "borderOptions": { 26 | "startArrowhead": "None", 27 | "endArrowhead": "None", 28 | "dashPattern": [], 29 | "lineEnd": "Butt", 30 | "lineJoin": "Miter" 31 | }, 32 | "blur": { 33 | "center": { 34 | "x": 0.5, 35 | "y": 0.5 36 | }, 37 | "motionAngle": 0, 38 | "radius": 10, 39 | "enabled": false, 40 | "blurType": "Gaussian" 41 | }, 42 | "fills": [], 43 | "borders": [], 44 | "shadows": [], 45 | "innerShadows": [], 46 | "styleType": "Text", 47 | "alignment": "right", 48 | "verticalAlignment": "center", 49 | "kerning": 0, 50 | "lineHeight": null, 51 | "paragraphSpacing": 0, 52 | "textColor": "#09536bff", 53 | "fontSize": 20, 54 | "textTransform": "none", 55 | "fontFamily": "QuadraatSansComp", 56 | "fontWeight": 5 57 | }, 58 | "sharedStyleId": null, 59 | "text": "TEST", 60 | "lineSpacing": "constantBaseline", 61 | "fixedWidth": false 62 | } 63 | -------------------------------------------------------------------------------- /src/__mocks__/getColorsResult.json: -------------------------------------------------------------------------------- 1 | { 2 | "#e89827ff": [ 3 | { 4 | "id": "147194A8-9324-4ECC-A1C5-2098D99BF995", 5 | "name": "Rectangle Copy 6", 6 | "type": "ShapePath", 7 | "colorType": "fill", 8 | "parents": [ 9 | { 10 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 11 | "type": "Page", 12 | "name": "Page 1" 13 | }, 14 | { 15 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 16 | "type": "Artboard", 17 | "name": "MOAR COLORSS" 18 | } 19 | ] 20 | } 21 | ], 22 | "#df2b2bff": [ 23 | { 24 | "id": "3D4F563C-A00D-4A32-9299-F9A0587DF91F", 25 | "name": "Rectangle Copy", 26 | "type": "ShapePath", 27 | "colorType": "fill", 28 | "parents": [ 29 | { 30 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 31 | "type": "Page", 32 | "name": "Page 1" 33 | }, 34 | { 35 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 36 | "type": "Artboard", 37 | "name": "Desktop HD" 38 | } 39 | ] 40 | }, 41 | { 42 | "id": "CBB5C7DD-1145-466F-BC68-3522EC63A4F2", 43 | "name": "Rectangle Copy 6", 44 | "type": "ShapePath", 45 | "colorType": "fill", 46 | "parents": [ 47 | { 48 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 49 | "type": "Page", 50 | "name": "Page 1" 51 | }, 52 | { 53 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 54 | "type": "Artboard", 55 | "name": "Desktop HD" 56 | } 57 | ] 58 | }, 59 | { 60 | "id": "AB4C1D67-2D9A-4F46-954C-5E63EBE15988", 61 | "name": "Rectangle Copy 8", 62 | "type": "ShapePath", 63 | "colorType": "fill", 64 | "parents": [ 65 | { 66 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 67 | "type": "Page", 68 | "name": "Page 1" 69 | }, 70 | { 71 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 72 | "type": "Artboard", 73 | "name": "MOAR COLORSS" 74 | } 75 | ] 76 | }, 77 | { 78 | "id": "EB1096C9-1389-468E-9D6E-A7805338A0C3", 79 | "name": "Rectangle Copy 9", 80 | "type": "ShapePath", 81 | "colorType": "fill", 82 | "parents": [ 83 | { 84 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 85 | "type": "Page", 86 | "name": "Page 1" 87 | }, 88 | { 89 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 90 | "type": "Artboard", 91 | "name": "MOAR COLORSS" 92 | } 93 | ] 94 | }, 95 | { 96 | "id": "3467F8BB-0488-4805-B9CE-23260C8EEF15", 97 | "name": "Rectangle Copy 10", 98 | "type": "ShapePath", 99 | "colorType": "fill", 100 | "parents": [ 101 | { 102 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 103 | "type": "Page", 104 | "name": "Page 1" 105 | } 106 | ] 107 | } 108 | ], 109 | "#dbd284ff": [ 110 | { 111 | "id": "96321D1C-90B2-41CA-B05A-751A25076143", 112 | "name": "Rectangle Copy 2", 113 | "type": "ShapePath", 114 | "colorType": "fill", 115 | "parents": [ 116 | { 117 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 118 | "type": "Page", 119 | "name": "Page 1" 120 | }, 121 | { 122 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 123 | "type": "Artboard", 124 | "name": "Desktop HD" 125 | } 126 | ] 127 | } 128 | ], 129 | "#d8d8d8ff": [ 130 | { 131 | "id": "C5E79A03-EE69-44FD-A02E-07709756E03A", 132 | "name": "Rectangle", 133 | "type": "ShapePath", 134 | "colorType": "fill", 135 | "parents": [ 136 | { 137 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 138 | "type": "Page", 139 | "name": "Page 1" 140 | }, 141 | { 142 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 143 | "type": "Artboard", 144 | "name": "Desktop HD" 145 | } 146 | ] 147 | } 148 | ], 149 | "#979797ff": [ 150 | { 151 | "id": "C5E79A03-EE69-44FD-A02E-07709756E03A", 152 | "name": "Rectangle", 153 | "type": "ShapePath", 154 | "colorType": "border", 155 | "parents": [ 156 | { 157 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 158 | "type": "Page", 159 | "name": "Page 1" 160 | }, 161 | { 162 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 163 | "type": "Artboard", 164 | "name": "Desktop HD" 165 | } 166 | ] 167 | }, 168 | { 169 | "id": "96321D1C-90B2-41CA-B05A-751A25076143", 170 | "name": "Rectangle Copy 2", 171 | "type": "ShapePath", 172 | "colorType": "border", 173 | "parents": [ 174 | { 175 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 176 | "type": "Page", 177 | "name": "Page 1" 178 | }, 179 | { 180 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 181 | "type": "Artboard", 182 | "name": "Desktop HD" 183 | } 184 | ] 185 | }, 186 | { 187 | "id": "82AC35C6-8F48-49F9-8C6D-FE5745756B1B", 188 | "name": "Rectangle Copy 3", 189 | "type": "ShapePath", 190 | "colorType": "border", 191 | "parents": [ 192 | { 193 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 194 | "type": "Page", 195 | "name": "Page 1" 196 | }, 197 | { 198 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 199 | "type": "Artboard", 200 | "name": "Desktop HD" 201 | }, 202 | { 203 | "id": "0A1FC6C4-6843-4343-B5C9-6D8C2B16DF0B", 204 | "type": "Group", 205 | "name": "Group" 206 | } 207 | ] 208 | }, 209 | { 210 | "id": "90949147-07CD-46E0-9CDB-1E0236E5BFB7", 211 | "name": "Rectangle Copy 4", 212 | "type": "ShapePath", 213 | "colorType": "border", 214 | "parents": [ 215 | { 216 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 217 | "type": "Page", 218 | "name": "Page 1" 219 | }, 220 | { 221 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 222 | "type": "Artboard", 223 | "name": "Desktop HD" 224 | }, 225 | { 226 | "id": "0A1FC6C4-6843-4343-B5C9-6D8C2B16DF0B", 227 | "type": "Group", 228 | "name": "Group" 229 | } 230 | ] 231 | }, 232 | { 233 | "id": "3D4F563C-A00D-4A32-9299-F9A0587DF91F", 234 | "name": "Rectangle Copy", 235 | "type": "ShapePath", 236 | "colorType": "border", 237 | "parents": [ 238 | { 239 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 240 | "type": "Page", 241 | "name": "Page 1" 242 | }, 243 | { 244 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 245 | "type": "Artboard", 246 | "name": "Desktop HD" 247 | } 248 | ] 249 | }, 250 | { 251 | "id": "CBB5C7DD-1145-466F-BC68-3522EC63A4F2", 252 | "name": "Rectangle Copy 6", 253 | "type": "ShapePath", 254 | "colorType": "border", 255 | "parents": [ 256 | { 257 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 258 | "type": "Page", 259 | "name": "Page 1" 260 | }, 261 | { 262 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 263 | "type": "Artboard", 264 | "name": "Desktop HD" 265 | } 266 | ] 267 | }, 268 | { 269 | "id": "87877A72-6CBE-45F4-B1DF-2F769CFEBE08", 270 | "name": "Rectangle Copy 7", 271 | "type": "ShapePath", 272 | "colorType": "border", 273 | "parents": [ 274 | { 275 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 276 | "type": "Page", 277 | "name": "Page 1" 278 | }, 279 | { 280 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 281 | "type": "Artboard", 282 | "name": "Desktop HD" 283 | } 284 | ] 285 | }, 286 | { 287 | "id": "87877A72-6CBE-45F4-B1DF-2F769CFEBE08", 288 | "name": "Rectangle Copy 7", 289 | "type": "ShapePath", 290 | "colorType": "fill", 291 | "parents": [ 292 | { 293 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 294 | "type": "Page", 295 | "name": "Page 1" 296 | }, 297 | { 298 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 299 | "type": "Artboard", 300 | "name": "Desktop HD" 301 | } 302 | ] 303 | }, 304 | { 305 | "id": "C0CA5E54-4EF4-46B5-A142-7905EA7E4C37", 306 | "name": "Rectangle Copy 5", 307 | "type": "ShapePath", 308 | "colorType": "border", 309 | "parents": [ 310 | { 311 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 312 | "type": "Page", 313 | "name": "Page 1" 314 | }, 315 | { 316 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 317 | "type": "Artboard", 318 | "name": "Desktop HD" 319 | } 320 | ] 321 | }, 322 | { 323 | "id": "147194A8-9324-4ECC-A1C5-2098D99BF995", 324 | "name": "Rectangle Copy 6", 325 | "type": "ShapePath", 326 | "colorType": "border", 327 | "parents": [ 328 | { 329 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 330 | "type": "Page", 331 | "name": "Page 1" 332 | }, 333 | { 334 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 335 | "type": "Artboard", 336 | "name": "MOAR COLORSS" 337 | } 338 | ] 339 | }, 340 | { 341 | "id": "AB4C1D67-2D9A-4F46-954C-5E63EBE15988", 342 | "name": "Rectangle Copy 8", 343 | "type": "ShapePath", 344 | "colorType": "border", 345 | "parents": [ 346 | { 347 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 348 | "type": "Page", 349 | "name": "Page 1" 350 | }, 351 | { 352 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 353 | "type": "Artboard", 354 | "name": "MOAR COLORSS" 355 | } 356 | ] 357 | }, 358 | { 359 | "id": "EB1096C9-1389-468E-9D6E-A7805338A0C3", 360 | "name": "Rectangle Copy 9", 361 | "type": "ShapePath", 362 | "colorType": "border", 363 | "parents": [ 364 | { 365 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 366 | "type": "Page", 367 | "name": "Page 1" 368 | }, 369 | { 370 | "id": "E9600003-2F70-44B8-BF0B-379E8F3C470E", 371 | "type": "Artboard", 372 | "name": "MOAR COLORSS" 373 | } 374 | ] 375 | }, 376 | { 377 | "id": "3467F8BB-0488-4805-B9CE-23260C8EEF15", 378 | "name": "Rectangle Copy 10", 379 | "type": "ShapePath", 380 | "colorType": "border", 381 | "parents": [ 382 | { 383 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 384 | "type": "Page", 385 | "name": "Page 1" 386 | } 387 | ] 388 | } 389 | ], 390 | "#5f3e10ff": [ 391 | { 392 | "id": "C0CA5E54-4EF4-46B5-A142-7905EA7E4C37", 393 | "name": "Rectangle Copy 5", 394 | "type": "ShapePath", 395 | "colorType": "fill", 396 | "parents": [ 397 | { 398 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 399 | "type": "Page", 400 | "name": "Page 1" 401 | }, 402 | { 403 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 404 | "type": "Artboard", 405 | "name": "Desktop HD" 406 | } 407 | ] 408 | } 409 | ], 410 | "#5d2063ff": [ 411 | { 412 | "id": "82AC35C6-8F48-49F9-8C6D-FE5745756B1B", 413 | "name": "Rectangle Copy 3", 414 | "type": "ShapePath", 415 | "colorType": "fill", 416 | "parents": [ 417 | { 418 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 419 | "type": "Page", 420 | "name": "Page 1" 421 | }, 422 | { 423 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 424 | "type": "Artboard", 425 | "name": "Desktop HD" 426 | }, 427 | { 428 | "id": "0A1FC6C4-6843-4343-B5C9-6D8C2B16DF0B", 429 | "type": "Group", 430 | "name": "Group" 431 | } 432 | ] 433 | } 434 | ], 435 | "#26cbcdff": [ 436 | { 437 | "id": "90949147-07CD-46E0-9CDB-1E0236E5BFB7", 438 | "name": "Rectangle Copy 4", 439 | "type": "ShapePath", 440 | "colorType": "fill", 441 | "parents": [ 442 | { 443 | "id": "FF29A9D3-282A-4446-BF66-FD889B1DF905", 444 | "type": "Page", 445 | "name": "Page 1" 446 | }, 447 | { 448 | "id": "45445BB5-4928-40A0-8C4A-38760BE23D05", 449 | "type": "Artboard", 450 | "name": "Desktop HD" 451 | }, 452 | { 453 | "id": "0A1FC6C4-6843-4343-B5C9-6D8C2B16DF0B", 454 | "type": "Group", 455 | "name": "Group" 456 | } 457 | ] 458 | } 459 | ] 460 | } -------------------------------------------------------------------------------- /src/get-colors.test.ts: -------------------------------------------------------------------------------- 1 | import { getSelectedDocument } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import getColors from './get-colors'; 3 | import getColorsResult from './__mocks__/getColorsResult.json'; 4 | import TreeMock from './__mocks__/MockSketchDocument.json'; 5 | import * as fromGetColorHelper from './helpers/get-colors'; 6 | 7 | jest.mock('sketch', () => ({ 8 | getSelectedDocument: jest.fn(), 9 | }), { virtual: true }); 10 | 11 | describe('getColors', () => { 12 | describe('if layers are not selected', () => { 13 | beforeAll(() => { 14 | (getSelectedDocument as jest.Mock).mockImplementation(() => TreeMock); 15 | }); 16 | 17 | test('returns an object with all the colors that were found', () => { 18 | expect(getColors()).toEqual(getColorsResult); 19 | }); 20 | }); 21 | 22 | describe('if layers are selected', () => { 23 | const selectedLayers = { 24 | isEmpty: false, 25 | layers: [], 26 | }; 27 | 28 | beforeAll(() => { 29 | (getSelectedDocument as jest.Mock).mockImplementation(() => ({ 30 | selectedLayers, 31 | })); 32 | }); 33 | 34 | test('should call getPagesWithSelectedLayers with the selectedLayers', () => { 35 | const spy = jest.spyOn(fromGetColorHelper, 'getPagesWithSelectedLayers'); 36 | 37 | getColors(); 38 | 39 | expect(spy).toHaveBeenCalledTimes(1); 40 | expect(spy).toHaveBeenCalledWith(selectedLayers); 41 | 42 | spy.mockRestore(); 43 | }); 44 | }); 45 | }); 46 | 47 | jest.unmock('sketch'); 48 | -------------------------------------------------------------------------------- /src/get-colors.ts: -------------------------------------------------------------------------------- 1 | import sketch, { Page } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import { traverse } from './helpers/traverse'; 3 | import { 4 | hasBorder, 5 | hasFill, 6 | hasTextColor, 7 | createDataStructure, 8 | getColorArray, 9 | getPagesWithSelectedLayers, 10 | } from './helpers/get-colors'; 11 | import { ColorType } from '../enums/color-type.enum'; 12 | 13 | export default function () { 14 | const colorsObject = {}; 15 | const selectedDocument = sketch.getSelectedDocument(); 16 | const { selectedLayers } = selectedDocument; 17 | 18 | const documentPages: Page[] = selectedLayers.isEmpty 19 | ? selectedDocument.pages 20 | : getPagesWithSelectedLayers(selectedLayers); 21 | 22 | const traversedPages: any[] = []; 23 | 24 | documentPages.forEach((page) => { 25 | const traversedPage = traverse(page); 26 | traversedPages.push(traversedPage); 27 | }); 28 | 29 | traversedPages.forEach((pageWithLayers: any[]) => { 30 | pageWithLayers.forEach((layerWithParents) => { 31 | const { layer, parents } = layerWithParents; 32 | 33 | if ('style' in layer) { 34 | if (hasBorder(layer)) { 35 | const { color } = layer.style.borders[0]; 36 | 37 | const dataStructure = createDataStructure(layer, ColorType.border, parents); 38 | colorsObject[color] = getColorArray(colorsObject, color, dataStructure); 39 | } 40 | 41 | if (hasFill(layer)) { 42 | const { color } = layer.style.fills[0]; 43 | 44 | const dataStructure = createDataStructure(layer, ColorType.fill, parents); 45 | colorsObject[color] = getColorArray(colorsObject, color, dataStructure); 46 | } 47 | 48 | if (hasTextColor(layer)) { 49 | const color = layer.style.textColor; 50 | 51 | const dataStructure = createDataStructure(layer, ColorType.text, parents); 52 | colorsObject[color] = getColorArray(colorsObject, color, dataStructure); 53 | } 54 | } 55 | }); 56 | }); 57 | 58 | const orderedColorsObject = {}; 59 | 60 | Object.keys(colorsObject) 61 | .sort() 62 | .reverse() 63 | .forEach((key) => { 64 | orderedColorsObject[key] = colorsObject[key]; 65 | }); 66 | 67 | return orderedColorsObject; 68 | } 69 | -------------------------------------------------------------------------------- /src/helpers/environment.ts: -------------------------------------------------------------------------------- 1 | export const ENV = process.env.REACT_APP_ENV; 2 | 3 | export const isProd = () => { 4 | return ENV === 'production'; 5 | }; 6 | 7 | export const isStaging = () => { 8 | return ENV === 'staging'; 9 | }; 10 | 11 | export const isDev = () => { 12 | return ENV === 'develop'; 13 | }; 14 | -------------------------------------------------------------------------------- /src/helpers/get-colors.test.ts: -------------------------------------------------------------------------------- 1 | import MockLayer from '../__mocks__/MockLayer.json'; 2 | import MockTextLayer from '../__mocks__/MockTextLayer.json'; 3 | import { 4 | getParents, 5 | hasBorder, 6 | hasFill, 7 | createDataStructure, 8 | getColorArray, 9 | hasTextColor, 10 | getPagesWithSelectedLayers, 11 | } from './get-colors'; 12 | import { ColorType } from '../../enums/color-type.enum'; 13 | 14 | describe('Helpers / get-colors', () => { 15 | test('getParents returns an array of the parents and the current layer id', () => { 16 | const parents = ['grandfatherID', 'motherID']; 17 | const currentLayer = { id: 'foo', type: 'foo', name: 'foo' }; 18 | const expectedParentsArray = [...parents, currentLayer]; 19 | const parentsArray = getParents(parents, currentLayer); 20 | 21 | expect(parentsArray).toEqual(expectedParentsArray); 22 | }); 23 | 24 | describe('hasBorder', () => { 25 | test('returns true if the layer has a border', () => { 26 | expect(hasBorder(MockLayer)).toBeTruthy(); 27 | }); 28 | 29 | test('returns false if the layer has no border', () => { 30 | const UnborderedMockLayer = { 31 | ...MockLayer, 32 | style: { 33 | ...MockLayer.style, 34 | borders: [], 35 | }, 36 | }; 37 | 38 | expect(hasBorder(UnborderedMockLayer)).toBeFalsy(); 39 | }); 40 | }); 41 | 42 | describe('hasFill', () => { 43 | test('returns true if the layer has a fill', () => { 44 | expect(hasFill(MockLayer)).toBeTruthy(); 45 | }); 46 | 47 | test('returns false if the layer has no fill', () => { 48 | const UnfilledMockLayer = { 49 | ...MockLayer, 50 | style: { 51 | ...MockLayer.style, 52 | fills: [], 53 | }, 54 | }; 55 | 56 | expect(hasFill(UnfilledMockLayer)).toBeFalsy(); 57 | }); 58 | }); 59 | 60 | describe('hasTextColor', () => { 61 | test('returns true if the layer has a textColor', () => { 62 | expect(hasTextColor(MockTextLayer)).toBeTruthy(); 63 | }); 64 | 65 | test('returns false if the layer has no text color', () => { 66 | const UncoloredTextLayer = { 67 | ...MockTextLayer, 68 | style: { 69 | ...MockTextLayer.style, 70 | textColor: '', 71 | }, 72 | }; 73 | 74 | expect(hasTextColor(UncoloredTextLayer)).toBeFalsy(); 75 | }); 76 | }); 77 | 78 | test('createDataStructure returns an object containing layer.id as id, colorType and parents', () => { 79 | const layer = { id: 'imalayer' }; 80 | const colorType = ColorType.border; 81 | const parents = ['id1', 'id2']; 82 | const dataStructure = createDataStructure(layer, colorType, parents); 83 | 84 | expect(dataStructure).toEqual({ id: layer.id, colorType, parents }); 85 | }); 86 | 87 | describe('getColorArray', () => { 88 | let colorsObject: any; 89 | let includedColor: any; 90 | let excludedColor: any; 91 | let includedColorArray: any; 92 | let dataStructureValue: any; 93 | 94 | beforeEach(() => { 95 | includedColor = '#123456'; 96 | excludedColor = '#ABCDEF'; 97 | includedColorArray = ['datastructure1', 'datastructure2']; 98 | dataStructureValue = 'datastructure3'; 99 | colorsObject = { [includedColor]: includedColorArray }; 100 | }); 101 | 102 | test('adds the datastructure to an already existing colorArray', () => { 103 | const colorArray = getColorArray(colorsObject, includedColor, dataStructureValue); 104 | 105 | expect(colorArray).toEqual([...includedColorArray, dataStructureValue]); 106 | }); 107 | 108 | test('creates a new key value with the given color as key and the given datastructure as value in an array', () => { 109 | const colorArray = getColorArray(colorsObject, excludedColor, dataStructureValue); 110 | const expectedColorsObject = [dataStructureValue]; 111 | 112 | expect(colorArray).toEqual(expectedColorsObject); 113 | }); 114 | }); 115 | 116 | describe('getPagesWithSelectedLayers', () => { 117 | test('should map the layers to pages with only the selected layers, preserving the layer itself', () => { 118 | const layer = { 119 | id: 'Rectangle', 120 | name: 'Rectangle', 121 | type: 'ShapePath', 122 | style: {}, 123 | parent: { 124 | id: 'Group', 125 | name: 'Group', 126 | type: 'Group', 127 | parent: { 128 | id: 'Page', 129 | name: 'Page', 130 | type: 'Page', 131 | parent: { 132 | name: 'Document', type: 'Document', 133 | }, 134 | }, 135 | }, 136 | }; 137 | const input: any = { 138 | layers: [layer], 139 | }; 140 | 141 | const output = [{ 142 | id: 'Page', 143 | name: 'Page', 144 | type: 'Page', 145 | layers: [{ 146 | id: 'Group', 147 | name: 'Group', 148 | type: 'Group', 149 | layers: [layer], 150 | }], 151 | }]; 152 | 153 | expect(getPagesWithSelectedLayers(input)).toEqual(output); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/helpers/get-colors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Layer, Page, Group, Selection, Fill, Border, 3 | } from 'sketch'; // eslint-disable-line import/no-unresolved 4 | 5 | export const getParents = (parents: any[], id: { id: string, type: string, name: string }) => [...parents, id]; 6 | 7 | export const hasBorder = (layer: any): boolean => !!layer.style.borders.filter((border: Border) => border.enabled).length; 8 | 9 | export const hasFill = (layer: any): boolean => !!layer.style.fills.filter((fill: Fill) => fill.enabled).length; 10 | 11 | export const hasTextColor = (layer: any) => !!layer.style.textColor; 12 | 13 | export const createDataStructure = (layer: any, colorType: any, parents: any) => ({ 14 | id: layer.id, 15 | name: layer.name, 16 | type: layer.type, 17 | colorType, 18 | parents, 19 | }); 20 | 21 | export const getColorArray = (colorsObject: any, color: any, dataStructure: any) => ( 22 | colorsObject[color] ? [...colorsObject[color], dataStructure] : [dataStructure] 23 | ); 24 | 25 | const mapLayerToPageWithOnlyLayerAcc = (accLayer: Partial | Partial | Layer | null, layer: Layer) => { 26 | let mappedLayer: Partial | Partial | Layer; 27 | 28 | if (!accLayer) { 29 | mappedLayer = layer; // leaf layer is totally preserved in order to keep all the properties (e.g. styles) 30 | } else { 31 | // parent layers are mapped to exclude the unselected children 32 | mappedLayer = { 33 | id: layer.id, 34 | type: layer.type, 35 | name: layer.name, 36 | layers: [accLayer], 37 | } as Partial | Partial; 38 | } 39 | 40 | if (layer.parent.type === 'Document') { 41 | return mappedLayer; 42 | } 43 | 44 | return mapLayerToPageWithOnlyLayerAcc(mappedLayer, layer.parent as Layer); 45 | }; 46 | 47 | const mapLayerToPageWithOnlyLayer = (layer: Layer): Partial => mapLayerToPageWithOnlyLayerAcc(null, layer); 48 | 49 | export const getPagesWithSelectedLayers = (selectedLayers: Selection): Page[] => { 50 | return selectedLayers.layers.map((layer: Layer) => mapLayerToPageWithOnlyLayer(layer)) as Page[]; 51 | }; 52 | -------------------------------------------------------------------------------- /src/helpers/get-page.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Layer } from 'sketch'; // eslint-disable-line import/no-unresolved 3 | import { getPage } from './get-page'; 4 | import { LayerType } from '../../enums/layer-type.enum'; 5 | 6 | describe('getPage', () => { 7 | test('should return the layer if is a page', () => { 8 | const layer = { 9 | type: LayerType.page, 10 | }; 11 | 12 | expect(getPage(layer as Layer)).toBe(layer); 13 | }); 14 | 15 | test('should return the first parent page', () => { 16 | const page = { 17 | type: LayerType.page, 18 | parent: { 19 | type: LayerType.document, 20 | }, 21 | }; 22 | const layer = { 23 | type: LayerType.shapePath, 24 | parent: { 25 | type: LayerType.artboard, 26 | parent: page, 27 | }, 28 | }; 29 | 30 | expect(getPage(layer as Layer)).toBe(page); 31 | }); 32 | 33 | test('should throw an error if the layer has no parent page', () => { 34 | const layer = { 35 | type: LayerType.shapePath, 36 | }; 37 | 38 | expect(() => getPage(layer as Layer)).toThrow(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/helpers/get-page.ts: -------------------------------------------------------------------------------- 1 | import { Page, Layer } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import { LayerType } from '../../enums/layer-type.enum'; 3 | 4 | export const getPage = (layer: Layer): Page => { 5 | if (layer.type === LayerType.page) { 6 | return layer; 7 | } 8 | 9 | if (!layer.parent) { 10 | throw new Error(`Layer is not child on any page! ${JSON.stringify(layer, null, 2)}`); 11 | } 12 | 13 | return getPage(layer.parent as Layer); 14 | }; 15 | -------------------------------------------------------------------------------- /src/helpers/replace-color-in-layers.test.ts: -------------------------------------------------------------------------------- 1 | import { getSelectedDocument } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import { replaceColorInLayers } from './replace-color-in-layers'; 3 | 4 | jest.mock('sketch', () => ({ 5 | getSelectedDocument: jest.fn(), 6 | }), { virtual: true }); 7 | 8 | describe('replaceColorInLayers', () => { 9 | let layers: any; 10 | 11 | beforeAll(() => { 12 | (getSelectedDocument as jest.Mock).mockImplementation(() => ({ 13 | getLayerWithID: id => layers.find(layer => id === layer.id), 14 | })); 15 | }); 16 | 17 | beforeEach(() => { 18 | layers = [ 19 | { id: 'firstMatch', style: { fills: [{ color: 'red' }], textColor: 'red', borders: [{ color: 'red' }] } }, 20 | { id: 'secondMatch', style: { fills: [{ color: 'red' }], textColor: 'red', borders: [{ color: 'red' }] } }, 21 | { id: 'noMatch', style: { fills: [{ color: 'red' }], textColor: 'red', borders: [{ color: 'red' }] } }, 22 | ]; 23 | }); 24 | 25 | test('should throw an exception if the layer ID is not found', () => { 26 | const layerId = 'a'; 27 | expect(() => replaceColorInLayers('#000000', '#ffffff', [layerId])) 28 | .toThrowError(`Layer with ID ${layerId} not found!`); 29 | }); 30 | 31 | test('should not select the document for the replacement if the colors are the same', () => { 32 | replaceColorInLayers('#000000', '#000000', ['firstMatch']); 33 | replaceColorInLayers('#abcdef', '#ABCDEF', ['firstMatch']); 34 | 35 | expect(getSelectedDocument).not.toHaveBeenCalled(); 36 | }); 37 | 38 | describe('in all the matching layer ids', () => { 39 | it('if the colorToReplace match, should replace the fill, text and borders color', () => { 40 | replaceColorInLayers('red', 'blue', ['firstMatch', 'secondMatch']); 41 | 42 | expect(layers).toEqual([ 43 | { id: 'firstMatch', style: { fills: [{ color: 'blue' }], textColor: 'blue', borders: [{ color: 'blue' }] } }, 44 | { id: 'secondMatch', style: { fills: [{ color: 'blue' }], textColor: 'blue', borders: [{ color: 'blue' }] } }, 45 | { id: 'noMatch', style: { fills: [{ color: 'red' }], textColor: 'red', borders: [{ color: 'red' }] } }, 46 | ]); 47 | }); 48 | 49 | it('if the colorToReplace does not match, should not replace anything', () => { 50 | const layersBeforeMutation = layers; 51 | 52 | replaceColorInLayers('foo', 'blue', ['firstMatch', 'secondMatch']); 53 | 54 | expect(layers).toEqual(layersBeforeMutation); 55 | }); 56 | }); 57 | }); 58 | 59 | jest.unmock('sketch'); 60 | -------------------------------------------------------------------------------- /src/helpers/replace-color-in-layers.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import sketch from 'sketch'; 3 | 4 | export const replaceColorInLayers = (colorToReplace: string, targetColor: string, layerIds: string[]): void => { 5 | if (colorToReplace.toLowerCase() === targetColor.toLowerCase()) { 6 | return; 7 | } 8 | 9 | const doc = sketch.getSelectedDocument(); 10 | 11 | layerIds.forEach((id) => { 12 | const layer = doc.getLayerWithID(id); 13 | if (layer) { 14 | const { style } = layer; 15 | 16 | style.fills.forEach((fill) => { 17 | if (fill.color === colorToReplace) { 18 | fill.color = targetColor; 19 | } 20 | }); 21 | 22 | if (style.textColor === colorToReplace) { 23 | style.textColor = targetColor; 24 | } 25 | 26 | style.borders.forEach((border) => { 27 | if (border.color === colorToReplace) { 28 | border.color = targetColor; 29 | } 30 | }); 31 | } else { 32 | const msg = `Layer with ID ${id} not found!`; 33 | throw new Error(msg); 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/helpers/traverse.test.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import MockSketchDocument from '../__mocks__/MockSketchDocument.json'; 3 | import { traverse } from './traverse'; 4 | 5 | describe('Helpers / traverse', () => { 6 | let layers: any[]; 7 | 8 | beforeEach(() => { 9 | layers = traverse(MockSketchDocument.pages[0] as unknown as Page); 10 | }); 11 | test('it should return an array with all the layers of the given input', () => { 12 | expect(layers.length).toEqual(16); 13 | }); 14 | 15 | test('it should add the parental array to the layers', () => { 16 | expect(layers[0]).toHaveProperty('layer'); 17 | expect(layers[0]).toHaveProperty('parents'); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/helpers/traverse.ts: -------------------------------------------------------------------------------- 1 | import { Page, Layer } from 'sketch'; // eslint-disable-line import/no-unresolved 2 | import { getParents } from './get-colors'; 3 | 4 | export const traverse = (layer: Page | Layer, layers: any[] = [], parents: any[] = []): any => { 5 | if (('layers' in layer) && layer.layers!.length) { 6 | layer.layers!.forEach((subLayer: any) => ( 7 | traverse(subLayer, layers, getParents(parents, { id: layer.id, type: layer.type, name: layer.name })) 8 | )); 9 | } 10 | 11 | const layerWithParents = { 12 | layer, 13 | parents, 14 | }; 15 | 16 | layers.push(layerWithParents); 17 | 18 | return layers; 19 | }; 20 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": "com.colormate.plugin", 3 | "appcast": "https://api.sketchpacks.com/v1/plugins/com.colormate.plugin/appcast", 4 | "compatibleVersion": 3, 5 | "bundleVersion": 1, 6 | "icon": "icon.png", 7 | "commands": [ 8 | { 9 | "name": "Colormate", 10 | "identifier": "listColors", 11 | "shortcut": "cmd shift 8", 12 | "script": "./my-command.js" 13 | } 14 | ], 15 | "menu": { 16 | "isRoot": true, 17 | "items": [ 18 | "listColors" 19 | ] 20 | }, 21 | "version": "1.6.0" 22 | } 23 | -------------------------------------------------------------------------------- /src/models/layer.model.ts: -------------------------------------------------------------------------------- 1 | export interface Layer { 2 | type: string; 3 | id: string; 4 | frame: {x: number; y: number; width: number; height: number; }; 5 | name: string; 6 | selected: boolean; 7 | sharedStyleId: string | null; 8 | layers?: (Layer & any)[] 9 | } 10 | -------------------------------------------------------------------------------- /src/my-command.js: -------------------------------------------------------------------------------- 1 | import BrowserWindow from 'sketch-module-web-view'; 2 | import sketch, { UI, Settings } from 'sketch'; // eslint-disable-line import/no-unresolved 3 | import { getPage } from './helpers/get-page'; 4 | import { browserWindowSize } from '../constants.ts'; 5 | import { replaceColorInLayers } from './helpers/replace-color-in-layers.ts'; 6 | import getColors from './get-colors.ts'; 7 | import webview from '../resources/webview.html'; 8 | 9 | export default function () { 10 | const options = { 11 | identifier: 'unique.id', 12 | width: browserWindowSize.width, 13 | height: browserWindowSize.height, 14 | show: true, 15 | resizable: false, 16 | alwaysOnTop: true, 17 | acceptsFirstMouse: true, 18 | }; 19 | 20 | const browserWindow = new BrowserWindow(options); 21 | 22 | // only show the window when the page has loaded 23 | browserWindow.once('ready-to-show', () => { 24 | browserWindow.show(); 25 | }); 26 | 27 | const { webContents } = browserWindow; 28 | 29 | // add a handler for a call from web content's javascript 30 | webContents.on('getColors', (s) => { 31 | const colors = getColors(); 32 | UI.message(s); 33 | webContents.executeJavaScript(`sendUsedColors(${JSON.stringify(colors)})`) 34 | .catch(console.error); // eslint-disable-line no-console 35 | }); 36 | 37 | webContents.on('selectLayer', (layerID, idToCenterOn) => { 38 | const document = sketch.getDocuments()[0]; 39 | 40 | const sketchLayer = document.getLayerWithID(layerID); 41 | const layerToCenterOn = document.getLayerWithID(idToCenterOn); 42 | const page = getPage(layerToCenterOn); 43 | 44 | if (document.selectedPage !== page) { 45 | document.selectedPage = page; 46 | } 47 | 48 | document.centerOnLayer(layerToCenterOn); 49 | document.selectedLayers = [sketchLayer]; 50 | }); 51 | 52 | webContents.on('replaceColor', ({ 53 | message, colorToReplace, targetColor, layerIds, 54 | }) => { 55 | if (colorToReplace.toLowerCase() === targetColor.toLowerCase()) { 56 | return; 57 | } 58 | 59 | UI.message(message); 60 | replaceColorInLayers(colorToReplace, targetColor, layerIds); 61 | 62 | const args = { colorToReplace, targetColor, layerIds }; 63 | webContents.executeJavaScript(`replaceColor(${JSON.stringify(args)})`); 64 | }); 65 | 66 | webContents.on('openUrlInBrowser', (url) => { 67 | NSWorkspace.sharedWorkspace().openURL(NSURL.URLWithString(url)); // eslint-disable-line no-undef 68 | }); 69 | 70 | webContents.on('closeWindow', () => { 71 | browserWindow.close(); 72 | }); 73 | 74 | webContents.on('isBannerVisible', () => { 75 | // showBannerFromDate is a unix timestamp or false (in the case should never show) 76 | let showBannerFromDate = Settings.settingForKey('showBannerFromDate'); 77 | 78 | if (showBannerFromDate === undefined) { 79 | showBannerFromDate = Date.now(); 80 | Settings.setSettingForKey('showBannerFromDate', showBannerFromDate); 81 | } 82 | 83 | 84 | let isVisible; 85 | if (showBannerFromDate === false) { 86 | isVisible = false; 87 | } else { 88 | const currentDate = Date.now(); 89 | isVisible = currentDate > showBannerFromDate; 90 | } 91 | 92 | webContents.executeJavaScript(`isBannerVisible(${JSON.stringify(isVisible)})`); 93 | }); 94 | 95 | webContents.on('hideBanner', () => { 96 | Settings.setSettingForKey('showBannerFromDate', false); 97 | }); 98 | 99 | const postponeBannerWithDays = (extraDays) => { 100 | const showBannerFromDate = new Date(Date.now() + 1000 * 60 * 60 * 24 * extraDays).getTime(); 101 | 102 | Settings.setSettingForKey('showBannerFromDate', showBannerFromDate); 103 | }; 104 | 105 | webContents.on('postponeBanner', () => { 106 | const extraDays = 2; 107 | postponeBannerWithDays(extraDays); 108 | }); 109 | 110 | browserWindow.loadURL(webview); 111 | } 112 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "es2015", 5 | "downlevelIteration": true, 6 | "strict": true, 7 | "noUnusedLocals": false, 8 | "noUnusedParameters": false, 9 | "moduleResolution": "node", 10 | "allowSyntheticDefaultImports": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "sourceMap": true, 14 | "jsx": "react", 15 | "noImplicitAny": false 16 | }, 17 | "include": [ 18 | "src/**/*", 19 | "resources/**/*", 20 | "typings/**/*.d.ts", 21 | "enums/**/*" 22 | ], 23 | "exclude": [ 24 | "**/*.test.*" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "setupTest.ts", 5 | "**/*.test.*", 6 | "typings/**/*.d.ts", 7 | ], 8 | } -------------------------------------------------------------------------------- /typings/assets.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg"; 2 | declare module "*.otf"; 3 | declare module "*.gif"; 4 | declare module "*.html"; -------------------------------------------------------------------------------- /typings/sketch.d.ts: -------------------------------------------------------------------------------- 1 | // these interfaces are not complete 2 | // add a prop if needed (https://developer.sketch.com/reference/api/) 3 | declare module 'sketch' { 4 | export type LayerType = "Document" | "Page" | "Group" | "ShapePath"; 5 | 6 | export interface Layer { 7 | id: string; 8 | type: LayerType; 9 | name: string; 10 | parent: Group | Document; 11 | style: Style; 12 | } 13 | 14 | export interface Group extends Layer { 15 | layers?: Layer[]; 16 | } 17 | 18 | export interface Page extends Group { 19 | 20 | } 21 | 22 | export interface Document { 23 | id: string; 24 | pages: Page[]; 25 | selectedPage: Page; 26 | selectedLayers: Selection; 27 | type: LayerType; 28 | sharedLayerStyles: any; 29 | sharedTextStyles: any; 30 | colors: ColorAsset[]; 31 | gradients: any; 32 | getLayerWithID(id: string): Layer | undefined; 33 | } 34 | 35 | export interface ColorAsset { 36 | name: string; 37 | color: string; 38 | } 39 | 40 | export interface Style { 41 | opacity: number; 42 | textColor: string; 43 | fills: Fill[]; 44 | borders: Border[] 45 | } 46 | 47 | export interface Fill { 48 | color: string; 49 | enabled: boolean; 50 | } 51 | 52 | export interface Border { 53 | color: string; 54 | enabled: boolean; 55 | } 56 | 57 | export interface Selection { 58 | layers: Layer[]; 59 | readonly length: number; 60 | readonly isEmpty: boolean; 61 | map(layer: any): any[]; 62 | forEach(layer: any): void; 63 | reduce(acc: any, layer: any): any; 64 | } 65 | 66 | export function getSelectedDocument(): Document 67 | 68 | export class Settings { 69 | static globalSettingForKey(kUUIDKey: string): string | void 70 | static setGlobalSettingForKey(uuid: any, kUUIDKey: string): void 71 | } 72 | } 73 | 74 | declare var NSUUID: any; 75 | declare var NSURLSession: any; 76 | declare var NSURL: any; 77 | declare var NSWorkspace: any; 78 | -------------------------------------------------------------------------------- /typings/window.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | postMessage(message: string): void; 3 | postMessage(message: string, body: any): void; 4 | postMessage(message: string, ...args: any): void; 5 | 6 | // custom functions 7 | sendUsedColors: any; 8 | replaceColor: any 9 | isBannerVisible: (isVisible: boolean) => void 10 | } -------------------------------------------------------------------------------- /updateAppcast.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo 'Version: ' $1 4 | 5 | URL="https://github.com/themainingredient/colormate/releases/download/v"$1"/colormate.sketchplugin.zip" 6 | 7 | xmlstarlet ed -L -s "/rss/channel" -t elem -n itemTmp -v "" \ 8 | -s //itemTmp -t elem -n title -v "Version v$1" \ 9 | -s //itemTmp -t elem -n enclosureTmp -v "" \ 10 | -s //enclosureTmp -t attr -n url -v $URL \ 11 | -s //enclosureTmp -t attr -n sparkle:version -v $1 \ 12 | -r //enclosureTmp -v enclosure \ 13 | -r //itemTmp -v item \ 14 | .appcast.xml 15 | 16 | cat .appcast.xml 17 | -------------------------------------------------------------------------------- /webpack.skpm.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | const webpack = require('webpack'); 3 | const dotenv = require('dotenv'); 4 | 5 | module.exports = config => { 6 | config.resolve.extensions = ['.sketch.js', '.js', '.jsx', '.ts', '.tsx']; 7 | 8 | config.module.rules.push({ 9 | test: /\.(html)$/, 10 | use: [ 11 | { 12 | loader: '@skpm/extract-loader', 13 | }, 14 | { 15 | loader: 'html-loader', 16 | options: { 17 | attrs: ['img:src', 'link:href'], 18 | interpolate: true, 19 | }, 20 | }, 21 | ], 22 | }); 23 | 24 | config.module.rules.push({ 25 | test: /\.(css)$/, 26 | use: [ 27 | { 28 | loader: '@skpm/extract-loader', 29 | }, 30 | { 31 | loader: 'css-loader', 32 | }, 33 | ], 34 | }); 35 | 36 | config.module.rules.push({ 37 | test: /\.svg$/, 38 | use: [ 39 | { 40 | loader: 'babel-loader', 41 | }, 42 | { 43 | loader: 'react-svg-loader', 44 | options: { 45 | jsx: true, // true outputs JSX tags 46 | svgo: { 47 | plugins: [ 48 | { 49 | removeViewBox: false, 50 | }, 51 | ], 52 | } 53 | }, 54 | }, 55 | ], 56 | }); 57 | 58 | config.module.rules.push({ 59 | test: /\.(gif|png|otf)$/, 60 | use: [ 61 | { 62 | loader: 'file-loader', 63 | options: { 64 | name: '[name].[ext]', 65 | publicPath: '..', 66 | }, 67 | }, 68 | ], 69 | }); 70 | 71 | // All files with a '.ts' or '.tsx' extension will be handled by 'ts-loader' 72 | config.module.rules.push({ 73 | test: /\.tsx?$/, 74 | exclude: /node_modules/, 75 | loader: 'ts-loader' 76 | }); 77 | 78 | // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. 79 | config.module.rules.push({ 80 | enforce: "pre", 81 | test: /\.js$/, 82 | loader: "source-map-loader" 83 | }) 84 | 85 | const env = dotenv.config().parsed; 86 | 87 | // reduce it to a nice object, the same as before 88 | const envKeys = Object.keys(env).reduce((prev, next) => { 89 | prev[`process.env.${next}`] = JSON.stringify(env[next]); 90 | return prev; 91 | }, {}); 92 | 93 | config.plugins.push(new webpack.DefinePlugin(envKeys)); 94 | 95 | config.devtool = "source-map" 96 | }; 97 | 98 | /* eslint-enable */ 99 | --------------------------------------------------------------------------------