├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── error-handling-unhandled-plugin-exception.png ├── error-handling-unregistered-command.png ├── figma-plugins-architecture-original.png ├── figma-plugins-architecture.png ├── multiple-use-cases-figma-plugin.png ├── paint-current-user-avatar-use-case.png ├── plugin-use-cases.png ├── shapes-creator-form-use-case-result.png ├── shapes-creator-form-use-case.png ├── shapes-creator-parametrized-suggestions-use-case.png ├── shapes-creator-parametrized-use-case.png └── single-use-case-figma-plugin.png ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── browser-commands │ └── network-request │ │ ├── NetworkRequestCommand.ts │ │ └── NetworkRequestCommandHandler.ts ├── commands-setup │ ├── Command.ts │ ├── CommandHandler.ts │ ├── CommandsMapping.ts │ ├── executeCommand.ts │ └── handleCommand.ts ├── figma-entrypoint.ts ├── scene-commands │ ├── cancel │ │ ├── CancelCommand.ts │ │ └── CancelCommandHandler.ts │ ├── create-shapes │ │ ├── CreateShapesCommand.ts │ │ └── CreateShapesCommandHandler.ts │ └── paint-current-user-avatar │ │ ├── PaintCurrentUserAvatarCommand.ts │ │ └── PaintCurrentUserAvatarCommandHandler.ts └── ui │ ├── register-ui-command-handlers.ts │ ├── register-ui-event-listeners.ts │ ├── ui.css │ ├── ui.html │ └── ui.ts ├── tests ├── .eslintrc └── scene-commands │ └── CancelCommandHandler.test.ts ├── tsconfig.json └── webpack.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | extends: [ 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended", 6 | ], 7 | plugins: ["simple-import-sort", "import"], 8 | parserOptions: { 9 | ecmaVersion: 12, 10 | sourceType: "module", 11 | }, 12 | rules: { 13 | "simple-import-sort/imports": "error", 14 | "simple-import-sort/exports": "error", 15 | "import/first": "error", 16 | "import/newline-after-import": "error", 17 | "import/no-duplicates": "error", 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: push 4 | 5 | jobs: 6 | unit: 7 | runs-on: ubuntu-latest 8 | name: 🚀 Lint and test 9 | timeout-minutes: 5 10 | steps: 11 | - name: 👍 Checkout 12 | uses: actions/checkout@v2 13 | 14 | - name: 📦 Cache node modules 15 | uses: actions/cache@v2 16 | env: 17 | cache-name: cache-node-modules 18 | with: 19 | path: ~/.npm 20 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 21 | restore-keys: | 22 | ${{ runner.os }}-build-${{ env.cache-name }}- 23 | ${{ runner.os }}-build- 24 | ${{ runner.os }}- 25 | 26 | - name: 📥 Install dependencies 27 | run: npm install 28 | 29 | - name: 💅 Lint code style 30 | run: npm run lint 31 | 32 | - name: ✅ Run tests 33 | run: npm run test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | dist/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Codely Enseña y Entretiene SL 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Codely logo 4 | 5 |

6 | 7 |

8 | 🪆 Codely Figma Plugin Skeleton 9 |

10 | 11 |

12 | Build status 13 | Codely Open Source 14 | CodelyTV Courses 15 |

16 | 17 |

18 | Template intended to serve as a starting point if you want to bootstrap a Figma Plugin in TypeScript. 19 |
20 |
21 | Take a look, play and have fun with this. 22 | Stars are welcome 😊 23 |

24 | 25 | The purpose of this repository is to leave it with the bare minimum dependencies and tools needed to build Figma Plugins 26 | but **based on software development best practices such as SOLID principles, testing, and tooling already configured** 27 | 🤟 28 | 29 | ## 🚀 Running the app 30 | 31 | - Install the dependencies: `npm install` 32 | - Execute the tests: `npm run test` 33 | - Check linter errors: `npm run lint` 34 | - Fix linter errors: `npm run lint:fix` 35 | - Make a build unifying everything in the same `dist/figmaEntrypoint.js` file: `npm run build` 36 | - Run a watcher on your plugin files and make the build on every change: `npm run dev` 37 | 38 | ## 🗺️ Steps to develop your own plugin 39 | 40 | 1. Click on the "Use this template" button in order to create your own repository based on this one 41 | 2. Clone your repository 42 | 3. Replace the skeleton branding by your own: 43 | 44 | - Modify the `name` property of your [`manifest.json`](manifest.json) file, and set the `id` value following the next 45 | steps in order to obtain it from Figma: 46 | 1. Generate a plugin in the Figma App: `Figma menu` > `Plugins` > `Development` > `New Plugin…` 47 | 2. Give it a random name and choose any kind of plugin 48 | 3. Save the Figma plugin files locally 49 | 4. Open the saved `manifest.json` and copy the `id` property value 50 | - Modify the following [`package.json`](package.json) properties: `name`, `description`, `repository.url`, `bugs.url`, 51 | and `homepage` 52 | 53 | 4. Install all the plugin dependencies running: `npm install` 54 | 5. Develop in a continuos feedback loop with the watcher: `npm run dev` 55 | 6. Install your plugin in your Figma App: `Figma menu` > `Plugins` > `Development` > `Import plugin from manifest…` 56 | 7. [Remove the unnecessary code](https://github.com/CodelyTV/figma-plugin-skeleton#-remove-unnecessary-code) 57 | 8. [Add your new use case Command](https://github.com/CodelyTV/figma-plugin-skeleton#-how-to-add-new-commands) 58 | 9. Now you can call to the `handleCommand` function passing in the created command 59 | 60 | ℹ️ And remember to star this repository in order to promote the work behind it 🌟😊 61 | 62 | ## 🏗️ Software Architecture 63 | 64 | ### 📍 Figma entrypoint 65 | 66 | You will find the entrypoint that Figma will execute once the plugin is executed in 67 | the [`src/figma-entrypoint.ts`](src/figma-entrypoint.ts) file, which is intended to represent the interaction with the 68 | Figma UI, leaving the logic of your plugin to the different commands that will be executed in the Browser or in the 69 | Figma Scene Sandbox. 70 | 71 | ### 🎨 UI 72 | 73 | In the [`src/ui`](src/ui) folder you will find the HTML, CSS, and TS files corresponding to the plugin user interface. 74 | We have decided to split them up in order to allow better code modularization, and leaving Webpack to transpile the 75 | TypeScript code into JavaScript and inline it into the HTML due to Figma restrictions 😊 76 | 77 | ### ⚡ Commands 78 | 79 | Commands are the different actions an end user can perform from the plugin UI. In the [`src/ui/ui.ts`](src/ui/ui.ts) you 80 | will see that we are adding event listeners to the plugin UI in order to execute these Commands such as the following 81 | one: 82 | 83 | ```typescript 84 | import { executeCommand } from "./commands-setup/executeCommand"; 85 | 86 | document.addEventListener("click", function(event: MouseEvent) { 87 | const target = event.target as HTMLElement; 88 | 89 | switch (target.id) { 90 | case "cancel": 91 | executeCommand(new CancelCommand()); 92 | break; 93 | // […] 94 | } 95 | }); 96 | ``` 97 | 98 | This `executeCommand(new CancelCommand());` function call is needed due to how Figma Plugins run, that is, communicating 99 | ourselves between the following types of elements: 100 | 101 | ![Codely Figma Plugin Skeleton Architecture](assets/figma-plugins-architecture.png 'There are 2 different "worlds". The Figma scene one, and the browser iframe one') 102 | 103 | 1. The [`src/figma-entrypoint.ts`](src/figma-entrypoint.ts): As described before, in general this is the file that Figma 104 | will execute once the user runs your plugin. However, there are multiple scenarios depending on the type of plugin: 105 | 106 | - **Plugins with a single use case**: 107 | 108 | ![Single Use Case Figma Plugins does not have a dropdown menu](assets/single-use-case-figma-plugin.png) 109 | - **Plugins with UI**: If you do not have a `"menu"` key declared in the [`manifest.json`](manifest.json), but 110 | a `"ui"` one, Figma will render the [`src/ui/ui.html`](src/ui/ui.html) which is bundled together with 111 | the [`src/ui/ui.ts`](src/ui/ui.ts) and [`src/ui/ui.css`](src/ui/ui.css). That UI will run inside the Figma Browser 112 | iframe, and you will be able to execute Commands as in the previous example, that is, using the `executeCommand` 113 | method from the `ui.ts`. These commands will arribe to this `figma-entrypoint.ts` in order to be executed. You 114 | have an example for the "Cancel" button of the plugin UI mentioned 115 | before: [`CancelCommand`](src/scene-commands/cancel/CancelCommand.ts) mapped in 116 | the [`CommandsMapping`](src/commands-setup/CommandsMapping.ts#L12) to 117 | the [`CancelCommandHandler`](src/scene-commands/cancel/CancelCommandHandler.ts) and tested out in 118 | the [`CancelCommandHandler.test`](tests/scene-commands/CancelCommandHandler.test.ts). 119 | - **Plugins without UI**: If you do not have either a `"menu"` key declared in the [`manifest.json`](manifest.json), 120 | nor a `"ui"` one, Figma will render the [`src/figma-entrypoint.ts`](src/figma-entrypoint.ts) and you will be able 121 | to execute Commands directly from there with the `handleCommand` method. These commands will arribe to 122 | this `figma-entrypoint.ts` in order to be executed. 123 | - **Plugins with multiple use cases**: 124 | 125 | ![Multiple Use Cases Figma Plugins does have a dropdown menu](assets/multiple-use-cases-figma-plugin.png) 126 | 127 | You can define several use cases will be defined as `menu` items declared in the [`manifest.json`](manifest.json). 128 | In this case, this entrypoint will directly execute the Command Handler mapped in 129 | the [`src/commands-setup/CommandsMapping.ts`](src/commands-setup/CommandsMapping.ts) that corresponds to 130 | the `menu.[].command` key. You have an example for instance for the `createShapes` Command which is mapped to 131 | the [`src/scene-commands/create-shapes/CreateShapesCommandHandler.ts`](src/scene-commands/create-shapes/CreateShapesCommandHandler.ts) 132 | . 133 | One of this use cases can actually be to show the UI. You can see 134 | it [declared with the `showUi` command name](manifest.json#L13) 135 | and [handled as a particular case](src/figma-entrypoint.ts#L11). 136 | 137 | 2. The Browser iframe Figma creates for us in order to run the plugin UI. This iframe is needed in order to gain access 138 | to the browser APIs in order to perform HTTP requests for instance. 139 | 3. The Figma scene exposed in order to create elements or access to the different layers from 140 | the [`src/scene-commands`](src/scene-commands) which runs inside the Figma sandbox. 141 | 4. The previous commands could need some information from the external world, so they must send out a command to be 142 | handled inside the iframe. You can see an example of this in 143 | the [`PaintCurrentUserAvatarCommandHandler`](src/scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommandHandler.ts) 144 | . All you have to do to perform the request is executing a `NetworkRequestCommand`: 145 | ```typescript 146 | executeCommand( 147 | new NetworkRequestCommand("https://example.com/some/api/endpoint", "text") 148 | ); 149 | ``` 150 | And listen for the response: 151 | ```typescript 152 | return new Promise((resolve) => { 153 | this.figma.ui.onmessage = async (message) => { 154 | await this.doThingsWith(message.payload); 155 | resolve(); 156 | }; 157 | }); 158 | ``` 159 | 160 | #### 🆕 How to add new commands 161 | 162 | If you want to add new capabilities to your plugin, we have intended to allow you to do so without having to worry about 163 | all the TypeScript stuff behind the Commands concept. It is as simple as: 164 | 165 | 1. Create a folder giving a name to your Command. Example: [`src/scene-commands/cancel`](src/scene-commands/cancel) 166 | 2. Create the class that will represent your Command. 167 | 168 | - Example of the simplest Command you can think of (only provides 169 | semantics): [`src/scene-commands/cancel/CancelCommand.ts`](src/scene-commands/cancel/CancelCommand.ts) 170 | - Example of a Command needing 171 | parameters: [`src/scene-commands/create-shapes/CreateShapesCommand.ts`](src/scene-commands/create-shapes/CreateShapesCommand.ts) 172 | 173 | 3. Create the CommandHandler that will receive your Command and will represent the business logic behind it. Following 174 | the previous examples: 175 | 176 | - [`src/scene-commands/cancel/CancelCommandHandler.ts`](src/scene-commands/cancel/CancelCommandHandler.ts) 177 | - [`src/scene-commands/create-shapes/CreateShapesCommandHandler.ts`](src/scene-commands/create-shapes/CreateShapesCommandHandler.ts) 178 | 179 | 4. Link your Command to your CommandHandler adding it to 180 | the [`src/commands-setup/CommandsMapping.ts`](src/commands-setup/CommandsMapping.ts) 181 | 5. Send the command from one of the following places depending on your plugin type: 182 | 183 | - Plugins with UI: From [`src/ui/ui.ts`](src/ui/ui.ts) with `executeCommand(new CancelCommand());` 184 | - Plugins without UI: From the [`src/figma-entrypoint.ts`](src/figma-entrypoint.ts) 185 | with `await handleCommand(new CancelCommand());` 186 | 187 | ## 🌈 Features 188 | 189 | ### ✨ Illustrative working examples 190 | 191 | In order to show the potential Figma Plugins have, we have developed several use cases: 192 | 193 | ![Plugin menu with the 3 use cases](assets/plugin-use-cases.png) 194 | 195 | #### 👀 Shapes Creator Form 196 | 197 | ![Shapes Creator Form](assets/shapes-creator-form-use-case.png) 198 | ![Shapes Creator Form Result](assets/shapes-creator-form-use-case-result.png) 199 | 200 | Demonstrative purposes: 201 | 202 | - Render a UI allowing it to be modular and scalable (Webpack bundling working in Figma thanks to JS inline) 203 | - How to communicate from the Figma Browser iframe where the UI lives to the Figma Scene Sandbox in order to execute 204 | commands like the `createShapes` one which require to modify the viewport, create and select objects, and so on 205 | - Work with the Figma Plugins API randomizing multiple variables to make it a little more playful: 206 | - The shapes to create (rectangles and ellipses) 207 | - The rotation of each shape 208 | - The color of the shapes 209 | 210 | #### ⌨️ Shapes Creator Parametrized 211 | 212 | You can launch parametrized menu commands from the Figma Quick Actions search bar: 213 | 214 | ![Shapes Creator Parametrized in the Quick Actions search bar](assets/shapes-creator-parametrized-use-case.png) 215 | 216 | It even allows you to configure optional parameters and suggestions for them: 217 | 218 | ![Filtering our the type of shapes parameter value](assets/shapes-creator-parametrized-suggestions-use-case.png) 219 | 220 | Demonstrative purposes: 221 | 222 | - Take advantage of the Parametrized Figma Plugins in order to offer a simple UI integrated with the Figma ecosystem 223 | without having to implement any HTML or CSS 224 | - Reuse the very same use 225 | case ([`CreateShapesCommandHandler`](src/scene-commands/create-shapes/CreateShapesCommandHandler.ts)) from multiple 226 | entry-points. That, is we are using that very same business logic class: 227 | - Calling it [from the `ui.ts`](src/ui/ui.ts#L23) once the user clicks on the `create` button because we are executing 228 | the command with `executeCommand(new CreateShapesCommand(count));` 229 | - Configuring [the parametrized menu entry in the `manifest.json`](manifest.json#L14) with the very same `command` 230 | name [as mapped in the `CommandsMapping`](src/commands-setup/CommandsMapping.ts#L13), and the same parameters `key` 231 | as defined in [the command constructor](src/scene-commands/create-shapes/CreateShapesCommand.ts#L12) 232 | - Configure optional parameters and how they map to nullable TypeScript arguments 233 | - Specify suggestions for some parameter values that can be programmatically set. Example 234 | in [the `figma-entrypoint`](src/figma-entrypoint.ts#L28) for the `typeOfShapes` parameter. 235 | 236 | #### 🎨 Paint current user avatar 237 | 238 | ![How the use case paint out the avatar and its user name](assets/paint-current-user-avatar-use-case.png) 239 | 240 | Demonstrative purposes: 241 | 242 | - Communicate back from the Figma Scene Sandbox to the Figma Browser iframe in order to perform the HTTP request in 243 | order to get the actual user avatar image based on its URL due to not having access to browser APIs inside 244 | the `src/scene-commands` world 245 | - Define the architecture in order to have that HTTP request response handler defined in a cohesive way inside the 246 | actual use case which fires it. Example in 247 | the [`PaintCurrentUserAvatarCommandHandler`](src/scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommandHandler.ts#L29) 248 | . 249 | - Paint an image inside the Figma scene based on its binary information 250 | - Declare a more complex menu structure containing separators and sub-menu items 251 | - Loading the text font needed in order to create a text layer and position it relative to the image size 252 | 253 | ### 🫵 Simplified communication 254 | 255 | If you take a look at the official documentation 256 | on [how Figma Plugins run](https://www.figma.com/plugin-docs/how-plugins-run/), you will see that there is 257 | a `postMessage` function in order to communicate between the two Figma Plugin worlds previously described: 258 | 259 | ![Original Figma Plugins Architecture](assets/figma-plugins-architecture-original.png) 260 | 261 | However, that `postMessage` function is different depending on where you are executing it: 262 | 263 | - From the Figma Scene sandbox to the UI iframe: `figma.ui.postMessage(message)` 264 | - From the UI iframe to the Figma Scene sandbox: `window.parent.postMessage({ pluginMessage: command }, "*")` 265 | 266 | We have simplified this with an abstraction that also provides semantics and type constraints making it easier to use. 267 | You only have to use [the `executeCommand` function](src/commands-setup/executeCommand.ts) without worrying about 268 | anything else: 269 | 270 | ```typescript 271 | import { executeCommand } from "./commands-setup/executeCommand"; 272 | 273 | executeCommand(new CancelCommand()); 274 | ``` 275 | 276 | This is why you will see it on the Codely Figma Plugin Architecture diagram while communicating on both ways: 277 | 278 | ![Codely Figma Plugin Skeleton Architecture](assets/figma-plugins-architecture.png 'There are 2 different "worlds". The Figma scene one, and the browser iframe one') 279 | 280 | ### ✅ Software development best practices 281 | 282 | Focus of all the decisions made in the development of this skeleton: Let you, the developer of the plugin that end users 283 | will install, **focus on implementing your actual use cases** instead of all the surrounding boilerplate ⚡ 284 | 285 | We have followed an approach for developing this Codely Figma Plugin Skeleton based on the SOLID Software Principles, 286 | specially the Open/Closed Principle in order to make it easy for you to **extend the capabilities of your plugin with 287 | just adding little pieces of code** in a very structured way 😊 288 | 289 | ### ✨ Developer and end user experience 290 | 291 | This skeleton already provides a friendly way to handle error produced by the plugins built with it. 292 | 293 | If your plugin makes use of the `executeCommand` method in order to execute commands, we already have you covered in 294 | case you have not registered them yet. It would be visible in the actual Figma interface, and specify all the details in 295 | the JavaScript console, ¡even suggesting a fix! 🌈: 296 | 297 | ![Error seen if you do not add your new command.](assets/error-handling-unhandled-plugin-exception.png) 298 | 299 | In case you already registered your command, but it throws an unhandled by you error for whatever reason, we propagate 300 | it to the end user in a very friendly way 😇: 301 | 302 | ![Error seen if you do not handle it.](assets/error-handling-unregistered-command.png) 303 | 304 | ### 🧰 Tooling already configured 305 | 306 | - [TypeScript](https://typescriptlang.org) (v4) 307 | - [Prettier](https://prettier.io) 308 | - [Webpack](https://webpack.js.org) 309 | - [ESLint](https://eslint.org) with: 310 | - [Simple Import Sort](https://github.com/lydell/eslint-plugin-simple-import-sort) 311 | - [Import plugin](https://github.com/benmosher/eslint-plugin-import) 312 | - And a few other ES2015+ related rules 313 | - [Jest](https://jestjs.io) with [DOM Testing Library](https://testing-library.com/docs/dom-testing-library/intro) 314 | - [GitHub Action workflows](https://github.com/CodelyTV/figma-plugin-skeleton/actions) set up to run tests and linting 315 | on push 316 | - [SWC](https://swc.rs): Execute your tests in less than 200ms 317 | 318 | ### 🤏 Decisions made to promote code quality and structure consistency 319 | 320 | - Specify proper dependencies version restriction (no wild wildcards `*`) 321 | - Encapsulate all the transpiled code into the `dist` folder 322 | - Encapsulate all the Plugin source code into the `src` folder 323 | - Configure TypeScript through the `tsconfig.json` in order to promote safety and robust contracts (no more `any` 324 | paradise) 325 | - Add code style checker with Prettier and ESLint 326 | - Add test suite runner with Jest 327 | - Add Continuous Integration Workflow with GitHub Actions 328 | 329 | ### 🧽 Remove unnecessary code 330 | 331 | Depending on your plugin type you will find unnecessary code in this template. However, here you have the instructions 332 | on how to delete it with a few commands 😊 333 | 334 | #### 🙈 Plugins without UI 335 | 336 | ☝️ Attention: We will not remove the `ui` key from the `manifest.json` and some JS code such as 337 | the `registerUiCommandHandlers` function call because we still need them even if we do not have a UI. The reason why is 338 | that this code is used as an invisible UI while communicating from the Scene Sandbox to the UI iframe in order to access 339 | browser APIs. These browser APIs are used for instance while performing network requests from our plugin. See more on 340 | the "⚡ Commands" software architecture section. 341 | 342 | - Remove unneeded dependencies: `npm remove style-loader css-loader figma-plugin-ds` 343 | - [`webpack.config.js`](webpack.config.js): Remove the css and static assets rules from `module.exports.module.rules` 344 | only leaving out the ts files one 345 | - Remove the visual parts of the UI: 346 | - `rm src/ui/register-ui-command-handlers.ts` 347 | - `echo -n "" >| src/ui/ui.html` 348 | - `echo "import { registerUiCommandHandlers } from \"./register-ui-command-handlers\";\n\nregisterUiCommandHandlers();" >| src/ui/ui.ts` 349 | 350 | #### ☝️ Plugins without menus (just a single use case) 351 | 352 | - [`manifest.json`](manifest.json): Remove the `menu` property 353 | - Modify the [`src/figma-entrypoint.ts`](src/figma-entrypoint.ts) removing the support for menu commands and directly 354 | executing your use case command keeping the support for the invisible UI. Example for a plugin which only would 355 | execute the `paintCurrentUserAvatar` command: 356 | ```typescript 357 | import { handleCommand } from "./commands-setup/handleCommand"; 358 | import { PaintCurrentUserAvatarCommand } from "./scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommand"; 359 | 360 | createInvisibleUiForBrowserApiAccess(); 361 | 362 | await handleCommand(new PaintCurrentUserAvatarCommand()); 363 | 364 | function createInvisibleUiForBrowserApiAccess() { 365 | figma.showUI(__html__, { visible: false }); 366 | } 367 | ``` 368 | 369 | #### 🖌️ Plugins without FigJam support 370 | 371 | [`manifest.json`](manifest.json): Remove the `figjam` value from the `editorType` property, leaving the property as an 372 | array but only containing the `figma` value. 373 | 374 | #### 🧊 Plugins without tests 375 | 376 | - Remove the `✅ Run tests` step from [the Continuous Integration pipeline](.github/workflows/ci.yml) 377 | - `rm -rf tests` 378 | - `rm -rf jest.config.js` 379 | - `npm remove jest @types/jest jest-mock-extended @swc/jest @swc/core` 380 | - Remove the `scripts.test` property from the [`package.json`](package.json) 381 | 382 | #### 🔒 Plugins without special permissions 383 | 384 | Remove the `permissions` key from your `manifest.json`. 385 | 386 | ## 👀 Inspiration 387 | 388 | Other Figma plugins repositories where we found inspiration to create this one: 389 | 390 | - [figma-plugin-typescript-boilerplate](https://github.com/aarongarciah/figma-plugin-typescript-boilerplate) 391 | - [Create Figma Plugin](https://yuanqing.github.io/create-figma-plugin/) 392 | 393 | ## 👌 Codely Code Quality Standards 394 | 395 | Publishing this package we are committing ourselves to the following code quality standards: 396 | 397 | - 🤝 Respect **Semantic Versioning**: No breaking changes in patch or minor versions 398 | - 🤏 No surprises in transitive dependencies: Use the **bare minimum dependencies** needed to meet the purpose 399 | - 🎯 **One specific purpose** to meet without having to carry a bunch of unnecessary other utilities 400 | - ✅ **Tests** as documentation and usage examples 401 | - 📖 **Well documented ReadMe** showing how to install and use 402 | - ⚖️ **License favoring Open Source** and collaboration 403 | 404 | ## 🔀 Related skeleton templates 405 | 406 | Opinionated TypeScript skeletons ready for different purposes: 407 | 408 | - [🔷🌱 TypeScript Basic Skeleton](https://github.com/CodelyTV/typescript-basic-skeleton) 409 | - [🔷🕸️ TypeScript Web Skeleton](https://github.com/CodelyTV/typescript-web-skeleton) 410 | - [🔷🌍 TypeScript API Skeleton](https://github.com/CodelyTV/typescript-api-skeleton) 411 | - [🔷✨ TypeScript DDD Skeleton](https://github.com/CodelyTV/typescript-ddd-skeleton) 412 | 413 | This very same basic skeleton philosophy implemented in other programming languages: 414 | 415 | - [✨ JavaScript Basic Skeleton](https://github.com/CodelyTV/javascript-basic-skeleton) 416 | - [☕ Java Basic Skeleton](https://github.com/CodelyTV/java-basic-skeleton) 417 | - [📍 Kotlin Basic Skeleton](https://github.com/CodelyTV/kotlin-basic-skeleton) 418 | - [🧬 Scala Basic Skeleton](https://github.com/CodelyTV/scala-basic-skeleton) 419 | - [🦈 C# Basic Skeleton](https://github.com/CodelyTV/csharp-basic-skeleton) 420 | - [🐘 PHP Basic Skeleton](https://github.com/CodelyTV/php-basic-skeleton) 421 | -------------------------------------------------------------------------------- /assets/error-handling-unhandled-plugin-exception.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/error-handling-unhandled-plugin-exception.png -------------------------------------------------------------------------------- /assets/error-handling-unregistered-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/error-handling-unregistered-command.png -------------------------------------------------------------------------------- /assets/figma-plugins-architecture-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/figma-plugins-architecture-original.png -------------------------------------------------------------------------------- /assets/figma-plugins-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/figma-plugins-architecture.png -------------------------------------------------------------------------------- /assets/multiple-use-cases-figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/multiple-use-cases-figma-plugin.png -------------------------------------------------------------------------------- /assets/paint-current-user-avatar-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/paint-current-user-avatar-use-case.png -------------------------------------------------------------------------------- /assets/plugin-use-cases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/plugin-use-cases.png -------------------------------------------------------------------------------- /assets/shapes-creator-form-use-case-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/shapes-creator-form-use-case-result.png -------------------------------------------------------------------------------- /assets/shapes-creator-form-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/shapes-creator-form-use-case.png -------------------------------------------------------------------------------- /assets/shapes-creator-parametrized-suggestions-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/shapes-creator-parametrized-suggestions-use-case.png -------------------------------------------------------------------------------- /assets/shapes-creator-parametrized-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/shapes-creator-parametrized-use-case.png -------------------------------------------------------------------------------- /assets/single-use-case-figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-skeleton/53b0d408fc633f6b54a408846d9db68cae862878/assets/single-use-case-figma-plugin.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ["**/tests/**/*.test.ts"], 3 | transform: { 4 | "\\.ts$": "@swc/jest", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Codely Figma Plugin Skeleton", 3 | "id": "1131536692847981535", 4 | "api": "1.0.0", 5 | "main": "dist/figmaEntrypoint.js", 6 | "ui": "dist/ui.html", 7 | "permissions": [ 8 | "currentuser" 9 | ], 10 | "editorType": [ 11 | "figma", 12 | "figjam" 13 | ], 14 | "menu": [ 15 | { 16 | "name": "Shapes Creator Form", 17 | "command": "showUi" 18 | }, 19 | { 20 | "name": "Shapes Creator Parametrized", 21 | "command": "createShapes", 22 | "parameters": [ 23 | { 24 | "name": "Number of shapes", 25 | "key": "numberOfShapes", 26 | "allowFreeform": true 27 | }, 28 | { 29 | "name": "Type of shapes", 30 | "key": "typeOfShapes", 31 | "description": "Only supports Rectangle and Ellipse, but it has autocomplete suggestions!", 32 | "optional": true 33 | } 34 | ] 35 | }, 36 | { 37 | "separator": true 38 | }, 39 | { 40 | "name": "Other actions", 41 | "menu": [ 42 | { 43 | "name": "Paint current user avatar", 44 | "command": "paintCurrentUserAvatar" 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codely/figma-plugin-skeleton", 3 | "version": "1.0.0", 4 | "description": "Bootstrap your new Figma Plugin with the bare minimum dependencies", 5 | "private": true, 6 | "scripts": { 7 | "build": "webpack", 8 | "dev": "webpack --watch", 9 | "lint": "eslint --ignore-path .gitignore . --ext .ts", 10 | "lint:fix": "npm run lint -- --fix", 11 | "test": "jest" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/CodelyTV/figma-plugin-skeleton.git" 16 | }, 17 | "author": "codelytv", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/CodelyTV/figma-plugin-skeleton/issues" 21 | }, 22 | "homepage": "https://github.com/CodelyTV/figma-plugin-skeleton#readme", 23 | "devDependencies": { 24 | "@figma/plugin-typings": "^1.49.0", 25 | "@swc/core": "^1.2.218", 26 | "@swc/jest": "^0.2.22", 27 | "@types/jest": "^28.1.6", 28 | "@typescript-eslint/eslint-plugin": "^5.30.7", 29 | "@typescript-eslint/parser": "^5.30.7", 30 | "css-loader": "^6.7.1", 31 | "eslint": "^8.20.0", 32 | "eslint-config-prettier": "^8.5.0", 33 | "eslint-plugin-import": "^2.26.0", 34 | "eslint-plugin-jest": "^26.6.0", 35 | "eslint-plugin-prettier": "^4.2.1", 36 | "eslint-plugin-simple-import-sort": "^7.0.0", 37 | "html-inline-script-webpack-plugin": "^3.0.1", 38 | "html-webpack-plugin": "^5.5.0", 39 | "jest": "^28.1.3", 40 | "jest-mock-extended": "^2.0.7", 41 | "prettier": "^2.7.1", 42 | "style-loader": "^3.3.1", 43 | "ts-loader": "^9.3.1", 44 | "typescript": "^4.7.4", 45 | "webpack": "^5.74.0", 46 | "webpack-cli": "^4.10.0" 47 | }, 48 | "dependencies": { 49 | "figma-plugin-ds": "^1.0.1" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/browser-commands/network-request/NetworkRequestCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../../commands-setup/Command"; 2 | 3 | type SupportedResponseTypes = "text" | "arraybuffer"; 4 | 5 | export class NetworkRequestCommand implements Command { 6 | readonly type = "networkRequest"; 7 | readonly payload: { 8 | url: string; 9 | responseType: SupportedResponseTypes; 10 | }; 11 | 12 | constructor(url: string, responseType: SupportedResponseTypes) { 13 | this.payload = { url, responseType }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/browser-commands/network-request/NetworkRequestCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from "../../commands-setup/CommandHandler"; 2 | import { executeCommand } from "../../commands-setup/executeCommand"; 3 | import { NetworkRequestCommand } from "./NetworkRequestCommand"; 4 | 5 | export class NetworkRequestCommandHandler 6 | implements CommandHandler 7 | { 8 | async handle(command: NetworkRequestCommand): Promise { 9 | const url = `https://cors-anywhere.herokuapp.com/${command.payload.url}`; 10 | const method = "GET"; 11 | 12 | return new Promise((resolve) => { 13 | const request = new XMLHttpRequest(); 14 | request.open(method, url); 15 | request.responseType = command.payload.responseType; 16 | request.onload = () => { 17 | const commandToPost = { 18 | type: "networkRequestResponse", 19 | payload: request.response, 20 | }; 21 | 22 | executeCommand(commandToPost); 23 | resolve(); 24 | }; 25 | request.send(); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/commands-setup/Command.ts: -------------------------------------------------------------------------------- 1 | import { CommandsMapping } from "./CommandsMapping"; 2 | 3 | export interface Command { 4 | readonly type: keyof typeof CommandsMapping; 5 | readonly payload?: unknown; 6 | } 7 | -------------------------------------------------------------------------------- /src/commands-setup/CommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "./Command"; 2 | 3 | export abstract class CommandHandler { 4 | abstract handle(command: CommandType): Promise | void; 5 | } 6 | -------------------------------------------------------------------------------- /src/commands-setup/CommandsMapping.ts: -------------------------------------------------------------------------------- 1 | import { NetworkRequestCommandHandler } from "../browser-commands/network-request/NetworkRequestCommandHandler"; 2 | import { CancelCommandHandler } from "../scene-commands/cancel/CancelCommandHandler"; 3 | import { CreateShapesCommandHandler } from "../scene-commands/create-shapes/CreateShapesCommandHandler"; 4 | import { PaintCurrentUserAvatarCommandHandler } from "../scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommandHandler"; 5 | import { Command } from "./Command"; 6 | import { CommandHandler } from "./CommandHandler"; 7 | 8 | // 👋 Add below your new commands. 9 | // Define its arbitrary key and its corresponding Handler class. 10 | // Tip: Declare your Command and CommandHandler classes creating a folder inside the `src/scene-commands` or `src/browser-commands` ones depending on the things you need to get access to (see the README explanation) 😊 11 | export const CommandsMapping: Record CommandHandler> = { 12 | cancel: () => new CancelCommandHandler(figma), 13 | createShapes: () => new CreateShapesCommandHandler(figma), 14 | paintCurrentUserAvatar: () => new PaintCurrentUserAvatarCommandHandler(figma), 15 | networkRequest: () => new NetworkRequestCommandHandler(), 16 | }; 17 | -------------------------------------------------------------------------------- /src/commands-setup/executeCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "./Command"; 2 | 3 | export const executeCommand = (command: Command): void => { 4 | const isFromSceneSandboxToUiIframe = typeof window === "undefined"; 5 | 6 | isFromSceneSandboxToUiIframe 7 | ? figma.ui.postMessage(command) 8 | : window.parent.postMessage({ pluginMessage: command }, "*"); 9 | }; 10 | -------------------------------------------------------------------------------- /src/commands-setup/handleCommand.ts: -------------------------------------------------------------------------------- 1 | import manifest from "../../manifest.json"; 2 | import { Command } from "./Command"; 3 | import { CommandsMapping } from "./CommandsMapping"; 4 | 5 | export async function handleCommand(command: Command): Promise { 6 | if (!(command.type in CommandsMapping)) { 7 | notifyErrorToEndUser( 8 | `Trying to execute the command \`${command.type}\` but it is not registered in the \`CommandsMapping.ts\` file. If you are the developer, go to the \`CommandsMapping.ts\` file and register it to the const with: \`${command.type}: ${command.type}CommandHandler,\`` 9 | ); 10 | 11 | figma.closePlugin(); 12 | return; 13 | } 14 | 15 | const commandHandler = CommandsMapping[command.type](); 16 | 17 | try { 18 | await commandHandler.handle(command); 19 | } catch (error) { 20 | notifyErrorToEndUser( 21 | `"${error}" executing the command \`${command.type}\`. This command is mapped to a class in the \`CommandsMapping.ts\` file. It could be a good starting point to look for the bug 😊` 22 | ); 23 | } finally { 24 | const isACommandInsideAnotherCommand = command.type === "networkRequest"; 25 | 26 | if (!isACommandInsideAnotherCommand) { 27 | figma.closePlugin(); 28 | } 29 | } 30 | } 31 | 32 | function notifyErrorToEndUser(errorMessage: string): void { 33 | figma.notify( 34 | `🫣 Error in Figma plugin "${manifest.name}". See the JavaScript console for more info.`, 35 | { error: true } 36 | ); 37 | 38 | console.error( 39 | `🫣️ Error in Figma plugin "${manifest.name}"\r\nFigma Plugin ID: "${figma.pluginId}"\r\n\r\n${errorMessage}.` 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/figma-entrypoint.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "./commands-setup/Command"; 2 | import { handleCommand } from "./commands-setup/handleCommand"; 3 | 4 | registerPluginMenuCommandHandlers(); 5 | registerPluginMenuCommandParametersSuggestions(); 6 | registerPluginUiCommandHandlers(); 7 | 8 | function registerPluginMenuCommandHandlers() { 9 | figma.on("run", async (event: RunEvent) => { 10 | const hasToAccessPluginIframe = event.command === "showUi"; 11 | if (hasToAccessPluginIframe) { 12 | figma.showUI(__html__, { themeColors: true }); 13 | figma.ui.resize(450, 300); 14 | return; 15 | } 16 | 17 | createInvisibleUiForBrowserApiAccess(); 18 | 19 | const command = { 20 | type: event.command, 21 | payload: event.parameters, 22 | }; 23 | 24 | await handleCommand(command); 25 | }); 26 | } 27 | 28 | function registerPluginMenuCommandParametersSuggestions() { 29 | figma.parameters.on( 30 | "input", 31 | async ({ key, query, result }: ParameterInputEvent) => { 32 | switch (key) { 33 | case "typeOfShapes": 34 | const shapes = ["Rectangle", "Ellipse"]; 35 | const queryMatchingShapes = shapes.filter((s) => s.startsWith(query)); 36 | 37 | result.setSuggestions(queryMatchingShapes); 38 | break; 39 | default: 40 | break; 41 | } 42 | } 43 | ); 44 | } 45 | 46 | function registerPluginUiCommandHandlers() { 47 | figma.ui.onmessage = async ( 48 | command: CommandType 49 | ) => await handleCommand(command); 50 | } 51 | 52 | function createInvisibleUiForBrowserApiAccess() { 53 | figma.showUI(__html__, { visible: false }); 54 | } 55 | -------------------------------------------------------------------------------- /src/scene-commands/cancel/CancelCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../../commands-setup/Command"; 2 | 3 | export class CancelCommand implements Command { 4 | readonly type = "cancel"; 5 | } 6 | -------------------------------------------------------------------------------- /src/scene-commands/cancel/CancelCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from "../../commands-setup/CommandHandler"; 2 | import { CancelCommand } from "./CancelCommand"; 3 | 4 | export class CancelCommandHandler implements CommandHandler { 5 | constructor(private readonly figma: PluginAPI) {} 6 | 7 | // `command` argument needed due to polymorphism. 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | handle(command: CancelCommand): void { 10 | this.figma.notify("👋 Good bye!"); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/scene-commands/create-shapes/CreateShapesCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../../commands-setup/Command"; 2 | 3 | export type SupportedShapes = "Rectangle" | "Ellipse"; 4 | 5 | export class CreateShapesCommand implements Command { 6 | readonly type = "createShapes"; 7 | readonly payload: { 8 | numberOfShapes: number; 9 | typeOfShapes?: SupportedShapes; 10 | }; 11 | 12 | constructor(numberOfShapes: number, typeOfShapes?: SupportedShapes) { 13 | this.payload = { numberOfShapes, typeOfShapes }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/scene-commands/create-shapes/CreateShapesCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from "../../commands-setup/CommandHandler"; 2 | import { CreateShapesCommand, SupportedShapes } from "./CreateShapesCommand"; 3 | 4 | export class CreateShapesCommandHandler 5 | implements CommandHandler 6 | { 7 | private readonly separationBetweenShapes = 150; 8 | 9 | constructor(private readonly figma: PluginAPI) {} 10 | 11 | handle({ 12 | payload: { numberOfShapes, typeOfShapes }, 13 | }: CreateShapesCommand): void { 14 | const shapes = Array.from({ length: numberOfShapes }).map( 15 | this.toShape(typeOfShapes) 16 | ); 17 | 18 | this.focusUiOn(shapes); 19 | } 20 | 21 | private toShape( 22 | typeOfShapes?: SupportedShapes 23 | ): (_: unknown, iteration: number) => SceneNode { 24 | return (_: unknown, iteration: number): SceneNode => { 25 | const hasToRandomizeShapeTypes = typeOfShapes === undefined; 26 | 27 | const shapesCreator = hasToRandomizeShapeTypes 28 | ? this.createRandomShape.bind(this) 29 | : this.createShape(typeOfShapes); 30 | 31 | const shape = shapesCreator(); 32 | this.styleShape(shape, iteration); 33 | 34 | this.figma.currentPage.appendChild(shape); 35 | 36 | return shape; 37 | }; 38 | } 39 | 40 | private createShape( 41 | typeOfShapes: SupportedShapes 42 | ): () => RectangleNode | EllipseNode { 43 | return (): RectangleNode | EllipseNode => 44 | typeOfShapes === "Rectangle" 45 | ? this.figma.createRectangle() 46 | : this.figma.createEllipse(); 47 | } 48 | 49 | private createRandomShape(): RectangleNode | EllipseNode { 50 | const isRectangleShape = this.randomBoolean(); 51 | 52 | if (isRectangleShape) { 53 | return this.figma.createRectangle(); 54 | } 55 | 56 | return this.figma.createEllipse(); 57 | } 58 | 59 | private styleShape( 60 | shape: RectangleNode | EllipseNode, 61 | shapeNumber: number 62 | ): void { 63 | shape.x = shapeNumber * this.separationBetweenShapes; 64 | shape.rotation = Math.random() * 100; 65 | shape.fills = [{ type: "SOLID", color: this.randomColor() }]; 66 | shape.name = `${shape.name} ${shapeNumber}`; 67 | } 68 | 69 | private randomBoolean(): boolean { 70 | return Math.random() < 0.5; 71 | } 72 | 73 | private randomColor(): RGB { 74 | return { r: Math.random(), g: Math.random(), b: Math.random() }; 75 | } 76 | 77 | private focusUiOn(createdShapes: SceneNode[]) { 78 | this.figma.currentPage.selection = createdShapes; 79 | this.figma.viewport.scrollAndZoomIntoView(createdShapes); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../../commands-setup/Command"; 2 | 3 | export class PaintCurrentUserAvatarCommand implements Command { 4 | readonly type = "paintCurrentUserAvatar"; 5 | } 6 | -------------------------------------------------------------------------------- /src/scene-commands/paint-current-user-avatar/PaintCurrentUserAvatarCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { NetworkRequestCommand } from "../../browser-commands/network-request/NetworkRequestCommand"; 2 | import { CommandHandler } from "../../commands-setup/CommandHandler"; 3 | import { executeCommand } from "../../commands-setup/executeCommand"; 4 | import { PaintCurrentUserAvatarCommand } from "./PaintCurrentUserAvatarCommand"; 5 | 6 | export class PaintCurrentUserAvatarCommandHandler 7 | implements CommandHandler 8 | { 9 | private readonly avatarImageSize = 100; 10 | 11 | constructor(private readonly figma: PluginAPI) {} 12 | 13 | // `command` argument needed due to polymorphism. 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | handle(command: PaintCurrentUserAvatarCommand): Promise { 16 | const currentUserAvatarUrl = this.figma.currentUser?.photoUrl; 17 | const currentUserName = this.figma.currentUser?.name; 18 | 19 | if (currentUserAvatarUrl === undefined || currentUserAvatarUrl === null) { 20 | this.figma.notify("Sorry but you do not have an avatar to add 😅"); 21 | 22 | return Promise.resolve(); 23 | } 24 | 25 | const responseType = "arraybuffer"; 26 | executeCommand( 27 | new NetworkRequestCommand(currentUserAvatarUrl, responseType) 28 | ); 29 | 30 | return new Promise((resolve) => { 31 | this.figma.ui.onmessage = async (command) => { 32 | this.ensureToOnlyReceiveNetworkRequestResponse(command); 33 | 34 | await this.createAvatarBadge( 35 | command.payload as ArrayBuffer, 36 | currentUserName as string 37 | ); 38 | resolve(); 39 | }; 40 | }); 41 | } 42 | 43 | private ensureToOnlyReceiveNetworkRequestResponse(command: { type: string }) { 44 | if (command.type !== "networkRequestResponse") { 45 | const errorMessage = 46 | "Unexpected command received while performing the request for painting the user avatar."; 47 | 48 | throw new Error(errorMessage); 49 | } 50 | } 51 | 52 | private async createAvatarBadge( 53 | imageBuffer: ArrayBuffer, 54 | userName: string 55 | ): Promise { 56 | const avatarImage = this.createAvatarImage(imageBuffer, userName); 57 | const userNameText = await this.createAvatarText(userName); 58 | 59 | const elementsToFocus = [avatarImage, userNameText]; 60 | this.figma.currentPage.selection = elementsToFocus; 61 | this.figma.viewport.scrollAndZoomIntoView(elementsToFocus); 62 | } 63 | 64 | private createAvatarImage( 65 | avatarImage: ArrayBuffer, 66 | currentUserName: string 67 | ): EllipseNode { 68 | const imageUint8Array = new Uint8Array(avatarImage); 69 | const figmaImage = this.figma.createImage(imageUint8Array); 70 | const imageWrapper = this.figma.createEllipse(); 71 | 72 | imageWrapper.x = this.figma.viewport.center.x; 73 | imageWrapper.y = this.figma.viewport.center.y; 74 | imageWrapper.resize(this.avatarImageSize, this.avatarImageSize); 75 | imageWrapper.fills = [ 76 | { type: "IMAGE", scaleMode: "FILL", imageHash: figmaImage.hash }, 77 | ]; 78 | imageWrapper.name = `${currentUserName} avatar`; 79 | 80 | this.figma.currentPage.appendChild(imageWrapper); 81 | 82 | return imageWrapper; 83 | } 84 | 85 | private async createAvatarText(userName: string): Promise { 86 | const userNameText = this.figma.createText(); 87 | userNameText.x = this.figma.viewport.center.x - userName.length / 2; 88 | userNameText.y = 89 | this.figma.viewport.center.y + 90 | this.avatarImageSize + 91 | this.avatarImageSize / 12; 92 | 93 | await this.figma.loadFontAsync(userNameText.fontName as FontName); 94 | userNameText.characters = userName; 95 | userNameText.fontSize = 14; 96 | 97 | return userNameText; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/ui/register-ui-command-handlers.ts: -------------------------------------------------------------------------------- 1 | import { handleCommand } from "../commands-setup/handleCommand"; 2 | 3 | export function registerUiCommandHandlers() { 4 | window.onmessage = async (event: MessageEvent) => { 5 | const command = { 6 | type: event.data.pluginMessage.type, 7 | payload: event.data.pluginMessage.payload, 8 | }; 9 | 10 | await handleCommand(command); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/register-ui-event-listeners.ts: -------------------------------------------------------------------------------- 1 | import { executeCommand } from "../commands-setup/executeCommand"; 2 | import { CancelCommand } from "../scene-commands/cancel/CancelCommand"; 3 | import { CreateShapesCommand } from "../scene-commands/create-shapes/CreateShapesCommand"; 4 | 5 | export function registerUiEventListeners(): void { 6 | document.addEventListener("click", function (event: MouseEvent) { 7 | const target = event.target as HTMLElement; 8 | 9 | switch (target.id) { 10 | case "create": 11 | const textBox = document.getElementById("count") as HTMLInputElement; 12 | const countBase = 10; 13 | const count = parseInt(textBox.value, countBase); 14 | 15 | executeCommand(new CreateShapesCommand(count)); 16 | break; 17 | case "cancel": 18 | executeCommand(new CancelCommand()); 19 | break; 20 | } 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/ui.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: var(--figma-color-bg); 3 | color: var(--figma-color-text); 4 | padding: 24px; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/ui.html: -------------------------------------------------------------------------------- 1 | 2 | Codely Figma Plugin Skeleton 3 | 4 | 5 |

Shape Creator

6 | 7 |

ℹ️ You can change your color scheme in "Figma menu > Preferences… > Teme" and check out how the plugin aesthetics adapts.

8 | 9 |
10 |
11 | 14 |
15 | 16 | 17 | 18 |
19 | 20 | -------------------------------------------------------------------------------- /src/ui/ui.ts: -------------------------------------------------------------------------------- 1 | import "figma-plugin-ds/dist/figma-plugin-ds.css"; 2 | import "./ui.css"; 3 | 4 | import { registerUiCommandHandlers } from "./register-ui-command-handlers"; 5 | import { registerUiEventListeners } from "./register-ui-event-listeners"; 6 | 7 | registerUiEventListeners(); 8 | registerUiCommandHandlers(); 9 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest"], 3 | "env": { 4 | "jest/globals": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/scene-commands/CancelCommandHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "jest-mock-extended"; 2 | 3 | import { CancelCommand } from "../../src/scene-commands/cancel/CancelCommand"; 4 | import { CancelCommandHandler } from "../../src/scene-commands/cancel/CancelCommandHandler"; 5 | 6 | describe("CancelCommandHandler", () => { 7 | it("can be instantiated without throwing errors", () => { 8 | const figmaPluginApiMock = mock(); 9 | 10 | const cancelCommandHandlerInstantiator = () => { 11 | new CancelCommandHandler(figmaPluginApiMock); 12 | }; 13 | 14 | expect(cancelCommandHandlerInstantiator).not.toThrow(TypeError); 15 | }); 16 | 17 | it("notifies the end used with a farewell message", () => { 18 | const figmaPluginApiMock = mock(); 19 | const cancelCommandHandler = new CancelCommandHandler(figmaPluginApiMock); 20 | const randomCancelCommand = new CancelCommand(); 21 | 22 | cancelCommandHandler.handle(randomCancelCommand); 23 | 24 | const farewellMessage = "👋 Good bye!"; 25 | expect(figmaPluginApiMock.notify).toHaveBeenCalledWith(farewellMessage); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "resolveJsonModule": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "outDir": "dist", 13 | "typeRoots": [ 14 | "./node_modules/@types", 15 | "./node_modules/@figma" 16 | ] 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | const path = require("path"); 5 | 6 | module.exports = { 7 | mode: "production", 8 | devtool: false, 9 | experiments: { 10 | topLevelAwait: true, 11 | }, 12 | entry: { 13 | ui: "./src/ui/ui.ts", 14 | figmaEntrypoint: "./src/figma-entrypoint.ts", 15 | }, 16 | resolve: { 17 | extensions: [".ts"], 18 | }, 19 | module: { 20 | rules: [ 21 | { test: /\.ts$/, loader: "ts-loader", exclude: /node_modules/ }, 22 | 23 | // Enables including CSS by doing `import './file.css'` in your TypeScript code 24 | { test: /\.css$/, use: ["style-loader", "css-loader"] }, 25 | 26 | // Allows you to use `<%= require('./file.svg') %>` in your HTML code to get a data URI 27 | { test: /\.(png|jpg|gif|svg|webp)$/, type: "asset/inline" }, 28 | ], 29 | }, 30 | output: { 31 | filename: "[name].js", 32 | path: path.resolve(__dirname, "dist"), 33 | }, 34 | 35 | // Tells Webpack to generate `ui.html` and to inline `ui.ts` into it 36 | plugins: [ 37 | new HtmlWebpackPlugin({ 38 | template: "./src/ui/ui.html", 39 | filename: "ui.html", 40 | inlineSource: ".(js)$", 41 | chunks: ["ui"], 42 | }), 43 | new HtmlInlineScriptPlugin({ 44 | scriptMatchPattern: [/ui.js/], 45 | htmlMatchPattern: [/ui.html/], 46 | }), 47 | ], 48 | }; 49 | --------------------------------------------------------------------------------