├── .github └── workflows │ └── main.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CODEOWNERS ├── README.md ├── data ├── dark-mode.json └── light-mode.json ├── dist ├── 0.js ├── code.js ├── ui.html └── ui.js ├── manifest.json ├── package.json ├── src ├── app │ ├── assets │ │ ├── dark-mode-thumbnail.svg │ │ ├── icon-chevron-down.svg │ │ ├── icon-chevron-left.svg │ │ ├── icon-chevron-right.svg │ │ ├── icon-dark-design-linter.svg │ │ ├── icon-dark-table-creator.svg │ │ ├── icon-dark-theme-switcher.svg │ │ ├── icon-design-linter.svg │ │ ├── icon-external-link.svg │ │ ├── icon-help.svg │ │ ├── icon-info.svg │ │ ├── icon-layer-boolean-operation.svg │ │ ├── icon-layer-ellipse.svg │ │ ├── icon-layer-line.svg │ │ ├── icon-layer-polygon.svg │ │ ├── icon-layer-rectangle.svg │ │ ├── icon-layer-text.svg │ │ ├── icon-more.svg │ │ ├── icon-table-creator.svg │ │ ├── icon-theme-switcher.svg │ │ ├── light-mode-thumbnail.svg │ │ ├── logo.svg │ │ └── one-core-team-photo.png │ ├── components │ │ ├── App.tsx │ │ ├── DesignLinter │ │ │ ├── ColorLinter.tsx │ │ │ ├── ColorTile.tsx │ │ │ ├── DesignLinter.tsx │ │ │ └── LanguageLinterPlugin.tsx │ │ ├── Home.tsx │ │ ├── PluginContext.ts │ │ ├── Resizer.tsx │ │ ├── TableCreator │ │ │ ├── ColumnConfiguration.tsx │ │ │ ├── DimensionsSelection.tsx │ │ │ └── TableCreator.tsx │ │ ├── ThemeSwitcher.tsx │ │ └── utils.ts │ ├── custom.d.ts │ ├── index.html │ ├── index.tsx │ └── styles │ │ └── ui.css └── plugin │ ├── color-linter │ └── colorLinter.ts │ ├── controller.ts │ ├── custom.d.ts │ ├── language-linter │ └── languageLinter.ts │ ├── oneCorePaintStyles.js │ ├── table-creator │ └── tableCreator.ts │ └── theme-switcher │ └── themeSwitcher.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow that is manually triggered 2 | 3 | name: Fetch Design Tokens 4 | 5 | # Controls when the action will run. Workflow runs when manually triggered using the UI 6 | # or API. 7 | on: 8 | workflow_dispatch: 9 | schedule: 10 | # * is a special character in YAML so you have to quote this string 11 | - cron: "* 12 * * *" 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | # This workflow contains a single job 16 | fetchFigmaFileData: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Runs a single command using the runners shell 23 | - name: Checkout 🛎️ 24 | uses: actions/checkout@v3 25 | with: 26 | persist-credentials: false 27 | - name: Fetch light mode data 28 | # You may pin to the exact commit or the version. 29 | # uses: JamesIves/fetch-api-data-action@c38518c0358c6a522f0d4880212b65963e2d6574 30 | uses: JamesIves/fetch-api-data-action@v2 31 | with: 32 | # The URL of the endpoint you would like to retrieve data from. 33 | endpoint: https://api.figma.com/v1/files/RsXZaTPG4VeIZTSjQPSosc/styles 34 | # Any applicable configuration settings that should be set such as authentication tokens. You can reference secrets using the secrets syntax, or you can reference data returned from the `TOKEN_ENDPOINT` request using the triple bracket syntax. 35 | configuration: '{ "method": "GET", "headers": {"X-Figma-Token": "${{ secrets.FIGMA_API_KEY }}"} }' 36 | set-output: false 37 | save-name: light-mode 38 | - name: Fetch dark mode data 39 | # You may pin to the exact commit or the version. 40 | # uses: JamesIves/fetch-api-data-action@c38518c0358c6a522f0d4880212b65963e2d6574 41 | uses: JamesIves/fetch-api-data-action@v2 42 | with: 43 | # The URL of the endpoint you would like to retrieve data from. 44 | endpoint: https://api.figma.com/v1/files/r2A9XUUQxda5FmgXSsqPU2/styles 45 | # Any applicable configuration settings that should be set such as authentication tokens. You can reference secrets using the secrets syntax, or you can reference data returned from the `TOKEN_ENDPOINT` request using the triple bracket syntax. 46 | configuration: '{ "method": "GET", "headers": {"X-Figma-Token": "${{ secrets.FIGMA_API_KEY }}"} }' 47 | set-output: false 48 | save-name: dark-mode 49 | - name: Build and Deploy 🚀 50 | uses: JamesIves/github-pages-deploy-action@v4 51 | with: 52 | branch: main # Pushes the updates to the main branch. 53 | folder: fetch-api-data-action # The location of the data.json file saved by the Fetch API Data action. 54 | target-folder: data # Saves the data into the 'data' directory on the main branch. 55 | commit-message: "chore: fetch design tokens from Figma" 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build 5 | .DS_Store 6 | *.tgz 7 | my-app* 8 | template/src/__tests__/__snapshots__/ 9 | lerna-debug.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | /.changelog 14 | .npm/ -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.14 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .vscode/ 3 | node_modules/ 4 | build 5 | .DS_Store 6 | *.tgz 7 | my-app* 8 | template/src/__tests__/__snapshots__/ 9 | lerna-debug.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | /.changelog 14 | .npm/ 15 | dist/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @newrelic/nr-design-system 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![803c3a7d-ef71-43fa-a53d-432828455793-cover](https://user-images.githubusercontent.com/812989/178552600-53d23eff-f351-4c14-8137-8d36d27a597b.jpeg) 4 | 5 |
6 | Table of Contents 7 |
    8 |
  1. 9 | About The Project 10 | 14 |
  2. 15 |
  3. 16 | Getting Started 17 | 21 |
  4. 22 |
  5. Demo
  6. 23 |
  7. Acknowledgements
  8. 24 |
25 |
26 | 27 | ## About the project 28 | This Figma plugin is an internal resource for New Relic. It's functionality is closely tied to One Core's (New Relic's design system) design tokens, and components. The goal of the project is to take some of the load of utilizing One Core off of the shoulders of New Relic product designers and place it on the shoulders of Figma itself. 29 | 30 | The plugin is automatically installed for all Figma users at New Relic. [Usage stats](https://staging.onenr.io/0oqQao6x5R1) for the plugin are tracked in New Relic (staging). These usage stats have often served as the first warning about bugs before users report them. 31 | 32 | ### Project status 33 | |Feature|Status|Notes| 34 | |:--|:--|:--| 35 | |Table creator|✅ Shipped|Will need to be updated when table redesign ships!| 36 | |Theme switcher|✅ Shipped|Token library in plugin needs to be [updated regularly](#project-architecture-and-token-management)| 37 | |Color linter|✅ Shipped|Token library in plugin needs to be [updated regularly](#project-architecture-and-token-management)| 38 | |Language linter|✅ Shipped|Used in the plugin via a [language linter NPM package](https://github.com/danielgolden/language-linter)| 39 | 40 | 41 | 42 | ### Built With 43 | 44 | - [React](https://reactjs.org) 45 | - [webpack](https://webpack.org) 46 | - [TypeScript](http://typescriptlang.org) 47 | - [Prettier precommit hook](https://prettier.io/docs/en/precommit.html) 48 | 49 | 50 | ## Getting started 51 | 52 | ### Running it locally 53 | - Run `yarn` to install dependencies. 54 | - Run `yarn build:watch` to start webpack in watch mode. 55 | - Open `Figma` -> `Plugins` -> `Development` -> `New Plugin...` and choose `manifest.json` file from this repo. 56 | 57 | ⭐ To change the UI of the plugin (the react code), start editing [App.tsx](./src/app/components/App.tsx). 58 | ⭐ To interact with the Figma API edit [controller.ts](./src/plugin/controller.ts). 59 | ⭐ Read more on the [Figma API Overview](https://www.figma.com/plugin-docs/api/api-overview/). 60 | 61 | 62 | ### [Project architecture and token management](https://www.figma.com/file/8hBp8ilmrgt7bcYqDG0u4g/Key-Project-Report?node-id=2%3A138) 63 | CleanShot 2022-07-12 at 12 40 32@2x 64 | 65 | 66 | 67 | ## Demo 68 | https://user-images.githubusercontent.com/812989/169329176-a1f0ddd1-f2f7-4ba8-ac4a-588f7773b998.mov 69 | 70 | 71 | ## Acknowledgements 72 | - Boilerplate plugin template: [Figma Plugin React Template](https://github.com/nirsky/figma-plugin-react-template) by [nirsky](https://github.com/nirsky) 73 | - Design for the Grid UI in the table creator came straight from [Table Creator](https://www.figma.com/community/plugin/885838970710285271/Table-Creator) by [Gavin McFarland](https://www.figma.com/@gavinmcfarland) 74 | 75 |

(back to top)

76 | 77 | 78 | 79 | 80 | 81 | 82 | [contributors-shield]: https://img.shields.io/github/contributors/newrelic/one-core-toolbox.svg?style=for-the-badge 83 | [contributors-url]: https://github.com/newrelic/one-core-toolbox/graphs/contributors 84 | [forks-shield]: https://img.shields.io/github/forks/newrelic/one-core-toolbox.svg?style=for-the-badge 85 | [forks-url]: https://github.com/newrelic/one-core-toolbox/network/members 86 | [stars-shield]: https://img.shields.io/github/stars/newrelic/one-core-toolbox.svg?style=for-the-badge 87 | [stars-url]: https://github.com/newrelic/one-core-toolbox/stargazers 88 | [issues-shield]: https://img.shields.io/github/issues/newrelic/one-core-toolbox.svg?style=for-the-badge 89 | [issues-url]: https://github.com/newrelic/one-core-toolbox/issues 90 | [license-shield]: https://img.shields.io/github/license/newrelic/one-core-toolbox.svg?style=for-the-badge 91 | [license-url]: https://github.com/newrelic/one-core-toolbox/blob/master/LICENSE.txt 92 | [linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555 93 | [linkedin-url]: https://linkedin.com/in/linkedin_username 94 | [product-screenshot]: images/screenshot.png 95 | [Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white 96 | [Next-url]: https://nextjs.org/ 97 | [React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB 98 | [React-url]: https://reactjs.org/ 99 | [Vue.js]: https://img.shields.io/badge/Vue.js-35495E?style=for-the-badge&logo=vuedotjs&logoColor=4FC08D 100 | [Vue-url]: https://vuejs.org/ 101 | [Angular.io]: https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular&logoColor=white 102 | [Angular-url]: https://angular.io/ 103 | [Svelte.dev]: https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte&logoColor=FF3E00 104 | [Svelte-url]: https://svelte.dev/ 105 | [Laravel.com]: https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white 106 | [Laravel-url]: https://laravel.com 107 | [Bootstrap.com]: https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white 108 | [Bootstrap-url]: https://getbootstrap.com 109 | [JQuery.com]: https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white 110 | [JQuery-url]: https://jquery.com 111 | -------------------------------------------------------------------------------- /data/dark-mode.json: -------------------------------------------------------------------------------- 1 | {"error":false,"status":200,"meta":{"styles":[]},"i18n":null} -------------------------------------------------------------------------------- /data/light-mode.json: -------------------------------------------------------------------------------- 1 | {"error":false,"status":200,"meta":{"styles":[]},"i18n":null} -------------------------------------------------------------------------------- /dist/0.js: -------------------------------------------------------------------------------- 1 | (window["webpackJsonp"] = window["webpackJsonp"] || []).push([ 2 | [0], 3 | { 4 | /***/ "./node_modules/node-fetch/src/utils/multipart-parser.js": 5 | /*!***************************************************************!*\ 6 | !*** ./node_modules/node-fetch/src/utils/multipart-parser.js ***! 7 | \***************************************************************/ 8 | /*! exports provided: toFormData */ 9 | /***/ function (module, __webpack_exports__, __webpack_require__) { 10 | "use strict"; 11 | __webpack_require__.r(__webpack_exports__); 12 | /* harmony export (binding) */ __webpack_require__.d( 13 | __webpack_exports__, 14 | "toFormData", 15 | function () { 16 | return toFormData; 17 | } 18 | ); 19 | /* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ = 20 | __webpack_require__( 21 | /*! fetch-blob/from.js */ "./node_modules/fetch-blob/from.js" 22 | ); 23 | /* harmony import */ var fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0___default = 24 | /*#__PURE__*/ __webpack_require__.n( 25 | fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__ 26 | ); 27 | /* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ = 28 | __webpack_require__( 29 | /*! formdata-polyfill/esm.min.js */ "./node_modules/formdata-polyfill/esm.min.js" 30 | ); 31 | /* harmony import */ var formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1___default = 32 | /*#__PURE__*/ __webpack_require__.n( 33 | formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__ 34 | ); 35 | 36 | let s = 0; 37 | const S = { 38 | START_BOUNDARY: s++, 39 | HEADER_FIELD_START: s++, 40 | HEADER_FIELD: s++, 41 | HEADER_VALUE_START: s++, 42 | HEADER_VALUE: s++, 43 | HEADER_VALUE_ALMOST_DONE: s++, 44 | HEADERS_ALMOST_DONE: s++, 45 | PART_DATA_START: s++, 46 | PART_DATA: s++, 47 | END: s++, 48 | }; 49 | 50 | let f = 1; 51 | const F = { 52 | PART_BOUNDARY: f, 53 | LAST_BOUNDARY: (f *= 2), 54 | }; 55 | 56 | const LF = 10; 57 | const CR = 13; 58 | const SPACE = 32; 59 | const HYPHEN = 45; 60 | const COLON = 58; 61 | const A = 97; 62 | const Z = 122; 63 | 64 | const lower = (c) => c | 0x20; 65 | 66 | const noop = () => {}; 67 | 68 | class MultipartParser { 69 | /** 70 | * @param {string} boundary 71 | */ 72 | constructor(boundary) { 73 | this.index = 0; 74 | this.flags = 0; 75 | 76 | this.onHeaderEnd = noop; 77 | this.onHeaderField = noop; 78 | this.onHeadersEnd = noop; 79 | this.onHeaderValue = noop; 80 | this.onPartBegin = noop; 81 | this.onPartData = noop; 82 | this.onPartEnd = noop; 83 | 84 | this.boundaryChars = {}; 85 | 86 | boundary = "\r\n--" + boundary; 87 | const ui8a = new Uint8Array(boundary.length); 88 | for (let i = 0; i < boundary.length; i++) { 89 | ui8a[i] = boundary.charCodeAt(i); 90 | this.boundaryChars[ui8a[i]] = true; 91 | } 92 | 93 | this.boundary = ui8a; 94 | this.lookbehind = new Uint8Array(this.boundary.length + 8); 95 | this.state = S.START_BOUNDARY; 96 | } 97 | 98 | /** 99 | * @param {Uint8Array} data 100 | */ 101 | write(data) { 102 | let i = 0; 103 | const length_ = data.length; 104 | let previousIndex = this.index; 105 | let { lookbehind, boundary, boundaryChars, index, state, flags } = 106 | this; 107 | const boundaryLength = this.boundary.length; 108 | const boundaryEnd = boundaryLength - 1; 109 | const bufferLength = data.length; 110 | let c; 111 | let cl; 112 | 113 | const mark = (name) => { 114 | this[name + "Mark"] = i; 115 | }; 116 | 117 | const clear = (name) => { 118 | delete this[name + "Mark"]; 119 | }; 120 | 121 | const callback = (callbackSymbol, start, end, ui8a) => { 122 | if (start === undefined || start !== end) { 123 | this[callbackSymbol](ui8a && ui8a.subarray(start, end)); 124 | } 125 | }; 126 | 127 | const dataCallback = (name, clear) => { 128 | const markSymbol = name + "Mark"; 129 | if (!(markSymbol in this)) { 130 | return; 131 | } 132 | 133 | if (clear) { 134 | callback(name, this[markSymbol], i, data); 135 | delete this[markSymbol]; 136 | } else { 137 | callback(name, this[markSymbol], data.length, data); 138 | this[markSymbol] = 0; 139 | } 140 | }; 141 | 142 | for (i = 0; i < length_; i++) { 143 | c = data[i]; 144 | 145 | switch (state) { 146 | case S.START_BOUNDARY: 147 | if (index === boundary.length - 2) { 148 | if (c === HYPHEN) { 149 | flags |= F.LAST_BOUNDARY; 150 | } else if (c !== CR) { 151 | return; 152 | } 153 | 154 | index++; 155 | break; 156 | } else if (index - 1 === boundary.length - 2) { 157 | if (flags & F.LAST_BOUNDARY && c === HYPHEN) { 158 | state = S.END; 159 | flags = 0; 160 | } else if (!(flags & F.LAST_BOUNDARY) && c === LF) { 161 | index = 0; 162 | callback("onPartBegin"); 163 | state = S.HEADER_FIELD_START; 164 | } else { 165 | return; 166 | } 167 | 168 | break; 169 | } 170 | 171 | if (c !== boundary[index + 2]) { 172 | index = -2; 173 | } 174 | 175 | if (c === boundary[index + 2]) { 176 | index++; 177 | } 178 | 179 | break; 180 | case S.HEADER_FIELD_START: 181 | state = S.HEADER_FIELD; 182 | mark("onHeaderField"); 183 | index = 0; 184 | // falls through 185 | case S.HEADER_FIELD: 186 | if (c === CR) { 187 | clear("onHeaderField"); 188 | state = S.HEADERS_ALMOST_DONE; 189 | break; 190 | } 191 | 192 | index++; 193 | if (c === HYPHEN) { 194 | break; 195 | } 196 | 197 | if (c === COLON) { 198 | if (index === 1) { 199 | // empty header field 200 | return; 201 | } 202 | 203 | dataCallback("onHeaderField", true); 204 | state = S.HEADER_VALUE_START; 205 | break; 206 | } 207 | 208 | cl = lower(c); 209 | if (cl < A || cl > Z) { 210 | return; 211 | } 212 | 213 | break; 214 | case S.HEADER_VALUE_START: 215 | if (c === SPACE) { 216 | break; 217 | } 218 | 219 | mark("onHeaderValue"); 220 | state = S.HEADER_VALUE; 221 | // falls through 222 | case S.HEADER_VALUE: 223 | if (c === CR) { 224 | dataCallback("onHeaderValue", true); 225 | callback("onHeaderEnd"); 226 | state = S.HEADER_VALUE_ALMOST_DONE; 227 | } 228 | 229 | break; 230 | case S.HEADER_VALUE_ALMOST_DONE: 231 | if (c !== LF) { 232 | return; 233 | } 234 | 235 | state = S.HEADER_FIELD_START; 236 | break; 237 | case S.HEADERS_ALMOST_DONE: 238 | if (c !== LF) { 239 | return; 240 | } 241 | 242 | callback("onHeadersEnd"); 243 | state = S.PART_DATA_START; 244 | break; 245 | case S.PART_DATA_START: 246 | state = S.PART_DATA; 247 | mark("onPartData"); 248 | // falls through 249 | case S.PART_DATA: 250 | previousIndex = index; 251 | 252 | if (index === 0) { 253 | // boyer-moore derrived algorithm to safely skip non-boundary data 254 | i += boundaryEnd; 255 | while (i < bufferLength && !(data[i] in boundaryChars)) { 256 | i += boundaryLength; 257 | } 258 | 259 | i -= boundaryEnd; 260 | c = data[i]; 261 | } 262 | 263 | if (index < boundary.length) { 264 | if (boundary[index] === c) { 265 | if (index === 0) { 266 | dataCallback("onPartData", true); 267 | } 268 | 269 | index++; 270 | } else { 271 | index = 0; 272 | } 273 | } else if (index === boundary.length) { 274 | index++; 275 | if (c === CR) { 276 | // CR = part boundary 277 | flags |= F.PART_BOUNDARY; 278 | } else if (c === HYPHEN) { 279 | // HYPHEN = end boundary 280 | flags |= F.LAST_BOUNDARY; 281 | } else { 282 | index = 0; 283 | } 284 | } else if (index - 1 === boundary.length) { 285 | if (flags & F.PART_BOUNDARY) { 286 | index = 0; 287 | if (c === LF) { 288 | // unset the PART_BOUNDARY flag 289 | flags &= ~F.PART_BOUNDARY; 290 | callback("onPartEnd"); 291 | callback("onPartBegin"); 292 | state = S.HEADER_FIELD_START; 293 | break; 294 | } 295 | } else if (flags & F.LAST_BOUNDARY) { 296 | if (c === HYPHEN) { 297 | callback("onPartEnd"); 298 | state = S.END; 299 | flags = 0; 300 | } else { 301 | index = 0; 302 | } 303 | } else { 304 | index = 0; 305 | } 306 | } 307 | 308 | if (index > 0) { 309 | // when matching a possible boundary, keep a lookbehind reference 310 | // in case it turns out to be a false lead 311 | lookbehind[index - 1] = c; 312 | } else if (previousIndex > 0) { 313 | // if our boundary turned out to be rubbish, the captured lookbehind 314 | // belongs to partData 315 | const _lookbehind = new Uint8Array( 316 | lookbehind.buffer, 317 | lookbehind.byteOffset, 318 | lookbehind.byteLength 319 | ); 320 | callback("onPartData", 0, previousIndex, _lookbehind); 321 | previousIndex = 0; 322 | mark("onPartData"); 323 | 324 | // reconsider the current character even so it interrupted the sequence 325 | // it could be the beginning of a new sequence 326 | i--; 327 | } 328 | 329 | break; 330 | case S.END: 331 | break; 332 | default: 333 | throw new Error(`Unexpected state entered: ${state}`); 334 | } 335 | } 336 | 337 | dataCallback("onHeaderField"); 338 | dataCallback("onHeaderValue"); 339 | dataCallback("onPartData"); 340 | 341 | // Update properties for the next call 342 | this.index = index; 343 | this.state = state; 344 | this.flags = flags; 345 | } 346 | 347 | end() { 348 | if ( 349 | (this.state === S.HEADER_FIELD_START && this.index === 0) || 350 | (this.state === S.PART_DATA && 351 | this.index === this.boundary.length) 352 | ) { 353 | this.onPartEnd(); 354 | } else if (this.state !== S.END) { 355 | throw new Error( 356 | "MultipartParser.end(): stream ended unexpectedly" 357 | ); 358 | } 359 | } 360 | } 361 | 362 | function _fileName(headerValue) { 363 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 364 | const m = headerValue.match( 365 | /\bfilename=("(.*?)"|([^()<>@,;:\\"/[\]?={}\s\t]+))($|;\s)/i 366 | ); 367 | if (!m) { 368 | return; 369 | } 370 | 371 | const match = m[2] || m[3] || ""; 372 | let filename = match.slice(match.lastIndexOf("\\") + 1); 373 | filename = filename.replace(/%22/g, '"'); 374 | filename = filename.replace(/&#(\d{4});/g, (m, code) => { 375 | return String.fromCharCode(code); 376 | }); 377 | return filename; 378 | } 379 | 380 | async function toFormData(Body, ct) { 381 | if (!/multipart/i.test(ct)) { 382 | throw new TypeError("Failed to fetch"); 383 | } 384 | 385 | const m = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i); 386 | 387 | if (!m) { 388 | throw new TypeError( 389 | "no or bad content-type header, no multipart boundary" 390 | ); 391 | } 392 | 393 | const parser = new MultipartParser(m[1] || m[2]); 394 | 395 | let headerField; 396 | let headerValue; 397 | let entryValue; 398 | let entryName; 399 | let contentType; 400 | let filename; 401 | const entryChunks = []; 402 | const formData = 403 | new formdata_polyfill_esm_min_js__WEBPACK_IMPORTED_MODULE_1__[ 404 | "FormData" 405 | ](); 406 | 407 | const onPartData = (ui8a) => { 408 | entryValue += decoder.decode(ui8a, { stream: true }); 409 | }; 410 | 411 | const appendToFile = (ui8a) => { 412 | entryChunks.push(ui8a); 413 | }; 414 | 415 | const appendFileToFormData = () => { 416 | const file = new fetch_blob_from_js__WEBPACK_IMPORTED_MODULE_0__[ 417 | "File" 418 | ](entryChunks, filename, { type: contentType }); 419 | formData.append(entryName, file); 420 | }; 421 | 422 | const appendEntryToFormData = () => { 423 | formData.append(entryName, entryValue); 424 | }; 425 | 426 | const decoder = new TextDecoder("utf-8"); 427 | decoder.decode(); 428 | 429 | parser.onPartBegin = function () { 430 | parser.onPartData = onPartData; 431 | parser.onPartEnd = appendEntryToFormData; 432 | 433 | headerField = ""; 434 | headerValue = ""; 435 | entryValue = ""; 436 | entryName = ""; 437 | contentType = ""; 438 | filename = null; 439 | entryChunks.length = 0; 440 | }; 441 | 442 | parser.onHeaderField = function (ui8a) { 443 | headerField += decoder.decode(ui8a, { stream: true }); 444 | }; 445 | 446 | parser.onHeaderValue = function (ui8a) { 447 | headerValue += decoder.decode(ui8a, { stream: true }); 448 | }; 449 | 450 | parser.onHeaderEnd = function () { 451 | headerValue += decoder.decode(); 452 | headerField = headerField.toLowerCase(); 453 | 454 | if (headerField === "content-disposition") { 455 | // matches either a quoted-string or a token (RFC 2616 section 19.5.1) 456 | const m = headerValue.match( 457 | /\bname=("([^"]*)"|([^()<>@,;:\\"/[\]?={}\s\t]+))/i 458 | ); 459 | 460 | if (m) { 461 | entryName = m[2] || m[3] || ""; 462 | } 463 | 464 | filename = _fileName(headerValue); 465 | 466 | if (filename) { 467 | parser.onPartData = appendToFile; 468 | parser.onPartEnd = appendFileToFormData; 469 | } 470 | } else if (headerField === "content-type") { 471 | contentType = headerValue; 472 | } 473 | 474 | headerValue = ""; 475 | headerField = ""; 476 | }; 477 | 478 | for await (const chunk of Body) { 479 | parser.write(chunk); 480 | } 481 | 482 | parser.end(); 483 | 484 | return formData; 485 | } 486 | 487 | /***/ 488 | }, 489 | }, 490 | ]); 491 | //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9ub2RlX21vZHVsZXMvbm9kZS1mZXRjaC9zcmMvdXRpbHMvbXVsdGlwYXJ0LXBhcnNlci5qcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7O0FBQUE7QUFBQTtBQUFBO0FBQUE7QUFBQTtBQUFBO0FBQXdDO0FBQ2M7O0FBRXREO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7O0FBRUE7O0FBRUE7QUFDQTtBQUNBLFlBQVksT0FBTztBQUNuQjtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTs7QUFFQTtBQUNBO0FBQ0EsaUJBQWlCLHFCQUFxQjtBQUN0QztBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQSxZQUFZLFdBQVc7QUFDdkI7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8seURBQXlEO0FBQ2hFO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0EsSUFBSTtBQUNKO0FBQ0E7QUFDQTtBQUNBOztBQUVBLGFBQWEsYUFBYTtBQUMxQjs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTztBQUNQO0FBQ0E7O0FBRUE7QUFDQTtBQUNBLE1BQU07QUFDTjtBQUNBO0FBQ0E7QUFDQSxPQUFPO0FBQ1A7QUFDQTtBQUNBO0FBQ0EsT0FBTztBQUNQO0FBQ0E7O0FBRUE7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQSxPQUFPO0FBQ1A7QUFDQTtBQUNBLE1BQU07QUFDTjtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU87QUFDUDtBQUNBO0FBQ0EsT0FBTztBQUNQO0FBQ0E7QUFDQSxNQUFNO0FBQ047QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPO0FBQ1A7QUFDQTtBQUNBO0FBQ0E7QUFDQSxRQUFRO0FBQ1I7QUFDQTtBQUNBLE9BQU87QUFDUDtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQSxNQUFNO0FBQ047QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0Esa0RBQWtELE1BQU07QUFDeEQ7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLEdBQUc7QUFDSDtBQUNBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0EsNERBQTRELFlBQVksWUFBWTtBQUNwRjtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBO0FBQ0Esb0NBQW9DLEVBQUUsRUFBRTtBQUN4QztBQUNBLEVBQUU7QUFDRjtBQUNBOztBQUVPO0FBQ1A7QUFDQTtBQUNBOztBQUVBLCtDQUErQzs7QUFFL0M7QUFDQTtBQUNBOztBQUVBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0Esc0JBQXNCLHFFQUFROztBQUU5QjtBQUNBLHNDQUFzQyxhQUFhO0FBQ25EOztBQUVBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBLG1CQUFtQix1REFBSSx5QkFBeUIsa0JBQWtCO0FBQ2xFO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOztBQUVBO0FBQ0E7O0FBRUE7QUFDQTtBQUNBOztBQUVBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7QUFDQSx1Q0FBdUMsYUFBYTtBQUNwRDs7QUFFQTtBQUNBLHVDQUF1QyxhQUFhO0FBQ3BEOztBQUVBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0EsNERBQTRELFlBQVk7O0FBRXhFO0FBQ0E7QUFDQTs7QUFFQTs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLEdBQUc7QUFDSDtBQUNBOztBQUVBO0FBQ0E7QUFDQTs7QUFFQTtBQUNBO0FBQ0E7O0FBRUE7O0FBRUE7QUFDQSIsImZpbGUiOiIwLmpzIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHtGaWxlfSBmcm9tICdmZXRjaC1ibG9iL2Zyb20uanMnO1xuaW1wb3J0IHtGb3JtRGF0YX0gZnJvbSAnZm9ybWRhdGEtcG9seWZpbGwvZXNtLm1pbi5qcyc7XG5cbmxldCBzID0gMDtcbmNvbnN0IFMgPSB7XG5cdFNUQVJUX0JPVU5EQVJZOiBzKyssXG5cdEhFQURFUl9GSUVMRF9TVEFSVDogcysrLFxuXHRIRUFERVJfRklFTEQ6IHMrKyxcblx0SEVBREVSX1ZBTFVFX1NUQVJUOiBzKyssXG5cdEhFQURFUl9WQUxVRTogcysrLFxuXHRIRUFERVJfVkFMVUVfQUxNT1NUX0RPTkU6IHMrKyxcblx0SEVBREVSU19BTE1PU1RfRE9ORTogcysrLFxuXHRQQVJUX0RBVEFfU1RBUlQ6IHMrKyxcblx0UEFSVF9EQVRBOiBzKyssXG5cdEVORDogcysrXG59O1xuXG5sZXQgZiA9IDE7XG5jb25zdCBGID0ge1xuXHRQQVJUX0JPVU5EQVJZOiBmLFxuXHRMQVNUX0JPVU5EQVJZOiBmICo9IDJcbn07XG5cbmNvbnN0IExGID0gMTA7XG5jb25zdCBDUiA9IDEzO1xuY29uc3QgU1BBQ0UgPSAzMjtcbmNvbnN0IEhZUEhFTiA9IDQ1O1xuY29uc3QgQ09MT04gPSA1ODtcbmNvbnN0IEEgPSA5NztcbmNvbnN0IFogPSAxMjI7XG5cbmNvbnN0IGxvd2VyID0gYyA9PiBjIHwgMHgyMDtcblxuY29uc3Qgbm9vcCA9ICgpID0+IHt9O1xuXG5jbGFzcyBNdWx0aXBhcnRQYXJzZXIge1xuXHQvKipcblx0ICogQHBhcmFtIHtzdHJpbmd9IGJvdW5kYXJ5XG5cdCAqL1xuXHRjb25zdHJ1Y3Rvcihib3VuZGFyeSkge1xuXHRcdHRoaXMuaW5kZXggPSAwO1xuXHRcdHRoaXMuZmxhZ3MgPSAwO1xuXG5cdFx0dGhpcy5vbkhlYWRlckVuZCA9IG5vb3A7XG5cdFx0dGhpcy5vbkhlYWRlckZpZWxkID0gbm9vcDtcblx0XHR0aGlzLm9uSGVhZGVyc0VuZCA9IG5vb3A7XG5cdFx0dGhpcy5vbkhlYWRlclZhbHVlID0gbm9vcDtcblx0XHR0aGlzLm9uUGFydEJlZ2luID0gbm9vcDtcblx0XHR0aGlzLm9uUGFydERhdGEgPSBub29wO1xuXHRcdHRoaXMub25QYXJ0RW5kID0gbm9vcDtcblxuXHRcdHRoaXMuYm91bmRhcnlDaGFycyA9IHt9O1xuXG5cdFx0Ym91bmRhcnkgPSAnXFxyXFxuLS0nICsgYm91bmRhcnk7XG5cdFx0Y29uc3QgdWk4YSA9IG5ldyBVaW50OEFycmF5KGJvdW5kYXJ5Lmxlbmd0aCk7XG5cdFx0Zm9yIChsZXQgaSA9IDA7IGkgPCBib3VuZGFyeS5sZW5ndGg7IGkrKykge1xuXHRcdFx0dWk4YVtpXSA9IGJvdW5kYXJ5LmNoYXJDb2RlQXQoaSk7XG5cdFx0XHR0aGlzLmJvdW5kYXJ5Q2hhcnNbdWk4YVtpXV0gPSB0cnVlO1xuXHRcdH1cblxuXHRcdHRoaXMuYm91bmRhcnkgPSB1aThhO1xuXHRcdHRoaXMubG9va2JlaGluZCA9IG5ldyBVaW50OEFycmF5KHRoaXMuYm91bmRhcnkubGVuZ3RoICsgOCk7XG5cdFx0dGhpcy5zdGF0ZSA9IFMuU1RBUlRfQk9VTkRBUlk7XG5cdH1cblxuXHQvKipcblx0ICogQHBhcmFtIHtVaW50OEFycmF5fSBkYXRhXG5cdCAqL1xuXHR3cml0ZShkYXRhKSB7XG5cdFx0bGV0IGkgPSAwO1xuXHRcdGNvbnN0IGxlbmd0aF8gPSBkYXRhLmxlbmd0aDtcblx0XHRsZXQgcHJldmlvdXNJbmRleCA9IHRoaXMuaW5kZXg7XG5cdFx0bGV0IHtsb29rYmVoaW5kLCBib3VuZGFyeSwgYm91bmRhcnlDaGFycywgaW5kZXgsIHN0YXRlLCBmbGFnc30gPSB0aGlzO1xuXHRcdGNvbnN0IGJvdW5kYXJ5TGVuZ3RoID0gdGhpcy5ib3VuZGFyeS5sZW5ndGg7XG5cdFx0Y29uc3QgYm91bmRhcnlFbmQgPSBib3VuZGFyeUxlbmd0aCAtIDE7XG5cdFx0Y29uc3QgYnVmZmVyTGVuZ3RoID0gZGF0YS5sZW5ndGg7XG5cdFx0bGV0IGM7XG5cdFx0bGV0IGNsO1xuXG5cdFx0Y29uc3QgbWFyayA9IG5hbWUgPT4ge1xuXHRcdFx0dGhpc1tuYW1lICsgJ01hcmsnXSA9IGk7XG5cdFx0fTtcblxuXHRcdGNvbnN0IGNsZWFyID0gbmFtZSA9PiB7XG5cdFx0XHRkZWxldGUgdGhpc1tuYW1lICsgJ01hcmsnXTtcblx0XHR9O1xuXG5cdFx0Y29uc3QgY2FsbGJhY2sgPSAoY2FsbGJhY2tTeW1ib2wsIHN0YXJ0LCBlbmQsIHVpOGEpID0+IHtcblx0XHRcdGlmIChzdGFydCA9PT0gdW5kZWZpbmVkIHx8IHN0YXJ0ICE9PSBlbmQpIHtcblx0XHRcdFx0dGhpc1tjYWxsYmFja1N5bWJvbF0odWk4YSAmJiB1aThhLnN1YmFycmF5KHN0YXJ0LCBlbmQpKTtcblx0XHRcdH1cblx0XHR9O1xuXG5cdFx0Y29uc3QgZGF0YUNhbGxiYWNrID0gKG5hbWUsIGNsZWFyKSA9PiB7XG5cdFx0XHRjb25zdCBtYXJrU3ltYm9sID0gbmFtZSArICdNYXJrJztcblx0XHRcdGlmICghKG1hcmtTeW1ib2wgaW4gdGhpcykpIHtcblx0XHRcdFx0cmV0dXJuO1xuXHRcdFx0fVxuXG5cdFx0XHRpZiAoY2xlYXIpIHtcblx0XHRcdFx0Y2FsbGJhY2sobmFtZSwgdGhpc1ttYXJrU3ltYm9sXSwgaSwgZGF0YSk7XG5cdFx0XHRcdGRlbGV0ZSB0aGlzW21hcmtTeW1ib2xdO1xuXHRcdFx0fSBlbHNlIHtcblx0XHRcdFx0Y2FsbGJhY2sobmFtZSwgdGhpc1ttYXJrU3ltYm9sXSwgZGF0YS5sZW5ndGgsIGRhdGEpO1xuXHRcdFx0XHR0aGlzW21hcmtTeW1ib2xdID0gMDtcblx0XHRcdH1cblx0XHR9O1xuXG5cdFx0Zm9yIChpID0gMDsgaSA8IGxlbmd0aF87IGkrKykge1xuXHRcdFx0YyA9IGRhdGFbaV07XG5cblx0XHRcdHN3aXRjaCAoc3RhdGUpIHtcblx0XHRcdFx0Y2FzZSBTLlNUQVJUX0JPVU5EQVJZOlxuXHRcdFx0XHRcdGlmIChpbmRleCA9PT0gYm91bmRhcnkubGVuZ3RoIC0gMikge1xuXHRcdFx0XHRcdFx0aWYgKGMgPT09IEhZUEhFTikge1xuXHRcdFx0XHRcdFx0XHRmbGFncyB8PSBGLkxBU1RfQk9VTkRBUlk7XG5cdFx0XHRcdFx0XHR9IGVsc2UgaWYgKGMgIT09IENSKSB7XG5cdFx0XHRcdFx0XHRcdHJldHVybjtcblx0XHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdFx0aW5kZXgrKztcblx0XHRcdFx0XHRcdGJyZWFrO1xuXHRcdFx0XHRcdH0gZWxzZSBpZiAoaW5kZXggLSAxID09PSBib3VuZGFyeS5sZW5ndGggLSAyKSB7XG5cdFx0XHRcdFx0XHRpZiAoZmxhZ3MgJiBGLkxBU1RfQk9VTkRBUlkgJiYgYyA9PT0gSFlQSEVOKSB7XG5cdFx0XHRcdFx0XHRcdHN0YXRlID0gUy5FTkQ7XG5cdFx0XHRcdFx0XHRcdGZsYWdzID0gMDtcblx0XHRcdFx0XHRcdH0gZWxzZSBpZiAoIShmbGFncyAmIEYuTEFTVF9CT1VOREFSWSkgJiYgYyA9PT0gTEYpIHtcblx0XHRcdFx0XHRcdFx0aW5kZXggPSAwO1xuXHRcdFx0XHRcdFx0XHRjYWxsYmFjaygnb25QYXJ0QmVnaW4nKTtcblx0XHRcdFx0XHRcdFx0c3RhdGUgPSBTLkhFQURFUl9GSUVMRF9TVEFSVDtcblx0XHRcdFx0XHRcdH0gZWxzZSB7XG5cdFx0XHRcdFx0XHRcdHJldHVybjtcblx0XHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0aWYgKGMgIT09IGJvdW5kYXJ5W2luZGV4ICsgMl0pIHtcblx0XHRcdFx0XHRcdGluZGV4ID0gLTI7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0aWYgKGMgPT09IGJvdW5kYXJ5W2luZGV4ICsgMl0pIHtcblx0XHRcdFx0XHRcdGluZGV4Kys7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdGNhc2UgUy5IRUFERVJfRklFTERfU1RBUlQ6XG5cdFx0XHRcdFx0c3RhdGUgPSBTLkhFQURFUl9GSUVMRDtcblx0XHRcdFx0XHRtYXJrKCdvbkhlYWRlckZpZWxkJyk7XG5cdFx0XHRcdFx0aW5kZXggPSAwO1xuXHRcdFx0XHRcdC8vIGZhbGxzIHRocm91Z2hcblx0XHRcdFx0Y2FzZSBTLkhFQURFUl9GSUVMRDpcblx0XHRcdFx0XHRpZiAoYyA9PT0gQ1IpIHtcblx0XHRcdFx0XHRcdGNsZWFyKCdvbkhlYWRlckZpZWxkJyk7XG5cdFx0XHRcdFx0XHRzdGF0ZSA9IFMuSEVBREVSU19BTE1PU1RfRE9ORTtcblx0XHRcdFx0XHRcdGJyZWFrO1xuXHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdGluZGV4Kys7XG5cdFx0XHRcdFx0aWYgKGMgPT09IEhZUEhFTikge1xuXHRcdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0aWYgKGMgPT09IENPTE9OKSB7XG5cdFx0XHRcdFx0XHRpZiAoaW5kZXggPT09IDEpIHtcblx0XHRcdFx0XHRcdFx0Ly8gZW1wdHkgaGVhZGVyIGZpZWxkXG5cdFx0XHRcdFx0XHRcdHJldHVybjtcblx0XHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdFx0ZGF0YUNhbGxiYWNrKCdvbkhlYWRlckZpZWxkJywgdHJ1ZSk7XG5cdFx0XHRcdFx0XHRzdGF0ZSA9IFMuSEVBREVSX1ZBTFVFX1NUQVJUO1xuXHRcdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0Y2wgPSBsb3dlcihjKTtcblx0XHRcdFx0XHRpZiAoY2wgPCBBIHx8IGNsID4gWikge1xuXHRcdFx0XHRcdFx0cmV0dXJuO1xuXHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdGJyZWFrO1xuXHRcdFx0XHRjYXNlIFMuSEVBREVSX1ZBTFVFX1NUQVJUOlxuXHRcdFx0XHRcdGlmIChjID09PSBTUEFDRSkge1xuXHRcdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0bWFyaygnb25IZWFkZXJWYWx1ZScpO1xuXHRcdFx0XHRcdHN0YXRlID0gUy5IRUFERVJfVkFMVUU7XG5cdFx0XHRcdFx0Ly8gZmFsbHMgdGhyb3VnaFxuXHRcdFx0XHRjYXNlIFMuSEVBREVSX1ZBTFVFOlxuXHRcdFx0XHRcdGlmIChjID09PSBDUikge1xuXHRcdFx0XHRcdFx0ZGF0YUNhbGxiYWNrKCdvbkhlYWRlclZhbHVlJywgdHJ1ZSk7XG5cdFx0XHRcdFx0XHRjYWxsYmFjaygnb25IZWFkZXJFbmQnKTtcblx0XHRcdFx0XHRcdHN0YXRlID0gUy5IRUFERVJfVkFMVUVfQUxNT1NUX0RPTkU7XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdGNhc2UgUy5IRUFERVJfVkFMVUVfQUxNT1NUX0RPTkU6XG5cdFx0XHRcdFx0aWYgKGMgIT09IExGKSB7XG5cdFx0XHRcdFx0XHRyZXR1cm47XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0c3RhdGUgPSBTLkhFQURFUl9GSUVMRF9TVEFSVDtcblx0XHRcdFx0XHRicmVhaztcblx0XHRcdFx0Y2FzZSBTLkhFQURFUlNfQUxNT1NUX0RPTkU6XG5cdFx0XHRcdFx0aWYgKGMgIT09IExGKSB7XG5cdFx0XHRcdFx0XHRyZXR1cm47XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0Y2FsbGJhY2soJ29uSGVhZGVyc0VuZCcpO1xuXHRcdFx0XHRcdHN0YXRlID0gUy5QQVJUX0RBVEFfU1RBUlQ7XG5cdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdGNhc2UgUy5QQVJUX0RBVEFfU1RBUlQ6XG5cdFx0XHRcdFx0c3RhdGUgPSBTLlBBUlRfREFUQTtcblx0XHRcdFx0XHRtYXJrKCdvblBhcnREYXRhJyk7XG5cdFx0XHRcdFx0Ly8gZmFsbHMgdGhyb3VnaFxuXHRcdFx0XHRjYXNlIFMuUEFSVF9EQVRBOlxuXHRcdFx0XHRcdHByZXZpb3VzSW5kZXggPSBpbmRleDtcblxuXHRcdFx0XHRcdGlmIChpbmRleCA9PT0gMCkge1xuXHRcdFx0XHRcdFx0Ly8gYm95ZXItbW9vcmUgZGVycml2ZWQgYWxnb3JpdGhtIHRvIHNhZmVseSBza2lwIG5vbi1ib3VuZGFyeSBkYXRhXG5cdFx0XHRcdFx0XHRpICs9IGJvdW5kYXJ5RW5kO1xuXHRcdFx0XHRcdFx0d2hpbGUgKGkgPCBidWZmZXJMZW5ndGggJiYgIShkYXRhW2ldIGluIGJvdW5kYXJ5Q2hhcnMpKSB7XG5cdFx0XHRcdFx0XHRcdGkgKz0gYm91bmRhcnlMZW5ndGg7XG5cdFx0XHRcdFx0XHR9XG5cblx0XHRcdFx0XHRcdGkgLT0gYm91bmRhcnlFbmQ7XG5cdFx0XHRcdFx0XHRjID0gZGF0YVtpXTtcblx0XHRcdFx0XHR9XG5cblx0XHRcdFx0XHRpZiAoaW5kZXggPCBib3VuZGFyeS5sZW5ndGgpIHtcblx0XHRcdFx0XHRcdGlmIChib3VuZGFyeVtpbmRleF0gPT09IGMpIHtcblx0XHRcdFx0XHRcdFx0aWYgKGluZGV4ID09PSAwKSB7XG5cdFx0XHRcdFx0XHRcdFx0ZGF0YUNhbGxiYWNrKCdvblBhcnREYXRhJywgdHJ1ZSk7XG5cdFx0XHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdFx0XHRpbmRleCsrO1xuXHRcdFx0XHRcdFx0fSBlbHNlIHtcblx0XHRcdFx0XHRcdFx0aW5kZXggPSAwO1xuXHRcdFx0XHRcdFx0fVxuXHRcdFx0XHRcdH0gZWxzZSBpZiAoaW5kZXggPT09IGJvdW5kYXJ5Lmxlbmd0aCkge1xuXHRcdFx0XHRcdFx0aW5kZXgrKztcblx0XHRcdFx0XHRcdGlmIChjID09PSBDUikge1xuXHRcdFx0XHRcdFx0XHQvLyBDUiA9IHBhcnQgYm91bmRhcnlcblx0XHRcdFx0XHRcdFx0ZmxhZ3MgfD0gRi5QQVJUX0JPVU5EQVJZO1xuXHRcdFx0XHRcdFx0fSBlbHNlIGlmIChjID09PSBIWVBIRU4pIHtcblx0XHRcdFx0XHRcdFx0Ly8gSFlQSEVOID0gZW5kIGJvdW5kYXJ5XG5cdFx0XHRcdFx0XHRcdGZsYWdzIHw9IEYuTEFTVF9CT1VOREFSWTtcblx0XHRcdFx0XHRcdH0gZWxzZSB7XG5cdFx0XHRcdFx0XHRcdGluZGV4ID0gMDtcblx0XHRcdFx0XHRcdH1cblx0XHRcdFx0XHR9IGVsc2UgaWYgKGluZGV4IC0gMSA9PT0gYm91bmRhcnkubGVuZ3RoKSB7XG5cdFx0XHRcdFx0XHRpZiAoZmxhZ3MgJiBGLlBBUlRfQk9VTkRBUlkpIHtcblx0XHRcdFx0XHRcdFx0aW5kZXggPSAwO1xuXHRcdFx0XHRcdFx0XHRpZiAoYyA9PT0gTEYpIHtcblx0XHRcdFx0XHRcdFx0XHQvLyB1bnNldCB0aGUgUEFSVF9CT1VOREFSWSBmbGFnXG5cdFx0XHRcdFx0XHRcdFx0ZmxhZ3MgJj0gfkYuUEFSVF9CT1VOREFSWTtcblx0XHRcdFx0XHRcdFx0XHRjYWxsYmFjaygnb25QYXJ0RW5kJyk7XG5cdFx0XHRcdFx0XHRcdFx0Y2FsbGJhY2soJ29uUGFydEJlZ2luJyk7XG5cdFx0XHRcdFx0XHRcdFx0c3RhdGUgPSBTLkhFQURFUl9GSUVMRF9TVEFSVDtcblx0XHRcdFx0XHRcdFx0XHRicmVhaztcblx0XHRcdFx0XHRcdFx0fVxuXHRcdFx0XHRcdFx0fSBlbHNlIGlmIChmbGFncyAmIEYuTEFTVF9CT1VOREFSWSkge1xuXHRcdFx0XHRcdFx0XHRpZiAoYyA9PT0gSFlQSEVOKSB7XG5cdFx0XHRcdFx0XHRcdFx0Y2FsbGJhY2soJ29uUGFydEVuZCcpO1xuXHRcdFx0XHRcdFx0XHRcdHN0YXRlID0gUy5FTkQ7XG5cdFx0XHRcdFx0XHRcdFx0ZmxhZ3MgPSAwO1xuXHRcdFx0XHRcdFx0XHR9IGVsc2Uge1xuXHRcdFx0XHRcdFx0XHRcdGluZGV4ID0gMDtcblx0XHRcdFx0XHRcdFx0fVxuXHRcdFx0XHRcdFx0fSBlbHNlIHtcblx0XHRcdFx0XHRcdFx0aW5kZXggPSAwO1xuXHRcdFx0XHRcdFx0fVxuXHRcdFx0XHRcdH1cblxuXHRcdFx0XHRcdGlmIChpbmRleCA+IDApIHtcblx0XHRcdFx0XHRcdC8vIHdoZW4gbWF0Y2hpbmcgYSBwb3NzaWJsZSBib3VuZGFyeSwga2VlcCBhIGxvb2tiZWhpbmQgcmVmZXJlbmNlXG5cdFx0XHRcdFx0XHQvLyBpbiBjYXNlIGl0IHR1cm5zIG91dCB0byBiZSBhIGZhbHNlIGxlYWRcblx0XHRcdFx0XHRcdGxvb2tiZWhpbmRbaW5kZXggLSAxXSA9IGM7XG5cdFx0XHRcdFx0fSBlbHNlIGlmIChwcmV2aW91c0luZGV4ID4gMCkge1xuXHRcdFx0XHRcdFx0Ly8gaWYgb3VyIGJvdW5kYXJ5IHR1cm5lZCBvdXQgdG8gYmUgcnViYmlzaCwgdGhlIGNhcHR1cmVkIGxvb2tiZWhpbmRcblx0XHRcdFx0XHRcdC8vIGJlbG9uZ3MgdG8gcGFydERhdGFcblx0XHRcdFx0XHRcdGNvbnN0IF9sb29rYmVoaW5kID0gbmV3IFVpbnQ4QXJyYXkobG9va2JlaGluZC5idWZmZXIsIGxvb2tiZWhpbmQuYnl0ZU9mZnNldCwgbG9va2JlaGluZC5ieXRlTGVuZ3RoKTtcblx0XHRcdFx0XHRcdGNhbGxiYWNrKCdvblBhcnREYXRhJywgMCwgcHJldmlvdXNJbmRleCwgX2xvb2tiZWhpbmQpO1xuXHRcdFx0XHRcdFx0cHJldmlvdXNJbmRleCA9IDA7XG5cdFx0XHRcdFx0XHRtYXJrKCdvblBhcnREYXRhJyk7XG5cblx0XHRcdFx0XHRcdC8vIHJlY29uc2lkZXIgdGhlIGN1cnJlbnQgY2hhcmFjdGVyIGV2ZW4gc28gaXQgaW50ZXJydXB0ZWQgdGhlIHNlcXVlbmNlXG5cdFx0XHRcdFx0XHQvLyBpdCBjb3VsZCBiZSB0aGUgYmVnaW5uaW5nIG9mIGEgbmV3IHNlcXVlbmNlXG5cdFx0XHRcdFx0XHRpLS07XG5cdFx0XHRcdFx0fVxuXG5cdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdGNhc2UgUy5FTkQ6XG5cdFx0XHRcdFx0YnJlYWs7XG5cdFx0XHRcdGRlZmF1bHQ6XG5cdFx0XHRcdFx0dGhyb3cgbmV3IEVycm9yKGBVbmV4cGVjdGVkIHN0YXRlIGVudGVyZWQ6ICR7c3RhdGV9YCk7XG5cdFx0XHR9XG5cdFx0fVxuXG5cdFx0ZGF0YUNhbGxiYWNrKCdvbkhlYWRlckZpZWxkJyk7XG5cdFx0ZGF0YUNhbGxiYWNrKCdvbkhlYWRlclZhbHVlJyk7XG5cdFx0ZGF0YUNhbGxiYWNrKCdvblBhcnREYXRhJyk7XG5cblx0XHQvLyBVcGRhdGUgcHJvcGVydGllcyBmb3IgdGhlIG5leHQgY2FsbFxuXHRcdHRoaXMuaW5kZXggPSBpbmRleDtcblx0XHR0aGlzLnN0YXRlID0gc3RhdGU7XG5cdFx0dGhpcy5mbGFncyA9IGZsYWdzO1xuXHR9XG5cblx0ZW5kKCkge1xuXHRcdGlmICgodGhpcy5zdGF0ZSA9PT0gUy5IRUFERVJfRklFTERfU1RBUlQgJiYgdGhpcy5pbmRleCA9PT0gMCkgfHxcblx0XHRcdCh0aGlzLnN0YXRlID09PSBTLlBBUlRfREFUQSAmJiB0aGlzLmluZGV4ID09PSB0aGlzLmJvdW5kYXJ5Lmxlbmd0aCkpIHtcblx0XHRcdHRoaXMub25QYXJ0RW5kKCk7XG5cdFx0fSBlbHNlIGlmICh0aGlzLnN0YXRlICE9PSBTLkVORCkge1xuXHRcdFx0dGhyb3cgbmV3IEVycm9yKCdNdWx0aXBhcnRQYXJzZXIuZW5kKCk6IHN0cmVhbSBlbmRlZCB1bmV4cGVjdGVkbHknKTtcblx0XHR9XG5cdH1cbn1cblxuZnVuY3Rpb24gX2ZpbGVOYW1lKGhlYWRlclZhbHVlKSB7XG5cdC8vIG1hdGNoZXMgZWl0aGVyIGEgcXVvdGVkLXN0cmluZyBvciBhIHRva2VuIChSRkMgMjYxNiBzZWN0aW9uIDE5LjUuMSlcblx0Y29uc3QgbSA9IGhlYWRlclZhbHVlLm1hdGNoKC9cXGJmaWxlbmFtZT0oXCIoLio/KVwifChbXigpPD5ALDs6XFxcXFwiL1tcXF0/PXt9XFxzXFx0XSspKSgkfDtcXHMpL2kpO1xuXHRpZiAoIW0pIHtcblx0XHRyZXR1cm47XG5cdH1cblxuXHRjb25zdCBtYXRjaCA9IG1bMl0gfHwgbVszXSB8fCAnJztcblx0bGV0IGZpbGVuYW1lID0gbWF0Y2guc2xpY2UobWF0Y2gubGFzdEluZGV4T2YoJ1xcXFwnKSArIDEpO1xuXHRmaWxlbmFtZSA9IGZpbGVuYW1lLnJlcGxhY2UoLyUyMi9nLCAnXCInKTtcblx0ZmlsZW5hbWUgPSBmaWxlbmFtZS5yZXBsYWNlKC8mIyhcXGR7NH0pOy9nLCAobSwgY29kZSkgPT4ge1xuXHRcdHJldHVybiBTdHJpbmcuZnJvbUNoYXJDb2RlKGNvZGUpO1xuXHR9KTtcblx0cmV0dXJuIGZpbGVuYW1lO1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gdG9Gb3JtRGF0YShCb2R5LCBjdCkge1xuXHRpZiAoIS9tdWx0aXBhcnQvaS50ZXN0KGN0KSkge1xuXHRcdHRocm93IG5ldyBUeXBlRXJyb3IoJ0ZhaWxlZCB0byBmZXRjaCcpO1xuXHR9XG5cblx0Y29uc3QgbSA9IGN0Lm1hdGNoKC9ib3VuZGFyeT0oPzpcIihbXlwiXSspXCJ8KFteO10rKSkvaSk7XG5cblx0aWYgKCFtKSB7XG5cdFx0dGhyb3cgbmV3IFR5cGVFcnJvcignbm8gb3IgYmFkIGNvbnRlbnQtdHlwZSBoZWFkZXIsIG5vIG11bHRpcGFydCBib3VuZGFyeScpO1xuXHR9XG5cblx0Y29uc3QgcGFyc2VyID0gbmV3IE11bHRpcGFydFBhcnNlcihtWzFdIHx8IG1bMl0pO1xuXG5cdGxldCBoZWFkZXJGaWVsZDtcblx0bGV0IGhlYWRlclZhbHVlO1xuXHRsZXQgZW50cnlWYWx1ZTtcblx0bGV0IGVudHJ5TmFtZTtcblx0bGV0IGNvbnRlbnRUeXBlO1xuXHRsZXQgZmlsZW5hbWU7XG5cdGNvbnN0IGVudHJ5Q2h1bmtzID0gW107XG5cdGNvbnN0IGZvcm1EYXRhID0gbmV3IEZvcm1EYXRhKCk7XG5cblx0Y29uc3Qgb25QYXJ0RGF0YSA9IHVpOGEgPT4ge1xuXHRcdGVudHJ5VmFsdWUgKz0gZGVjb2Rlci5kZWNvZGUodWk4YSwge3N0cmVhbTogdHJ1ZX0pO1xuXHR9O1xuXG5cdGNvbnN0IGFwcGVuZFRvRmlsZSA9IHVpOGEgPT4ge1xuXHRcdGVudHJ5Q2h1bmtzLnB1c2godWk4YSk7XG5cdH07XG5cblx0Y29uc3QgYXBwZW5kRmlsZVRvRm9ybURhdGEgPSAoKSA9PiB7XG5cdFx0Y29uc3QgZmlsZSA9IG5ldyBGaWxlKGVudHJ5Q2h1bmtzLCBmaWxlbmFtZSwge3R5cGU6IGNvbnRlbnRUeXBlfSk7XG5cdFx0Zm9ybURhdGEuYXBwZW5kKGVudHJ5TmFtZSwgZmlsZSk7XG5cdH07XG5cblx0Y29uc3QgYXBwZW5kRW50cnlUb0Zvcm1EYXRhID0gKCkgPT4ge1xuXHRcdGZvcm1EYXRhLmFwcGVuZChlbnRyeU5hbWUsIGVudHJ5VmFsdWUpO1xuXHR9O1xuXG5cdGNvbnN0IGRlY29kZXIgPSBuZXcgVGV4dERlY29kZXIoJ3V0Zi04Jyk7XG5cdGRlY29kZXIuZGVjb2RlKCk7XG5cblx0cGFyc2VyLm9uUGFydEJlZ2luID0gZnVuY3Rpb24gKCkge1xuXHRcdHBhcnNlci5vblBhcnREYXRhID0gb25QYXJ0RGF0YTtcblx0XHRwYXJzZXIub25QYXJ0RW5kID0gYXBwZW5kRW50cnlUb0Zvcm1EYXRhO1xuXG5cdFx0aGVhZGVyRmllbGQgPSAnJztcblx0XHRoZWFkZXJWYWx1ZSA9ICcnO1xuXHRcdGVudHJ5VmFsdWUgPSAnJztcblx0XHRlbnRyeU5hbWUgPSAnJztcblx0XHRjb250ZW50VHlwZSA9ICcnO1xuXHRcdGZpbGVuYW1lID0gbnVsbDtcblx0XHRlbnRyeUNodW5rcy5sZW5ndGggPSAwO1xuXHR9O1xuXG5cdHBhcnNlci5vbkhlYWRlckZpZWxkID0gZnVuY3Rpb24gKHVpOGEpIHtcblx0XHRoZWFkZXJGaWVsZCArPSBkZWNvZGVyLmRlY29kZSh1aThhLCB7c3RyZWFtOiB0cnVlfSk7XG5cdH07XG5cblx0cGFyc2VyLm9uSGVhZGVyVmFsdWUgPSBmdW5jdGlvbiAodWk4YSkge1xuXHRcdGhlYWRlclZhbHVlICs9IGRlY29kZXIuZGVjb2RlKHVpOGEsIHtzdHJlYW06IHRydWV9KTtcblx0fTtcblxuXHRwYXJzZXIub25IZWFkZXJFbmQgPSBmdW5jdGlvbiAoKSB7XG5cdFx0aGVhZGVyVmFsdWUgKz0gZGVjb2Rlci5kZWNvZGUoKTtcblx0XHRoZWFkZXJGaWVsZCA9IGhlYWRlckZpZWxkLnRvTG93ZXJDYXNlKCk7XG5cblx0XHRpZiAoaGVhZGVyRmllbGQgPT09ICdjb250ZW50LWRpc3Bvc2l0aW9uJykge1xuXHRcdFx0Ly8gbWF0Y2hlcyBlaXRoZXIgYSBxdW90ZWQtc3RyaW5nIG9yIGEgdG9rZW4gKFJGQyAyNjE2IHNlY3Rpb24gMTkuNS4xKVxuXHRcdFx0Y29uc3QgbSA9IGhlYWRlclZhbHVlLm1hdGNoKC9cXGJuYW1lPShcIihbXlwiXSopXCJ8KFteKCk8PkAsOzpcXFxcXCIvW1xcXT89e31cXHNcXHRdKykpL2kpO1xuXG5cdFx0XHRpZiAobSkge1xuXHRcdFx0XHRlbnRyeU5hbWUgPSBtWzJdIHx8IG1bM10gfHwgJyc7XG5cdFx0XHR9XG5cblx0XHRcdGZpbGVuYW1lID0gX2ZpbGVOYW1lKGhlYWRlclZhbHVlKTtcblxuXHRcdFx0aWYgKGZpbGVuYW1lKSB7XG5cdFx0XHRcdHBhcnNlci5vblBhcnREYXRhID0gYXBwZW5kVG9GaWxlO1xuXHRcdFx0XHRwYXJzZXIub25QYXJ0RW5kID0gYXBwZW5kRmlsZVRvRm9ybURhdGE7XG5cdFx0XHR9XG5cdFx0fSBlbHNlIGlmIChoZWFkZXJGaWVsZCA9PT0gJ2NvbnRlbnQtdHlwZScpIHtcblx0XHRcdGNvbnRlbnRUeXBlID0gaGVhZGVyVmFsdWU7XG5cdFx0fVxuXG5cdFx0aGVhZGVyVmFsdWUgPSAnJztcblx0XHRoZWFkZXJGaWVsZCA9ICcnO1xuXHR9O1xuXG5cdGZvciBhd2FpdCAoY29uc3QgY2h1bmsgb2YgQm9keSkge1xuXHRcdHBhcnNlci53cml0ZShjaHVuayk7XG5cdH1cblxuXHRwYXJzZXIuZW5kKCk7XG5cblx0cmV0dXJuIGZvcm1EYXRhO1xufVxuIl0sInNvdXJjZVJvb3QiOiIifQ== 492 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "One Core Toolbox", 3 | "id": "1031203732970689806", 4 | "api": "1.0.0", 5 | "main": "dist/code.js", 6 | "ui": "dist/ui.html", 7 | "editorType": ["figma"], 8 | "enablePrivatePluginApi": true, 9 | "permissions": ["currentuser"], 10 | "menu": [ 11 | { "name": "Home", "command": "open-home" }, 12 | { "separator": true }, 13 | { "name": "Table creator", "command": "open-table-creator" }, 14 | { 15 | "name": "Theme switcher", 16 | "menu": [ 17 | { "name": "Light mode", "command": "theme-switcher-to-light" }, 18 | { "name": "Dark mode", "command": "theme-switcher-to-dark" } 19 | ] 20 | }, 21 | { 22 | "name": "Design linter", 23 | "menu": [ 24 | { "name": "Language", "command": "open-language-linter" }, 25 | { "name": "Color", "command": "open-color-linter" } 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "one-core-toolbox", 3 | "version": "1.0.0", 4 | "description": "A Figma plugin for New Relic product designers.", 5 | "license": "ISC", 6 | "scripts": { 7 | "build": "NODE_OPTIONS=--openssl-legacy-provider webpack --mode=production", 8 | "build:watch": "NODE_OPTIONS=--openssl-legacy-provider webpack --mode=development --watch", 9 | "prettier:format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,json}' ", 10 | "client-build": "cd src/app/components/LanguageLinter && npm install", 11 | "prepare": "husky install" 12 | }, 13 | "dependencies": { 14 | "@figma-plugin/helpers": "^0.15.2", 15 | "@types/newrelic": "^7.0.3", 16 | "@types/node": "^17.0.25", 17 | "babel-polyfill": "^6.26.0", 18 | "chroma-js": "^2.4.2", 19 | "classnames": "^2.3.1", 20 | "dictionary-en": "^3.1.0", 21 | "new-relic-language-linter": "^1.2.38", 22 | "prop-types": "^15.8.1", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "react-feather": "^2.0.9", 26 | "react-figma-plugin-ds": "^2.3.0", 27 | "react-konami-code": "^2.3.0", 28 | "uuid-random": "^1.3.2" 29 | }, 30 | "devDependencies": { 31 | "@figma/plugin-typings": "^1.47.0", 32 | "@types/react": "^17.0.11", 33 | "@types/react-dom": "^17.0.7", 34 | "css-loader": "^5.0.1", 35 | "html-webpack-inline-source-plugin": "^0.0.10", 36 | "html-webpack-plugin": "^3.2.0", 37 | "husky": ">=6", 38 | "lint-staged": ">=10", 39 | "prettier": "^2.3.1", 40 | "style-loader": "^2.0.0", 41 | "ts-loader": "^8.0.11", 42 | "typescript": "*", 43 | "url-loader": "^4.1.1", 44 | "webpack": "^4.41.4", 45 | "webpack-cli": "^3.3.6" 46 | }, 47 | "lint-staged": { 48 | "*.{js,css,md}": "prettier --write" 49 | }, 50 | "engines": { 51 | "node": ">=18.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/assets/dark-mode-thumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/app/assets/icon-chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-chevron-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-dark-design-linter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/assets/icon-dark-table-creator.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/assets/icon-dark-theme-switcher.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/assets/icon-design-linter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/assets/icon-external-link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-help.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-info.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-boolean-operation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-ellipse.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-line.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-polygon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-layer-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/app/assets/icon-more.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/app/assets/icon-table-creator.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/app/assets/icon-theme-switcher.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app/assets/light-mode-thumbnail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/app/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/assets/one-core-team-photo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/newrelic/one-core-toolbox/b320e9294049bdbc99acdbe5cccfca9f289fe00a/src/app/assets/one-core-team-photo.png -------------------------------------------------------------------------------- /src/app/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useEffect } from "react"; 3 | import Konami from "react-konami-code"; 4 | import Team from "../assets/one-core-team-photo.png"; 5 | import TableCreator from "./TableCreator/TableCreator"; 6 | import DesignLinter from "./DesignLinter/DesignLinter"; 7 | import Resizer from "./Resizer"; 8 | import ThemeSwitcher from "./ThemeSwitcher"; 9 | import { PluginContext } from "./PluginContext"; 10 | import Home from "./Home"; 11 | import { isEqual } from "./utils"; 12 | import "../styles/ui.css"; 13 | 14 | declare function require(path: string): any; 15 | 16 | interface rgbFloat { 17 | r: number; 18 | g: number; 19 | b: number; 20 | } 21 | interface color { 22 | blendMode: string; 23 | color: rgbFloat; 24 | opacity: number; 25 | type: string; 26 | visible: boolean; 27 | } 28 | interface colorList { 29 | color: color[]; 30 | colorId: string; 31 | colorInHex: string; 32 | colorStyleId: string; 33 | colorType: string; 34 | hasColorStyle: boolean; 35 | layerId: string; 36 | layerName: string; 37 | layerType: string; 38 | visible: boolean; 39 | } 40 | 41 | const App = ({}) => { 42 | const [activePlugin, setActivePlugin] = useState("home"); 43 | const [latestFigmaCommand, setLatestFigmaCommand] = useState(""); 44 | const [selectedTextLayers, setSelectedTextLayers] = useState([]); 45 | const [sampleTextIndex, setSampleTextIndex] = useState(0); 46 | const [localCustomDictionary, setLocalCustomDictionary] = useState([]); 47 | const [ 48 | localCustomDictionaryInitialized, 49 | setLocalCustomDictionaryInitialized, 50 | ] = useState(false); 51 | const [colorsWithIssues, setColorsWithIssues] = useState>( 52 | [] 53 | ); 54 | const [loadingColorData, setLoadingColorData] = useState(false); 55 | const [selectionMade, setSelectionMade] = useState(false); 56 | const [currentSelection, setCurrentSelection] = useState(); 57 | const [incomingSelection, setIncomingSelection] = useState(); 58 | const [currentLayersLintedForLanguage, setCurrentLayersLintedForLanguage] = 59 | useState(); 60 | const [colorTokens, setColorTokens] = useState(); 61 | const [activeColorTile, setActiveColorTile] = useState(); 62 | const [colorIssuesFixed, setColorIssuesFixed] = useState(0); 63 | const [loadingDarkSwitch, setLoadingDarkSwitch] = useState(false); 64 | const [loadingLightSwitch, setLoadingLightSwitch] = useState(false); 65 | 66 | const triggerNewRelicCustomEvent = ( 67 | eventName: string, 68 | customData: Object 69 | ) => { 70 | // `newrelic` is included at the top of ui.html in a 71 | // a script tag. Typescript will complain. So... 72 | // @ts-ignore 73 | newrelic.addPageAction(eventName, customData); 74 | 75 | return new Promise((resolve) => { 76 | setTimeout(() => { 77 | resolve(customData); 78 | }, 550); 79 | }); 80 | }; 81 | 82 | // Listen for messages from controller 83 | useEffect(() => { 84 | parent.postMessage( 85 | { pluginMessage: { type: "initialize-selection" } }, 86 | "*" 87 | ); 88 | 89 | window.onmessage = (event) => { 90 | const { type, message } = event.data.pluginMessage; 91 | 92 | switch (type) { 93 | // Navigation 94 | case "figma-command": 95 | triggerNewRelicCustomEvent(`OneCoreToolbox: plugin-opened`, message); 96 | setLatestFigmaCommand(message.openedTo); 97 | break; 98 | // Keep selection updated (used by several plugins) 99 | case "initial-selection": 100 | setCurrentSelection(message); 101 | break; 102 | case "selection-changed": 103 | setIncomingSelection(message); 104 | break; 105 | // Table creator 106 | case "table-created": 107 | triggerNewRelicCustomEvent("OneCoreToolbox: table-created", message); 108 | parent.postMessage({ pluginMessage: { type: "close-plugin" } }, "*"); 109 | break; 110 | // Language linter 111 | case "new-text-selection": 112 | // reset the index of the sample text to 0 113 | setSampleTextIndex(0); 114 | 115 | // Filter out text layers that have no suggestions 116 | setSelectedTextLayers(message.textLayers); 117 | setCurrentLayersLintedForLanguage(message.selectedLayers); 118 | break; 119 | // // The problem with this event is that it fires like 3 times for every layer 120 | // // that's linted. That's a problem. 121 | // case "text-linted": 122 | // triggerNewRelicCustomEvent("OneCoreToolbox: text-layer-linted", { 123 | // ...message.customEventData, 124 | // suggestions: message.minimalReport, 125 | // }); 126 | 127 | // break; 128 | case "language-linter-run": 129 | triggerNewRelicCustomEvent("OneCoreToolbox: language-linted", { 130 | ...message.customEventData, 131 | }); 132 | 133 | break; 134 | case "local-custom-dictionary-retrieved": 135 | setLocalCustomDictionaryInitialized(true); 136 | setLocalCustomDictionary(message); 137 | break; 138 | // Color linter 139 | case "color-stats": 140 | setLoadingColorData(false); 141 | setColorsWithIssues( 142 | message?.colorStats?.colorsNotUsingOneCoreColorStyle 143 | ); 144 | setSelectionMade(message?.selectionMade); 145 | setColorTokens(message.colorTokens); 146 | setColorIssuesFixed(0); 147 | 148 | message?.selectionMade && 149 | triggerNewRelicCustomEvent(`OneCoreToolbox: colors-linted`, { 150 | fileName: message.fileName, 151 | fileKey: message.fileKey, 152 | "User Name": message["User Name"], 153 | "User Avatar": message["User Avatar"], 154 | "User ID": message["User ID"], 155 | "Session ID": message["Session ID"], 156 | selectedLayersWithColor: 157 | message.colorStats.selectedLayersWithColor.length, 158 | allInstancesOfColor: 159 | message.colorStats.allInstancesOfColor.length, 160 | colorsWithColorStyle: 161 | message.colorStats.colorsWithColorStyle.length, 162 | colorsUsingOneCoreStyle: 163 | message.colorStats.colorsUsingOneCoreStyle.length, 164 | colorsNotUsingOneCoreColorStyle: 165 | message.colorStats.colorsNotUsingOneCoreColorStyle.length, 166 | oneCoreColorStyleCoverage: 167 | message.colorStats.oneCoreColorStyleCoverage.length, 168 | idsOfAllInstancesOfColor: 169 | message.colorStats.idsOfAllInstancesOfColor.length, 170 | }); 171 | break; 172 | case "color-replaced": 173 | triggerNewRelicCustomEvent(`OneCoreToolbox: color-replaced`, { 174 | ...message, 175 | }); 176 | break; 177 | case "loading-light-theme-switch": 178 | setLoadingLightSwitch(true); 179 | break; 180 | case "loading-dark-theme-switch": 181 | setLoadingDarkSwitch(true); 182 | break; 183 | case "theme-switched": 184 | if (message.switchedTo === "dark") { 185 | setLoadingDarkSwitch(false); 186 | } else if (message.switchedTo === "light") { 187 | setLoadingLightSwitch(false); 188 | } 189 | 190 | const sendThemeSwitcherEvent = async () => { 191 | await triggerNewRelicCustomEvent(`OneCoreToolbox: theme-switched`, { 192 | ...message, 193 | }); 194 | 195 | if (message.closeAfterRun) { 196 | parent.postMessage( 197 | { pluginMessage: { type: "close-plugin" } }, 198 | "*" 199 | ); 200 | } 201 | }; 202 | sendThemeSwitcherEvent(); 203 | break; 204 | } 205 | }; 206 | }, []); 207 | 208 | // Handle submenu navigation: Part 2 209 | // Every time `latestFigmaCommand` is updated, handle subnavigation 210 | useEffect(() => { 211 | if (latestFigmaCommand.length > 0) { 212 | switch (latestFigmaCommand) { 213 | case "open-home": 214 | setActivePlugin("home"); 215 | break; 216 | case "open-table-creator": 217 | setActivePlugin("table-creator"); 218 | break; 219 | case "open-theme-switcher": 220 | setActivePlugin("theme-switcher"); 221 | break; 222 | case "open-language-linter": 223 | setActivePlugin("language-linter"); 224 | break; 225 | case "open-color-linter": 226 | setActivePlugin("color-linter"); 227 | break; 228 | case "open-language-linter": 229 | setActivePlugin("language-linter"); 230 | break; 231 | } 232 | } 233 | }, [latestFigmaCommand]); 234 | 235 | useEffect(() => { 236 | if (currentSelection && incomingSelection) { 237 | if (!isEqual(currentSelection, incomingSelection)) { 238 | setCurrentSelection(incomingSelection); 239 | } 240 | } 241 | }, [incomingSelection]); 242 | 243 | const handlePluginNavigation = (destination) => { 244 | setActivePlugin(destination); 245 | 246 | parent.postMessage( 247 | { 248 | pluginMessage: { 249 | type: "navigate-to-tab", 250 | tabClicked: destination, 251 | }, 252 | }, 253 | "*" 254 | ); 255 | }; 256 | 257 | const memorial = () => { 258 | return ( 259 | 260 |
261 | 262 |

263 | To one of the greatest groups of folks I've ever worked with: Thank 264 | you ❤️ 265 |

266 | – Daniel 267 |
268 |
269 | ); 270 | }; 271 | 272 | const renderPluginBody = () => { 273 | switch (activePlugin) { 274 | case "home": 275 | return ; 276 | case "table-creator": 277 | return ; 278 | case "language-linter": 279 | return ( 280 | 284 | ); 285 | case "theme-switcher": 286 | return ; 287 | case "color-linter": 288 | return ( 289 | 293 | ); 294 | } 295 | }; 296 | 297 | /*-- vars for context --*/ 298 | const functions = { 299 | handlePluginNavigation, 300 | triggerNewRelicCustomEvent, 301 | }; 302 | 303 | const state = { 304 | selectedTextLayers, 305 | setSelectedTextLayers, 306 | sampleTextIndex, 307 | setSampleTextIndex, 308 | localCustomDictionary, 309 | setLocalCustomDictionary, 310 | localCustomDictionaryInitialized, 311 | setLocalCustomDictionaryInitialized, 312 | colorsWithIssues, 313 | setColorsWithIssues, 314 | loadingColorData, 315 | setLoadingColorData, 316 | selectionMade, 317 | setSelectionMade, 318 | currentSelection, 319 | incomingSelection, 320 | currentLayersLintedForLanguage, 321 | colorTokens, 322 | activeColorTile, 323 | setActiveColorTile, 324 | colorIssuesFixed, 325 | setColorIssuesFixed, 326 | loadingDarkSwitch, 327 | loadingLightSwitch, 328 | }; 329 | 330 | return ( 331 | 332 | 333 |
{renderPluginBody()}
334 | {memorial()} 335 |
336 | ); 337 | }; 338 | 339 | export default App; 340 | -------------------------------------------------------------------------------- /src/app/components/DesignLinter/ColorLinter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useContext } from "react"; 3 | import { PluginContext } from "../PluginContext"; 4 | import ColorTile from "./ColorTile"; 5 | import classNames from "classnames"; 6 | require("babel-polyfill"); 7 | import "../../styles/ui.css"; 8 | 9 | declare function require(path: string): any; 10 | 11 | const ColorLinter = () => { 12 | const { state } = useContext(PluginContext); 13 | const { 14 | colorsWithIssues, 15 | setColorsWithIssues, 16 | loadingColorData, 17 | setLoadingColorData, 18 | selectionMade, 19 | colorIssuesFixed, 20 | } = state; 21 | 22 | React.useEffect(() => { 23 | setLoadingColorData(true); 24 | parent.postMessage({ pluginMessage: { type: "run-color-linter" } }, "*"); 25 | }, []); 26 | 27 | const ignoreColorIssue: (colorId: string) => void = (colorId) => { 28 | const colorToBeRemoved = colorsWithIssues.find((color) => { 29 | return color.colorId === colorId; 30 | }); 31 | 32 | const indexOfColorToBeRemoved = colorsWithIssues.indexOf(colorToBeRemoved); 33 | const newColorsWithIssues = [...colorsWithIssues]; 34 | 35 | newColorsWithIssues.splice(indexOfColorToBeRemoved, 1); 36 | 37 | setColorsWithIssues(newColorsWithIssues); 38 | }; 39 | 40 | const renderColorIssues = () => { 41 | if (colorsWithIssues?.length > 0) { 42 | return colorsWithIssues.map((color, index) => { 43 | return ( 44 | 49 | ); 50 | }); 51 | } 52 | }; 53 | 54 | const handleRescanLayersClick = () => { 55 | setLoadingColorData(true); 56 | parent.postMessage({ pluginMessage: { type: "run-color-linter" } }, "*"); 57 | }; 58 | 59 | const renderEmptyState = () => { 60 | const headingText = !loadingColorData 61 | ? "Select a layer(s) to get started" 62 | : "Loading color data..."; 63 | const descriptionText = !loadingColorData 64 | ? "To check your colors select any layer, frame, or group of layers and then lint colors." 65 | : "This may take a moment"; 66 | 67 | return ( 68 |
69 | {loadingColorData &&
} 70 |

{headingText}

71 |

{descriptionText}

72 | {!loadingColorData && ( 73 | 79 | )} 80 |
81 | ); 82 | }; 83 | 84 | const renderColorLintingSummary = () => { 85 | if (!loadingColorData && selectionMade) { 86 | return ( 87 |
88 |

89 | {colorsWithIssues.length - colorIssuesFixed} color issues found 90 |

91 |

92 | {colorsWithIssues.length - colorIssuesFixed > 0 ? ( 93 | <> 94 | To fix these issues, replace each of the colors listed below 95 | with a One Core color style.{` `} 96 | 100 | See color docs 101 | {" "} 102 | for more info. 103 | 104 | ) : ( 105 | <> 106 | There are no color issues with the selected layers :). To check 107 | for issues in other layers select another layer(s) and click 108 | "Re-scan colors". 109 | 110 | )} 111 |

112 |
113 | ); 114 | } else { 115 | return renderEmptyState(); 116 | } 117 | }; 118 | 119 | const noColorIssues = colorsWithIssues?.length === 0; 120 | 121 | const colorLinterContainerClasses = classNames("color-linter-container", { 122 | "summary-is-loading": loadingColorData, 123 | "summary-is-empty": noColorIssues, 124 | "no-selection-made": !selectionMade, 125 | }); 126 | 127 | return ( 128 | <> 129 |
130 | {renderColorLintingSummary()} 131 | {!loadingColorData && !noColorIssues && ( 132 |
    {renderColorIssues()}
133 | )} 134 |
135 | {!loadingColorData && selectionMade && ( 136 |
137 | 143 |
144 | )} 145 | 146 | ); 147 | }; 148 | 149 | export default ColorLinter; 150 | -------------------------------------------------------------------------------- /src/app/components/DesignLinter/ColorTile.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useContext, useEffect } from "react"; 3 | import { PluginContext } from "../PluginContext"; 4 | import chroma from "chroma-js"; 5 | import classNames from "classnames"; 6 | import iconLayerText from "../../assets/icon-layer-text.svg"; 7 | import iconLayerEllipse from "../../assets/icon-layer-ellipse.svg"; 8 | import iconLayerLine from "../../assets/icon-layer-line.svg"; 9 | import iconLayerPolygon from "../../assets/icon-layer-polygon.svg"; 10 | import iconLayerRectangle from "../../assets/icon-layer-rectangle.svg"; 11 | import iconLayerBooleanOperation from "../../assets/icon-layer-boolean-operation.svg"; 12 | import { Icon } from "react-figma-plugin-ds"; 13 | import { Check } from "react-feather"; 14 | import "react-figma-plugin-ds/figma-plugin-ds.css"; 15 | require("babel-polyfill"); 16 | 17 | import "../../styles/ui.css"; 18 | 19 | declare function require(path: string): any; 20 | 21 | interface colorData { 22 | colorId: string; 23 | layerId: string; 24 | layerName: string; 25 | colorType: string; 26 | colorInHex: string; 27 | layerType: string; 28 | } 29 | 30 | interface colorStyleData { 31 | colorStyleKey: string; 32 | colorStyleName: string; 33 | colorStyleColor: string; 34 | } 35 | 36 | interface props { 37 | colorData: colorData; 38 | ignoreColorIssue: (colorId) => void; 39 | } 40 | 41 | const ColorTile = (props: props) => { 42 | const { state } = useContext(PluginContext); 43 | const { 44 | colorTokens, 45 | activeColorTile, 46 | setActiveColorTile, 47 | colorIssuesFixed, 48 | setColorIssuesFixed, 49 | } = state; 50 | 51 | const { 52 | colorId, 53 | layerId, 54 | layerName, 55 | layerType, 56 | // colorStyleId, 57 | // hasColorStyle, 58 | // visible, 59 | colorType, 60 | colorInHex, 61 | } = props.colorData; 62 | 63 | const { ignoreColorIssue } = props; 64 | 65 | const [isExpanded, setIsExpanded] = useState(false); 66 | const [menuActive, setMenuActive] = useState(false); 67 | const [menuButtonHovered, setMenuButtonHovered] = useState(false); 68 | const [menuHovered, setMenuHovered] = useState(false); 69 | const [menuTimer, setMenuTimer] = useState(null); 70 | const [issueFixed, setIssueFixed] = useState(false); 71 | 72 | useEffect(() => { 73 | if (activeColorTile === colorId) { 74 | setIsExpanded(true); 75 | } else { 76 | setIsExpanded(false); 77 | } 78 | }, [activeColorTile]); 79 | 80 | const handleColorTileClick = (layerId: string) => { 81 | if (!isExpanded) { 82 | setActiveColorTile(colorId); 83 | } else { 84 | setActiveColorTile(""); 85 | } 86 | 87 | // Tell the controller to select and zoom into it 88 | parent.postMessage( 89 | { 90 | pluginMessage: { 91 | type: "select-layer", 92 | layerId: layerId, 93 | }, 94 | }, 95 | "*" 96 | ); 97 | 98 | setMenuActive(false); 99 | }; 100 | 101 | const truncateLayerName = ( 102 | layerName: string, 103 | length: number = 25 104 | ): string => { 105 | if (layerName.length > length) { 106 | return layerName.substring(0, length) + "..."; 107 | } 108 | 109 | return layerName; 110 | }; 111 | 112 | const renderTokenSuggestions = () => { 113 | if (colorTokens?.length > 0) { 114 | // Set `colorTokens` to colorTokensOutput minus the private tokens 115 | const filteredColorTokens = colorTokens.filter( 116 | (token) => !token.name.toLowerCase().includes("private") 117 | ); 118 | 119 | let relevantColorStyles = []; 120 | let mostRelevantColorStyles = []; 121 | 122 | if (layerType === "TEXT") { 123 | // text styles sorted reverse alphabetically 124 | relevantColorStyles = filteredColorTokens 125 | .filter((token) => token.name.toLowerCase().includes("text")) 126 | .sort((a, b) => { 127 | if (a.name.toLowerCase() > b.name.toLowerCase()) { 128 | return -1; 129 | } else { 130 | return 1; 131 | } 132 | }); 133 | } else if (colorType === "fill") { 134 | // background styles sorted reverse alphabetically 135 | relevantColorStyles = filteredColorTokens 136 | .filter((token) => token.name.toLowerCase().includes("background")) 137 | .sort((a, b) => { 138 | if (a.name.toLowerCase() > b.name.toLowerCase()) { 139 | return -1; 140 | } else { 141 | return 1; 142 | } 143 | }); 144 | } else if (colorType === "stroke") { 145 | // border styles sorted reverse alphabetically 146 | relevantColorStyles = filteredColorTokens 147 | .filter((token) => token.name.toLowerCase().includes("border")) 148 | .sort((a, b) => { 149 | if (a.name.toLowerCase() > b.name.toLowerCase()) { 150 | return -1; 151 | } else { 152 | return 1; 153 | } 154 | }); 155 | } 156 | 157 | // sort colors styles by proximity to source color 158 | mostRelevantColorStyles = relevantColorStyles 159 | .map((relevantColorStyle) => { 160 | if (relevantColorStyle.hex === "None") { 161 | return { 162 | ...relevantColorStyle, 163 | similarity: 0, 164 | }; 165 | } 166 | // Add a similarity property to each relevantColorStyle 167 | const oneCoreColor = relevantColorStyle.hex; 168 | const similarity: number = 169 | 100 - chroma.distance(oneCoreColor, colorInHex); 170 | 171 | return { 172 | ...relevantColorStyle, 173 | similarity, 174 | }; 175 | }) 176 | // sort them by proximity 177 | .sort((a, b) => { 178 | return b.similarity - a.similarity; 179 | }) 180 | // If there's a pair of light and dark mode tokens, remove one. 181 | // For example: If the color we're suggesting tokens for is #000 182 | // we may end up suggesting `Attention/Text/Text On Notification Inverted` 183 | // twice. Once for it's light mode version, and once for dark mode ( 184 | // since they have unequal but similar color values). This is undesirable. 185 | .filter((token) => { 186 | const isDarkModeToken = token.theme === "dark"; 187 | const isDuplicate = relevantColorStyles.some((relevantStyle) => { 188 | return ( 189 | relevantStyle.name.toLowerCase() === token.name.toLowerCase() 190 | ); 191 | }); 192 | 193 | return !(isDuplicate && isDarkModeToken); 194 | }) 195 | // Take the top 5 closest suggestions 196 | .slice(0, 4); 197 | 198 | return mostRelevantColorStyles.map((colorStyle, index) => { 199 | return ( 200 |
  • 204 | handleSuggestionFixClick(e, { 205 | colorStyleKey: colorStyle.key, 206 | colorStyleName: colorStyle.name, 207 | colorStyleColor: colorStyle.hex, 208 | }) 209 | } 210 | > 211 | 215 |
    216 |
    217 | {colorStyle.name} 218 |
    219 |

    220 | {colorStyle.description} 221 |

    222 |
    223 | 224 |
  • 225 | ); 226 | }); 227 | } 228 | }; 229 | 230 | const handleMenuClick = (e) => { 231 | e.stopPropagation(); 232 | setMenuActive(!menuActive); 233 | }; 234 | 235 | const handleBtnMenuMouseEnter = () => { 236 | setMenuButtonHovered(true); 237 | clearTimeout(menuTimer); 238 | }; 239 | 240 | const handleBtnMenuMouseLeave = () => { 241 | setMenuButtonHovered(false); 242 | 243 | setMenuTimer( 244 | setTimeout(() => { 245 | setMenuActive(false); 246 | }, 400) 247 | ); 248 | 249 | return () => clearTimeout(menuTimer); 250 | }; 251 | 252 | const handleMenuMouseEnter = () => { 253 | setMenuHovered(true); 254 | clearTimeout(menuTimer); 255 | }; 256 | 257 | const handleMenuMouseLeave = () => { 258 | setMenuHovered(false); 259 | 260 | setMenuTimer( 261 | setTimeout(() => { 262 | setMenuActive(false); 263 | }, 200) 264 | ); 265 | 266 | return () => clearTimeout(menuTimer); 267 | }; 268 | 269 | const handleIgnoreIssueClick = (e) => { 270 | e.stopPropagation(); 271 | ignoreColorIssue(colorId); 272 | setMenuActive(false); 273 | }; 274 | 275 | const handleSuggestionFixClick = (e, colorStyleData: colorStyleData) => { 276 | e.stopPropagation(); 277 | 278 | parent.postMessage( 279 | { 280 | pluginMessage: { 281 | type: "apply-color-style", 282 | colorType, 283 | layerId: layerId, 284 | originalColor: colorInHex, 285 | colorStyleKey: colorStyleData.colorStyleKey, 286 | colorStyleName: colorStyleData.colorStyleName, 287 | colorStyleColor: colorStyleData.colorStyleColor, 288 | }, 289 | }, 290 | "*" 291 | ); 292 | 293 | handleColorTileClick(layerId); 294 | setIssueFixed(true); 295 | !issueFixed && setColorIssuesFixed(colorIssuesFixed + 1); 296 | }; 297 | 298 | const layerTypeIcons = { 299 | TEXT: , 300 | ELLIPSE: , 301 | FRAME: , 302 | GROUP: , 303 | COMPONENT: , 304 | INSTANCE: , 305 | LINE: , 306 | POLYGON: , 307 | RECTANGLE: , 308 | SHAPE_WITH_TEXT: , 309 | STAR: , 310 | BOOLEAN_OPERATION: ( 311 | 312 | ), 313 | }; 314 | 315 | const colorTileHeadingText = () => { 316 | if (issueFixed) { 317 | return ( 318 | <> 319 | Fixed {` `} 320 | 321 | ); 322 | } else { 323 | return ( 324 | colorType.charAt(0).toUpperCase() + 325 | colorType.slice(1) + 326 | " needs One Core color style" 327 | ); 328 | } 329 | }; 330 | 331 | const colorTileContainerClasses = classNames("color-tile-container", { 332 | "menu-active": menuActive, 333 | "color-tile-hover-state-disabled": menuButtonHovered || menuHovered, 334 | expanded: isExpanded, 335 | "issue-fixed": issueFixed, 336 | }); 337 | 338 | return ( 339 |
  • handleColorTileClick(layerId)} 342 | > 343 |
    344 |
    345 |

    {colorTileHeadingText()}

    346 | 354 |
    handleMenuMouseEnter()} 357 | onMouseLeave={() => handleMenuMouseLeave()} 358 | > 359 |
      360 |
    • handleColorTileClick(layerId)} 363 | > 364 | Select layer 365 |
    • 366 |
    • handleIgnoreIssueClick(e)} 369 | > 370 | Ignore issue 371 |
    • 372 |
    373 |
    374 | 384 |
    385 |
    386 | 387 |
      388 |
    • 389 | 393 | {colorInHex} 394 |
    • 395 |
    • 396 | {layerTypeIcons[layerType]} 397 | 398 | {truncateLayerName(layerName)} 399 | 400 |
    • 401 |
    • Type: {colorType}
    • 402 |
    403 |
    404 | 405 |
    406 |
    407 |
    408 | Suggested color styles 409 |
    410 |
    411 | 412 |
      413 | {renderTokenSuggestions()} 414 |
    415 |
    416 |
  • 417 | ); 418 | }; 419 | 420 | export default ColorTile; 421 | -------------------------------------------------------------------------------- /src/app/components/DesignLinter/DesignLinter.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import "../../styles/ui.css"; 4 | import ColorLinter from "./ColorLinter"; 5 | import LanguageLinterPlugin from "./LanguageLinterPlugin"; 6 | import IconChevronLeft from "../../assets/icon-chevron-left.svg"; 7 | 8 | declare function require(path: string): any; 9 | 10 | interface props { 11 | setActivePlugin: (tab: string) => void; 12 | openTo: string; 13 | } 14 | 15 | const DesignLinter = (props: props) => { 16 | const { setActivePlugin, openTo } = props; 17 | const [activeTab, setActiveTab] = useState(openTo); 18 | 19 | const handleTabClick = (nameOfTab: string) => { 20 | const tabAsSingleWord = nameOfTab.split("-")[0]; 21 | 22 | setActiveTab(tabAsSingleWord); 23 | setActivePlugin(nameOfTab); 24 | }; 25 | 26 | // Render the nav tabs in the plugin UI 27 | const renderNavigationTabs = () => { 28 | const tabs: string[] = ["language-linter", "color-linter"]; 29 | 30 | // for each tab in the above array 31 | return tabs.map((tab, index) => { 32 | let tabClasses: string[] = ["tab-navigation-tab"]; 33 | let tabClassesOutput = tabClasses.join(" "); 34 | // create the label from the value of `tab` 35 | let tabLabel = 36 | tab.charAt(0).toUpperCase() + tab.split("-")[0].substring(1); 37 | 38 | // If it's the active tab, apply the class "active" to it 39 | if (activeTab + "-linter" === tab) { 40 | tabClasses.push("active"); 41 | tabClassesOutput = tabClasses.join(" "); 42 | } 43 | 44 | if (tabLabel === "Language") { 45 | tabLabel = "Language"; 46 | tabClassesOutput = `${tabClassesOutput} language-tab`; 47 | } 48 | 49 | return ( 50 |
  • handleTabClick(tab)} 53 | key={index} 54 | > 55 | {tabLabel} {tabLabel === "Language"} 56 |
  • 57 | ); 58 | }); 59 | }; 60 | 61 | const renderDesignLinter = () => { 62 | switch (activeTab) { 63 | case "language": 64 | return ; 65 | case "color": 66 | return ; 67 | } 68 | }; 69 | 70 | return ( 71 | <> 72 |
      73 |
    • setActivePlugin("home")} 76 | > 77 | back button 78 |
    • 79 | {renderNavigationTabs()} 80 |
    81 | {renderDesignLinter()} 82 | 83 | ); 84 | }; 85 | 86 | export default DesignLinter; 87 | -------------------------------------------------------------------------------- /src/app/components/DesignLinter/LanguageLinterPlugin.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useEffect, useContext } from "react"; 3 | import classNames from "classnames"; 4 | import LanguageLinter, { lintMyText } from "new-relic-language-linter"; 5 | import { PluginContext } from "../PluginContext"; 6 | import { truncateLayerName } from "../utils"; 7 | require("babel-polyfill"); 8 | import "../../styles/ui.css"; 9 | 10 | declare function require(path: string): any; 11 | 12 | const LanguageLinterPlugin = () => { 13 | const [textLayersWithSuggestions, setTextLayersWithSuggestions] = useState( 14 | [] 15 | ); 16 | const [sampleText, setSampleText] = useState(""); 17 | const [maxTextIndex, setMaxTextIndex] = useState(0); 18 | const [selectionHasChanged, setSelectionHasChanged] = useState(false); 19 | 20 | const { state } = useContext(PluginContext); 21 | const { 22 | selectedTextLayers, 23 | sampleTextIndex, 24 | setSampleTextIndex, 25 | localCustomDictionary, 26 | localCustomDictionaryInitialized, 27 | currentSelection, 28 | currentLayersLintedForLanguage, 29 | } = state; 30 | 31 | const [linterIsLoading, setLinterIsLoading] = useState( 32 | currentSelection.length > 0 33 | ); 34 | 35 | const emptyStateActive = 36 | selectedTextLayers.length === 0 || textLayersWithSuggestions.length === 0; 37 | 38 | // Get the selected layer when the plugin loads 39 | // This is how we read messages sent from the plugin controller 40 | useEffect(() => { 41 | parent.postMessage( 42 | { pluginMessage: { type: "request-local-custom-dictionary" } }, 43 | "*" 44 | ); 45 | setSelectionHasChanged(false); 46 | parent.postMessage({ pluginMessage: { type: "run-language-linter" } }, "*"); 47 | }, []); 48 | 49 | // when `selectedTextLayers` is updated update the sample text 50 | useEffect(() => { 51 | if (textLayersWithSuggestions.length > 0) { 52 | setSampleText(textLayersWithSuggestions[0].characters); 53 | setMaxTextIndex(textLayersWithSuggestions.length); 54 | } else { 55 | setSampleText(""); 56 | setMaxTextIndex(0); 57 | } 58 | }, [textLayersWithSuggestions]); 59 | 60 | // Every time the `sampleText` gets updated so that we can 61 | // scroll and zoom the active layer into the center of the screen 62 | useEffect(() => { 63 | if (sampleText !== "") { 64 | parent.postMessage( 65 | { 66 | pluginMessage: { 67 | type: "sample-text-changed", 68 | activeNodeId: textLayersWithSuggestions[sampleTextIndex].id, 69 | }, 70 | }, 71 | "*" 72 | ); 73 | } 74 | }, [sampleTextIndex, sampleText]); 75 | 76 | useEffect(() => { 77 | if (localCustomDictionaryInitialized && selectedTextLayers.length > 0) { 78 | getTextLayersWithSuggestions(); 79 | } else if ( 80 | localCustomDictionaryInitialized && 81 | selectedTextLayers.length === 0 82 | ) { 83 | setSampleText(""); 84 | setTextLayersWithSuggestions([]); 85 | setLinterIsLoading(false); 86 | } else { 87 | parent.postMessage( 88 | { pluginMessage: { type: "request-local-custom-dictionary" } }, 89 | "*" 90 | ); 91 | } 92 | }, [selectedTextLayers, localCustomDictionary]); 93 | 94 | // Determine whether the the selection has changed. 95 | // Then save that to state. 96 | useEffect(() => { 97 | let selectionIsCompletelyNew = true; 98 | 99 | if (currentLayersLintedForLanguage && currentSelection.length) { 100 | // Do any of the currently selected layers match any of the 101 | // previously selected layers? Because we don't want to show 102 | // the "check current selection" button if the only selection 103 | // change has been selecting one of the already selected layers 104 | selectionIsCompletelyNew = !selectedTextLayers.some( 105 | (selectedTextLayer) => { 106 | return selectedTextLayer.id === currentSelection[0].id; 107 | } 108 | ); 109 | 110 | // Because if we've *added* to the selection, we should allow 111 | // a recheck check of the seleciton 112 | if (!selectionIsCompletelyNew) { 113 | selectionIsCompletelyNew = 114 | currentSelection.length > selectedTextLayers.length; 115 | } 116 | } else { 117 | setSelectionHasChanged(false); 118 | return; 119 | } 120 | 121 | if (selectionIsCompletelyNew) { 122 | setSelectionHasChanged(true); 123 | return; 124 | } else { 125 | setSelectionHasChanged(false); 126 | } 127 | }, [currentSelection, currentLayersLintedForLanguage]); 128 | 129 | const lintSelectedLayers = () => { 130 | setSelectionHasChanged(false); 131 | parent.postMessage({ pluginMessage: { type: "run-language-linter" } }, "*"); 132 | }; 133 | 134 | const updateSourceText = (updatedText) => { 135 | parent.postMessage( 136 | { 137 | pluginMessage: { 138 | type: "update-source-text", 139 | layerId: textLayersWithSuggestions[sampleTextIndex].id, 140 | updatedText: updatedText, 141 | }, 142 | }, 143 | "*" 144 | ); 145 | 146 | setSampleText(updatedText); 147 | }; 148 | 149 | const handleTextLayerNavigation = (indexOfNewLayer: number) => { 150 | setSampleTextIndex(indexOfNewLayer); 151 | setSampleText(textLayersWithSuggestions[indexOfNewLayer].characters); 152 | }; 153 | 154 | const asyncFilter = async (arr, callback) => { 155 | const fail = Symbol(); 156 | return ( 157 | await Promise.all( 158 | arr.map(async (item) => ((await callback(item)) ? item : fail)) 159 | ) 160 | ).filter((i) => i !== fail); 161 | }; 162 | 163 | const getTextLayersWithSuggestions = async () => { 164 | const layersWithSuggestions = await asyncFilter( 165 | selectedTextLayers, 166 | async (item) => { 167 | let report: any = ""; 168 | 169 | await Promise.resolve( 170 | lintMyText(item.characters, localCustomDictionary) 171 | ).then((result) => (report = result)); 172 | 173 | if (report.messages.length > 0) { 174 | const reportWithEnumerableProps = JSON.parse( 175 | JSON.stringify(report.messages) 176 | ); 177 | 178 | const minimalReport = reportWithEnumerableProps.map((message) => ({ 179 | actual: message.actual, 180 | expected: message.expected.slice(0, 3), 181 | rule: message.source, 182 | })); 183 | 184 | parent.postMessage( 185 | { 186 | pluginMessage: { 187 | type: "text-linted", 188 | minimalReport, 189 | fullReport: reportWithEnumerableProps, 190 | }, 191 | }, 192 | "*" 193 | ); 194 | } 195 | return !!report.messages[0]?.message; 196 | } 197 | ); 198 | 199 | setTextLayersWithSuggestions(layersWithSuggestions); 200 | }; 201 | 202 | // @ts-ignore 203 | const addToDictionary = (wordToAdd, suggestionId) => { 204 | parent.postMessage( 205 | { 206 | pluginMessage: { 207 | type: "add-to-dictionary", 208 | wordToAdd: wordToAdd, 209 | }, 210 | }, 211 | "*" 212 | ); 213 | }; 214 | 215 | const provideSampleText = () => { 216 | const layerIsSelected = selectedTextLayers.length > 0; 217 | const suggestionsAvailable = textLayersWithSuggestions.length > 0; 218 | 219 | if (!layerIsSelected) { 220 | return ""; 221 | } else if (layerIsSelected && !suggestionsAvailable) { 222 | // LanguageLinter will show a "no issue found" empty state 223 | // if their sampleText provided to it has no issues. Since 224 | // None of our layers have issues, we'll trigger that 225 | // empty state. 226 | return "Hello, World"; 227 | } else { 228 | return sampleText; 229 | } 230 | }; 231 | 232 | const languageLinterContainerClasses = classNames( 233 | "language-linter-container", 234 | { 235 | "selection-has-changed": selectionHasChanged && !emptyStateActive, 236 | "empty-state-active": emptyStateActive, 237 | "linter-is-loading": linterIsLoading, 238 | } 239 | ); 240 | 241 | const renderEmptyState = () => { 242 | const noSuggestionsFound = textLayersWithSuggestions.length === 0; 243 | const shouldShowDefaultCopy = 244 | noSuggestionsFound && selectedTextLayers.length === 0; 245 | 246 | const headingText = shouldShowDefaultCopy 247 | ? "Select a text layer(s) to get started" 248 | : "Your copy looks good!"; 249 | const descriptionText = () => { 250 | if (shouldShowDefaultCopy) { 251 | return `Select one or more text layers to lint them against the One Core writing style guide.`; 252 | } else { 253 | return ( 254 | <> 255 | No basic language issues found. Consider reaching out to the {` `} 256 | 260 | #ui-writing 261 | {" "} 262 | team for more in-depth and accurate feedback from our wonderful UI 263 | writers. 264 | 265 | ); 266 | } 267 | }; 268 | const CTAText = "Check language"; 269 | 270 | return ( 271 |
    272 |

    {headingText}

    273 |

    {descriptionText()}

    274 | 280 | 281 | 286 | Try the browser version{" "} 287 | 295 | 301 | 302 | 303 |
    304 | ); 305 | }; 306 | 307 | const renderLoadingState = () => { 308 | return ( 309 |
    310 |
    311 |

    312 | Linting selected text layers... 313 |

    314 |

    This may take a moment

    315 |
    316 | ); 317 | }; 318 | 319 | const renderLanguageLinterUI = () => { 320 | const textLayerSelected = selectedTextLayers.length > 0; 321 | 322 | if (!textLayerSelected && !linterIsLoading) return renderEmptyState(); 323 | if (textLayersWithSuggestions.length === 0 && !linterIsLoading) 324 | return renderEmptyState(); 325 | 326 | const languageLinterUI = () => ( 327 | 340 | ); 341 | const layerNavigationUI = () => ( 342 | 385 | ); 386 | 387 | if (textLayersWithSuggestions) { 388 | return ( 389 | <> 390 | {textLayersWithSuggestions.length > 1 && 391 | !linterIsLoading && 392 | layerNavigationUI()}{" "} 393 | {languageLinterUI()} 394 | 395 | ); 396 | } else { 397 | return languageLinterUI(); 398 | } 399 | }; 400 | 401 | return ( 402 | <> 403 |
    404 | {linterIsLoading && renderLoadingState()} 405 | {renderLanguageLinterUI()} 406 | 407 | {selectionHasChanged && !emptyStateActive && ( 408 | 438 | )} 439 |
    440 | 441 | ); 442 | }; 443 | 444 | export default LanguageLinterPlugin; 445 | -------------------------------------------------------------------------------- /src/app/components/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import "../styles/ui.css"; 3 | 4 | const Home = (props) => { 5 | const { setActivePlugin } = props; 6 | 7 | const PluginTile = (props) => { 8 | const { heading, description, onClick, pluginName } = props; 9 | 10 | const tileClasses = { 11 | tableCreator: "table-creator", 12 | themeSwitcher: "theme-switcher", 13 | designLinter: "design-linter", 14 | }; 15 | 16 | return ( 17 |
    onClick()} 20 | > 21 | 22 |
    23 |

    {heading}

    24 |

    {description}

    25 |
    26 |
    27 | ); 28 | }; 29 | 30 | return ( 31 |
    32 | setActivePlugin("table-creator")} 37 | /> 38 | setActivePlugin("theme-switcher")} 43 | /> 44 | setActivePlugin("language-linter")} 49 | /> 50 | 51 |

    52 | Report a bug, share a feature idea, or get support at{" "} 53 | #help-one-core. 54 |

    55 |
    56 | ); 57 | }; 58 | 59 | export default Home; 60 | -------------------------------------------------------------------------------- /src/app/components/PluginContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | export const PluginContext = createContext(null); 4 | -------------------------------------------------------------------------------- /src/app/components/Resizer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const Resizer: React.FunctionComponent = () => { 4 | const resizerRef = React.useRef(null); 5 | const [pressed, setPressed] = React.useState(false); 6 | 7 | React.useEffect(() => { 8 | const handlePointerDown = (e: PointerEvent) => { 9 | resizerRef.current.setPointerCapture(e.pointerId); 10 | setPressed(true); 11 | }; 12 | 13 | const handlePointerUp = (e: PointerEvent) => { 14 | resizerRef.current.releasePointerCapture(e.pointerId); 15 | setPressed(false); 16 | }; 17 | 18 | const handleMouseMove = (e: MouseEvent) => { 19 | if (pressed) { 20 | parent.postMessage( 21 | { 22 | pluginMessage: { 23 | type: "resize", 24 | size: { x: e.clientX, y: e.clientY }, 25 | }, 26 | }, 27 | "*" 28 | ); 29 | } 30 | }; 31 | 32 | resizerRef.current.addEventListener("pointerdown", handlePointerDown); 33 | resizerRef.current.addEventListener("pointerup", handlePointerUp); 34 | resizerRef.current.addEventListener("mousemove", handleMouseMove); 35 | 36 | return () => { 37 | resizerRef.current.removeEventListener("pointerdown", handlePointerDown); 38 | resizerRef.current.removeEventListener("pointerup", handlePointerUp); 39 | resizerRef.current.removeEventListener("mousemove", handleMouseMove); 40 | }; 41 | }, [pressed]); 42 | 43 | return
    ; 44 | }; 45 | 46 | export default Resizer; 47 | -------------------------------------------------------------------------------- /src/app/components/TableCreator/ColumnConfiguration.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import "../../styles/ui.css"; 4 | 5 | declare function require(path: string): any; 6 | 7 | const ColumnConfiguration = ({ 8 | createTable, 9 | activeCol, 10 | isMultiValue, 11 | columnConfiguration, 12 | setColumnConfiguration, 13 | }) => { 14 | const [activeColConfigurationScreen, setActiveColConfigurationScreen] = 15 | useState(0); 16 | 17 | const handleColumnConfigurationUpdate = (attr) => { 18 | let columnConfigurationArray = [...columnConfiguration]; 19 | const element = event.target as HTMLInputElement; 20 | 21 | columnConfigurationArray[activeColConfigurationScreen][attr] = 22 | attr !== "multiValue" ? element.value : element.checked; 23 | 24 | columnConfigurationArray[activeColConfigurationScreen][attr] = 25 | attr !== "sortControls" ? element.value : element.checked; 26 | 27 | if ( 28 | columnConfigurationArray[activeColConfigurationScreen][attr] === "metric" 29 | ) { 30 | columnConfigurationArray[activeColConfigurationScreen]["alignment"] = 31 | "right"; 32 | } 33 | 34 | setColumnConfiguration(columnConfigurationArray); 35 | }; 36 | 37 | const handleNavButtonClick = (direction) => { 38 | if (direction === "previous" && activeColConfigurationScreen !== 0) { 39 | setActiveColConfigurationScreen(activeColConfigurationScreen - 1); 40 | } else if ( 41 | direction === "next" && 42 | activeColConfigurationScreen !== columnConfiguration.length - 1 43 | ) { 44 | setActiveColConfigurationScreen(activeColConfigurationScreen + 1); 45 | } 46 | }; 47 | 48 | const renderColumnNavigation = () => { 49 | const handleColumnOptionSelection = (e) => { 50 | const selectedOption = e.target.options[e.target.options.selectedIndex]; 51 | 52 | setActiveColConfigurationScreen(parseInt(selectedOption.value) - 1); 53 | }; 54 | 55 | return ( 56 | 92 | ); 93 | }; 94 | 95 | const renderConfigurationBody = () => { 96 | const multiValueDisabled = () => { 97 | const currentCellType = 98 | columnConfiguration[activeColConfigurationScreen]["cellType"]; 99 | if ( 100 | currentCellType === "Text" || 101 | currentCellType === "link" || 102 | currentCellType === "metric" || 103 | currentCellType === "entity" 104 | ) { 105 | return false; 106 | } else { 107 | return true; 108 | } 109 | }; 110 | 111 | const cellTypeIsMetric = (() => { 112 | return ( 113 | columnConfiguration[activeColConfigurationScreen]["cellType"] === 114 | "metric" 115 | ); 116 | })(); 117 | 118 | const cellTypeIsFavorite = (() => { 119 | return ( 120 | columnConfiguration[activeColConfigurationScreen]["cellType"] === 121 | "favorite" 122 | ); 123 | })(); 124 | 125 | const cellTypeIsAction = (() => { 126 | return ( 127 | columnConfiguration[activeColConfigurationScreen]["cellType"] === 128 | "actions" 129 | ); 130 | })(); 131 | 132 | const colShouldHaveHeaderText = (() => { 133 | if (cellTypeIsAction || cellTypeIsFavorite) return false; 134 | 135 | return true; 136 | })(); 137 | 138 | return ( 139 |
    140 | {renderColumnNavigation()} 141 | 142 |
    143 |

    Cell formatting

    144 |
    Choose the properties for the cells in this column
    145 |
    146 | 147 |
    152 | 153 | handleColumnConfigurationUpdate("name")} 160 | value={columnConfiguration[activeColConfigurationScreen]["name"]} 161 | disabled={!colShouldHaveHeaderText} 162 | /> 163 |
    164 |
    165 | 166 |
    167 | 228 |
    229 |
    230 |
    235 | 236 |
    237 | 260 |
    261 |
    262 | {isMultiValue && ( 263 |
    268 | 269 | handleColumnConfigurationUpdate("multiValue")} 273 | checked={ 274 | !multiValueDisabled() 275 | ? columnConfiguration[activeColConfigurationScreen][ 276 | "multiValue" 277 | ] 278 | : false 279 | } 280 | disabled={multiValueDisabled() || !colShouldHaveHeaderText} 281 | /> 282 |
    283 | )} 284 |
    289 | 290 | handleColumnConfigurationUpdate("sortControls")} 294 | checked={ 295 | !multiValueDisabled() 296 | ? columnConfiguration[activeColConfigurationScreen][ 297 | "sortControls" 298 | ] 299 | : false 300 | } 301 | disabled={multiValueDisabled() || !colShouldHaveHeaderText} 302 | /> 303 |
    304 |
    305 | ); 306 | }; 307 | 308 | const renderCallsToAction = () => { 309 | return ( 310 |
    311 | 317 |
    318 | ); 319 | }; 320 | 321 | return ( 322 | <> 323 |
    324 | {renderConfigurationBody()} 325 | {renderCallsToAction()} 326 |
    327 | 328 | ); 329 | }; 330 | 331 | export default ColumnConfiguration; 332 | -------------------------------------------------------------------------------- /src/app/components/TableCreator/DimensionsSelection.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import "../../styles/ui.css"; 4 | 5 | declare function require(path: string): any; 6 | 7 | const DimensionsSelection = ({ 8 | handleGridSquareClick, 9 | activeCol, 10 | activeRow, 11 | isMultiValue, 12 | setIsMultiValue, 13 | handeGridSelectionInputs, 14 | goToColumnConfiguration, 15 | }) => { 16 | const [hoveredCol, setHoveredCol] = useState(0); 17 | const [hoveredRow, setHoveredRow] = useState(0); 18 | const [tableHovered, setTableHovered] = useState(false); 19 | 20 | const handleGridSquareMouseEnter = (colIndex, rowIndex) => { 21 | setHoveredCol(colIndex); 22 | setHoveredRow(rowIndex); 23 | }; 24 | 25 | const handleMultiValueInput = (e: React.ChangeEvent) => { 26 | const isChecked = e.target.checked; 27 | 28 | setIsMultiValue(isChecked); 29 | }; 30 | 31 | const renderTable = () => { 32 | return ( 33 | setTableHovered(true)} 36 | onMouseLeave={() => setTableHovered(false)} 37 | > 38 | 39 | {[...Array(8).keys()].map((rowIndex) => { 40 | return ( 41 | 42 | {[...Array(8).keys()].map((colIndex) => { 43 | return ( 44 | 73 | ); 74 | })} 75 | 76 | ); 77 | })} 78 | 79 |
    45 | 50 | 72 |
    80 | ); 81 | }; 82 | 83 | return ( 84 |
    85 |
    86 |
    87 | 88 | handeGridSelectionInputs("col")} 96 | value={activeCol === 0 && activeRow === 0 ? "" : activeCol} 97 | placeholder={ 98 | tableHovered ? (hoveredCol + 1).toString() : "(24 max)" 99 | } 100 | /> 101 |
    102 |
    103 | 104 | handeGridSelectionInputs("row")} 112 | value={activeRow === 0 && activeCol === 0 ? "" : activeRow} 113 | placeholder={ 114 | tableHovered ? (hoveredRow + 1).toString() : "(100 max)" 115 | } 116 | /> 117 |
    118 |
    119 | 120 | {renderTable()} 121 | 122 |
    123 |
    124 | handleMultiValueInput(e)} 132 | /> 133 | 134 |
    135 | 136 | 139 |
    140 |
    141 | 148 |
    149 |
    150 | ); 151 | }; 152 | 153 | export default DimensionsSelection; 154 | -------------------------------------------------------------------------------- /src/app/components/TableCreator/TableCreator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState, useEffect } from "react"; 3 | import "../../styles/ui.css"; 4 | import ColumnConfiguration from "./ColumnConfiguration"; 5 | import DimensionsSelection from "./DimensionsSelection"; 6 | import IconChevronLeft from "../../assets/icon-chevron-left.svg"; 7 | 8 | declare function require(path: string): any; 9 | 10 | const TableCreator = (props) => { 11 | const { setActivePlugin } = props; 12 | 13 | const [activeTab] = useState("table-creator"); 14 | const [activeScreen, setActiveScreen] = useState("DimensionsSelection"); 15 | const [activeCol, setActiveCol] = useState(0); 16 | const [activeRow, setActiveRow] = useState(0); 17 | const [isMultiValue, setIsMultiValue] = useState(false); 18 | const [columnConfiguration, setColumnConfiguration] = useState([]); 19 | 20 | // Because the back button behavior should take you 1 step back 21 | // not always to the same place 22 | const handleBackButtonClick = () => { 23 | if (activeScreen === "DimensionsSelection") { 24 | setActivePlugin("home"); 25 | } else if (activeScreen === "ColumnConfiguration") { 26 | goToDimensionsSelection(); 27 | } 28 | }; 29 | 30 | const handleColumnConfiguration = () => { 31 | let columnConfigurationArray = []; 32 | 33 | if (columnConfiguration.length === 0) { 34 | [...Array(activeCol).keys()].map(() => { 35 | columnConfigurationArray.push({ 36 | name: "", 37 | alignment: "Left", 38 | cellType: "Text", 39 | multiValue: false, 40 | sortControls: true, 41 | }); 42 | }); 43 | } else if (columnConfiguration.length > 0) { 44 | columnConfigurationArray = columnConfiguration; 45 | 46 | if (columnConfigurationArray.length > activeCol) { 47 | const columnsToRemove = columnConfigurationArray.length - activeCol; 48 | [...Array(columnsToRemove).keys()].map(() => { 49 | columnConfigurationArray.pop(); 50 | }); 51 | } else { 52 | const columnsToAdd = activeCol - columnConfigurationArray.length; 53 | [...Array(columnsToAdd).keys()].map(() => { 54 | columnConfigurationArray.push({ 55 | name: "", 56 | alignment: "Left", 57 | cellType: "Text", 58 | multiValue: false, 59 | sortControls: true, 60 | }); 61 | }); 62 | } 63 | } 64 | 65 | setColumnConfiguration(columnConfigurationArray); 66 | }; 67 | 68 | useEffect(handleColumnConfiguration, [activeCol]); 69 | 70 | const handeGridSelectionInputs = (type) => { 71 | const element = event.target as HTMLInputElement; 72 | let { value, min, max } = element; 73 | const calculatedValue = Math.max( 74 | Number(min), 75 | Math.min(Number(max), Number(value)) 76 | ); 77 | 78 | if (type === "col") { 79 | setActiveCol(calculatedValue); 80 | } else if (type === "row") { 81 | setActiveRow(calculatedValue); 82 | } 83 | }; 84 | 85 | const handleGridSquareClick = (colIndex, rowIndex) => { 86 | setActiveCol(colIndex + 1); 87 | setActiveRow(rowIndex + 1); 88 | }; 89 | 90 | const createTable = () => { 91 | const hasNoMultiValueCols = !columnConfiguration.some( 92 | (col) => col.multiValue 93 | ); 94 | 95 | // Because you shouldn't be able to have a multi-value table with no 96 | // multi-value columns. 97 | if (isMultiValue && hasNoMultiValueCols) { 98 | parent.postMessage( 99 | { 100 | pluginMessage: { 101 | type: "display-error", 102 | content: `You must check enable "Show multi-value" on at least one column.`, 103 | }, 104 | }, 105 | "*" 106 | ); 107 | 108 | return; 109 | } 110 | 111 | parent.postMessage( 112 | { 113 | pluginMessage: { 114 | type: "create-table", 115 | cols: activeCol, 116 | rows: activeRow, 117 | isMultiValue: isMultiValue, 118 | columnConfiguration: columnConfiguration, 119 | }, 120 | }, 121 | "*" 122 | ); 123 | 124 | parent.postMessage({ pluginMessage: { type: "creation-feedback" } }, "*"); 125 | }; 126 | 127 | const goToColumnConfiguration = () => { 128 | setActiveScreen("ColumnConfiguration"); 129 | }; 130 | 131 | const goToDimensionsSelection = () => { 132 | setActiveScreen("DimensionsSelection"); 133 | }; 134 | 135 | const renderTableCreator = () => { 136 | if (activeScreen === "DimensionsSelection") { 137 | return ( 138 | 147 | ); 148 | } else if (activeScreen === "ColumnConfiguration") { 149 | return ( 150 | 157 | ); 158 | } 159 | }; 160 | 161 | // Render the nav tabs in the plug UI 162 | const renderNavigationTabs = () => { 163 | const tabs: string[] = ["table-creator"]; 164 | 165 | // for each tab in the above array 166 | return tabs.map((tab, index) => { 167 | let tabClasses: string[] = ["tab-navigation-tab"]; 168 | let tabClassesOutput = tabClasses.join(" "); 169 | // create the label from the value of `tab` 170 | let tabLabel = 171 | tab.charAt(0).toUpperCase() + tab.split("-").join(" ").substring(1); 172 | 173 | // If it's the active tab, apply the class "active" to it 174 | if (activeTab === tab) { 175 | tabClasses.push("active"); 176 | tabClassesOutput = tabClasses.join(" "); 177 | } 178 | 179 | return ( 180 |
  • handleNavigationTabClick(tab)} 183 | key={index} 184 | > 185 | {tabLabel} 186 |
  • 187 | ); 188 | }); 189 | }; 190 | 191 | return ( 192 | <> 193 |
      194 |
    • handleBackButtonClick()} 197 | > 198 | back button 199 |
    • 200 | {renderNavigationTabs()} 201 |
    202 | {renderTableCreator()} 203 | 204 | ); 205 | }; 206 | 207 | export default TableCreator; 208 | -------------------------------------------------------------------------------- /src/app/components/ThemeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useContext, useEffect, useRef, useState } from "react"; 3 | import { PluginContext } from "./PluginContext"; 4 | import "../styles/ui.css"; 5 | import IconChevronLeft from "../assets/icon-chevron-left.svg"; 6 | import LightModeThumbnail from "../assets/light-mode-thumbnail.svg"; 7 | import DarkModeThumbnail from "../assets/dark-mode-thumbnail.svg"; 8 | 9 | declare function require(path: string): any; 10 | 11 | interface props { 12 | setActivePlugin: (tab: string) => void; 13 | } 14 | 15 | const ThemeSwitcher = (props: props) => { 16 | const { state } = useContext(PluginContext); 17 | const { currentSelection, loadingLightSwitch, loadingDarkSwitch } = state; 18 | const [localLoadingLightSwitch, setLocalLoadingLightSwitch] = useState(false); 19 | const [localLoadingDarkSwitch, setLocalLoadingDarkSwitch] = useState(false); 20 | 21 | const { setActivePlugin } = props; 22 | const lightModeRadioOption = useRef(null); 23 | const darkModeRadioOption = useRef(null); 24 | 25 | useEffect(() => { 26 | if (currentSelection?.length > []) { 27 | lightModeRadioOption.current.checked = false; 28 | darkModeRadioOption.current.checked = false; 29 | } 30 | }, [currentSelection]); 31 | 32 | useEffect(() => { 33 | loadingLightSwitch === false && setLocalLoadingLightSwitch(false); 34 | }, [loadingLightSwitch]); 35 | 36 | useEffect(() => { 37 | loadingDarkSwitch === false && setLocalLoadingDarkSwitch(false); 38 | }, [loadingDarkSwitch]); 39 | 40 | const handleTabClick = (nameOfTab: string) => { 41 | setActivePlugin(nameOfTab); 42 | }; 43 | 44 | // Render the nav tabs in the plugin UI 45 | const renderNavigationTabs = () => { 46 | const tabs: string[] = ["theme-switcher"]; 47 | 48 | // for each tab in the above array 49 | return tabs.map((tab, index) => { 50 | let tabClasses: string[] = ["tab-navigation-tab", "active"]; 51 | let tabClassesOutput = tabClasses.join(" "); 52 | 53 | return ( 54 |
  • handleTabClick(tab)} 57 | key={index} 58 | > 59 | Theme switcher 60 |
  • 61 | ); 62 | }); 63 | }; 64 | 65 | const setDarkTheme = () => { 66 | setLocalLoadingDarkSwitch(true); 67 | parent.postMessage( 68 | { pluginMessage: { type: "theme-switcher-to-dark" } }, 69 | "*" 70 | ); 71 | }; 72 | 73 | const setLightTheme = () => { 74 | setLocalLoadingLightSwitch(true); 75 | parent.postMessage( 76 | { pluginMessage: { type: "theme-switcher-to-light" } }, 77 | "*" 78 | ); 79 | }; 80 | 81 | return ( 82 | <> 83 |
      84 |
    • setActivePlugin("home")} 87 | > 88 | back button 89 |
    • 90 | {renderNavigationTabs()} 91 |
    92 |
    93 |

    Choose a theme

    94 |

    95 | Select some layers, then chose a theme below. 96 |

    97 |
    98 |
    99 | 106 | 122 |
    123 |
    124 | 131 | 147 |
    148 |
    149 |
    150 | 151 | ); 152 | }; 153 | 154 | export default ThemeSwitcher; 155 | -------------------------------------------------------------------------------- /src/app/components/utils.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Check if two objects or arrays are equal 3 | * (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com 4 | * @param {*} obj1 The first item 5 | * @param {*} obj2 The second item 6 | * @return {Boolean} Returns true if they're equal in value 7 | */ 8 | const isEqual = (obj1, obj2) => { 9 | /** 10 | * More accurately check the type of a JavaScript object 11 | * @param {Object} obj The object 12 | * @return {String} The object type 13 | */ 14 | function getType(obj) { 15 | return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); 16 | } 17 | 18 | function areArraysEqual() { 19 | // Check length 20 | if (obj1.length !== obj2.length) return false; 21 | 22 | // Check each item in the array 23 | for (let i = 0; i < obj1.length; i++) { 24 | if (!isEqual(obj1[i], obj2[i])) return false; 25 | } 26 | 27 | // If no errors, return true 28 | return true; 29 | } 30 | 31 | function areObjectsEqual() { 32 | if (Object.keys(obj1).length !== Object.keys(obj2).length) return false; 33 | 34 | // Check each item in the object 35 | for (let key in obj1) { 36 | if (Object.prototype.hasOwnProperty.call(obj1, key)) { 37 | if (!isEqual(obj1[key], obj2[key])) return false; 38 | } 39 | } 40 | 41 | // If no errors, return true 42 | return true; 43 | } 44 | 45 | function areFunctionsEqual() { 46 | return obj1.toString() === obj2.toString(); 47 | } 48 | 49 | function arePrimativesEqual() { 50 | return obj1 === obj2; 51 | } 52 | 53 | // Get the object type 54 | let type = getType(obj1); 55 | 56 | // If the two items are not the same type, return false 57 | if (type !== getType(obj2)) return false; 58 | 59 | // Compare based on type 60 | if (type === "array") return areArraysEqual(); 61 | if (type === "object") return areObjectsEqual(); 62 | if (type === "function") return areFunctionsEqual(); 63 | return arePrimativesEqual(); 64 | }; 65 | 66 | /*! 67 | * Get the name of a layer with a maximum length. Trucate it if necessary 68 | * @param {string} layerName The name of the layer 69 | * @param {number} maxLength The maximum lenght of the string to be retunred 70 | * @return {string} Returns the layer name truncated if necessary 71 | */ 72 | const truncateLayerName = ( 73 | layerName: string, 74 | maxLength: number = 25 75 | ): string => { 76 | if (layerName.length > maxLength) { 77 | return layerName.substring(0, maxLength) + "..."; 78 | } 79 | 80 | return layerName; 81 | }; 82 | 83 | /*! 84 | * Capitalize the first letter of the value provided 85 | * @param {string|boolean} valueToConvert The name of the layer 86 | * @return {string} The value with the first letter capitalized 87 | */ 88 | const toCapitalizedString = (valueToConvert: boolean | string): string => { 89 | let outputValue = valueToConvert.toString(); 90 | outputValue = outputValue[0].toUpperCase() + outputValue.substring(1); 91 | 92 | return outputValue; 93 | }; 94 | 95 | /*! 96 | * Convert a Figma RGB value to a hex value 97 | * @param {number} r The red value 98 | * @param {number} g The green value 99 | * @param {number} b The blue value 100 | * @return {string} A hex value equivalent to the rgb value provided 101 | */ 102 | const rgbToHex = (r: number, g: number, b: number): string => { 103 | /*! 104 | * Convert an color value to a 2 character hex value 105 | * @param {number} colorValue The color value 106 | * @return {string} A 2 character hex value 107 | */ 108 | const componentToHex = (colorValue: number): string => { 109 | colorValue = Math.round(colorValue * 255); 110 | let hex = colorValue.toString(16); 111 | return hex.length === 1 ? "0" + hex : hex; 112 | }; 113 | 114 | /*! 115 | * Combines the two character hex values into a full hex color 116 | * @param {number} r The red value 117 | * @param {number} g The green value 118 | * @param {number} b The blue value 119 | * @return {string} A hex value equivalent to the rgb value provided 120 | */ 121 | const combineComponents = (r: number, g: number, b: number): string => { 122 | return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b); 123 | }; 124 | 125 | return combineComponents(r, g, b); 126 | }; 127 | 128 | /*! 129 | * Selects a layer in Figma and zooms into it at 100% 130 | * @param {string} layerId The layerId of the layer to be selected and zoomed 131 | * @return {undefined} No value is returned 132 | */ 133 | const selectAndZoomToLayer = (layerId: string) => { 134 | const layer: SceneNode = figma.getNodeById(layerId) as SceneNode; 135 | 136 | figma.currentPage.selection = [layer]; 137 | figma.viewport.scrollAndZoomIntoView([layer]); 138 | }; 139 | 140 | export { 141 | isEqual, 142 | truncateLayerName, 143 | toCapitalizedString, 144 | rgbToHex, 145 | selectAndZoomToLayer, 146 | }; 147 | -------------------------------------------------------------------------------- /src/app/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: any; 3 | export default content; 4 | } 5 | 6 | declare module "*.png" { 7 | const value: any; 8 | export default value; 9 | } 10 | 11 | declare module "https://*"; 12 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import App from "./components/App"; 4 | 5 | ReactDOM.render(, document.getElementById("react-page")); 6 | -------------------------------------------------------------------------------- /src/plugin/color-linter/colorLinter.ts: -------------------------------------------------------------------------------- 1 | import uuid from "uuid-random"; 2 | import { rgbToHex } from "../../app/components/utils"; 3 | import rawLightColorTokens from "../../../data/light-mode.json"; 4 | import rawDarkColorTokens from "../../../data/dark-mode.json"; 5 | import { isVisibleNode } from "@figma-plugin/helpers"; 6 | 7 | // ============================================================== 8 | // Color linter functions 9 | // ============================================================== 10 | 11 | // Utility function for pushing a color into an array 12 | // it's just saving me from repeating myself a bit 13 | const pushColorToArray = ( 14 | layer, 15 | colorType: string, 16 | array: any[], 17 | layerHasSegmentStyles: Boolean = false 18 | ) => { 19 | const styleIdType = colorType === "fills" ? "fillStyleId" : "strokeStyleId"; 20 | const isSolidColor = layer?.fills[0]?.type === "SOLID"; 21 | const colorIsImage = 22 | colorType === "fills" && layer?.fills[0]?.type === "IMAGE"; 23 | const colorIsGradient = 24 | colorType === "fills" && layer?.fills[0]?.type.includes("GRADIENT"); 25 | const colorIsVisible = layerHasSegmentStyles 26 | ? true 27 | : layer[colorType][0].visible; 28 | const colorInHex = (colorInRGB) => { 29 | return rgbToHex(colorInRGB.r, colorInRGB.g, colorInRGB.b); 30 | }; 31 | const segmentColorInHex = layerHasSegmentStyles 32 | ? colorInHex(layer.segment.fills[0].color) 33 | : false; 34 | const hasColorStyle = () => { 35 | if (layerHasSegmentStyles) { 36 | return layer.segment.fillStyleId.length > 0; 37 | } else { 38 | return isSolidColor ? layer[styleIdType].length > 0 : false; 39 | } 40 | }; 41 | 42 | if ( 43 | !colorIsImage && 44 | !colorIsGradient && 45 | colorIsVisible && 46 | !layer.isChildOfIconWithFill 47 | ) { 48 | array.push({ 49 | colorId: uuid(), // generate a Unique ID to keep track of colors, 50 | layerId: layer.layerId, 51 | layerName: layer.name, 52 | layerType: layer.type, 53 | color: layerHasSegmentStyles ? layer.segment.fills[0] : layer[colorType], 54 | colorStyleId: layerHasSegmentStyles 55 | ? layer.segment.fillStyleId 56 | : layer[styleIdType], 57 | // if it's a gradient assume it doesn't have a color style 58 | // Unsafe assumption? Yes. Time saver? Yes. 59 | hasColorStyle: hasColorStyle(), 60 | visible: layer.visible, 61 | colorType: colorType.slice(0, -1), // it's plural, make it singular 62 | colorInHex: layerHasSegmentStyles 63 | ? segmentColorInHex 64 | : colorInHex(layer[colorType][0].color), 65 | layerHasSegmentStyles: layerHasSegmentStyles, 66 | segment: layerHasSegmentStyles && layer.segment, 67 | }); 68 | } 69 | }; 70 | 71 | let colorTokens = []; 72 | 73 | // Add hex values to colorTokens objects 74 | export const getColorTokens = async (mapThemesToEachOther: Boolean) => { 75 | let lightThemeTokens = await Promise.all( 76 | rawLightColorTokens.meta.styles.map(async (style) => { 77 | return { 78 | ...style, 79 | theme: "light", 80 | }; 81 | }) 82 | ); 83 | let darkThemeTokens = await Promise.all( 84 | rawDarkColorTokens.meta.styles.map(async (style) => { 85 | return { 86 | ...style, 87 | theme: "dark", 88 | }; 89 | }) 90 | ); 91 | 92 | // Add a `lightThemeToken` or `darkThemeToken` to each token 93 | // so we know which token to swap it for in the theme switcher 94 | if (mapThemesToEachOther) { 95 | // For every light theme token... 96 | lightThemeTokens = lightThemeTokens.map((token) => { 97 | // Check for a dark theme token with a matching name 98 | const darkThemeToken = darkThemeTokens.filter( 99 | (darkToken) => token.name.toLowerCase() === darkToken.name.toLowerCase() 100 | ); 101 | 102 | // Return the token with a new `darkThemeToken` property 103 | return { 104 | ...token, 105 | darkThemeToken: darkThemeToken[0]?.key ? darkThemeToken[0].key : null, 106 | }; 107 | }); 108 | 109 | // For every dark theme token... 110 | darkThemeTokens = darkThemeTokens.map((token) => { 111 | // Check for a dark theme token with a matching name 112 | const lightThemeToken = lightThemeTokens.filter( 113 | (lightToken) => 114 | token.name.toLowerCase() === lightToken.name.toLowerCase() 115 | ); 116 | 117 | // Return the token with a new `lightThemeToken` property 118 | return { 119 | ...token, 120 | lightThemeToken: lightThemeToken[0]?.key 121 | ? lightThemeToken[0].key 122 | : null, 123 | }; 124 | }); 125 | } 126 | 127 | const allColorTokens = lightThemeTokens.concat(darkThemeTokens); 128 | const tempRectangle = figma.createRectangle(); 129 | 130 | colorTokens = await Promise.all( 131 | allColorTokens.map(async (style) => { 132 | // Create a rectangle which we'll apply the token to 133 | // in order to get it's hex value 134 | tempRectangle.visible = false; 135 | 136 | let colorStyleWithHex = {}; 137 | 138 | // Apply the token to `tempRectangle` 139 | const importedStyle = await figma.importStyleByKeyAsync(style.key); 140 | tempRectangle.fillStyleId = importedStyle.id; 141 | 142 | // Set the colorStyleWithHex prop for this token 143 | if (tempRectangle.fills[0].color !== undefined) { 144 | colorStyleWithHex = { 145 | ...style, 146 | hex: rgbToHex( 147 | tempRectangle.fills[0].color.r, 148 | tempRectangle.fills[0].color.g, 149 | tempRectangle.fills[0].color.b 150 | ), 151 | }; 152 | } else { 153 | colorStyleWithHex = { 154 | ...style, 155 | hex: "None", // Currently, some colors in the file are empty & listed as "TBD" 156 | }; 157 | } 158 | 159 | return colorStyleWithHex; 160 | }) 161 | ); 162 | 163 | // remove the rectangle from the document 164 | tempRectangle.remove(); 165 | 166 | return colorTokens; 167 | }; 168 | 169 | export const getColorStats = async (forThemeSwitcher: Boolean = false) => { 170 | // Set for performance: 171 | // https://www.figma.com/plugin-docs/accessing-document/#optimizing-traversals 172 | figma.skipInvisibleInstanceChildren = true; 173 | 174 | await getColorTokens(true); 175 | const getRawLayersWithColor = () => { 176 | // get the selected layers 177 | let selection = figma.currentPage.selection; 178 | 179 | const relevantLayers: SceneNode[][] = selection.map((selectedLayer) => { 180 | // Get all styles in selection that have a color 181 | // (the output will have a lot of data stored in prototype properties) 182 | 183 | let outputForLayersWithChildren: SceneNode[] = []; 184 | 185 | const isRelevantLayer = (n) => { 186 | let acceptableNodetypes = [ 187 | "ELLIPSE", 188 | "FRAME", 189 | "GROUP", 190 | "COMPONENT", 191 | "INSTANCE", 192 | "LINE", 193 | "POLYGON", 194 | "RECTANGLE", 195 | "SHAPE_WITH_TEXT", 196 | "STAR", 197 | "TEXT", 198 | "BOOLEAN_OPERATION", 199 | // 'VECTOR' 200 | ]; 201 | 202 | const hasFill = "fills" in n && n?.fills[0] !== undefined; 203 | const hasStroke = "strokes" in n && n?.strokes[0] !== undefined; 204 | // Because boolean options have their own fill that applies to all children 205 | const isChildOfBooleanOperation = n.parent.type === "BOOLEAN_OPERATION"; 206 | 207 | // Check for segments of text styled differently 208 | const textLayerHasSegmentStyles = () => { 209 | if (n.type !== "TEXT") { 210 | return false; 211 | } else { 212 | return n.getStyledTextSegments(["fills"]).length > 1; 213 | } 214 | }; 215 | 216 | // If this function is being run for the Theme switcher, be sure 217 | // to include Vector layers so they aren't left unconverted to 218 | // the chosen theme. 219 | forThemeSwitcher && acceptableNodetypes.push("VECTOR"); 220 | 221 | const hasFillOrStroke = hasFill || hasStroke; 222 | const nodeIsValidNodeType = acceptableNodetypes.some( 223 | (nodeType) => n.type === nodeType 224 | ); 225 | 226 | return ( 227 | nodeIsValidNodeType && 228 | (hasFillOrStroke || textLayerHasSegmentStyles()) && 229 | !isChildOfBooleanOperation 230 | ); 231 | }; 232 | 233 | // return the layer if it fit's the criteria of isRelevantLayer() 234 | const selectedLayerHasChildren = 235 | "findAll" in selectedLayer && selectedLayer?.children?.length > 0; 236 | 237 | if (selectedLayerHasChildren) { 238 | // if it has children 239 | isRelevantLayer(selectedLayer); 240 | outputForLayersWithChildren = selectedLayer.findAll((n) => 241 | isRelevantLayer(n) 242 | ); 243 | 244 | if (!isRelevantLayer(selectedLayer)) { 245 | return [...outputForLayersWithChildren]; 246 | } else { 247 | return [selectedLayer, ...outputForLayersWithChildren]; 248 | } 249 | } else if (isRelevantLayer(selectedLayer)) { 250 | // if it's a single layer 251 | return [selectedLayer]; 252 | } else { 253 | return []; 254 | } 255 | }); 256 | 257 | let output = relevantLayers.flat(); 258 | output = output.filter((layer) => isVisibleNode(layer)); 259 | 260 | return output; 261 | }; 262 | 263 | /*-------------------------*/ 264 | /*-- Meat and potatoes --*/ 265 | /*-------------------------*/ 266 | 267 | // Get all styles in figma doc that have a color 268 | // (the output will have a lot of data stored in prototype properites) 269 | // (To check colors for entire file, swap `figma.currentPage.selection` 270 | // `for figma.root`) 271 | const rawLayersWithColor = getRawLayersWithColor(); 272 | 273 | // Pull out the data taht we care about and make it accessible 274 | // without needing to access prototype properties. 275 | const layersWithColor = rawLayersWithColor.map((layer) => { 276 | const hasFill = "fills" in layer && layer.fills[0] !== undefined; 277 | const hasStroke = "strokes" in layer && layer.strokes[0] !== undefined; 278 | const textLayerHasSegmentStyles = 279 | layer.type === "TEXT" && 280 | layer.getStyledTextSegments(["fills"]).length > 1; 281 | const hasFillAndStroke = hasFill && hasStroke; 282 | const isChildOfIcon = layer.parent.type === "BOOLEAN_OPERATION"; 283 | let parentIconHasFill = false; 284 | const isChildOfIconWithFill = isChildOfIcon && parentIconHasFill; 285 | 286 | const checkParentForFill = () => { 287 | if ("fills" in layer.parent) { 288 | parentIconHasFill = (layer.parent.fills as []).length > 0; 289 | } 290 | }; 291 | 292 | checkParentForFill(); 293 | 294 | return { 295 | layerId: layer.id, 296 | name: layer.name, 297 | fills: "fills" in layer && layer.fills, 298 | strokes: "strokes" in layer && layer.strokes, 299 | fillStyleId: "fillStyleId" in layer && layer.fillStyleId, 300 | strokeStyleId: "strokeStyleId" in layer && layer.strokeStyleId, 301 | visible: layer.visible, 302 | type: layer.type, 303 | hasFill: hasFill, 304 | hasStroke: hasStroke, 305 | hasFillAndStroke: hasFillAndStroke, 306 | isChildOfIconWithFill: isChildOfIconWithFill, 307 | hasSegmentStyles: textLayerHasSegmentStyles, 308 | }; 309 | }); 310 | 311 | const allInstancesOfColor = layersWithColor 312 | .map((layer) => { 313 | let tempColors = []; 314 | 315 | // get all each fill and stroke that isn't empty and add it 316 | // as an item in a new flat array containing all color instances 317 | if (layer.hasFillAndStroke) { 318 | pushColorToArray(layer, "fills", tempColors); 319 | pushColorToArray(layer, "strokes", tempColors); 320 | } else if (layer.hasFill) { 321 | pushColorToArray(layer, "fills", tempColors); 322 | } else if (layer.hasStroke) { 323 | pushColorToArray(layer, "strokes", tempColors); 324 | } else if (layer.hasSegmentStyles) { 325 | const node = figma.getNodeById(layer.layerId) as TextNode; 326 | const segmentedFills = node.getStyledTextSegments(["fills"]); 327 | 328 | // Store the fillStyleId in the segment 329 | segmentedFills.forEach((segment) => { 330 | const fillStyleId = node.getRangeFillStyleId( 331 | segment.start, 332 | segment.end 333 | ); 334 | const segmentToBePushed = { ...segment, fillStyleId }; 335 | const layerToBePushed = { ...layer, segment: segmentToBePushed }; 336 | 337 | pushColorToArray(layerToBePushed, "fills", tempColors, true); 338 | }); 339 | } 340 | 341 | return tempColors; 342 | }) 343 | .flat(); 344 | 345 | // Checklist for verifying that a layers uses a One Core color style 346 | // 1. If it's a fill, it's `fillStyleId` isn't an empty string (likewise if it's a stroke but for `strokeStyleId`) 347 | // 2. The key extracted from it's (fill/stroke)styleId matches a key from the `colorTokens` array 348 | 349 | // This will give you the total number of colors that use a color style 350 | // const amountOfColorsUsingColorStyle = allInstancesOfColor.reduce((prev, color, index) => { 351 | // return color.hasColorStyle ? prev + 1 : prev 352 | // }, 0) 353 | 354 | // If it's color matches a One Core color add it an array 355 | const colorsUsingOneCoreStyle = allInstancesOfColor 356 | .filter((color) => { 357 | return colorTokens.some((oneCoreColor) => { 358 | return color.colorStyleId.includes(oneCoreColor.key); 359 | }); 360 | }) 361 | .map((color) => { 362 | // Save the one core token as property on the color object 363 | let oneCoreToken = undefined; 364 | 365 | colorTokens.forEach((oneCoreColor) => { 366 | if (color.colorStyleId.includes(oneCoreColor.key)) { 367 | oneCoreToken = oneCoreColor; 368 | } 369 | }); 370 | 371 | return { 372 | ...color, 373 | token: oneCoreToken, 374 | }; 375 | }); 376 | 377 | const oneCoreColorStyleCoverage = `${( 378 | (colorsUsingOneCoreStyle.length / allInstancesOfColor.length) * 379 | 100 380 | ).toFixed(0)}%`; 381 | 382 | if (forThemeSwitcher) { 383 | return { 384 | colorsUsingOneCoreStyle, 385 | allInstancesOfColor, 386 | oneCoreColorStyleCoverage, 387 | }; 388 | } 389 | 390 | // for each color that has a color style 391 | const colorsWithColorStyle = allInstancesOfColor.filter((color) => { 392 | return color.hasColorStyle; 393 | }); 394 | 395 | // Every color that isn't using a one core color style 396 | // loop through all colors... 397 | const colorsNotUsingOneCoreColorStyle = allInstancesOfColor.filter( 398 | (color) => { 399 | return !colorTokens.some((oneCoreColor) => { 400 | return color.colorStyleId.includes(oneCoreColor.key); 401 | }); 402 | } 403 | ); 404 | 405 | const idsOfAllInstancesOfColor = allInstancesOfColor.map( 406 | (color) => color.colorId 407 | ); 408 | 409 | const colorStats = { 410 | selectedLayersWithColor: rawLayersWithColor, 411 | allInstancesOfColor: allInstancesOfColor, 412 | colorsWithColorStyle: colorsWithColorStyle, 413 | colorsUsingOneCoreStyle: colorsUsingOneCoreStyle, 414 | colorsNotUsingOneCoreColorStyle: colorsNotUsingOneCoreColorStyle, 415 | oneCoreColorStyleCoverage: oneCoreColorStyleCoverage, 416 | idsOfAllInstancesOfColor: idsOfAllInstancesOfColor, 417 | }; 418 | 419 | return colorStats; 420 | }; 421 | -------------------------------------------------------------------------------- /src/plugin/controller.ts: -------------------------------------------------------------------------------- 1 | import { createTable } from "./table-creator/tableCreator"; 2 | import { sendCurrentTextSelection } from "./language-linter/languageLinter"; 3 | import { getColorStats, getColorTokens } from "./color-linter/colorLinter"; 4 | import { switchToTheme } from "./theme-switcher/themeSwitcher"; 5 | import { selectAndZoomToLayer } from "../app/components/utils"; 6 | 7 | interface Message { 8 | type: string; 9 | [key: string]: any; 10 | } 11 | 12 | // Set the default size of the plugin window 13 | let uiSize = { 14 | width: 300, 15 | height: 448, 16 | }; 17 | 18 | // Prepare custom meta data to send to New Relic 19 | // 20 | // TODO: Move this into it's own file to be imported everywhere 21 | // it's used. Rather than adding an extra argument in each 22 | // function where it's used (the current implementation). 23 | const fileName = encodeURIComponent(figma.currentPage.parent.name); 24 | const currentSelection = figma.currentPage.selection; 25 | const currentNodeId = encodeURIComponent( 26 | currentSelection.length > 0 ? currentSelection[0].id : figma.currentPage.id 27 | ); 28 | 29 | // So that you can click a link in the dashboard and have it open in a 30 | const fileUrl = `https://figma.com/file/${figma.fileKey}/${fileName}?node-id=${currentNodeId}`; 31 | 32 | let customEventData = { 33 | fileName: figma.currentPage.parent.name, 34 | fileKey: figma.fileKey, 35 | // setting them in title case because that's how 36 | // I did it originally and though I regret it, 37 | // I don't want to lose track of historical data 38 | "User Name": figma.currentUser.name, 39 | "User Avatar": figma.currentUser.photoUrl, 40 | "User ID": figma.currentUser.id, 41 | "Session ID": figma.currentUser.sessionId, 42 | fileUrl, 43 | }; 44 | 45 | // ============================================================== 46 | // Handle navigation 47 | // https://www.figma.com/plugin-docs/api/figma/#command 48 | // ============================================================== 49 | 50 | // handle submenu navigation 51 | const navigateTo = (screen: string) => { 52 | figma.ui.postMessage({ 53 | type: "figma-command", 54 | message: { 55 | openedTo: screen, 56 | ...customEventData, 57 | }, 58 | }); 59 | }; 60 | 61 | const navigationActions = { 62 | "open-home": () => { 63 | figma.showUI(__html__, { themeColors: true, width: 300, height: 448 }); 64 | navigateTo("open-home"); 65 | }, 66 | "open-table-creator": () => { 67 | figma.showUI(__html__, { themeColors: true, width: 300, height: 448 }); 68 | navigateTo("open-table-creator"); 69 | }, 70 | "theme-switcher-to-light": () => { 71 | switchToTheme("light", true, customEventData); 72 | }, 73 | "theme-switcher-to-dark": () => { 74 | switchToTheme("dark", true, customEventData); 75 | }, 76 | "open-language-linter": () => { 77 | figma.showUI(__html__, { themeColors: true, width: 475, height: 500 }); 78 | navigateTo("open-language-linter"); 79 | }, 80 | "open-color-linter": () => { 81 | figma.showUI(__html__, { themeColors: true, width: 475, height: 500 }); 82 | navigateTo("open-color-linter"); 83 | }, 84 | }; 85 | 86 | // Choose the proper route based on the incoming command 87 | navigationActions[figma.command](); 88 | 89 | // ============================================================== 90 | // Receiving messages sent from the UI 91 | // https://www.figma.com/plugin-docs/creating-ui#sending-a-message-from-the-plugin-code-to-the-ui 92 | // ============================================================== 93 | 94 | const incomingMessageActions = { 95 | // Generic actions 96 | "navigate-to-tab": (msg: Message) => { 97 | const tabDestinations = { 98 | home: () => { 99 | uiSize = { 100 | width: 300, 101 | height: 448, 102 | }; 103 | figma.ui.resize(uiSize.width, uiSize.height); 104 | navigateTo("open-home"); 105 | }, 106 | "table-creator": () => { 107 | uiSize = { 108 | width: 300, 109 | height: 448, 110 | }; 111 | figma.ui.resize(uiSize.width, uiSize.height); 112 | navigateTo("open-table-creator"); 113 | }, 114 | "language-linter": () => { 115 | sendCurrentTextSelection(); 116 | uiSize = { 117 | width: 475, 118 | height: 500, 119 | }; 120 | figma.ui.resize(uiSize.width, uiSize.height); 121 | navigateTo("open-language-linter"); 122 | }, 123 | "color-linter": () => { 124 | uiSize = { 125 | width: 475, 126 | height: 500, 127 | }; 128 | figma.ui.resize(uiSize.width, uiSize.height); 129 | navigateTo("open-color-linter"); 130 | }, 131 | }; 132 | 133 | tabDestinations[msg.tabClicked](); 134 | }, 135 | "display-error": (msg: Message) => { 136 | figma.notify(msg.content, { error: true }); 137 | }, 138 | "select-layer": (msg: Message) => { 139 | selectAndZoomToLayer(msg.layerId); 140 | }, 141 | "initialize-selection": () => { 142 | figma.ui.postMessage({ 143 | type: "initial-selection", 144 | message: figma.currentPage.selection, 145 | }); 146 | }, 147 | resize: (msg: Message) => { 148 | figma.ui.resize( 149 | msg.size.x >= uiSize.width ? msg.size.x : uiSize.width, 150 | msg.size.y >= uiSize.height ? msg.size.y : uiSize.height 151 | ); 152 | }, 153 | "close-plugin": () => { 154 | figma.closePlugin(); 155 | }, 156 | 157 | // Table creator actions 158 | "create-table": (msg: Message) => { 159 | createTable(msg, customEventData); 160 | }, 161 | 162 | // Language linter actions 163 | "run-language-linter": () => { 164 | sendCurrentTextSelection(); 165 | 166 | figma.ui.postMessage({ 167 | type: "language-linter-run", 168 | message: { 169 | customEventData, 170 | }, 171 | }); 172 | }, 173 | "request-local-custom-dictionary": () => { 174 | figma.clientStorage 175 | .getAsync("languageLinterCustomDictionary") 176 | .then((result) => { 177 | figma.ui.postMessage({ 178 | type: "local-custom-dictionary-retrieved", 179 | message: result ? result : [], 180 | }); 181 | }); 182 | }, 183 | "get-sample-text": () => { 184 | const sampleText = figma.currentPage.selection; 185 | figma.ui.postMessage({ type: "sample-text", message: sampleText }); 186 | }, 187 | "sample-text-changed": (msg: Message) => { 188 | selectAndZoomToLayer(msg.activeNodeId); 189 | }, 190 | "update-source-text": async (msg: Message) => { 191 | const activeTextLayer = figma.getNodeById(msg.layerId) as TextNode; 192 | let fontName = activeTextLayer.fontName; 193 | 194 | if (fontName === figma.mixed) { 195 | // process each character individually 196 | // or simply get the color of the first character 197 | await Promise.all( 198 | activeTextLayer 199 | .getRangeAllFontNames(0, activeTextLayer.characters.length) 200 | .map(figma.loadFontAsync) 201 | ); 202 | } else { 203 | await figma.loadFontAsync(fontName); 204 | } 205 | 206 | activeTextLayer.deleteCharacters(0, activeTextLayer.characters.length); 207 | activeTextLayer.insertCharacters(0, msg.updatedText); 208 | 209 | figma.ui.postMessage({ 210 | type: "source-text-updated", 211 | message: msg.updatedText, 212 | }); 213 | }, 214 | "text-linted": (msg: Message) => { 215 | figma.ui.postMessage({ 216 | type: "text-linted", 217 | message: { 218 | customEventData, 219 | minimalReport: msg.minimalReport, 220 | fullReport: msg.fullReport, 221 | }, 222 | }); 223 | }, 224 | 225 | // Color linter 226 | "run-color-linter": () => { 227 | const sendColorData = async () => { 228 | const colorStats = await getColorStats(); 229 | 230 | figma.ui.postMessage({ 231 | type: "color-stats", 232 | message: { 233 | ...customEventData, 234 | colorStats: colorStats, 235 | colorTokens: await getColorTokens(false), 236 | selectionMade: figma.currentPage.selection.length > 0, 237 | }, 238 | }); 239 | }; 240 | 241 | sendColorData(); 242 | }, 243 | 244 | "apply-color-style": (msg: Message) => { 245 | figma.importStyleByKeyAsync(msg.colorStyleKey).then((imported) => { 246 | figma.getNodeById(msg.layerId)[`${msg.colorType}StyleId`] = imported.id; 247 | 248 | figma.ui.postMessage({ 249 | type: "color-replaced", 250 | message: { 251 | ...customEventData, 252 | layerId: msg.layerId, 253 | layerName: figma.getNodeById(msg.layerId).name, 254 | originalColor: msg.originalColor, 255 | colorStyleKey: msg.colorStyleKey, 256 | colorStyleName: msg.colorStyleName, 257 | colorStyleColor: msg.colorStyleColor, 258 | }, 259 | }); 260 | }); 261 | }, 262 | 263 | /*-- Theme switcher messages --*/ 264 | "theme-switcher-to-dark": () => { 265 | switchToTheme("dark", false, customEventData); 266 | }, 267 | "theme-switcher-to-light": () => { 268 | switchToTheme("light", false, customEventData); 269 | }, 270 | }; 271 | 272 | figma.ui.onmessage = async (msg) => { 273 | incomingMessageActions[msg.type](msg); 274 | }; 275 | 276 | // ============================================================== 277 | // Figma Events 278 | // https://www.figma.com/plugin-docs/api/properties/figma-on 279 | // ============================================================== 280 | figma.on("selectionchange", () => { 281 | figma.ui.postMessage({ 282 | type: "selection-changed", 283 | message: figma.currentPage.selection, 284 | }); 285 | }); 286 | -------------------------------------------------------------------------------- /src/plugin/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module "https://*"; 2 | -------------------------------------------------------------------------------- /src/plugin/language-linter/languageLinter.ts: -------------------------------------------------------------------------------- 1 | // import { isVisibleNode } from "@figma-plugin/helpers"; 2 | 3 | // ============================================================== 4 | // Language linter functions 5 | // ============================================================== 6 | const pushTextLayerToArray = (layer, array) => { 7 | array.push({ 8 | id: layer.id, 9 | name: layer.name, 10 | visible: layer.visible, 11 | x: layer.x, 12 | y: layer.y, 13 | type: layer.type, 14 | characters: layer?.characters, 15 | children: layer?.children, 16 | }); 17 | }; 18 | 19 | export const sendCurrentTextSelection = () => { 20 | // get the selected layers 21 | let selection = figma.currentPage.selection; 22 | 23 | // initialize an variable that we'll store our output in 24 | // as we loop over the selected layers 25 | let textLayers = []; 26 | let selectionExlusivelyTextLayers = true; 27 | 28 | selection.forEach((layer) => { 29 | if (layer.type !== "TEXT") { 30 | selectionExlusivelyTextLayers = false; 31 | return; 32 | } else { 33 | pushTextLayerToArray(layer, textLayers); 34 | } 35 | }); 36 | 37 | if (!selectionExlusivelyTextLayers) { 38 | figma.notify( 39 | "⚠️ Text layers only: Please limit your selection to text layers", 40 | { error: true } 41 | ); 42 | 43 | textLayers = []; 44 | } 45 | 46 | /* 47 | This snippet allows for the selection of any layer type. 48 | It deep-checks the children of the selected layers and 49 | extracts the text layers only. 50 | 51 | It's commented out because I decided to only allow the 52 | selection of text layers to limit the load placed on 53 | an already slow feature. 54 | */ 55 | // // for each selected layer 56 | // selection.forEach((selectedLayer) => { 57 | // // If the layer has children 58 | // if (!!(selectedLayer as FrameNode)?.children) { 59 | // // get all of the children of the layer that are text layers 60 | // const selectedTextLayers = (selectedLayer as FrameNode).findAll( 61 | // (n) => n.type === "TEXT" 62 | // ); 63 | 64 | // // Add any children that are text layers to the output array 65 | // selectedTextLayers.forEach((layer) => { 66 | // isVisibleNode(layer) && pushTextLayerToArray(layer, textLayers); 67 | // }); 68 | // } else if (selectedLayer.type === "TEXT") { 69 | // isVisibleNode(selectedLayer) && 70 | // pushTextLayerToArray(selectedLayer, textLayers); 71 | // } 72 | // }); 73 | 74 | // send the selection array to the UI 75 | return figma.ui.postMessage({ 76 | type: "new-text-selection", 77 | message: { 78 | textLayers, 79 | selectedLayers: selection, 80 | }, 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /src/plugin/table-creator/tableCreator.ts: -------------------------------------------------------------------------------- 1 | import { toCapitalizedString } from "../../app/components/utils"; 2 | 3 | interface Message { 4 | type: string; 5 | [key: string]: any; 6 | } 7 | interface CustomEventData { 8 | fileName: string; 9 | fileKey: string; 10 | "User Name": string; 11 | "User Avatar": string; 12 | "User ID": string; 13 | "Session ID": number; 14 | fileUrl: string; 15 | } 16 | 17 | // ============================================================== 18 | // Table creator functions 19 | // ============================================================== 20 | export const createTable = async ( 21 | msg: Message, 22 | customEventData: CustomEventData 23 | ) => { 24 | const tableFrame = figma.createFrame(); 25 | let headerCell = await figma.importComponentByKeyAsync( 26 | "ce8fa8e8cab07a19f83f4181ac8cbe76093c6bc3" 27 | ); 28 | let tableRow = figma.createComponent(); 29 | let cellFillContainerY = false; 30 | 31 | await figma.loadFontAsync({ family: "Open Sans", style: "Regular" }); 32 | await figma.loadFontAsync({ family: "Inter", style: "Regular" }); 33 | 34 | tableFrame.name = "Table"; 35 | tableFrame.fills = []; 36 | tableFrame.counterAxisSizingMode = "AUTO"; 37 | tableFrame.layoutMode = "VERTICAL"; 38 | tableFrame.x = figma.viewport.center.x; 39 | tableFrame.y = figma.viewport.center.y; 40 | 41 | // if any cell is set to Multi-value, set a variable we'll use later 42 | msg.columnConfiguration.find((col) => (cellFillContainerY = col.multiValue)); 43 | 44 | const hideSortControls = (headerCell) => { 45 | const arrowsLayer = headerCell.findOne((child) => child.name === "Arrows"); 46 | 47 | arrowsLayer.visible = false; 48 | }; 49 | 50 | [...Array(msg.rows + 1).keys()].map((rowIndex) => { 51 | tableRow.layoutMode = "HORIZONTAL"; 52 | tableRow.counterAxisSizingMode = "AUTO"; 53 | tableRow.name = "Row"; 54 | // tableRow.layoutAlign = 'SgMode = 'FIXED'; 55 | 56 | msg.columnConfiguration.forEach(async (col) => { 57 | let { 58 | name: colName, 59 | alignment: colAlignment, 60 | cellType: colCellType, 61 | multiValue: colMultiValue, 62 | sortControls: colSortControls, 63 | } = col; 64 | 65 | // Because this is how the variant properties are formatted :( 66 | colAlignment = toCapitalizedString(colAlignment); 67 | colCellType = toCapitalizedString(colCellType); 68 | colMultiValue = toCapitalizedString(colMultiValue); 69 | 70 | // Set header cell configuration 71 | if (rowIndex === 0) { 72 | let thisHeaderCell = headerCell.createInstance(); 73 | let textNodeOfHeaderCell = (thisHeaderCell.children[0] as InstanceNode) 74 | .children[0] as TextNode; 75 | const shouldHaveHeaderText = 76 | colCellType !== "Favorite" && colCellType !== "Actions"; 77 | const cellTypeIsActions = colCellType === "Actions"; 78 | const hasCustomColName = colName.length; 79 | 80 | const setHeaderTextCharacters = (newChars: string) => { 81 | textNodeOfHeaderCell.deleteCharacters( 82 | 0, 83 | textNodeOfHeaderCell.characters.length 84 | ); 85 | textNodeOfHeaderCell.insertCharacters(0, newChars); 86 | }; 87 | 88 | // Set column layer name and label text 89 | if (hasCustomColName) { 90 | thisHeaderCell.name = colName; 91 | setHeaderTextCharacters(colName); 92 | } else { 93 | thisHeaderCell.name = "Header"; 94 | setHeaderTextCharacters("Header"); 95 | } 96 | 97 | // If it should have header text, don't show header text or arrows 98 | if (!shouldHaveHeaderText) { 99 | hideSortControls(thisHeaderCell); 100 | setHeaderTextCharacters(" "); 101 | } 102 | 103 | thisHeaderCell.setProperties({ Alignment: colAlignment }); 104 | 105 | const determineHeaderCellWidth = () => { 106 | if (msg.isMultiValue) return 120; 107 | if (colCellType === "Entity") return 102; 108 | if (colCellType === "User") return 120; 109 | if (!shouldHaveHeaderText) return 50; 110 | if (shouldHaveHeaderText && colSortControls) return 90; 111 | 112 | return thisHeaderCell.width; 113 | }; 114 | 115 | // Set the width on each header cell 116 | thisHeaderCell.resize( 117 | determineHeaderCellWidth(), 118 | thisHeaderCell.height 119 | ); 120 | 121 | // if any cell is set to Multi-value then make all of them "fill container" vertically 122 | (thisHeaderCell.children[0] as FrameNode).layoutGrow = 123 | cellFillContainerY ? 1 : 0; 124 | thisHeaderCell.primaryAxisSizingMode = cellFillContainerY 125 | ? "FIXED" 126 | : "AUTO"; 127 | thisHeaderCell.counterAxisSizingMode = !shouldHaveHeaderText 128 | ? "AUTO" 129 | : "FIXED"; 130 | thisHeaderCell.counterAxisSizingMode = cellTypeIsActions 131 | ? "FIXED" 132 | : thisHeaderCell.counterAxisSizingMode; 133 | 134 | if (shouldHaveHeaderText && !colSortControls) { 135 | thisHeaderCell.counterAxisSizingMode = "AUTO"; 136 | } 137 | 138 | tableRow.appendChild(thisHeaderCell); 139 | 140 | // Handle sort controls 141 | const handleSortControls = () => { 142 | if (colCellType === "Actions") hideSortControls(thisHeaderCell); 143 | if (!colSortControls) hideSortControls(thisHeaderCell); 144 | }; 145 | 146 | handleSortControls(); 147 | } 148 | }); 149 | 150 | if (rowIndex === 0) { 151 | tableFrame.appendChild(tableRow); 152 | } else { 153 | // configure all rows except headers 154 | let thisTableRow = tableRow.createInstance(); 155 | 156 | // thisTableRow.layoutAlign = 'STRETCH' 157 | // thisTableRow.primaryAxisSizingMode = 'FIXED'; 158 | 159 | thisTableRow.children.map((cell, index) => { 160 | cell = cell as InstanceNode; 161 | let { cellType: colCellType, multiValue: colMultiValue } = 162 | msg.columnConfiguration[index]; 163 | 164 | const cellTypesThatCanBeMultiValue = [ 165 | "Text", 166 | "Metric", 167 | "Entity", 168 | "Link", 169 | ]; 170 | const tableMultiValue = toCapitalizedString(msg.isMultiValue); 171 | 172 | // Because this is how the variant properties are formatted :( 173 | colCellType = toCapitalizedString(colCellType); 174 | colMultiValue = toCapitalizedString(colMultiValue); 175 | const colCanBeMultiValue = cellTypesThatCanBeMultiValue.some( 176 | (cellType) => cellType === colCellType 177 | ); 178 | 179 | cell.name === "Header" ? (cell.name = "Cell") : null; 180 | cell.setProperties({ Type: "Body" }); 181 | ( 182 | (cell.children[0] as FrameNode).children[0] as InstanceNode 183 | ).setProperties({ 184 | Type: colCellType, 185 | "Multi-value": colCanBeMultiValue ? tableMultiValue : "False", 186 | }); 187 | 188 | cell.counterAxisSizingMode = "FIXED"; 189 | 190 | // If it's a multiValue table then set the visibility of this col's 191 | // additional value 192 | if (msg.isMultiValue && colMultiValue === "False") { 193 | if (!colCanBeMultiValue) return; 194 | 195 | const additionalValueLayer = cell.findOne( 196 | (child) => child.name === "Secondary value" && child.type === "TEXT" 197 | ); 198 | 199 | (additionalValueLayer as TextNode).characters = " "; 200 | } 201 | 202 | // Because cells can be reset here as they're replaced with another 203 | // component (variants), we again set the fill container setting if 204 | // any of the columns is set to "multi value" 205 | (cell.children[0] as FrameNode).layoutGrow = cellFillContainerY ? 1 : 0; 206 | cell.primaryAxisSizingMode = cellFillContainerY ? "FIXED" : "AUTO"; 207 | }); 208 | 209 | tableFrame.appendChild(thisTableRow); 210 | } 211 | }); 212 | 213 | let tableData = { 214 | fileName: figma.currentPage.parent.name, 215 | fileKey: figma.fileKey, 216 | "Column count": msg.columnConfiguration.length, 217 | "Row count": msg.rows, 218 | "Column Configuration": msg.columnConfiguration, 219 | ...customEventData, 220 | }; 221 | 222 | figma.ui.postMessage({ type: "table-created", message: tableData }); 223 | 224 | figma.notify("Table created ✅"); 225 | }; 226 | -------------------------------------------------------------------------------- /src/plugin/theme-switcher/themeSwitcher.ts: -------------------------------------------------------------------------------- 1 | import { getColorStats } from "../color-linter/colorLinter"; 2 | 3 | // ============================================================== 4 | // Theme switcher 5 | // ============================================================== 6 | 7 | // Define the notification here so we can cancel it later 8 | let themeSwitchedNotification = undefined; 9 | 10 | interface CustomEventData { 11 | fileName: string; 12 | fileKey: string; 13 | "User Name": string; 14 | "User Avatar": string; 15 | "User ID": string; 16 | "Session ID": number; 17 | fileUrl: string; 18 | } 19 | 20 | export const switchToTheme = async ( 21 | theme: "light" | "dark", 22 | closeAfterRun: Boolean = false, 23 | customEventData: CustomEventData 24 | ) => { 25 | if (closeAfterRun) { 26 | figma.showUI(__html__, { width: 70, height: 0 }); 27 | } else { 28 | figma.ui.postMessage({ type: `loading-${theme}-theme-switch` }); 29 | } 30 | 31 | // Check for a selection. If none exists, show error notification. 32 | if (figma.currentPage.selection.length === 0) { 33 | // If the notification is already set, turn it off 34 | themeSwitchedNotification && themeSwitchedNotification?.cancel(); 35 | 36 | figma.notify("Select some layers before choosing a theme", { error: true }); 37 | return closeAfterRun && figma.closePlugin(); 38 | } 39 | 40 | // If the notification is already set, turn it off 41 | themeSwitchedNotification && themeSwitchedNotification?.cancel(); 42 | 43 | // Tell the user we're working on the theme change 44 | const loadingNotification = figma.notify( 45 | `Converting selection to ${theme} mode...` 46 | ); 47 | 48 | // Get the list of colors that are using one core color styles 49 | const colorStats = await getColorStats(true); 50 | 51 | let importedStyles = []; 52 | let keysToLoad: () => string[] = () => { 53 | let keys = []; 54 | 55 | colorStats.colorsUsingOneCoreStyle.forEach((color) => { 56 | if ("theme" in color.token && color.token?.theme !== theme) { 57 | const keyOfTokenInOppositeTheme = 58 | theme === "light" 59 | ? color.token.lightThemeToken 60 | : color.token.darkThemeToken; 61 | const keyIsNotDuplicate = !keys.some( 62 | (key) => key === keyOfTokenInOppositeTheme 63 | ); 64 | 65 | // Check to see if there's an available token to switch to 66 | if (keyOfTokenInOppositeTheme === null) { 67 | console.error( 68 | `Missing token: No ${theme} theme token found for "${color.token.name}".` 69 | ); 70 | return; 71 | } 72 | 73 | // Add it to the keys array if it's not already there 74 | keyIsNotDuplicate && keys.push(keyOfTokenInOppositeTheme); 75 | } 76 | }); 77 | 78 | return keys; 79 | }; 80 | 81 | // Fetch the tokens 82 | importedStyles = await Promise.all( 83 | keysToLoad().map(async (key) => figma.importStyleByKeyAsync(key)) 84 | ); 85 | 86 | // Replace every one core color style with it's 87 | // dark mode equivalent 88 | for (const color of colorStats.colorsUsingOneCoreStyle) { 89 | if ("theme" in color.token && color.token?.theme !== theme) { 90 | const node = figma.getNodeById(color.layerId); 91 | const keyOfTokenInOppositeTheme = 92 | theme === "light" 93 | ? color.token.lightThemeToken 94 | : color.token.darkThemeToken; 95 | 96 | const importedStyle: BaseStyle = importedStyles.filter( 97 | (style) => style.key === keyOfTokenInOppositeTheme 98 | )[0]; 99 | 100 | // Check to see if there's an available token to switch to 101 | if (keyOfTokenInOppositeTheme === null) { 102 | console.error( 103 | `Missing token: No ${theme} theme token found for "${color.token.name}".` 104 | ); 105 | return; 106 | } 107 | 108 | // Get ready to set the token on the layer 109 | // First, check to see if the layer has segment styles 110 | if (color.layerHasSegmentStyles) { 111 | (node as TextNode).setRangeFillStyleId( 112 | color.segment.start, 113 | color.segment.end, 114 | importedStyle.id 115 | ); 116 | } else { 117 | // Set the token on the layer 118 | node[`${color.colorType}StyleId`] = importedStyle.id; 119 | } 120 | } 121 | } 122 | 123 | loadingNotification.cancel(); 124 | 125 | const coverageAsInteger = colorStats.oneCoreColorStyleCoverage.substring( 126 | 0, 127 | colorStats.oneCoreColorStyleCoverage.length - 1 128 | ); 129 | const isErrorWorthy = parseInt(coverageAsInteger) < 50; 130 | 131 | if (colorStats.oneCoreColorStyleCoverage === "100%") { 132 | themeSwitchedNotification = figma.notify( 133 | `${theme === "light" ? "🔆" : "🌙"} Selection set to ${theme} mode` 134 | ); 135 | } else { 136 | themeSwitchedNotification = figma.notify( 137 | `✋ Warning: Only ${colorStats.oneCoreColorStyleCoverage} converted to 138 | ${theme} mode because some colors aren't using One Core color styles.`, 139 | { timeout: isErrorWorthy ? 999999999 : 15000, error: isErrorWorthy } 140 | ); 141 | } 142 | 143 | figma.ui.postMessage({ 144 | type: "theme-switched", 145 | message: { 146 | switchedTo: theme, 147 | closeAfterRun, 148 | colorsUsingOneCoreStyle: colorStats.colorsUsingOneCoreStyle.length, 149 | colorsSelected: colorStats.allInstancesOfColor.length, 150 | colorsSwitched: colorStats.oneCoreColorStyleCoverage, 151 | ...customEventData, 152 | }, 153 | }); 154 | }; 155 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["node"], 4 | "target": "es6", 5 | "lib": ["es2019", "dom", "ES2021.String"], 6 | "outDir": "dist", 7 | "jsx": "react", 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "experimentalDecorators": true, 11 | "allowSyntheticDefaultImports": true, 12 | "removeComments": true, 13 | "noImplicitAny": false, 14 | "moduleResolution": "node", 15 | "typeRoots": ["./node_modules/@types", "./node_modules/@figma"], 16 | "resolveJsonModule": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const path = require("path"); 4 | 5 | module.exports = (env, argv) => ({ 6 | mode: argv.mode === "production" ? "production" : "development", 7 | 8 | // This is necessary because Figma's 'eval' works differently than normal eval 9 | devtool: argv.mode === "production" ? false : "inline-source-map", 10 | 11 | entry: { 12 | ui: "./src/app/index.tsx", // The entry point for your UI code 13 | code: "./src/plugin/controller.ts", // The entry point for your plugin code 14 | }, 15 | 16 | module: { 17 | rules: [ 18 | // Converts TypeScript code to JavaScript 19 | { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }, 20 | 21 | // Enables including CSS by doing "import './file.css'" in your TypeScript code 22 | { test: /\.css$/, use: ["style-loader", { loader: "css-loader" }] }, 23 | 24 | // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI 25 | { test: /\.(png|jpg|gif|webp|svg)$/, loader: "url-loader" }, 26 | ], 27 | }, 28 | 29 | // Webpack tries these extensions for you if you omit the extension like "import './file'" 30 | resolve: { extensions: [".tsx", ".ts", ".jsx", ".js"] }, 31 | 32 | output: { 33 | filename: "[name].js", 34 | path: path.resolve(__dirname, "dist"), // Compile into a folder called "dist" 35 | }, 36 | 37 | // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it 38 | plugins: [ 39 | new HtmlWebpackPlugin({ 40 | template: "./src/app/index.html", 41 | filename: "ui.html", 42 | inlineSource: ".(js)$", 43 | chunks: ["ui"], 44 | }), 45 | new HtmlWebpackInlineSourcePlugin(), 46 | ], 47 | }); 48 | --------------------------------------------------------------------------------