├── .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 |
4 |
5 |
6 |
7 |
8 | 🪆 Codely Figma Plugin Skeleton
9 |
10 |
11 |
12 |
13 |
14 |
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 | 
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 | 
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 | 
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 | 
194 |
195 | #### 👀 Shapes Creator Form
196 |
197 | 
198 | 
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 | 
215 |
216 | It even allows you to configure optional parameters and suggestions for them:
217 |
218 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
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 |
--------------------------------------------------------------------------------