├── .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 │ ├── create-pages │ ├── CreatePagesCommand.ts │ └── CreatePagesCommandHandler.ts │ └── paint-current-user-avatar │ ├── PaintCurrentUserAvatarCommand.ts │ └── PaintCurrentUserAvatarCommandHandler.ts ├── tests ├── .eslintrc ├── figma-mocks │ └── figma-mocks.ts └── scene-commands │ └── CreatePagesCommandHandler.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 Structurer Figma Plugin 9 |

10 | 11 |

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

16 | 17 |

18 | Figma Plugin for speeding up and ensure consistency in the structure of your Figma projects 19 |
20 |
21 | Stars are welcome 😊 22 |

23 | 24 | ## 👤 Using the plugin 25 | 26 | ### ⬇️ Installing the plugin 27 | 28 | ⚠️ ToDo 29 | 30 | ## 💻 Developing the plugin 31 | 32 | ### 🚀 Running the app 33 | 34 | - Install the dependencies: `npm install` 35 | - Execute the tests: `npm run test` 36 | - Check linter errors: `npm run lint` 37 | - Fix linter errors: `npm run lint:fix` 38 | - Make a build unifying everything in the same `dist/figmaEntrypoint.js` file: `npm run build` 39 | - Run a watcher on your plugin files and make the build on every change: `npm run dev` 40 | 41 | ### 👌 Codely Code Quality Standards 42 | 43 | Publishing this package we are committing ourselves to the following code quality standards: 44 | 45 | - 🤝 Respect **Semantic Versioning**: No breaking changes in patch or minor versions 46 | - 🤏 No surprises in transitive dependencies: Use the **bare minimum dependencies** needed to meet the purpose 47 | - 🎯 **One specific purpose** to meet without having to carry a bunch of unnecessary other utilities 48 | - ✅ **Tests** as documentation and usage examples 49 | - 📖 **Well documented ReadMe** showing how to install and use 50 | - ⚖️ **License favoring Open Source** and collaboration 51 | 52 | ## 🔀 Related resources 53 | 54 | - [🪆 Codely Figma Plugin Skeleton](https://github.com/CodelyTV/figma-plugin-skeleton): Used as a template to bootstrap this plugin 55 | - [⚠️ ToDo](https://codely.com): Course illustrating how to develop Figma plugins 56 | -------------------------------------------------------------------------------- /assets/error-handling-unhandled-plugin-exception.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/error-handling-unhandled-plugin-exception.png -------------------------------------------------------------------------------- /assets/error-handling-unregistered-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/error-handling-unregistered-command.png -------------------------------------------------------------------------------- /assets/figma-plugins-architecture-original.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/figma-plugins-architecture-original.png -------------------------------------------------------------------------------- /assets/figma-plugins-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/figma-plugins-architecture.png -------------------------------------------------------------------------------- /assets/multiple-use-cases-figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/multiple-use-cases-figma-plugin.png -------------------------------------------------------------------------------- /assets/paint-current-user-avatar-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/paint-current-user-avatar-use-case.png -------------------------------------------------------------------------------- /assets/plugin-use-cases.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/plugin-use-cases.png -------------------------------------------------------------------------------- /assets/shapes-creator-form-use-case-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/shapes-creator-form-use-case-result.png -------------------------------------------------------------------------------- /assets/shapes-creator-form-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/shapes-creator-form-use-case.png -------------------------------------------------------------------------------- /assets/shapes-creator-parametrized-suggestions-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/shapes-creator-parametrized-suggestions-use-case.png -------------------------------------------------------------------------------- /assets/shapes-creator-parametrized-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/assets/shapes-creator-parametrized-use-case.png -------------------------------------------------------------------------------- /assets/single-use-case-figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodelyTV/figma-plugin-structurer/f7f8b0a721d0eb3fff9b22955b3c207d1a27b554/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 Structurer Figma Plugin", 3 | "id": "1139148181170685187", 4 | "api": "1.0.0", 5 | "main": "dist/figmaEntrypoint.js", 6 | "editorType": ["figma"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codely/figma-plugin-structurer", 3 | "version": "1.0.0", 4 | "description": "Figma Plugin for speeding up and ensure consistency in the structure of your Figma projects", 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-structurer.git" 16 | }, 17 | "author": "codelytv", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/CodelyTV/figma-plugin-structurer/issues" 21 | }, 22 | "homepage": "https://github.com/CodelyTV/figma-plugin-structurer#readme", 23 | "devDependencies": { 24 | "@figma/plugin-typings": "^1.50.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 | "eslint": "^8.20.0", 31 | "eslint-config-prettier": "^8.5.0", 32 | "eslint-plugin-import": "^2.26.0", 33 | "eslint-plugin-jest": "^26.6.0", 34 | "eslint-plugin-prettier": "^4.2.1", 35 | "eslint-plugin-simple-import-sort": "^7.0.0", 36 | "jest": "^28.1.3", 37 | "jest-mock-extended": "^2.0.7", 38 | "prettier": "^2.7.1", 39 | "ts-loader": "^9.3.1", 40 | "typescript": "^4.7.4", 41 | "webpack": "^5.74.0", 42 | "webpack-cli": "^4.10.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 { CreatePagesCommandHandler } from "../scene-commands/create-pages/CreatePagesCommandHandler"; 3 | import { Command } from "./Command"; 4 | import { CommandHandler } from "./CommandHandler"; 5 | 6 | // 👋 Add below your new commands. 7 | // Define its arbitrary key and its corresponding Handler class. 8 | // 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) 😊 9 | export const CommandsMapping: Record CommandHandler> = { 10 | networkRequest: () => new NetworkRequestCommandHandler(), 11 | createPages: () => new CreatePagesCommandHandler(figma), 12 | }; 13 | -------------------------------------------------------------------------------- /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 { handleCommand } from "./commands-setup/handleCommand"; 2 | import { CreatePagesCommand } from "./scene-commands/create-pages/CreatePagesCommand"; 3 | 4 | createInvisibleUiForBrowserApiAccess(); 5 | 6 | await handleCommand(new CreatePagesCommand()); 7 | 8 | function createInvisibleUiForBrowserApiAccess() { 9 | const randomHtmlToAvoidFigmaError = ""; 10 | figma.showUI(randomHtmlToAvoidFigmaError, { visible: false }); 11 | } 12 | -------------------------------------------------------------------------------- /src/scene-commands/create-pages/CreatePagesCommand.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "../../commands-setup/Command"; 2 | 3 | export class CreatePagesCommand implements Command { 4 | readonly type = "createPages"; 5 | } 6 | -------------------------------------------------------------------------------- /src/scene-commands/create-pages/CreatePagesCommandHandler.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from "../../commands-setup/CommandHandler"; 2 | import { CreatePagesCommand } from "./CreatePagesCommand"; 3 | 4 | export class CreatePagesCommandHandler 5 | implements CommandHandler 6 | { 7 | constructor(private readonly figma: PluginAPI) {} 8 | 9 | // `command` argument needed due to polymorphism. 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | async handle(command: CreatePagesCommand): Promise { 12 | this.createPages(); 13 | const frame = await this.createCoverPageContent(); 14 | this.notifyEndUserWithFarewellMessage(); 15 | 16 | this.focusUiOn([frame]); 17 | } 18 | 19 | private createPages(): void { 20 | const renameCurrentPageToCover = (): void => { 21 | this.figma.currentPage.name = "🎇 Cover"; 22 | }; 23 | 24 | const createPage = (name: string): void => { 25 | const page = this.figma.createPage(); 26 | page.name = name; 27 | }; 28 | 29 | renameCurrentPageToCover(); 30 | 31 | createPage("---"); 32 | createPage("💻 Desktop"); 33 | createPage("📱 Mobile"); 34 | createPage("---"); 35 | createPage("💀 Graveyard"); 36 | } 37 | 38 | private async createCoverPageContent(): Promise { 39 | const createFrame = async (): Promise => { 40 | const frame = this.figma.createFrame(); 41 | 42 | const almostBlackColor = { r: 0.15, g: 0.15, b: 0.15 }; 43 | frame.fills = [{ type: "SOLID", color: almostBlackColor }]; 44 | 45 | frame.layoutMode = "VERTICAL"; 46 | frame.primaryAxisAlignItems = "MIN"; 47 | frame.counterAxisAlignItems = "CENTER"; 48 | frame.paddingTop = 100; 49 | frame.itemSpacing = 30; 50 | frame.resize(1240, 640); 51 | 52 | return frame; 53 | }; 54 | 55 | const createHeading = async ( 56 | container: FrameNode, 57 | name: string 58 | ): Promise => { 59 | const heading = this.figma.createText(); 60 | 61 | const font = { family: "Arial", style: "Bold" }; 62 | await this.figma.loadFontAsync(font); 63 | heading.fontName = font; 64 | heading.characters = name; 65 | heading.fontSize = 120; 66 | const whiteColor = { r: 1, g: 1, b: 1 }; 67 | heading.fills = [{ type: "SOLID", color: whiteColor }]; 68 | 69 | heading.textAlignHorizontal = "CENTER"; 70 | heading.layoutPositioning = "AUTO"; 71 | 72 | return heading; 73 | }; 74 | 75 | const createDescription = async ( 76 | container: FrameNode, 77 | heading: TextNode, 78 | text: string 79 | ): Promise => { 80 | const description = this.figma.createText(); 81 | 82 | const font = { family: "Arial", style: "Regular" }; 83 | await this.figma.loadFontAsync(font); 84 | description.fontName = font; 85 | description.fontSize = 64; 86 | const whiteColor = { r: 1, g: 1, b: 1 }; 87 | description.fills = [{ type: "SOLID", color: whiteColor }]; 88 | description.characters = text; 89 | 90 | description.textAlignHorizontal = "CENTER"; 91 | description.layoutPositioning = "AUTO"; 92 | 93 | return description; 94 | }; 95 | const frame = await createFrame(); 96 | const heading = await createHeading(frame, "✌️ Add your title ✌️"); 97 | const description = await createDescription( 98 | frame, 99 | heading, 100 | "🪩 Add your description 🪩" 101 | ); 102 | 103 | this.figma.currentPage.appendChild(frame); 104 | frame.appendChild(heading); 105 | frame.appendChild(description); 106 | 107 | this.figma.currentPage.selection = [heading]; 108 | 109 | return frame; 110 | } 111 | 112 | private notifyEndUserWithFarewellMessage(): void { 113 | const message = 114 | "✅ Pages created. Press enter to modify your Cover heading!"; 115 | const options = { timeout: 6000 }; 116 | this.figma.notify(message, options); 117 | } 118 | 119 | private focusUiOn(container: SceneNode[]) { 120 | this.figma.viewport.scrollAndZoomIntoView(container); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest"], 3 | "env": { 4 | "jest/globals": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /tests/figma-mocks/figma-mocks.ts: -------------------------------------------------------------------------------- 1 | type DeepPartial = T extends object 2 | ? { 3 | [P in keyof T]?: DeepPartial; 4 | } 5 | : T; 6 | 7 | function mockFigmaPluginApiWith( 8 | propertiesToMock: DeepPartial 9 | ): PluginAPI { 10 | return propertiesToMock as unknown as PluginAPI; 11 | } 12 | 13 | export const figmaPluginApiMockForCreatePagesCommand = mockFigmaPluginApiWith({ 14 | notify: jest.fn(), 15 | currentPage: { 16 | name: "", 17 | appendChild: jest.fn(), 18 | }, 19 | createFrame: jest 20 | .fn() 21 | .mockReturnValue({ width: 0, appendChild: jest.fn(), resize: jest.fn() }), 22 | createPage: jest.fn().mockReturnValue({ name: "" }), 23 | createText: jest.fn().mockReturnValue({ 24 | fontName: {}, 25 | characters: {}, 26 | fontSize: {}, 27 | textAlignHorizontal: {}, 28 | }), 29 | loadFontAsync: jest.fn(), 30 | viewport: { 31 | scrollAndZoomIntoView: jest.fn(), 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /tests/scene-commands/CreatePagesCommandHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "jest-mock-extended"; 2 | 3 | import { CreatePagesCommand } from "../../src/scene-commands/create-pages/CreatePagesCommand"; 4 | import { CreatePagesCommandHandler } from "../../src/scene-commands/create-pages/CreatePagesCommandHandler"; 5 | import { figmaPluginApiMockForCreatePagesCommand } from "../figma-mocks/figma-mocks"; 6 | 7 | describe("CreatePagesCommandHandler", () => { 8 | it("can be instantiated without throwing errors", () => { 9 | const figmaPluginApiMock = mock(); 10 | 11 | const commandHandlerInstantiator = () => { 12 | new CreatePagesCommandHandler(figmaPluginApiMock); 13 | }; 14 | 15 | expect(commandHandlerInstantiator).not.toThrow(TypeError); 16 | }); 17 | 18 | it("notifies the end used with a farewell message", async () => { 19 | const commandHandler = new CreatePagesCommandHandler( 20 | figmaPluginApiMockForCreatePagesCommand 21 | ); 22 | const command = new CreatePagesCommand(); 23 | 24 | await commandHandler.handle(command); 25 | 26 | assertExecutionHasBeenNotified(figmaPluginApiMockForCreatePagesCommand); 27 | }); 28 | 29 | it("rename Cover Page", async () => { 30 | const commandHandler = new CreatePagesCommandHandler( 31 | figmaPluginApiMockForCreatePagesCommand 32 | ); 33 | const command = new CreatePagesCommand(); 34 | 35 | await commandHandler.handle(command); 36 | 37 | assertCoverPageHasBeenRenamed(figmaPluginApiMockForCreatePagesCommand); 38 | }); 39 | 40 | it("create secondary pages", async () => { 41 | const commandHandler = new CreatePagesCommandHandler( 42 | figmaPluginApiMockForCreatePagesCommand 43 | ); 44 | const command = new CreatePagesCommand(); 45 | 46 | await commandHandler.handle(command); 47 | assertSecondaryPagesHasBeenCreated(figmaPluginApiMockForCreatePagesCommand); 48 | }); 49 | }); 50 | 51 | function assertExecutionHasBeenNotified(mock: PluginAPI) { 52 | const farewellMessage = 53 | "✅ Pages created. Press enter to modify your Cover heading!"; 54 | const options = { timeout: 6000 }; 55 | 56 | expect(mock.notify).toHaveBeenCalledWith(farewellMessage, options); 57 | } 58 | 59 | function assertCoverPageHasBeenRenamed(mock: PluginAPI) { 60 | expect(mock.currentPage.name).toBe("🎇 Cover"); 61 | } 62 | 63 | function assertSecondaryPagesHasBeenCreated(mock: PluginAPI) { 64 | const pageToBeCreatedNames = [ 65 | "---", 66 | "💻 Desktop", 67 | "📱 Mobile", 68 | "---", 69 | "💀 Graveyard", 70 | ]; 71 | 72 | pageToBeCreatedNames.forEach(() => 73 | expect(mock.createPage).toHaveBeenCalled() 74 | ); 75 | 76 | // 🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩 77 | // 🚩 Here we can see the limitations of the Figma API. 🚩 78 | // 🚩 We can not test out the names of the created pages :/ 🚩 79 | // 🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩🚨🚩🚘🚩 80 | } 81 | -------------------------------------------------------------------------------- /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", "tests/**/*.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const path = require("path"); 3 | 4 | module.exports = { 5 | mode: "production", 6 | devtool: false, 7 | experiments: { 8 | topLevelAwait: true, 9 | }, 10 | entry: { 11 | figmaEntrypoint: "./src/figma-entrypoint.ts", 12 | }, 13 | resolve: { 14 | extensions: [".ts"], 15 | }, 16 | module: { 17 | rules: [{ test: /\.ts$/, loader: "ts-loader", exclude: /node_modules/ }], 18 | }, 19 | output: { 20 | filename: "[name].js", 21 | path: path.resolve(__dirname, "dist"), 22 | }, 23 | }; 24 | --------------------------------------------------------------------------------