├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── packages ├── create-fwidgets ├── .release-it.js ├── .tmplr.yml ├── .tmplr │ ├── README.md │ └── package.json ├── CHANGELOG.md ├── README.md ├── bin.js ├── package.json └── scripts │ └── prepack.mjs ├── fwidgets ├── .release-it.js ├── index.ts ├── main │ ├── callFwidgets.ts │ ├── fwidgets.ts │ ├── index.ts │ ├── input.ts │ ├── output.ts │ ├── package.json │ └── ui.ts ├── package.json ├── shared │ └── constants.ts ├── tsconfig.json └── ui │ ├── components │ ├── FwInline.module.css │ ├── FwInline.module.css.d.ts │ ├── FwInline.tsx │ ├── Fwidgets.tsx │ ├── InlineWidget.tsx │ ├── InputButtons.module.css │ ├── InputButtons.module.css.d.ts │ ├── InputButtons.tsx │ ├── InputColor.tsx │ ├── InputDropdown.tsx │ ├── InputNumber.tsx │ ├── InputText.tsx │ ├── Label.tsx │ ├── NextButton.tsx │ ├── OutputClipboard.tsx │ └── OutputText.tsx │ ├── index.ts │ ├── package.json │ ├── utils.ts │ └── widgets.ts └── plugin ├── package.json ├── src ├── main.ts └── ui.tsx └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{yml,md,json}] 11 | indent_style = space 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | build/ 4 | build */ 5 | dist/ 6 | dist */ 7 | node_modules/ 8 | manifest.json 9 | *.tgz 10 | .tmplr-preview/ 11 | 12 | .env 13 | .idea 14 | .vscode 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.1.1](https://github.com/fwextensions/fwidgets/compare/0.1.0...0.1.1) (2024-03-10) 4 | 5 | ### Fixes 6 | 7 | - Add index.ts to files list in package.json ([caaa194](https://github.com/fwextensions/fwidgets/commit/caaa194167f4332c6792cc9fbd32e5b843b28a6a)) 8 | 9 | 10 | ### Chores 11 | 12 | - Update `typescript`, `plugin-typings`, `dotenv-cli`, `release-it` and `preact` ([18e12d2](https://github.com/fwextensions/fwidgets/commit/18e12d2d537e70392adf5b71e09818635c5d9759)) 13 | 14 | 15 | 16 | ## [0.1.0](https://github.com/fwextensions/fwidgets/compare/0.0.3...0.1.0) (2024-01-08) 17 | 18 | ### Features 19 | 20 | - Export `FwidgetsUI()` from `ui/index.ts` so just a single line is needed in the user's `ui.tsx` ([e88cd51](https://github.com/fwextensions/fwidgets/commit/e88cd519bef4d128dc0716d0c7199f2f020f5d87)) 21 | - Export `input`, `output` and `ui` modules from `fwidgets/main` so they can be imported outside of the `fwidgets()` function ([2f5ec80](https://github.com/fwextensions/fwidgets/commit/2f5ec8063ac7689b6704df074fd2dc185145ee0f)) 22 | - Export main modules from top-level `fwidgets` package, as well as `fwidgets/main` ([58f09ea](https://github.com/fwextensions/fwidgets/commit/58f09eab0ccfafe6b3710af4cd4ec78ce2d7e575)) 23 | - Make `input.text()` automatically grow in height as more text is entered ([2131958](https://github.com/fwextensions/fwidgets/commit/2131958c90128f8a911015aaa8a0a47db071a0ef)) 24 | - Support a `defaultValue` prop on text fields ([b9c9111](https://github.com/fwextensions/fwidgets/commit/b9c911188c33b076a2d73dc17d72ed3bf1d7ca5f)) 25 | 26 | 27 | ### Chores 28 | 29 | - Update `classnames`, `typescript`, `plugin-typings` and `release-it` ([ae6238c](https://github.com/fwextensions/fwidgets/commit/ae6238c5aee38ffc663b0e758841c614d54a88c0)) 30 | 31 | 32 | 33 | ## [0.0.3](https://github.com/fwextensions/fwidgets/compare/0.0.2...0.0.3) (2023-12-06) 34 | 35 | ### Features 36 | 37 | - Add `setPosition()` method to control the position of the plugin window ([c376d33](https://github.com/fwextensions/fwidgets/commit/c376d336a6eabcbe4932603771e4c6641db39fe5)) 38 | - Add option to show a toast message to output.clipboard() ([5701f71](https://github.com/fwextensions/fwidgets/commit/5701f7149c39ac2063a190defe240a3f19192e9c)) 39 | - Add support for showing tiny window when outputting to clipboard ([da7bf2d](https://github.com/fwextensions/fwidgets/commit/da7bf2d518bc1b315798cc82211794318eb8b433)) 40 | - Reorganize code into a monorepo ([9b9fc25](https://github.com/fwextensions/fwidgets/commit/9b9fc2536ace3d6807329b2bb0fa5953dc3dce77)) 41 | 42 | 43 | ### Chores 44 | 45 | - Bump typescript, preact, and plugin-typings to the latest ([293e694](https://github.com/fwextensions/fwidgets/commit/293e6942fc7b4a75784c1cf159176c0cdcba497b)) 46 | 47 | 48 | ### CI/CD 49 | 50 | - Ensure README.md gets packaged during the build ([f3d5d02](https://github.com/fwextensions/fwidgets/commit/f3d5d02b394c7449798c347c77faf127e7d0ec77)) 51 | 52 | 53 | 54 | ## [0.0.2](https://github.com/fwextensions/fwidgets/compare/0.0.1...0.0.2) (2023-11-18) 55 | 56 | ### Chores 57 | 58 | - Switch to figma-await-ipc 0.1.0 ([47b0270](https://github.com/fwextensions/fwidgets/commit/47b027094e2cdb7bc5f86610fe0fe05dc606f4b9)) 59 | 60 | 61 | 62 | ## 0.0.1 (2023-11-12) 63 | 64 | ### Features 65 | 66 | - Initial release. 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 John Dunning 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 | # fwidgets 2 | 3 | > Create a simple Figma plugin UI with zero UI code. 4 | 5 | ![screenshot](https://user-images.githubusercontent.com/61631/280552964-f63c103e-61db-4b7b-8610-2116613e665d.png) 6 | 7 | Many useful Figma plugins run only in the main thread, where they can access the API and modify the document. But sometimes you want to extend them with simple user interface elements, like a textbox to enter a name, or a color picker to customize the output. Unfortunately, Figma doesn't offer any built-in UI components, so you have to roll your own with a framework or vanilla JS/HTML/CSS. And then you have to post messages back and forth between the main and UI threads to respond to user actions. 8 | 9 | `fwidgets` is intended to dramatically simplify this process by letting you add basic UI functionality without writing any UI code. Think of it like adding a series of interactive prompts to a command line tool, similar to the wizard structure of something like `create-react-app`. `fwidgets` lets you show one UI element at a time to the user, awaiting their input and then responding to it, while keeping all of your code in the main thread. 10 | 11 | 12 | ## Install 13 | 14 | To create a new plugin using `fwidgets`: 15 | 16 | ```shell 17 | npm create fwidgets@latest 18 | ``` 19 | 20 | Follow the prompts to create a fully-scaffolded plugin directory: 21 | 22 | ```shell 23 | $ npm create fwidgets@latest 24 | Enter the name of the plugin: 25 | > My Plugin 26 | Enter the directory in which to create the plugin: 27 | > my-plugin 28 | ✔ Cloned: fwextensions/fwidgets/packages/plugin -> my-plugin 29 | ``` 30 | 31 | Then install the dependencies: 32 | 33 | ```shell 34 | $ cd my-plugin 35 | $ npm install 36 | ``` 37 | 38 | 39 | ## Usage 40 | 41 | Once the plugin skeleton has been created and all its dependencies installed, start the development server: 42 | 43 | ```shell 44 | npm run dev 45 | ``` 46 | 47 | The plugin will be rebuilt whenever files in the `src` directory change. (This package uses the excellent [`create-figma-plugin`](https://github.com/yuanqing/create-figma-plugin) tool to build and package the plugin.) 48 | 49 | In Figma, go to *Plugins > Development > Import plugin from manifest...* and select the `manifest.json` file that has been generated in your plugin's directory. Run your new plugin to see the sample code ask for a color, then a number of rectangles to create, and finally a confirmation before creating the colored rectangles and copying their bounding boxes to the clipboard as JSON. 50 | 51 | 52 | ### Awaiting user input 53 | 54 | Open [`src/main.ts`](packages/plugin/src/main.ts) to see the sample code. Most of it is contained within an async function that's passed to `fwidgets()`, whose return value is then exported as the default for this module. 55 | 56 | A typical use of the API looks like this: 57 | 58 | ```typescript 59 | // main.ts 60 | import fwidgets from "fwidgets"; 61 | 62 | export default fwidgets(async ({ input, output, ui }) => { 63 | // ... 64 | const count = await input.number("Number of rectangles:", { 65 | placeholder: "Count", 66 | minimum: 1, 67 | maximum: 10, 68 | integer: true 69 | }); 70 | // use the count value in a Figma API call 71 | }); 72 | ``` 73 | 74 | ![number screenshot](https://user-images.githubusercontent.com/61631/280553210-ce887bee-2fc7-4994-9cd2-b089456e9903.png) 75 | 76 | You make a call to an `input` method like `number()`, which will show a numeric entry field in the plugin window. Pass the method a label string and any options needed for that UI element. 77 | 78 | Since you're waiting for the user to enter something, you have to `await` the `input.number()` call so that your main thread pauses until a value is returned. The user can click the *Next* button next to the input to submit the value, or press enter. If the user clicks the plugin window's close box or presses escape, the plugin execution will immediately stop, much like a CLI process getting killed. 79 | 80 | The key difference between using `fwidgets` to show a user interface vs. building it in the UI thread is that the main thread is in control and runs from beginning to end, like a typical script. It may hand off control to the UI thread to get some user input, but it decides when to do that, rather than responding to events sent via `postMessage()`. 81 | 82 | 83 | ### Development workflow 84 | 85 | Try changing one of the strings that supplies a UI label and save the file to rebuild the plugin. Then reopen it in Figma to see the change. 86 | 87 | That's it! The whole workflow is basically just adding some code inside the call to `fwidgets()` in `main.ts`, saving the file, and then testing the plugin in Figma. 88 | 89 | Note that you shouldn't edit the one-line `ui.tsx` file, which is there just to set up the Preact code that listens for calls from the main thread and then renders the requested UI element. (If you know what you're doing, you could import `` from `fwidgets/ui` and `render()` from `@create-figma-plugin/ui`, and then render some static components around it, but that's left as an exercise for the reader.) 90 | 91 | 92 | ## API 93 | 94 | ### `fwidgets()` 95 | 96 | This is the only function you need to import in your main file. Pass it an async callback function containing your plugin logic, which it will wrap with code to handle communication with the UI thread. Export that wrapped callback as the module's default value, which is then called by `create-figma-plugin` when your plugin is launched. 97 | 98 | ```typescript 99 | // main.ts 100 | import { fwidgets } from "fwidgets"; 101 | 102 | export default fwidgets(async ({ input, output, ui }) => { 103 | // add your plugin code here 104 | }); 105 | ``` 106 | 107 | You can import code from other modules, and even make API calls before calling `fwidgets()`. But it should only be called once, since it calls `figma.closePlugin()` before returning, which disables the Figma APIs. 108 | 109 | Your function will be passed an object containing the [`input`](#input), [`output`](#output) and [`ui`](#ui) APIs that can be used to render UI elements and control the plugin window. You can also import these APIs using the standard `import` syntax in other modules, which is useful when your script grows beyond a single file. They are provided to your callback simply as a convenience. 110 | 111 |
112 |
113 | 114 | ### `input` 115 | 116 | All of the `input` methods listed below, except for `buttons()`, display a button labeled *Next* to the right of the UI element. The user clicks that to confirm the value and move to the next step, which is when the awaited method call returns with the value. The user can also press enter to confirm the value, or press escape to close the plugin and stop its execution. 117 | 118 | For any of the label parameters below, pass an empty string to not take up any space for a label at all. 119 | 120 | Calling one of these methods will automatically show the plugin window if it's not currently open or visible. 121 | 122 | All of the `fwidgets` UI elements support dark mode and will automatically switch their styling based on the current settings within the Figma app. 123 | 124 |
125 | 126 | #### `buttons(label, buttonLabels)` 127 | 128 | Shows a list of push buttons. 129 | 130 | - `label`: The label to show above the buttons. 131 | - `buttonLabels`: An array of button label strings. 132 | 133 | Returns the label of the button that was clicked. 134 | 135 | ![buttons screenshot](https://user-images.githubusercontent.com/61631/280553231-bbff221d-5b25-43fa-9672-550f3f493273.png) 136 | 137 | ```typescript 138 | const btn = await input.buttons("Continue?", ["Create Rectangles", "Cancel"]); 139 | ``` 140 | 141 |
142 | 143 | #### `color(label, options?)` 144 | 145 | Shows a color picker. The picker consists of a color swatch that can be clicked to show a full color palette, a text area in which a hex value can be entered, and another text area for opacity. 146 | 147 | - `label`: The label to show above the color picker. 148 | - `options`: An optional object with any of the following keys. 149 | - `placeholder`: A string to show when the hex input is empty. 150 | 151 | Returns an `{ a, r, g, b }` object containing the alpha, red, green and blue components of the selected color. 152 | 153 | ![color screenshot](https://user-images.githubusercontent.com/61631/280553180-2e416d19-b453-4c1a-ba2c-33d2a23d7c09.png) 154 | 155 | ```typescript 156 | const rgba = await input.color("Rectangle fill color:"); 157 | ``` 158 | 159 | If you just need the RGB components, you can extract them with the rest operator like this: 160 | 161 | ```typescript 162 | const { a, ...rgb } = await input.color("Enter a color:") 163 | const rect = figma.createRectangle(); 164 | rect.fills = [{ type: "SOLID", color: rgb }]; 165 | ``` 166 | 167 |
168 | 169 | #### `dropdown(label, items, options?)` 170 | 171 | Shows a dropdown menu. 172 | 173 | - `label`: The label to show above the dropdown menu. 174 | - `items`: An array of label strings to show in the menu. 175 | - `options`: An optional object with any of the following keys. 176 | - `placeholder`: A string to show when nothing has been selected. 177 | 178 | Returns the label of the menu item that was selected. 179 | 180 | ![dropdown screenshot](https://user-images.githubusercontent.com/61631/280553519-3b2eaa6c-966a-4c82-b918-38c0824ac510.png) 181 | 182 | ```typescript 183 | const align = await input.dropdown("Text alignment:", ["left", "center", "right"]); 184 | ``` 185 | 186 |
187 | 188 | #### `number(label, options?)` 189 | 190 | Shows a numeric entry field. 191 | 192 | - `label`: The label to show above the entry field. 193 | - `options`: An optional object with any of the following keys. 194 | - `placeholder`: A string to show when the field is empty. 195 | - `defaultValue`: The default value to show. 196 | - `suffix`: A suffix to show after the entered number, like `"%"`. 197 | - `incrementSmall`: How much to change the number when up or down arrows are pressed. 198 | - `incrementBig`: How much to change the number when shiftup or shiftdown arrows are pressed. 199 | - `minimum`: The smallest value that can be entered. 200 | - `maximum`: The largest value that can be entered. 201 | - `integer`: A boolean controlling whether a decimal can be entered. 202 | 203 | Returns the number that was entered. 204 | 205 | ![number screenshot](https://user-images.githubusercontent.com/61631/280553210-ce887bee-2fc7-4994-9cd2-b089456e9903.png) 206 | 207 | ```typescript 208 | const count = await input.number("Number of rectangles:", { integer: true }); 209 | ``` 210 | 211 |
212 | 213 | #### `page(label?, options?)` 214 | 215 | Shows a dropdown menu of all the pages in the current Figma document. 216 | 217 | - `label`: An optional label to show above the dropdown menu. Defaults to *Select a page:*. 218 | - `options`: An optional object with any of the following keys. 219 | - `placeholder`: A string to show when nothing has been selected. Defaults to *Pages*. 220 | 221 | Returns the `PageNode` of the selected page. 222 | 223 | ![page screenshot](https://user-images.githubusercontent.com/61631/280553620-24ff03a1-cd02-4e3f-a078-3c923bffcd92.png) 224 | 225 | ```typescript 226 | const selectedPage = await input.page(); 227 | ``` 228 | 229 |
230 | 231 | #### `text(label, options?)` 232 | 233 | Shows a text entry field. By default, the field starts out as one line but will grow as more text is entered. Press shiftenter to enter linebreaks without confirming the text. 234 | 235 | - `label`: The label to show above the entry field. 236 | - `options`: An optional object with any of the following keys. 237 | - `placeholder`: A string to show when the field is empty. 238 | - `defaultValue`: A string to show as the field's default value. 239 | - `rows`: The number of rows to show when the field is first rendered. Defaults to 1. 240 | - `grow`: A boolean controlling whether the field grows as more text is entered. Defaults to `true`. 241 | 242 | Returns the string that was entered. 243 | 244 | ![text screenshot](https://user-images.githubusercontent.com/61631/280553453-11358bc6-29e4-421a-a1bd-8ed44eba5c95.png) 245 | 246 | ```typescript 247 | const title = await input.text("Enter a title:"); 248 | ``` 249 | 250 |
251 |
252 | 253 | ### `output` 254 | 255 | The `output` methods listed below return a promise that resolves to `undefined`, but you still need to await the response to give them time to update the plugin UI before returning and to ensure that the calls are executed in order. 256 | 257 | Note that since the `output` methods don't wait for any user input, execution will immediately move to the next statement. So if you want to, say, show some text when a script finishes, be sure to call something like `await input.buttons(["Done"])` after the output, so that the plugin will wait for the user to click *Done* before closing. 258 | 259 |
260 | 261 | #### `clipboard(value, options?)` 262 | 263 | Copies a string version of `value` to the clipboard. If `value` is a non-null object, then it will be converted to formatted JSON before copying. 264 | 265 | Due to limitations in the Figma API, the plugin window has to be open and visible for text to be copied to the clipboard. If it's not currently open, calling `output.clipboard()` will open the window at its minimal size in the lower-right corner of Figma. This makes the opening and closing of the window as inconspicuous as possible when your plugin just needs to copy some text. The next time a `fwidgets` method is called to show a UI control, the window will be restored to its normal size in the middle of the screen. 266 | 267 | - `value`: The data to copy to the user's clipboard. 268 | - `options`: An optional object with any of the following keys, which are passed to `figma.notify()`. 269 | - `message`: A string to show in a toast notification marking the copy operation. Limited to 100 characters. 270 | - `error`: A boolean indicating whether the message is an error, which shows it in a different color. 271 | - `timeout`: The time in milliseconds to show the toast. Defaults to 3000. 272 | 273 | ```typescript 274 | await output.clipboard(figma.currentPage.selection[0].fills); 275 | ``` 276 | 277 |
278 | 279 | #### `text(string, options?)` 280 | 281 | Displays static text that can wrap to multiple lines. 282 | 283 | - `string`: The string to display. 284 | - `options`: An optional object with any of the following keys. 285 | - `align`: Control the alignment of the text by passing `"left" | "center" | "right"`. 286 | - `numeric`: A boolean controlling whether the text is rendered with monospaced numerals. 287 | 288 | ![text screenshot](https://user-images.githubusercontent.com/61631/280554012-6625df86-06ca-456f-8afa-38ae01fef893.png) 289 | 290 | ```typescript 291 | await output.text(`Export preview: 292 | Color: #cc3300 293 | Rectangle count: 5 294 | Format: JSON`); 295 | ``` 296 | 297 |
298 |
299 | 300 | ### `ui` 301 | 302 | These methods let you control the state of the plugin window. 303 | 304 |
305 | 306 | #### `hide()` 307 | 308 | Hides the plugin window if it's currently open, but does not close it or end plugin execution. 309 | 310 |
311 | 312 | #### `setPosition(position)` 313 | 314 | Sets the plugin window position, but does not show it if it's not currently visible. 315 | 316 | The `position` is relative to the top-left of the viewport in *screen* coordinates. This means you don't have to calculate the coordinates based on the viewport's current zoom and bounds. 317 | 318 | - `position`: 319 | - `x`: The desired X-position in px. 320 | - `y`: The desired Y-position in px. 321 | 322 |
323 | 324 | #### `setSize(size)` 325 | 326 | Sets the plugin window size, but does not show it if it's not currently visible. 327 | 328 | The plugin window defaults to 300px wide by 200px tall. 329 | 330 | - `size`: 331 | - `width`: The desired `width` in px. 332 | - `height`: The desired `height` in px. 333 | 334 |
335 | 336 | #### `show(options?)` 337 | 338 | Opens or shows the plugin window, and sets it to the specified size and/or position, if supplied. It's normally not necessary to call `show()`, as any call to an `input` method will automatically show the plugin window. 339 | 340 | The `position` coordinates are specified in screen pixels from the top-left of the Figma window, *not* in Figma viewport coordinates. 341 | 342 | - `options`: An optional object with any of the following keys. 343 | - `size`: 344 | - `width`: The desired `width` in px. 345 | - `height`: The desired `height` in px. 346 | - `position`: 347 | - `x`: The horizontal position. 348 | - `y`: The vertical position. 349 | 350 |
351 | 352 | 353 | ## Credits 354 | 355 | The idea for `fwidgets` was inspired by the [Airtable scripting API](https://airtable.com/developers/scripting/api), which is generally terrible except for its `input` and `output` methods. They're a clever solution for displaying interactive UI elements while executing a script without having to build a full event-driven app architecture. 356 | 357 | `fwidgets` also draws from the [JSML Library](https://www.johndunning.com/fireworks/about/JSMLLibrary) that I created for Adobe Fireworks a decade ago. That library let you create Fireworks dialogs and panels without any Flash or Flex code, just JavaScript. 358 | 359 | This package uses [`create-figma-plugin`](https://github.com/yuanqing/create-figma-plugin) for the [UI components](https://yuanqing.github.io/create-figma-plugin/ui/) and for building the plugin. 360 | 361 | 362 | ## License 363 | 364 | [MIT](./LICENSE) © [John Dunning](https://github.com/fwextensions) 365 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fwidgets-monorepo", 3 | "private": true, 4 | "author": "John Dunning", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/fwextensions/fwidgets.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/fwextensions/fwidgets/issues" 12 | }, 13 | "homepage": "https://github.com/fwextensions/fwidgets#readme", 14 | "workspaces": [ 15 | "packages/*" 16 | ], 17 | "scripts": { 18 | "build": "npm run tsc -w fwidgets", 19 | "release": "npm run release -w fwidgets", 20 | "create-fwidgets:release": "npm run release -w create-fwidgets", 21 | "plugin:build": "npm run build -w plugin", 22 | "plugin:dev": "npm run dev -w plugin" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/create-fwidgets/.release-it.js: -------------------------------------------------------------------------------- 1 | const commitPartial = `- {{#if subject}} 2 | {{~subject}} 3 | {{~else}} 4 | {{~header}} 5 | {{~/if}} 6 | 7 | {{~!-- commit link --}}{{~#if hash}} {{#if @root.linkReferences~}} 8 | ([{{shortHash}}]({{~@root.host}}/{{~@root.owner}}/{{~@root.repository}}/commit/{{hash}})) 9 | {{~else}} 10 | {{~shortHash}} 11 | {{~/if}}{{~/if}} 12 | 13 | {{~!-- commit references --}} 14 | {{~#if references~}} 15 | , closes 16 | {{~#each references}} {{#if @root.linkReferences~}} 17 | [ 18 | {{~#if this.owner}} 19 | {{~this.owner}}/ 20 | {{~/if}} 21 | {{~this.repository}}{{this.prefix}}{{this.issue}}]({{~@root.host}}/{{~@root.owner}}/{{~@root.repository}}/issues/{{id}}) 22 | {{~else}} 23 | {{~#if this.owner}} 24 | {{~this.owner}}/ 25 | {{~/if}} 26 | {{~this.repository}}{{this.prefix}}{{this.issue}} 27 | {{~/if}}{{/each}} 28 | {{~/if}} 29 | 30 | `; 31 | const tagName = "create-fwidgets@${version}"; 32 | 33 | module.exports = { 34 | git: { 35 | commitMessage: `Release ${tagName}`, 36 | tagName 37 | }, 38 | github: { 39 | releaseName: `Release ${tagName}`, 40 | release: false 41 | }, 42 | plugins: { 43 | "@release-it/conventional-changelog": { 44 | preset: { 45 | name: "conventionalcommits", 46 | types: [ 47 | { 48 | type: "feat", 49 | section: "Features", 50 | scope: "cf" 51 | }, 52 | { 53 | type: "fix", 54 | section: "Fixes", 55 | scope: "cf" 56 | }, 57 | { 58 | type: "chore", 59 | section: "Chores", 60 | scope: "cf" 61 | }, 62 | { 63 | type: "ci", 64 | section: "CI/CD", 65 | scope: "cf" 66 | } 67 | ] 68 | }, 69 | infile: "CHANGELOG.md", 70 | header: "# Changelog", 71 | ignoreRecommendedBump: true, 72 | writerOpts: { 73 | commitPartial 74 | } 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /packages/create-fwidgets/.tmplr.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | - read: pluginName 3 | prompt: 'Enter the name of the plugin:' 4 | default: 5 | eval: '{{ filesystem.scopedir | Capital Case }}' 6 | 7 | - read: outputDir 8 | prompt: 'Enter the directory in which to create the plugin:' 9 | default: 10 | eval: '{{ pluginName | kebab-case }}' 11 | 12 | - read: outputPath 13 | eval: '../{{ outputDir }}' 14 | 15 | # this doesn't detect an existing directory for some reason, so for now, don't 16 | # include the remove command 17 | # - if: 18 | # exists: '{{ outputPath }}' 19 | # prompt: '"{{ outputPath }}" already exists. Delete it before continuing?' 20 | # choices: 21 | # - Yes 22 | # - No: 23 | # skip: steps 24 | # 25 | # - remove: '{{ outputPath }}' 26 | 27 | - degit: fwextensions/fwidgets/packages/plugin 28 | to: '{{ outputPath }}' 29 | 30 | - copy: .tmplr/* 31 | to: '{{ outputPath }}' 32 | 33 | - remove: .tmplr 34 | - remove: .tmplr.yml 35 | - remove: bin.js 36 | -------------------------------------------------------------------------------- /packages/create-fwidgets/.tmplr/README.md: -------------------------------------------------------------------------------- 1 | # {{ tmplr.pluginName }} 2 | 3 | This plugin was generated using the [fwidgets](https://github.com/fwextensions/fwidgets) library. 4 | 5 | 6 | ## Customize the sample plugin 7 | 8 | Once the plugin skeleton has been created and all its dependencies installed, open the `package.json` file and edit this section to make sure the `id` and `name` have been set to your liking: 9 | 10 | ``` 11 | "figma-plugin": { 12 | "id": "{{ tmplr.pluginName | camelCase }}", 13 | "name": "{{ tmplr.pluginName | Capital Case }}", 14 | ... 15 | } 16 | ``` 17 | 18 | Then open a terminal and execute `npm run dev` to start a process that rebuilds the plugin whenever files in the `src` directory change. (The template uses the excellent [`create-figma-plugin`](https://github.com/yuanqing/create-figma-plugin) tool to build and package the plugin.) 19 | 20 | In Figma, go to *Plugins > Development > Import plugin from manifest...* and select the `manifest.json` file that has been generated in your plugin's directory. Run your new plugin to see the sample code ask for a color, then a number of rectangles to create, and finally a confirmation before creating the colored rectangles and copying their bounding boxes to the clipboard as JSON. 21 | 22 | 23 | ### Awaiting user input 24 | 25 | Open `src/main.ts` to see the sample code. Most of it is contained within an async function that's passed to `fwidgets()`, whose return value is then exported as the default for this module. 26 | 27 | A typical use of the API looks like this: 28 | 29 | ```typescript 30 | // main.ts 31 | import fwidgets from "fwidgets"; 32 | 33 | export default fwidgets(async ({ input, output, ui }) => { 34 | // ... 35 | const count = await input.number("Number of rectangles:", { 36 | placeholder: "Count", 37 | minimum: 1, 38 | maximum: 10, 39 | integer: true 40 | }); 41 | // use the count value in a Figma API call 42 | }); 43 | ``` 44 | 45 | ![number screenshot](https://user-images.githubusercontent.com/61631/280553210-ce887bee-2fc7-4994-9cd2-b089456e9903.png) 46 | 47 | You make a call to an `input` method like `number()`, which will show a numeric entry field in the plugin window. Pass the method a label string and any options needed for that UI element. 48 | 49 | Since you're waiting for the user to enter something, you have to `await` the `input.number()` call so that your main thread pauses until a value is returned. The user can click the *Next* button next to the input to submit the value, or press enter. If the user clicks the plugin window's close box or presses escape, the plugin execution will immediately stop, much like a CLI process getting killed. 50 | 51 | The key difference between using `fwidgets` to show a user interface vs. building it in the UI thread is that the main thread is in control and runs from beginning to end, like a typical script. It may hand off control to the UI thread to get some user input, but it decides when to do that, rather than responding to events sent via `postMessage()`. 52 | 53 | 54 | ### Development workflow 55 | 56 | Try changing one of the strings that supplies a UI label and save the file to rebuild the plugin. Then reopen it in Figma to see the change. 57 | 58 | That's it! The whole workflow is basically just adding some code inside the call to `fwidgets()` in `main.ts`, saving the file, and then testing the plugin in Figma. 59 | 60 | Note that you shouldn't edit the one-line `ui.tsx` file, which is there just to set up the Preact code that listens for calls from the main thread and then renders the requested UI element. (If you know what you're doing, you could import `` from `fwidgets/ui` and `render()` from `@create-figma-plugin/ui`, and then render some static components around it, but that's left as an exercise for the reader.) 61 | -------------------------------------------------------------------------------- /packages/create-fwidgets/.tmplr/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "{{ tmplr.pluginName | kebab-case }}", 3 | "scripts": { 4 | "build": "build-figma-plugin --typecheck --minify", 5 | "dev": "build-figma-plugin --watch", 6 | "watch": "build-figma-plugin --watch" 7 | }, 8 | "dependencies": { 9 | "@create-figma-plugin/ui": "^3.1.0", 10 | "@create-figma-plugin/utilities": "^3.1.0", 11 | "preact": "^10.19.6", 12 | "fwidgets": "latest" 13 | }, 14 | "devDependencies": { 15 | "@create-figma-plugin/build": "^3.1.0", 16 | "@create-figma-plugin/tsconfig": "^3.1.0", 17 | "@figma/plugin-typings": "1.88.0", 18 | "typescript": "^5.4.2" 19 | }, 20 | "figma-plugin": { 21 | "editorType": [ 22 | "figma" 23 | ], 24 | "id": "{{ tmplr.pluginName | camelCase }}", 25 | "name": "{{ tmplr.pluginName | Capital Case }}", 26 | "main": "src/main.ts", 27 | "ui": "src/ui.tsx" 28 | } 29 | } -------------------------------------------------------------------------------- /packages/create-fwidgets/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.0.6](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.5...create-fwidgets@0.0.6) (2024-03-11) 4 | 5 | 6 | ### Fixes 7 | 8 | - Make the package.json template in sync with the plugin ([ab5cbce](https://github.com/fwextensions/fwidgets/commit/ab5cbcea77d652c2a378995f3d25abdb284e8d3e)) 9 | 10 | ## [0.0.5](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.4...create-fwidgets@0.0.5) (2024-03-11) 11 | 12 | ### Fixes 13 | 14 | - Create template package.json file from plugin package ([caf445f](https://github.com/fwextensions/fwidgets/commit/caf445ff89ef5eb44717df4880ea95a75eb11478)) 15 | - Update the exported plugin README.md to match the repo's ([1cc2b5a](https://github.com/fwextensions/fwidgets/commit/1cc2b5a95a2b0563f3f70a2e836bd230512ef53d)) 16 | 17 | 18 | 19 | ## [0.0.4](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.3...create-fwidgets@0.0.4) (2024-01-07) 20 | 21 | ### Chores 22 | 23 | - Update `create-figma-plugin`, `preact`, `typescript`, `plugin-typings` and `release-it`. ([2a007ce](https://github.com/fwextensions/fwidgets/commit/2a007ceb38f555e16eb76b1ee4b91d339f33a40a)) 24 | 25 | 26 | 27 | ## [0.0.3](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.2...create-fwidgets@0.0.3) (2023-12-27) 28 | 29 | ### Fixes 30 | 31 | - Add `[@latest](https://github.com/latest)` to the `npm create fwidgets` command in the docs ([8c78ddf](https://github.com/fwextensions/fwidgets/commit/8c78ddf4cb1c3b4f6fc153e06a609a7539635e82)) 32 | - Add `bin.js` as `create-fwidgets` command ([d8492f7](https://github.com/fwextensions/fwidgets/commit/d8492f7d043f2b3f0b5a3d11efacfa190eb13f55)) 33 | 34 | 35 | 36 | ## [0.0.2](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.1...create-fwidgets@0.0.2) (2023-12-27) 37 | 38 | ### Fixes 39 | 40 | - Add a `create-fwidgets` key to the `bin` field ([10b3a31](https://github.com/fwextensions/fwidgets/commit/10b3a31e61aa39f9df864dcaa7b4e286411245ec)) 41 | 42 | 43 | 44 | ## [0.0.1](https://github.com/fwextensions/fwidgets/compare/create-fwidgets@0.0.0...create-fwidgets@0.0.1) (2023-12-27) 45 | 46 | ### Features 47 | 48 | - Add support for an `npm create fwidgets` command using `tmplr` ([dce2025](https://github.com/fwextensions/fwidgets/commit/dce2025fe09b842e1a1e42a77830f2dbe30258d5)) 49 | -------------------------------------------------------------------------------- /packages/create-fwidgets/README.md: -------------------------------------------------------------------------------- 1 | # create-fwidgets 2 | 3 | > Create a new Figma plugin using the [fwidgets](https://github.com/fwextensions/fwidgets) library. 4 | 5 | [![screenshot](https://user-images.githubusercontent.com/61631/280552964-f63c103e-61db-4b7b-8610-2116613e665d.png)](https://github.com/fwextensions/fwidgets) 6 | 7 | 8 | ## Usage 9 | 10 | ```shell 11 | npm create fwidgets@latest 12 | ``` 13 | 14 | Follow the prompts to create a fully-scaffolded plugin directory: 15 | 16 | ```shell 17 | $ npm create fwidgets@latest 18 | Enter the name of the plugin: 19 | > My Plugin 20 | Enter the directory in which to create the plugin: 21 | > my-plugin 22 | ✔ Cloned: fwextensions/fwidgets/packages/plugin -> my-plugin 23 | ``` 24 | 25 | Then install the dependencies and start the development server: 26 | 27 | ```shell 28 | $ cd my-plugin 29 | $ npm install 30 | $ npm run dev 31 | ``` 32 | 33 | The plugin will be rebuilt whenever files in the `src` directory change. (This package uses the excellent [`create-figma-plugin`](https://github.com/yuanqing/create-figma-plugin) tool to build and package the plugin.) 34 | 35 | In Figma, go to *Plugins > Development > Import plugin from manifest...* and select the `manifest.json` file that has been generated in your plugin's directory. Run your new plugin to see the sample code ask for a color, then a number of rectangles to create, and finally a confirmation before creating the colored rectangles and copying their bounding boxes to the clipboard as JSON. 36 | 37 | [Get started creating your own Figma plugin UI.](https://github.com/fwextensions/fwidgets#awaiting-user-input) 38 | -------------------------------------------------------------------------------- /packages/create-fwidgets/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { execSync } = require("child_process"); 4 | const { relative } = require("path"); 5 | 6 | const command = `npx tmplr use local:${relative(process.cwd(), __dirname)}`; 7 | 8 | // pass the stdio from the parent process. otherwise, there'll be no shell to 9 | // use for interacting with tmplr. 10 | execSync(command, { stdio: "inherit" }); 11 | -------------------------------------------------------------------------------- /packages/create-fwidgets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-fwidgets", 3 | "version": "0.0.6", 4 | "description": "Create a Figma plugin using the fwidgets UI package.", 5 | "keywords": [ 6 | "figma", 7 | "plugin", 8 | "ui" 9 | ], 10 | "author": "John Dunning", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/fwextensions/fwidgets.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/fwextensions/fwidgets/issues" 18 | }, 19 | "homepage": "https://github.com/fwextensions/fwidgets#readme", 20 | "bin": { 21 | "create-fwidgets": "bin.js" 22 | }, 23 | "scripts": { 24 | "prepack": "node scripts/prepack.mjs", 25 | "release": "npm run prepack && release-it" 26 | }, 27 | "devDependencies": { 28 | "@release-it/conventional-changelog": "^8.0.1", 29 | "release-it": "^17.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/create-fwidgets/scripts/prepack.mjs: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "node:fs/promises"; 2 | import { join } from "node:path"; 3 | 4 | function setValue( 5 | target, 6 | key, 7 | value) 8 | { 9 | const path = key.split("."); 10 | const finalKey = path.pop(); 11 | const parent = path.reduce((obj, key) => obj[key], target); 12 | 13 | parent[finalKey] = value; 14 | } 15 | 16 | const overrides = { 17 | "name": "{{ tmplr.pluginName | kebab-case }}", 18 | "dependencies.fwidgets": "latest", 19 | "figma-plugin.id": "{{ tmplr.pluginName | camelCase }}", 20 | "figma-plugin.name": "{{ tmplr.pluginName | Capital Case }}", 21 | }; 22 | 23 | // pull the package.json from the example plugin and then replace the id and 24 | // name keys with tokens. this template file will be copied to the local 25 | // copy of the plugin package, during which tmplr will replace the tokens 26 | // with the user's input. 27 | const pkg = JSON.parse(await readFile(join("../plugin", "package.json"), "utf8")); 28 | 29 | Object.entries(overrides).forEach(([key, value]) => { 30 | setValue(pkg, key, value); 31 | }); 32 | 33 | await writeFile(join(".tmplr", "package.json"), JSON.stringify(pkg, null, 2)); 34 | -------------------------------------------------------------------------------- /packages/fwidgets/.release-it.js: -------------------------------------------------------------------------------- 1 | const commitPartial = `- {{#if subject}} 2 | {{~subject}} 3 | {{~else}} 4 | {{~header}} 5 | {{~/if}} 6 | 7 | {{~!-- commit link --}}{{~#if hash}} {{#if @root.linkReferences~}} 8 | ([{{shortHash}}]({{~@root.host}}/{{~@root.owner}}/{{~@root.repository}}/commit/{{hash}})) 9 | {{~else}} 10 | {{~shortHash}} 11 | {{~/if}}{{~/if}} 12 | 13 | {{~!-- commit references --}} 14 | {{~#if references~}} 15 | , closes 16 | {{~#each references}} {{#if @root.linkReferences~}} 17 | [ 18 | {{~#if this.owner}} 19 | {{~this.owner}}/ 20 | {{~/if}} 21 | {{~this.repository}}{{this.prefix}}{{this.issue}}]({{~@root.host}}/{{~@root.owner}}/{{~@root.repository}}/issues/{{id}}) 22 | {{~else}} 23 | {{~#if this.owner}} 24 | {{~this.owner}}/ 25 | {{~/if}} 26 | {{~this.repository}}{{this.prefix}}{{this.issue}} 27 | {{~/if}}{{/each}} 28 | {{~/if}} 29 | 30 | `; 31 | 32 | module.exports = { 33 | github: { 34 | release: true 35 | }, 36 | plugins: { 37 | "@release-it/conventional-changelog": { 38 | preset: { 39 | name: "conventionalcommits", 40 | types: [ 41 | { 42 | type: "feat", 43 | section: "Features", 44 | scope: "fw" 45 | }, 46 | { 47 | type: "fix", 48 | section: "Fixes", 49 | scope: "fw" 50 | }, 51 | { 52 | type: "chore", 53 | section: "Chores", 54 | scope: "fw" 55 | }, 56 | { 57 | type: "ci", 58 | section: "CI/CD", 59 | scope: "fw" 60 | } 61 | ] 62 | }, 63 | infile: "../../CHANGELOG.md", 64 | header: "# Changelog", 65 | ignoreRecommendedBump: true, 66 | writerOpts: { 67 | commitPartial 68 | } 69 | } 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /packages/fwidgets/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./main"; 2 | -------------------------------------------------------------------------------- /packages/fwidgets/main/callFwidgets.ts: -------------------------------------------------------------------------------- 1 | import { call } from "figma-await-ipc"; 2 | import { FwidgetsCall } from "../shared/constants"; 3 | import { show, showMinimized } from "./ui"; 4 | 5 | export async function callFwidgets( 6 | type: string, 7 | options: object, 8 | minimizeWindow: boolean = false) 9 | { 10 | if (minimizeWindow) { 11 | showMinimized(); 12 | } else { 13 | show(); 14 | } 15 | 16 | const result = await call(FwidgetsCall, { type, options }); 17 | 18 | if (result === null) { 19 | // null means esc was pressed, so for now, just throw an empty error that 20 | // will be caught by fwidgets() and close the plugin with no message 21 | throw new Error(); 22 | } 23 | 24 | return result; 25 | } 26 | -------------------------------------------------------------------------------- /packages/fwidgets/main/fwidgets.ts: -------------------------------------------------------------------------------- 1 | import * as input from "./input"; 2 | import * as output from "./output"; 3 | import * as ui from "./ui"; 4 | 5 | const Modules = { 6 | input, 7 | output, 8 | ui, 9 | } as const; 10 | 11 | type MainFunction = (modules: typeof Modules) => Promise; 12 | 13 | export function fwidgets( 14 | main: MainFunction) 15 | { 16 | // wrap the call to the user's function in another function, so they can 17 | // just use `export default fwidgets(...)` 18 | return () => main(Modules) 19 | // use a then handler so we don't have to make fwidgets an async function 20 | .then((result) => figma.closePlugin(result ?? "")) 21 | .catch((error) => { 22 | if (error?.message) { 23 | // this isn't an empty error thrown by canceling, so write it to the 24 | // console so the user can see it 25 | console.error(`Uncaught exception handled in fwidgets():\n${error}`); 26 | } 27 | 28 | figma.closePlugin(error.message); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /packages/fwidgets/main/index.ts: -------------------------------------------------------------------------------- 1 | export { fwidgets } from "./fwidgets"; 2 | export * as input from "./input"; 3 | export * as output from "./output"; 4 | export * as ui from "./ui"; 5 | -------------------------------------------------------------------------------- /packages/fwidgets/main/input.ts: -------------------------------------------------------------------------------- 1 | import { callFwidgets } from "./callFwidgets"; 2 | 3 | export function buttons( 4 | label: string, 5 | buttons: string[], 6 | options: object = {}) 7 | { 8 | return callFwidgets("InputButtons", { ...options, label, buttons }); 9 | } 10 | 11 | export function color( 12 | label: string = "", 13 | options: object = {}) 14 | { 15 | return callFwidgets("InputColor", { ...options, label }); 16 | } 17 | 18 | export function dropdown( 19 | label: string, 20 | items: string[], 21 | options: object = {}) 22 | { 23 | return callFwidgets("InputDropdown", { ...options, label, options: items }); 24 | } 25 | 26 | export function number( 27 | label: string = "", 28 | options: object = {}) 29 | { 30 | return callFwidgets("InputNumber", { ...options, label }); 31 | } 32 | 33 | export async function page( 34 | label: string = "Select a page:", 35 | options: object = { placeholder: "Pages" }) 36 | { 37 | const pageNames = figma.root.children.map(({ name }) => name); 38 | const selectedPageName = await callFwidgets("InputDropdown", { ...options, label, options: pageNames }); 39 | 40 | return figma.root.children.find(({ name }) => name === selectedPageName); 41 | } 42 | 43 | export function text( 44 | label: string = "", 45 | options: object = {}) 46 | { 47 | return callFwidgets("InputText", { ...options, label }); 48 | } 49 | -------------------------------------------------------------------------------- /packages/fwidgets/main/output.ts: -------------------------------------------------------------------------------- 1 | import { callFwidgets } from "./callFwidgets"; 2 | 3 | type ClipboardOptions = { 4 | message?: string; 5 | error?: boolean; 6 | timeout?: number; 7 | } 8 | 9 | export function clipboard( 10 | value: unknown, 11 | options: ClipboardOptions = {}) 12 | { 13 | const { message, error, timeout } = options; 14 | const text = (value && typeof value === "object") 15 | ? JSON.stringify(value, null, "\t") 16 | : String(value); 17 | 18 | if (message) { 19 | figma.notify(message, { error, timeout }); 20 | } 21 | 22 | // pass true to make callFwidgets() call showMinimized() instead of show(), 23 | // so that we use a minimal window to do the copying 24 | return callFwidgets( 25 | "OutputClipboard", 26 | { ...options, text }, 27 | true 28 | ); 29 | } 30 | 31 | export function text( 32 | text: unknown, 33 | options: object = {}) 34 | { 35 | return callFwidgets("OutputText", { ...options, text }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/fwidgets/main/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fwidgets/main", 3 | "type": "module", 4 | "module": "index.ts", 5 | "types": "index.ts", 6 | "sideEffects": false 7 | } 8 | -------------------------------------------------------------------------------- /packages/fwidgets/main/ui.ts: -------------------------------------------------------------------------------- 1 | import { showUI } from "@create-figma-plugin/utilities"; 2 | 3 | type WindowSize = { 4 | width: number; 5 | height: number; 6 | }; 7 | type WindowPosition = null | { 8 | x: number; 9 | y: number; 10 | }; 11 | type ShowOptions = { 12 | size?: WindowSize; 13 | position?: WindowPosition; 14 | }; 15 | 16 | const DefaultSize: WindowSize = { 17 | width: 300, 18 | height: 200, 19 | }; 20 | const HeaderHeight = 40; 21 | 22 | let windowSize = { ...DefaultSize }; 23 | let windowPosition: WindowPosition = null; 24 | let isUIOpen = false; 25 | let isUIVisible = false; 26 | let isUIMinimized = false; 27 | 28 | export function show({ 29 | size, 30 | position, 31 | }: ShowOptions = {}) 32 | { 33 | const { zoom } = figma.viewport; 34 | // default to showing the window at its saved size 35 | let targetSize = { ...windowSize }; 36 | let targetPosition; 37 | 38 | if (size) { 39 | targetSize = windowSize = { ...size }; 40 | } 41 | 42 | if (isUIMinimized) { 43 | // since the plugin is currently minimized, its position and size have 44 | // been set to a little window in the bottom-right corner. ideally, we'd 45 | // now show it at its full size and previous position, but that's been 46 | // lost, since we programmatically set the position. so the best we can 47 | // do is center it on the viewport by converting the target plugin window 48 | // size and position into viewport units. 49 | const { x, y, width, height } = figma.viewport.bounds; 50 | const vpW = windowSize.width / zoom; 51 | const vpH = (HeaderHeight + windowSize.height) / zoom; 52 | const vpX = x + (width - vpW) / 2; 53 | const vpY = y + (height - vpH) / 2; 54 | 55 | targetPosition = { 56 | x: vpX, 57 | y: vpY, 58 | }; 59 | isUIMinimized = false; 60 | } 61 | 62 | // check position after isUIMinimized so that the position param can override 63 | // the centering that's done in the block above 64 | if (position) { 65 | const { x, y } = figma.viewport.bounds; 66 | 67 | windowPosition = { ...position }; 68 | targetPosition = { 69 | x: x + position.x / zoom, 70 | y: y + position.y / zoom, 71 | }; 72 | } 73 | 74 | isUIVisible = true; 75 | 76 | if (isUIOpen) { 77 | if (targetSize) { 78 | figma.ui.resize(targetSize.width, targetSize.height); 79 | } 80 | 81 | if (targetPosition) { 82 | figma.ui.reposition(targetPosition.x, targetPosition.y); 83 | } 84 | 85 | figma.ui.show(); 86 | } else { 87 | isUIOpen = true; 88 | showUI({ 89 | ...targetSize, 90 | ...(targetPosition ? { position: targetPosition } : null) 91 | }); 92 | } 93 | } 94 | 95 | export function showMinimized() 96 | { 97 | if (isUIVisible) { 98 | // if the window is currently visible, there's no reason to minimize it 99 | return; 100 | } 101 | 102 | const { x, y, width, height } = figma.viewport.bounds; 103 | const size = { width: 0, height: 0 }; 104 | // we don't know how wide the side panels around the viewport are, so 105 | // multiply the viewport width/height so that we force the plugin window to 106 | // the bottom-right corner, behind the help button 107 | const position = { x: x + width * 5, y: y + height * 5 }; 108 | 109 | isUIMinimized = true; 110 | isUIVisible = true; 111 | 112 | showUI({ ...size, position }); 113 | } 114 | 115 | export function hide() 116 | { 117 | isUIVisible = false; 118 | 119 | if (isUIOpen) { 120 | isUIOpen = false; 121 | figma.ui.hide(); 122 | } 123 | } 124 | 125 | export function setSize( 126 | size: WindowSize) 127 | { 128 | windowSize = { ...size }; 129 | 130 | if (isUIOpen) { 131 | figma.ui.resize(size.width, size.height); 132 | } 133 | } 134 | 135 | export function setPosition( 136 | position: WindowPosition) 137 | { 138 | if (position) { 139 | windowPosition = { ...position }; 140 | 141 | if (isUIOpen) { 142 | const { zoom, bounds: { x, y } } = figma.viewport; 143 | 144 | figma.ui.reposition( 145 | x + position.x / zoom, 146 | y + position.y / zoom 147 | ); 148 | } 149 | } else { 150 | windowPosition = null; 151 | } 152 | } 153 | 154 | export const isOpen = () => isUIOpen; 155 | export const isVisible = () => isUIVisible; 156 | export const getSize = () => ({ ...windowSize }); 157 | export const getPosition = () => (windowPosition ? { ...windowPosition } : null); 158 | -------------------------------------------------------------------------------- /packages/fwidgets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fwidgets", 3 | "version": "0.1.1", 4 | "description": "Create a simple Figma plugin UI with zero UI code.", 5 | "keywords": [ 6 | "figma", 7 | "plugin", 8 | "ui" 9 | ], 10 | "author": "John Dunning", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/fwextensions/fwidgets.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/fwextensions/fwidgets/issues" 18 | }, 19 | "homepage": "https://github.com/fwextensions/fwidgets#readme", 20 | "files": [ 21 | "index.ts", 22 | "shared", 23 | "main", 24 | "ui" 25 | ], 26 | "exports": { 27 | ".": "./index.ts", 28 | "./main": "./main/index.ts", 29 | "./ui": "./ui/index.ts" 30 | }, 31 | "scripts": { 32 | "build": "npx typed-css-modules ui && tsc", 33 | "prepack": "shx cp -f ../../README.md .", 34 | "postpack": "shx rm ./README.md", 35 | "release": "dotenv release-it" 36 | }, 37 | "dependencies": { 38 | "classnames": "^2.5.1", 39 | "figma-await-ipc": "^0.1.0" 40 | }, 41 | "peerDependencies": { 42 | "@create-figma-plugin/ui": "^3.0.2", 43 | "@create-figma-plugin/utilities": "^3.0.2", 44 | "preact": "^10.18.1" 45 | }, 46 | "devDependencies": { 47 | "@create-figma-plugin/tsconfig": "^3.1.0", 48 | "@figma/plugin-typings": "1.88.0", 49 | "@release-it/conventional-changelog": "^8.0.1", 50 | "dotenv-cli": "^7.4.1", 51 | "release-it": "^17.1.1", 52 | "shx": "^0.3.4", 53 | "typescript": "^5.4.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/fwidgets/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const FwidgetsCall = "FWIDGETS"; 2 | -------------------------------------------------------------------------------- /packages/fwidgets/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@create-figma-plugin/tsconfig", 3 | "compilerOptions": { 4 | "target":"ES2022", 5 | "typeRoots": ["node_modules/@figma", "node_modules/@types"], 6 | "declaration": true, 7 | "skipLibCheck": true, 8 | "allowJs": true, 9 | "outDir": "dist" 10 | }, 11 | "include": [ 12 | "main", 13 | "shared", 14 | "ui" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/FwInline.module.css: -------------------------------------------------------------------------------- 1 | .fwInline { 2 | display: flex; 3 | align-items: baseline; 4 | } 5 | 6 | /* because we can't pass in a style prop to affect the container element in 7 | textbox components, we have to match them by classname. but we don't want 8 | to change the width of a textboxColor component. */ 9 | .fwInline > :first-child[class*="textbox"]:not([class*="Color"]) { 10 | width: 100%; 11 | } 12 | 13 | .extraSmall { 14 | gap: var(--space-extra-small); 15 | } 16 | 17 | .medium { 18 | gap: var(--space-medium); 19 | } 20 | 21 | .large { 22 | gap: var(--space-large); 23 | } 24 | 25 | .small { 26 | gap: var(--space-small); 27 | } 28 | 29 | .extraLarge { 30 | gap: var(--space-extra-large); 31 | } 32 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/FwInline.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "extraLarge": string; 3 | readonly "extraSmall": string; 4 | readonly "fwInline": string; 5 | readonly "large": string; 6 | readonly "medium": string; 7 | readonly "small": string; 8 | }; 9 | export = styles; 10 | 11 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/FwInline.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren, h } from "preact"; 2 | import { Space } from "@create-figma-plugin/ui"; 3 | import classnames from "classnames/bind"; 4 | import styles from "./FwInline.module.css"; 5 | 6 | export type FwInlineProps = { 7 | children: ComponentChildren; 8 | space?: InlineSpace; 9 | } 10 | export type InlineSpace = Space; 11 | 12 | const cx = classnames.bind(styles); 13 | 14 | export default function FwInline({ 15 | space = "medium", 16 | children, 17 | ...rest 18 | }: FwInlineProps) 19 | { 20 | return ( 21 |
25 | {children} 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/Fwidgets.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { 3 | useCallback, 4 | useEffect, 5 | useLayoutEffect, 6 | useState 7 | } from "preact/hooks"; 8 | import { Container, Stack, VerticalSpace } from "@create-figma-plugin/ui"; 9 | import { receive } from "figma-await-ipc"; 10 | import { FwidgetsCall } from "../../shared/constants"; 11 | import { createWidgetSpec, createWidgetElement, WidgetSpec } from "../widgets"; 12 | 13 | function disable( 14 | widgets: WidgetSpec[]) 15 | { 16 | widgets.forEach(({ options }) => options && (options.disabled = true)); 17 | 18 | return widgets; 19 | } 20 | 21 | export default function Fwidgets() 22 | { 23 | const [widgets, setWidgets] = useState([]); 24 | 25 | useEffect(() => { 26 | receive(FwidgetsCall, (data) => { 27 | const response = createWidgetSpec(data); 28 | 29 | setWidgets((widgets) => [...disable(widgets), response]); 30 | 31 | return response.deferred; 32 | }); 33 | }, []); 34 | 35 | // using useEffect here scrolls to the previous scrollHeight, even though 36 | // useLayoutEffect is supposed to happen before rendering. and setting 37 | // document.body.scrollTop doesn't seem to work, so scroll the whole window. 38 | useLayoutEffect(() => { 39 | window.scrollTo({ 40 | top: document.body.scrollHeight, 41 | left: 0, 42 | behavior: "smooth" 43 | }); 44 | 45 | // the previous scroll call isn't always sufficient to scroll the window to 46 | // the bottom, so do it again after a slight delay 47 | // TODO: fix this kludge 48 | setTimeout(() => window.scrollTo({ 49 | top: document.body.scrollHeight, 50 | left: 0, 51 | }), 10); 52 | }, [widgets]); 53 | 54 | const handleConfirm = useCallback( 55 | (text: string) => widgets[widgets.length - 1]?.deferred.resolve(text), 56 | [widgets] 57 | ); 58 | 59 | const widgetElements = widgets.map((spec, i) => { 60 | const focused = i === widgets.length - 1; 61 | 62 | return createWidgetElement(spec, focused, handleConfirm); 63 | }); 64 | 65 | return ( 66 | 67 | 68 | 69 | {widgetElements} 70 | 71 | 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InlineWidget.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentChildren, h, JSX } from "preact"; 2 | import { Stack } from "@create-figma-plugin/ui"; 3 | import Label from "./Label"; 4 | import NextButton from "./NextButton"; 5 | import FwInline from "./FwInline"; 6 | 7 | interface InlineWidgetProps { 8 | label?: string, 9 | disabled?: boolean; 10 | nextEnabled?: boolean; 11 | onNextClick: JSX.MouseEventHandler; 12 | children: ComponentChildren; 13 | } 14 | 15 | export default function InlineWidget({ 16 | label = "", 17 | disabled = false, 18 | nextEnabled = true, 19 | onNextClick, 20 | children 21 | }: InlineWidgetProps) 22 | { 23 | return ( 24 | 25 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputButtons.module.css: -------------------------------------------------------------------------------- 1 | .inputButton { 2 | margin-bottom: var(--space-extra-small); 3 | } 4 | 5 | .selected { 6 | outline: 2px solid var(--figma-color-border-disabled-strong); 7 | outline-offset: 2px; 8 | } 9 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputButtons.module.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "inputButton": string; 3 | readonly "selected": string; 4 | }; 5 | export = styles; 6 | 7 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputButtons.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import JSX = h.JSX; 3 | import { useCallback, useState } from "preact/hooks"; 4 | import { 5 | Button, 6 | Inline, 7 | Stack, 8 | useInitialFocus 9 | } from "@create-figma-plugin/ui"; 10 | import classnames from "classnames/bind"; 11 | import { keepName } from "../utils"; 12 | import Label from "./Label"; 13 | import styles from "./InputButtons.module.css"; 14 | 15 | interface ButtonProps { 16 | label: string; 17 | value?: unknown; 18 | } 19 | 20 | interface InputButtonProps { 21 | confirm: (text: string | null) => void; 22 | buttons: string[]; 23 | // buttons: (string | ButtonProps)[]; 24 | disabled?: boolean; 25 | label?: string; 26 | } 27 | 28 | const cx = classnames.bind(styles); 29 | 30 | export default function InputButtons({ 31 | confirm, 32 | buttons, 33 | disabled = false, 34 | label = "", 35 | }: InputButtonProps) 36 | { 37 | const [selectedLabel, setSelectedLabel] = useState(""); 38 | const initialFocus = useInitialFocus(); 39 | 40 | const handleClick = useCallback( 41 | (label: string) => { 42 | setSelectedLabel(label); 43 | confirm(label); 44 | }, 45 | [] 46 | ); 47 | 48 | const handleKeyDown = useCallback( 49 | (event: JSX.TargetedKeyboardEvent) => { 50 | if (event.key === "Escape") { 51 | confirm(null); 52 | } 53 | }, 54 | [] 55 | ); 56 | 57 | const buttonElements = buttons.map((label, i) => ( 58 | 67 | )); 68 | 69 | return ( 70 | 71 | 81 | ); 82 | } 83 | 84 | keepName(InputButtons, "InputButtons"); 85 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputColor.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import JSX = h.JSX; 3 | import { useCallback, useState } from "preact/hooks"; 4 | import { TextboxColor, useInitialFocus } from "@create-figma-plugin/ui"; 5 | import { keepName } from "../utils"; 6 | import InlineWidget from "./InlineWidget"; 7 | 8 | const DefaultRGBA = { r: 0, g: 0, b: 0, a: 1 }; 9 | 10 | interface InputColorProps { 11 | confirm: (color: RGBA | null) => void; 12 | disabled?: boolean; 13 | placeholder?: string; 14 | label?: string; 15 | focused?: boolean; 16 | } 17 | 18 | export default function InputColor({ 19 | confirm, 20 | disabled = false, 21 | placeholder = "Select an item", 22 | label = "", 23 | focused = false, 24 | }: InputColorProps) 25 | { 26 | const [hexColor, setHexColor] = useState("000000"); 27 | const [opacity, setOpacity] = useState("100%"); 28 | const [rgbaColor, setRgbaColor] = useState(DefaultRGBA); 29 | const initialFocus = useInitialFocus(); 30 | const isValueValid = !!rgbaColor; 31 | 32 | const handleHexColorInput = (event: JSX.TargetedEvent) => { 33 | setHexColor(event.currentTarget.value); 34 | }; 35 | 36 | const handleOpacityInput = (event: JSX.TargetedEvent) => { 37 | setOpacity(event.currentTarget.value); 38 | }; 39 | 40 | const handleRGBAInput = (rgba: RGBA | null) => { 41 | setRgbaColor(rgba); 42 | }; 43 | 44 | const handleConfirm = useCallback( 45 | () => isValueValid && confirm(rgbaColor), 46 | [hexColor, opacity] 47 | ); 48 | 49 | const handleKeyDown = useCallback( 50 | (event: JSX.TargetedKeyboardEvent) => { 51 | if (event.key === "Enter") { 52 | handleConfirm(); 53 | } else if (event.key === "Escape") { 54 | confirm(null); 55 | } 56 | }, 57 | [handleConfirm] 58 | ); 59 | 60 | return ( 61 | 67 | 80 | 81 | ); 82 | } 83 | 84 | keepName(InputColor, "InputColor"); 85 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import JSX = h.JSX; 3 | import { useCallback, useState } from "preact/hooks"; 4 | import { Dropdown, useInitialFocus } from "@create-figma-plugin/ui"; 5 | import { keepName } from "../utils"; 6 | import InlineWidget from "./InlineWidget"; 7 | 8 | interface InputDropdownProps { 9 | confirm: (text: string) => void; 10 | options: string[]; 11 | disabled?: boolean; 12 | placeholder?: string; 13 | label?: string; 14 | focused?: boolean; 15 | } 16 | 17 | export default function InputDropdown({ 18 | confirm, 19 | options, 20 | disabled = false, 21 | placeholder = "Select an item", 22 | label = "", 23 | focused = false, 24 | }: InputDropdownProps) 25 | { 26 | const [value, setValue] = useState(null); 27 | const initialFocus = useInitialFocus(); 28 | const isValueValid = !!value; 29 | 30 | const handleChange = (event: JSX.TargetedEvent) => { 31 | setValue(event.currentTarget.value); 32 | }; 33 | 34 | const handleConfirm = useCallback( 35 | () => isValueValid && confirm(value ?? ""), 36 | [value] 37 | ); 38 | 39 | return ( 40 | 46 | ({ value }))} 50 | value={value} 51 | placeholder={placeholder} 52 | {...(focused ? initialFocus : {})} 53 | onChange={handleChange} 54 | /> 55 | 56 | ); 57 | } 58 | 59 | keepName(InputDropdown, "InputDropdown"); 60 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputNumber.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import JSX = h.JSX; 3 | import { useCallback, useState } from "preact/hooks"; 4 | import { TextboxNumeric, useInitialFocus } from "@create-figma-plugin/ui"; 5 | import { keepName } from "../utils"; 6 | import InlineWidget from "./InlineWidget"; 7 | 8 | interface InputNumberProps { 9 | confirm: (value: number | null) => void; 10 | disabled?: boolean; 11 | label?: string; 12 | defaultValue?: string; 13 | placeholder?: string; 14 | suffix?: string; 15 | incrementSmall?: number; 16 | incrementBig?: number; 17 | minimum?: number; 18 | maximum?: number; 19 | integer?: boolean; 20 | focused?: boolean; 21 | } 22 | 23 | export default function InputNumber({ 24 | confirm, 25 | disabled = false, 26 | label = "", 27 | placeholder, 28 | suffix, 29 | incrementSmall, 30 | incrementBig, 31 | minimum, 32 | maximum, 33 | defaultValue = Number.isFinite(minimum) ? String(minimum) : "", 34 | integer = false, 35 | focused = false, 36 | }: InputNumberProps) 37 | { 38 | const [value, setValue] = useState(defaultValue); 39 | const initialFocus = useInitialFocus(); 40 | const isValueValid = !!value; 41 | 42 | const handleInput = useCallback( 43 | (event: JSX.TargetedEvent) => setValue(event.currentTarget.value), 44 | [] 45 | ); 46 | 47 | const handleConfirm = useCallback( 48 | () => isValueValid && confirm(parseFloat(value)), 49 | [value] 50 | ); 51 | 52 | const handleKeyDown = useCallback( 53 | (event: JSX.TargetedKeyboardEvent) => { 54 | if (event.key === "Enter") { 55 | handleConfirm(); 56 | } else if (event.key === "Escape") { 57 | confirm(null); 58 | } 59 | }, 60 | [handleConfirm] 61 | ); 62 | 63 | return ( 64 | 70 | 85 | 86 | ); 87 | } 88 | 89 | keepName(InputNumber, "InputNumber"); 90 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/InputText.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import JSX = h.JSX; 3 | import { useCallback, useState } from "preact/hooks"; 4 | import { TextboxMultiline, useInitialFocus } from "@create-figma-plugin/ui"; 5 | import { keepName } from "../utils"; 6 | import InlineWidget from "./InlineWidget"; 7 | 8 | interface InputTextProps { 9 | confirm: (text: string | null) => void; 10 | disabled?: boolean; 11 | label?: string; 12 | defaultValue?: string; 13 | placeholder?: string; 14 | focused?: boolean; 15 | grow?: boolean; 16 | rows?: number; 17 | } 18 | 19 | type InputEvent = JSX.TargetedEvent; 20 | type KeyboardEvent = JSX.TargetedKeyboardEvent; 21 | 22 | export default function InputText({ 23 | confirm, 24 | disabled = false, 25 | label = "", 26 | defaultValue = "", 27 | placeholder = "", 28 | focused = false, 29 | grow = true, 30 | rows = 1, 31 | }: InputTextProps) 32 | { 33 | const [value, setValue] = useState(defaultValue); 34 | const initialFocus = useInitialFocus(); 35 | const isValueValid = !!value; 36 | const isMultiline = grow || rows > 1; 37 | 38 | const handleInput = useCallback( 39 | (event: InputEvent) => setValue(event.currentTarget.value), 40 | [] 41 | ); 42 | 43 | const handleConfirm = useCallback( 44 | () => isValueValid && confirm(value), 45 | [value] 46 | ); 47 | 48 | const handleKeyDown = useCallback( 49 | (event: KeyboardEvent) => { 50 | if (event.key === "Enter") { 51 | // allow newlines only when shift is pressed and the field can show 52 | // more than one line, so we don't get scrollbars in a tiny field. 53 | // ignore shift-enter for single-line fields, so the user doesn't 54 | // accidentally confirm the field. 55 | if (event.shiftKey) { 56 | if (!isMultiline) { 57 | event.preventDefault(); 58 | } 59 | } else { 60 | event.preventDefault(); 61 | handleConfirm(); 62 | } 63 | } else if (event.key === "Escape") { 64 | confirm(null); 65 | } 66 | }, 67 | [handleConfirm] 68 | ); 69 | 70 | return ( 71 | 77 | 88 | 89 | ); 90 | } 91 | 92 | keepName(InputText, "InputText"); 93 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/Label.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { Text } from "@create-figma-plugin/ui"; 3 | 4 | interface LabelProps { 5 | text?: string; 6 | disabled?: boolean; 7 | } 8 | 9 | export default function Label({ 10 | text = "", 11 | disabled = false }: LabelProps) 12 | { 13 | if (!text) { 14 | return null; 15 | } 16 | 17 | const style = disabled 18 | ? { color: "var(--figma-color-text-disabled)" } 19 | : undefined; 20 | 21 | return ( 22 | 25 | {text} 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/NextButton.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from "preact"; 2 | import { Button } from "@create-figma-plugin/ui"; 3 | 4 | interface NextButtonProps { 5 | onClick: JSX.MouseEventHandler; 6 | disabled?: boolean; 7 | hidden?: boolean; 8 | } 9 | 10 | export default function NextButton({ 11 | onClick, 12 | disabled = false, 13 | hidden = false, 14 | }: NextButtonProps) 15 | { 16 | if (hidden) { 17 | return null; 18 | } 19 | 20 | return ( 21 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/OutputClipboard.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | import { keepName } from "../utils"; 3 | 4 | function copyTextToClipboard( 5 | text: string) 6 | { 7 | const copyFrom = document.createElement("textarea"); 8 | const { body, activeElement } = document; 9 | let result = true; 10 | 11 | copyFrom.textContent = text; 12 | body.appendChild(copyFrom); 13 | copyFrom.focus({ preventScroll: true }); 14 | copyFrom.select(); 15 | 16 | // Figma doesn't support the clipboard API, so even though execCommand() is 17 | // deprecated, it's the only way to trigger a copy 18 | if (!document.execCommand("copy")) { 19 | result = false; 20 | } 21 | 22 | // now that the selected text is copied, remove the copy source 23 | body.removeChild(copyFrom); 24 | 25 | if (activeElement) { 26 | // refocus the previously active element, since we stole the 27 | // focus to copy the text from the temp textarea 28 | (activeElement as HTMLElement).focus({ preventScroll: true }); 29 | } 30 | 31 | return result; 32 | } 33 | 34 | interface OutputClipboardProps { 35 | confirm: (text: string) => void; 36 | text: string; 37 | } 38 | 39 | export default function OutputClipboard({ 40 | confirm, 41 | text 42 | }: OutputClipboardProps) 43 | { 44 | // we're not waiting for any user input, so resolve the promise immediately 45 | // when we're mounted 46 | useEffect( 47 | () => confirm(String(copyTextToClipboard(text))), 48 | [] 49 | ); 50 | 51 | return null; 52 | } 53 | 54 | keepName(OutputClipboard, "OutputClipboard"); 55 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/components/OutputText.tsx: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { useEffect } from "preact/hooks"; 3 | import { Text } from "@create-figma-plugin/ui"; 4 | import { keepName } from "../utils"; 5 | 6 | // we have to set the first two of these properties to override the included 7 | // CSS and make the text selectable 8 | const TextStyle = { 9 | userSelect: "text", 10 | pointerEvents: "auto", 11 | cursor: "auto", 12 | whiteSpace: "pre-wrap", 13 | }; 14 | 15 | interface OutputTextProps { 16 | confirm: (text: string) => void; 17 | text: string; 18 | numeric?: boolean; 19 | align?: "left"|"center"|"right"; 20 | } 21 | 22 | export default function OutputText({ 23 | confirm, 24 | text, 25 | numeric, 26 | align, 27 | }: OutputTextProps) 28 | { 29 | // we're not waiting for any user input, so resolve the promise immediately 30 | // when we're mounted 31 | useEffect( 32 | () => confirm(""), 33 | [] 34 | ); 35 | 36 | return ( 37 | 42 | {text} 43 | 44 | ); 45 | } 46 | 47 | keepName(OutputText, "OutputText"); 48 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { render } from "@create-figma-plugin/ui"; 2 | import Fwidgets from "./components/Fwidgets"; 3 | 4 | export { Fwidgets }; 5 | export const FwidgetsUI = render(Fwidgets); 6 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fwidgets/ui", 3 | "type": "module", 4 | "module": "index.ts", 5 | "types": "index.ts", 6 | "sideEffects": false 7 | } 8 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/utils.ts: -------------------------------------------------------------------------------- 1 | export function keepName( 2 | fn: T, 3 | name: string) 4 | { 5 | // override the function's name so that when it's minified, the name 6 | // doesn't change, which means we don't have to pass keepNames to esbuild 7 | // (which currently fails on Windows in cfp 3.0) 8 | Object.defineProperty(fn, "name", { value: name }); 9 | 10 | return fn; 11 | } 12 | -------------------------------------------------------------------------------- /packages/fwidgets/ui/widgets.ts: -------------------------------------------------------------------------------- 1 | import { h } from "preact"; 2 | import { DeferredPromise } from "figma-await-ipc"; 3 | import InputButtons from "./components/InputButtons"; 4 | import InputColor from "./components/InputColor"; 5 | import InputDropdown from "./components/InputDropdown"; 6 | import InputNumber from "./components/InputNumber"; 7 | import InputText from "./components/InputText"; 8 | import OutputClipboard from "./components/OutputClipboard"; 9 | import OutputText from "./components/OutputText"; 10 | 11 | const componentList = [ 12 | InputButtons, 13 | InputColor, 14 | InputDropdown, 15 | InputNumber, 16 | InputText, 17 | OutputClipboard, 18 | OutputText, 19 | ] as const; 20 | 21 | const components: Record = componentList.reduce((result, component) => ({ 22 | ...result, 23 | [component.name]: component 24 | }), {}); 25 | 26 | export interface WidgetCall { 27 | type: keyof typeof components; 28 | options?: Record; 29 | } 30 | 31 | export interface WidgetSpec extends WidgetCall { 32 | deferred: DeferredPromise; 33 | } 34 | 35 | export function createWidgetSpec( 36 | data: WidgetCall): WidgetSpec 37 | { 38 | if (!data || typeof data !== "object") { 39 | throw new Error(`Unrecognized widget data: ${data}.`); 40 | } 41 | 42 | const { type, options } = data; 43 | 44 | if (!(type in components)) { 45 | throw new Error(`Unrecognized widget type: ${type}.`); 46 | } 47 | 48 | return { 49 | type, 50 | options, 51 | deferred: new DeferredPromise() 52 | }; 53 | } 54 | 55 | export function createWidgetElement( 56 | spec: WidgetSpec, 57 | focused: boolean, 58 | confirm: (text: string) => void) 59 | { 60 | const { type, options } = spec; 61 | const Component = components[type]; 62 | 63 | // we can't seem to use here to instantiate an element, so use 64 | // h() directly like React.createElement() 65 | return h( 66 | Component, 67 | { 68 | focused, 69 | confirm, 70 | ...options 71 | } 72 | ); 73 | // TODO: this seems in the right direction, but still shows TS errors 74 | // return h>( 75 | // Component, 76 | // { 77 | // focused, 78 | // confirm, 79 | // ...options 80 | // } 81 | // ); 82 | } 83 | -------------------------------------------------------------------------------- /packages/plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin", 3 | "scripts": { 4 | "build": "build-figma-plugin --typecheck --minify", 5 | "dev": "build-figma-plugin --watch", 6 | "watch": "build-figma-plugin --watch" 7 | }, 8 | "dependencies": { 9 | "@create-figma-plugin/ui": "^3.1.0", 10 | "@create-figma-plugin/utilities": "^3.1.0", 11 | "preact": "^10.19.6", 12 | "fwidgets": "latest" 13 | }, 14 | "devDependencies": { 15 | "@create-figma-plugin/build": "^3.1.0", 16 | "@create-figma-plugin/tsconfig": "^3.1.0", 17 | "@figma/plugin-typings": "1.88.0", 18 | "typescript": "^5.4.2" 19 | }, 20 | "figma-plugin": { 21 | "editorType": [ 22 | "figma" 23 | ], 24 | "id": "fwidgetsTestPlugin", 25 | "name": "Fwidgets Test Plugin", 26 | "main": "src/main.ts", 27 | "ui": "src/ui.tsx" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/plugin/src/main.ts: -------------------------------------------------------------------------------- 1 | import { fwidgets } from "fwidgets"; 2 | 3 | // it's fine to have initialization code or utility functions declared outside 4 | // of the call to fwidgets() below, but any code that needs to interact with the 5 | // plugin UI must be executed during the lifetime of the callback. 6 | function createRectangles( 7 | count: number, 8 | color: RGB) 9 | { 10 | const nodes: Array = []; 11 | 12 | for (let i = 0; i < count; i++) { 13 | const rect = figma.createRectangle(); 14 | 15 | rect.x = figma.viewport.center.x + i * 150; 16 | rect.fills = [{ type: "SOLID", color }]; 17 | figma.currentPage.appendChild(rect); 18 | nodes.push(rect); 19 | } 20 | 21 | figma.currentPage.selection = nodes; 22 | figma.viewport.scrollAndZoomIntoView(nodes); 23 | 24 | return nodes; 25 | } 26 | 27 | // all of the code that interacts with the plugin UI should be triggered from 28 | // within the callback passed to fwidgets(), since the plugin window is closed 29 | // when the callback returns. it will be passed input, output and ui modules 30 | // that contain functions you can use to display UI controls, one at a time. 31 | export default fwidgets(async ({ input, output, ui }) => { 32 | // you can set the size of the plugin window 33 | ui.setSize({ 34 | width: 300, 35 | height: 250 36 | }); 37 | 38 | // the color picker returns an RGBA object, but we only need the RGB here 39 | const { a, ...rgb } = await input.color("Rectangle fill color:"); 40 | 41 | // limit the user to entering integers from 1 to 10 42 | const count = await input.number("Number of rectangles:", { 43 | placeholder: "Count", 44 | minimum: 1, 45 | maximum: 10, 46 | integer: true 47 | }); 48 | 49 | // let the user cancel before creating any rectangles 50 | const button = await input.buttons("Continue?", [ 51 | "Create Rectangles", 52 | "Cancel" 53 | ]); 54 | 55 | if (button === "Cancel") { 56 | // returning from this function automatically closes the plugin window 57 | return; 58 | } 59 | 60 | // create the rectangles with the color and number specified by the user 61 | const rects = createRectangles(count, rgb); 62 | 63 | // collect the position and size of each rectangle node 64 | const rectInfo = rects.map(({ x, y, width: w, height: h }) => ({ x, y, w, h })); 65 | 66 | // copy the info to the clipboard as JSON 67 | await output.clipboard(rectInfo); 68 | 69 | // return a message to show in a toast after the plugin closes 70 | return `${count} rectangles copied to the clipboard.` 71 | }); 72 | -------------------------------------------------------------------------------- /packages/plugin/src/ui.tsx: -------------------------------------------------------------------------------- 1 | export { FwidgetsUI as default } from "fwidgets/ui"; 2 | -------------------------------------------------------------------------------- /packages/plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@create-figma-plugin/tsconfig", 3 | "compilerOptions": { 4 | "target":"ES2022", 5 | "skipLibCheck": true, 6 | "typeRoots": ["node_modules/@figma", "node_modules/@types"], 7 | "paths": { 8 | "@/*": ["./src/*"] 9 | } 10 | }, 11 | "include": ["src"] 12 | } 13 | --------------------------------------------------------------------------------