├── .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 |
4 |
5 |
6 |
7 |
8 | 🏗️ Codely Structurer Figma Plugin
9 |
10 |
11 |
12 |
13 |
14 |
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 |
--------------------------------------------------------------------------------