├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── figma.d.ts ├── index.html ├── manifest.json ├── package.json ├── src ├── plugin.ts ├── types.ts └── ui │ ├── easy-shortcuts.ts │ ├── form-input.ts │ ├── form.ts │ └── index.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "plugin:@typescript-eslint/recommended"], 3 | "rules": { 4 | "no-tabs": "off", 5 | "@typescript-eslint/indent": ["error", 2], 6 | "max-len": ["error", { 7 | "code": 100 8 | }], 9 | "arrow-body-style": "off", 10 | "no-underscore-dangle": "off", 11 | "class-methods-use-this": "off" 12 | }, 13 | "settings": { 14 | "import/resolver": { 15 | "node": { 16 | "extensions": [".js", ".ts"] 17 | } 18 | }, 19 | }, 20 | "env": { 21 | "mocha": true, 22 | "node": true, 23 | "browser": true, 24 | }, 25 | "globals": { 26 | "figma": "readonly", 27 | "parent": "readonly", 28 | "__html__": "readonly", 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | plugin/ 3 | *.log 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Guide Mate](https://i.ibb.co/R6V6qkZ/Banner.png) 2 | 3 | Guide Mate is a [Figma](https://figma.com) plugin which makes adding guides to your frames or shapes super easy. It provides shortcuts to add most commonly used guides and a form to add custom guides. It will be an excellent companion when you're working with grid based design systems. 4 | 5 | ## Install 6 | 7 | Install it from the [Figma Plugin Store](https://www.figma.com/c/plugin/739342962452731553/Guide-Mate). 8 | 9 | ## Usage 10 | - Select a frame, group or shape. 11 | - Open Guide Mate from the plugins menu. 12 | - Click on one of the icon in the shortcut panel to add left, right, top, bottom or midpoint guides. 13 | - Enter details in the form and click on "Add Guides" to generate custom guides. 14 | 15 | ## Limitations 16 | - At least one frame or group or shape should be selected before using the plugin. 17 | - Multi selection is not supported. Instead, you can group the items and use the plugin. 18 | - Any selected items should be inside a frame. Page level guides are not supported yet. 19 | 20 | ## In Action 21 | 22 | ![Guide Mate in action](https://i.ibb.co/cY42cZ6/ezgif-com-video-to-gif.gif) 23 | 24 | ## Upcoming Updates 25 | 26 | - Ability to add page level guides. 27 | - Ability to pre-populate form will last used values. 28 | - Ability to clear all input values with a click. 29 | - Ability to save most used guide parameters locally. 30 | 31 | ## Inspiration 32 | 33 | - [Guide Guide](https://guideguide.me/) 34 | 35 | ## Support 36 | 37 | Guide mate will be free forever. If you enjoyed Guide Mate, please consider buying me a coffee by pressing the button below. A small contribution will encourage me to work on more cool things. 38 | 39 | 40 | 41 | 42 | 43 | 44 | ## License 45 | Apache-2.0 46 | -------------------------------------------------------------------------------- /figma.d.ts: -------------------------------------------------------------------------------- 1 | // Global variable with Figma's plugin API. 2 | declare const figma: PluginAPI 3 | declare const __html__: string 4 | 5 | interface PluginAPI { 6 | readonly apiVersion: "1.0.0" 7 | readonly command: string 8 | readonly root: DocumentNode 9 | readonly viewport: ViewportAPI 10 | closePlugin(message?: string): void 11 | 12 | showUI(html: string, options?: ShowUIOptions): void 13 | readonly ui: UIAPI 14 | 15 | readonly clientStorage: ClientStorageAPI 16 | 17 | getNodeById(id: string): BaseNode | null 18 | getStyleById(id: string): BaseStyle | null 19 | 20 | currentPage: PageNode 21 | 22 | readonly mixed: symbol 23 | 24 | createRectangle(): RectangleNode 25 | createLine(): LineNode 26 | createEllipse(): EllipseNode 27 | createPolygon(): PolygonNode 28 | createStar(): StarNode 29 | createVector(): VectorNode 30 | createText(): TextNode 31 | createBooleanOperation(): BooleanOperationNode 32 | createFrame(): FrameNode 33 | createComponent(): ComponentNode 34 | createPage(): PageNode 35 | createSlice(): SliceNode 36 | 37 | createPaintStyle(): PaintStyle 38 | createTextStyle(): TextStyle 39 | createEffectStyle(): EffectStyle 40 | createGridStyle(): GridStyle 41 | 42 | importComponentByKeyAsync(key: string): Promise 43 | importStyleByKeyAsync(key: string): Promise 44 | 45 | listAvailableFontsAsync(): Promise 46 | loadFontAsync(fontName: FontName): Promise 47 | readonly hasMissingFont: boolean 48 | 49 | createNodeFromSvg(svg: string): FrameNode 50 | 51 | createImage(data: Uint8Array): Image 52 | getImageByHash(hash: string): Image 53 | 54 | group(nodes: ReadonlyArray, parent: BaseNode & ChildrenMixin, index?: number): FrameNode 55 | flatten(nodes: ReadonlyArray, parent?: BaseNode & ChildrenMixin, index?: number): VectorNode 56 | } 57 | 58 | interface ClientStorageAPI { 59 | getAsync(key: string): Promise 60 | setAsync(key: string, value: any): Promise 61 | } 62 | 63 | type ShowUIOptions = { 64 | visible?: boolean, 65 | width?: number, 66 | height?: number, 67 | } 68 | 69 | type UIPostMessageOptions = { 70 | targetOrigin?: string, 71 | } 72 | 73 | type OnMessageProperties = { 74 | sourceOrigin: string, 75 | } 76 | 77 | interface UIAPI { 78 | show(): void 79 | hide(): void 80 | resize(width: number, height: number): void 81 | close(): void 82 | 83 | postMessage(pluginMessage: any, options?: UIPostMessageOptions): void 84 | onmessage: ((pluginMessage: any, props: OnMessageProperties) => void) | undefined 85 | } 86 | 87 | interface ViewportAPI { 88 | center: { x: number, y: number } 89 | zoom: number 90 | scrollAndZoomIntoView(nodes: ReadonlyArray) 91 | } 92 | 93 | //////////////////////////////////////////////////////////////////////////////// 94 | // Datatypes 95 | 96 | type Transform = [ 97 | [number, number, number], 98 | [number, number, number] 99 | ] 100 | 101 | interface Vector { 102 | readonly x: number 103 | readonly y: number 104 | } 105 | 106 | interface RGB { 107 | readonly r: number 108 | readonly g: number 109 | readonly b: number 110 | } 111 | 112 | interface RGBA { 113 | readonly r: number 114 | readonly g: number 115 | readonly b: number 116 | readonly a: number 117 | } 118 | 119 | interface FontName { 120 | readonly family: string 121 | readonly style: string 122 | } 123 | 124 | type TextCase = "ORIGINAL" | "UPPER" | "LOWER" | "TITLE" 125 | 126 | type TextDecoration = "NONE" | "UNDERLINE" | "STRIKETHROUGH" 127 | 128 | interface ArcData { 129 | readonly startingAngle: number 130 | readonly endingAngle: number 131 | readonly innerRadius: number 132 | } 133 | 134 | interface ShadowEffect { 135 | readonly type: "DROP_SHADOW" | "INNER_SHADOW" 136 | readonly color: RGBA 137 | readonly offset: Vector 138 | readonly radius: number 139 | readonly visible: boolean 140 | readonly blendMode: BlendMode 141 | } 142 | 143 | interface BlurEffect { 144 | readonly type: "LAYER_BLUR" | "BACKGROUND_BLUR" 145 | readonly radius: number 146 | readonly visible: boolean 147 | } 148 | 149 | type Effect = ShadowEffect | BlurEffect 150 | 151 | type ConstraintType = "MIN" | "CENTER" | "MAX" | "STRETCH" | "SCALE" 152 | 153 | interface Constraints { 154 | readonly horizontal: ConstraintType 155 | readonly vertical: ConstraintType 156 | } 157 | 158 | interface ColorStop { 159 | readonly position: number 160 | readonly color: RGBA 161 | } 162 | 163 | interface ImageFilters { 164 | exposure?: number 165 | contrast?: number 166 | saturation?: number 167 | temperature?: number 168 | tint?: number 169 | highlights?: number 170 | shadows?: number 171 | } 172 | 173 | interface SolidPaint { 174 | readonly type: "SOLID" 175 | readonly color: RGB 176 | 177 | readonly visible?: boolean 178 | readonly opacity?: number 179 | readonly blendMode?: BlendMode 180 | } 181 | 182 | interface GradientPaint { 183 | readonly type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND" 184 | readonly gradientTransform: Transform 185 | readonly gradientStops: ReadonlyArray 186 | 187 | readonly visible?: boolean 188 | readonly opacity?: number 189 | readonly blendMode?: BlendMode 190 | } 191 | 192 | interface ImagePaint { 193 | readonly type: "IMAGE" 194 | readonly scaleMode: "FILL" | "FIT" | "CROP" | "TILE" 195 | readonly imageHash: string | null 196 | readonly imageTransform?: Transform // setting for "CROP" 197 | readonly scalingFactor?: number // setting for "TILE" 198 | readonly filters?: ImageFilters 199 | 200 | readonly visible?: boolean 201 | readonly opacity?: number 202 | readonly blendMode?: BlendMode 203 | } 204 | 205 | type Paint = SolidPaint | GradientPaint | ImagePaint 206 | 207 | interface Guide { 208 | readonly axis: "X" | "Y" 209 | readonly offset: number 210 | } 211 | 212 | interface RowsColsLayoutGrid { 213 | readonly pattern: "ROWS" | "COLUMNS" 214 | readonly alignment: "MIN" | "MAX" | "STRETCH" | "CENTER" 215 | readonly gutterSize: number 216 | 217 | readonly count: number // Infinity when "Auto" is set in the UI 218 | readonly sectionSize?: number // Not set for alignment: "STRETCH" 219 | readonly offset?: number // Not set for alignment: "CENTER" 220 | 221 | readonly visible?: boolean 222 | readonly color?: RGBA 223 | } 224 | 225 | interface GridLayoutGrid { 226 | readonly pattern: "GRID" 227 | readonly sectionSize: number 228 | 229 | readonly visible?: boolean 230 | readonly color?: RGBA 231 | } 232 | 233 | type LayoutGrid = RowsColsLayoutGrid | GridLayoutGrid 234 | 235 | interface ExportSettingsConstraints { 236 | type: "SCALE" | "WIDTH" | "HEIGHT" 237 | value: number 238 | } 239 | 240 | interface ExportSettingsImage { 241 | format: "JPG" | "PNG" 242 | contentsOnly?: boolean // defaults to true 243 | suffix?: string 244 | constraint?: ExportSettingsConstraints 245 | } 246 | 247 | interface ExportSettingsSVG { 248 | format: "SVG" 249 | contentsOnly?: boolean // defaults to true 250 | suffix?: string 251 | svgOutlineText?: boolean // defaults to true 252 | svgIdAttribute?: boolean // defaults to false 253 | svgSimplifyStroke?: boolean // defaults to true 254 | } 255 | 256 | interface ExportSettingsPDF { 257 | format: "PDF" 258 | contentsOnly?: boolean // defaults to true 259 | suffix?: string 260 | } 261 | 262 | type ExportSettings = ExportSettingsImage | ExportSettingsSVG | ExportSettingsPDF 263 | 264 | type WindingRule = "NONZERO" | "EVENODD" 265 | 266 | interface VectorVertex { 267 | readonly x: number 268 | readonly y: number 269 | readonly strokeCap?: StrokeCap 270 | readonly strokeJoin?: StrokeJoin 271 | readonly cornerRadius?: number 272 | readonly handleMirroring?: HandleMirroring 273 | } 274 | 275 | interface VectorSegment { 276 | readonly start: number 277 | readonly end: number 278 | readonly tangentStart?: Vector // Defaults to { x: 0, y: 0 } 279 | readonly tangentEnd?: Vector // Defaults to { x: 0, y: 0 } 280 | } 281 | 282 | interface VectorRegion { 283 | readonly windingRule: WindingRule 284 | readonly loops: ReadonlyArray> 285 | } 286 | 287 | interface VectorNetwork { 288 | readonly vertices: ReadonlyArray 289 | readonly segments: ReadonlyArray 290 | readonly regions?: ReadonlyArray // Defaults to [] 291 | } 292 | 293 | interface VectorPath { 294 | readonly windingRule: WindingRule | "NONE" 295 | readonly data: string 296 | } 297 | 298 | type VectorPaths = ReadonlyArray 299 | 300 | type LetterSpacing = { 301 | readonly value: number 302 | readonly unit: "PIXELS" | "PERCENT" 303 | } 304 | 305 | type LineHeight = { 306 | readonly value: number 307 | readonly unit: "PIXELS" | "PERCENT" 308 | } | { 309 | readonly unit: "AUTO" 310 | } 311 | 312 | type BlendMode = 313 | "PASS_THROUGH" | 314 | "NORMAL" | 315 | "DARKEN" | 316 | "MULTIPLY" | 317 | "LINEAR_BURN" | 318 | "COLOR_BURN" | 319 | "LIGHTEN" | 320 | "SCREEN" | 321 | "LINEAR_DODGE" | 322 | "COLOR_DODGE" | 323 | "OVERLAY" | 324 | "SOFT_LIGHT" | 325 | "HARD_LIGHT" | 326 | "DIFFERENCE" | 327 | "EXCLUSION" | 328 | "HUE" | 329 | "SATURATION" | 330 | "COLOR" | 331 | "LUMINOSITY" 332 | 333 | interface Font { 334 | fontName: FontName 335 | } 336 | 337 | //////////////////////////////////////////////////////////////////////////////// 338 | // Mixins 339 | 340 | interface BaseNodeMixin { 341 | readonly id: string 342 | readonly parent: (BaseNode & ChildrenMixin) | null 343 | name: string // Note: setting this also sets `autoRename` to false on TextNodes 344 | readonly removed: boolean 345 | toString(): string 346 | remove(): void 347 | 348 | getPluginData(key: string): string 349 | setPluginData(key: string, value: string): void 350 | 351 | // Namespace is a string that must be at least 3 alphanumeric characters, and should 352 | // be a name related to your plugin. Other plugins will be able to read this data. 353 | getSharedPluginData(namespace: string, key: string): string 354 | setSharedPluginData(namespace: string, key: string, value: string): void 355 | } 356 | 357 | interface SceneNodeMixin { 358 | visible: boolean 359 | locked: boolean 360 | } 361 | 362 | interface ChildrenMixin { 363 | readonly children: ReadonlyArray 364 | 365 | appendChild(child: BaseNode): void 366 | insertChild(index: number, child: BaseNode): void 367 | 368 | findAll(callback?: (node: BaseNode) => boolean): ReadonlyArray 369 | findOne(callback: (node: BaseNode) => boolean): BaseNode | null 370 | } 371 | 372 | interface ConstraintMixin { 373 | constraints: Constraints 374 | } 375 | 376 | interface LayoutMixin { 377 | readonly absoluteTransform: Transform 378 | relativeTransform: Transform 379 | x: number 380 | y: number 381 | rotation: number // In degrees 382 | 383 | readonly width: number 384 | readonly height: number 385 | 386 | resize(width: number, height: number): void 387 | resizeWithoutConstraints(width: number, height: number): void 388 | } 389 | 390 | interface BlendMixin { 391 | opacity: number 392 | blendMode: BlendMode 393 | isMask: boolean 394 | effects: ReadonlyArray 395 | effectStyleId: string 396 | } 397 | 398 | interface FrameMixin { 399 | backgrounds: ReadonlyArray 400 | layoutGrids: ReadonlyArray 401 | clipsContent: boolean 402 | guides: ReadonlyArray 403 | gridStyleId: string 404 | backgroundStyleId: string 405 | } 406 | 407 | type StrokeCap = "NONE" | "ROUND" | "SQUARE" | "ARROW_LINES" | "ARROW_EQUILATERAL" 408 | type StrokeJoin = "MITER" | "BEVEL" | "ROUND" 409 | type HandleMirroring = "NONE" | "ANGLE" | "ANGLE_AND_LENGTH" 410 | 411 | interface GeometryMixin { 412 | fills: ReadonlyArray | symbol 413 | strokes: ReadonlyArray 414 | strokeWeight: number 415 | strokeAlign: "CENTER" | "INSIDE" | "OUTSIDE" 416 | strokeCap: StrokeCap | symbol 417 | strokeJoin: StrokeJoin | symbol 418 | dashPattern: ReadonlyArray 419 | fillStyleId: string | symbol 420 | strokeStyleId: string 421 | } 422 | 423 | interface CornerMixin { 424 | cornerRadius: number | symbol 425 | cornerSmoothing: number 426 | } 427 | 428 | interface ExportMixin { 429 | exportSettings: ExportSettings[] 430 | exportAsync(settings?: ExportSettings): Promise // Defaults to PNG format 431 | } 432 | 433 | interface DefaultShapeMixin extends 434 | BaseNodeMixin, SceneNodeMixin, 435 | BlendMixin, GeometryMixin, LayoutMixin, ExportMixin { 436 | } 437 | 438 | interface DefaultContainerMixin extends 439 | BaseNodeMixin, SceneNodeMixin, 440 | ChildrenMixin, FrameMixin, 441 | BlendMixin, ConstraintMixin, LayoutMixin, ExportMixin { 442 | } 443 | 444 | //////////////////////////////////////////////////////////////////////////////// 445 | // Nodes 446 | 447 | interface DocumentNode extends BaseNodeMixin, ChildrenMixin { 448 | readonly type: "DOCUMENT" 449 | } 450 | 451 | interface PageNode extends BaseNodeMixin, ChildrenMixin, ExportMixin { 452 | readonly type: "PAGE" 453 | clone(): PageNode 454 | 455 | guides: ReadonlyArray 456 | selection: ReadonlyArray 457 | } 458 | 459 | interface FrameNode extends DefaultContainerMixin { 460 | readonly type: "FRAME" | "GROUP" 461 | clone(): FrameNode 462 | } 463 | 464 | interface SliceNode extends BaseNodeMixin, SceneNodeMixin, LayoutMixin, ExportMixin { 465 | readonly type: "SLICE" 466 | clone(): SliceNode 467 | } 468 | 469 | interface RectangleNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 470 | readonly type: "RECTANGLE" 471 | clone(): RectangleNode 472 | topLeftRadius: number 473 | topRightRadius: number 474 | bottomLeftRadius: number 475 | bottomRightRadius: number 476 | } 477 | 478 | interface LineNode extends DefaultShapeMixin, ConstraintMixin { 479 | readonly type: "LINE" 480 | clone(): LineNode 481 | } 482 | 483 | interface EllipseNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 484 | readonly type: "ELLIPSE" 485 | clone(): EllipseNode 486 | arcData: ArcData 487 | } 488 | 489 | interface PolygonNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 490 | readonly type: "POLYGON" 491 | clone(): PolygonNode 492 | pointCount: number 493 | } 494 | 495 | interface StarNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 496 | readonly type: "STAR" 497 | clone(): StarNode 498 | pointCount: number 499 | innerRadius: number 500 | } 501 | 502 | interface VectorNode extends DefaultShapeMixin, ConstraintMixin, CornerMixin { 503 | readonly type: "VECTOR" 504 | clone(): VectorNode 505 | vectorNetwork: VectorNetwork 506 | vectorPaths: VectorPaths 507 | handleMirroring: HandleMirroring | symbol 508 | } 509 | 510 | interface TextNode extends DefaultShapeMixin, ConstraintMixin { 511 | readonly type: "TEXT" 512 | clone(): TextNode 513 | characters: string 514 | readonly hasMissingFont: boolean 515 | textAlignHorizontal: "LEFT" | "CENTER" | "RIGHT" | "JUSTIFIED" 516 | textAlignVertical: "TOP" | "CENTER" | "BOTTOM" 517 | textAutoResize: "NONE" | "WIDTH_AND_HEIGHT" | "HEIGHT" 518 | paragraphIndent: number 519 | paragraphSpacing: number 520 | autoRename: boolean 521 | 522 | textStyleId: string | symbol 523 | fontSize: number | symbol 524 | fontName: FontName | symbol 525 | textCase: TextCase | symbol 526 | textDecoration: TextDecoration | symbol 527 | letterSpacing: LetterSpacing | symbol 528 | lineHeight: LineHeight | symbol 529 | 530 | getRangeFontSize(start: number, end: number): number | symbol 531 | setRangeFontSize(start: number, end: number, value: number): void 532 | getRangeFontName(start: number, end: number): FontName | symbol 533 | setRangeFontName(start: number, end: number, value: FontName): void 534 | getRangeTextCase(start: number, end: number): TextCase | symbol 535 | setRangeTextCase(start: number, end: number, value: TextCase): void 536 | getRangeTextDecoration(start: number, end: number): TextDecoration | symbol 537 | setRangeTextDecoration(start: number, end: number, value: TextDecoration): void 538 | getRangeLetterSpacing(start: number, end: number): LetterSpacing | symbol 539 | setRangeLetterSpacing(start: number, end: number, value: LetterSpacing): void 540 | getRangeLineHeight(start: number, end: number): LineHeight | symbol 541 | setRangeLineHeight(start: number, end: number, value: LineHeight): void 542 | getRangeFills(start: number, end: number): Paint[] | symbol 543 | setRangeFills(start: number, end: number, value: Paint[]): void 544 | getRangeTextStyleId(start: number, end: number): string | symbol 545 | setRangeTextStyleId(start: number, end: number, value: string): void 546 | getRangeFillStyleId(start: number, end: number): string | symbol 547 | setRangeFillStyleId(start: number, end: number, value: string): void 548 | } 549 | 550 | interface ComponentNode extends DefaultContainerMixin { 551 | readonly type: "COMPONENT" 552 | clone(): ComponentNode 553 | 554 | createInstance(): InstanceNode 555 | description: string 556 | readonly remote: boolean 557 | readonly key: string // The key to use with "importComponentByKeyAsync" 558 | } 559 | 560 | interface InstanceNode extends DefaultContainerMixin { 561 | readonly type: "INSTANCE" 562 | clone(): InstanceNode 563 | masterComponent: ComponentNode 564 | } 565 | 566 | interface BooleanOperationNode extends DefaultShapeMixin, ChildrenMixin, CornerMixin { 567 | readonly type: "BOOLEAN_OPERATION" 568 | clone(): BooleanOperationNode 569 | booleanOperation: "UNION" | "INTERSECT" | "SUBTRACT" | "EXCLUDE" 570 | } 571 | 572 | type BaseNode = 573 | DocumentNode | 574 | PageNode | 575 | SceneNode 576 | 577 | type SceneNode = 578 | SliceNode | 579 | FrameNode | 580 | ComponentNode | 581 | InstanceNode | 582 | BooleanOperationNode | 583 | VectorNode | 584 | StarNode | 585 | LineNode | 586 | EllipseNode | 587 | PolygonNode | 588 | RectangleNode | 589 | TextNode 590 | 591 | type NodeType = 592 | "DOCUMENT" | 593 | "PAGE" | 594 | "SLICE" | 595 | "FRAME" | 596 | "GROUP" | 597 | "COMPONENT" | 598 | "INSTANCE" | 599 | "BOOLEAN_OPERATION" | 600 | "VECTOR" | 601 | "STAR" | 602 | "LINE" | 603 | "ELLIPSE" | 604 | "POLYGON" | 605 | "RECTANGLE" | 606 | "TEXT" 607 | 608 | //////////////////////////////////////////////////////////////////////////////// 609 | // Styles 610 | type StyleType = "PAINT" | "TEXT" | "EFFECT" | "GRID" 611 | 612 | interface BaseStyle { 613 | readonly id: string 614 | readonly type: StyleType 615 | name: string 616 | description: string 617 | remote: boolean 618 | readonly key: string // The key to use with "importStyleByKeyAsync" 619 | remove(): void 620 | } 621 | 622 | interface PaintStyle extends BaseStyle { 623 | type: "PAINT" 624 | paints: ReadonlyArray 625 | } 626 | 627 | interface TextStyle extends BaseStyle { 628 | type: "TEXT" 629 | fontSize: number 630 | textDecoration: TextDecoration 631 | fontName: FontName 632 | letterSpacing: LetterSpacing 633 | lineHeight: LineHeight 634 | paragraphIndent: number 635 | paragraphSpacing: number 636 | textCase: TextCase 637 | } 638 | 639 | interface EffectStyle extends BaseStyle { 640 | type: "EFFECT" 641 | effects: ReadonlyArray 642 | } 643 | 644 | interface GridStyle extends BaseStyle { 645 | type: "GRID" 646 | layoutGrids: ReadonlyArray 647 | } 648 | 649 | //////////////////////////////////////////////////////////////////////////////// 650 | // Other 651 | 652 | interface Image { 653 | readonly hash: string 654 | getBytesAsync(): Promise 655 | } 656 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Guide Mate", 3 | "id": "739342962452731553", 4 | "api": "1.0.0", 5 | "main": "plugin.js", 6 | "ui": "index.html" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guidemate", 3 | "version": "1.0.1-alpha.0", 4 | "description": "Guide plugin for figma", 5 | "main": "index.js", 6 | "repository": "https://github.com/praneshr/guidemate.git", 7 | "author": "Pranesh Ravi ", 8 | "license": "Apache-2.0", 9 | "private": false, 10 | "dependencies": { 11 | "@webcomponents/webcomponentsjs": "^2.2.10", 12 | "lit-element": "^2.2.1", 13 | "tippy.js": "^4.3.5", 14 | "typescript": "^3.5.3" 15 | }, 16 | "scripts": { 17 | "build:watch": "webpack --progress --watch", 18 | "build": "webpack --progress", 19 | "build:prod": "NODE_ENV=production webpack --progress" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^12.6.9", 23 | "@typescript-eslint/eslint-plugin": "^1.13.0", 24 | "@typescript-eslint/parser": "^1.13.0", 25 | "clean-webpack-plugin": "^3.0.0", 26 | "copy-webpack-plugin": "^5.0.4", 27 | "eslint": "5.3.0", 28 | "eslint-config-airbnb": "17.1.1", 29 | "eslint-plugin-import": "^2.18.0", 30 | "eslint-plugin-jsx-a11y": "^6.2.3", 31 | "eslint-plugin-react": "^7.14.2", 32 | "html-webpack-inline-source-plugin": "^0.0.10", 33 | "html-webpack-plugin": "^3.2.0", 34 | "ts-loader": "^6.0.4", 35 | "webpack": "^4.39.1", 36 | "webpack-cli": "^3.3.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ShortcutTypes, MessageTypes, FormValues, FormValuesAccess, FormInputs, Message, 3 | } from './types'; 4 | 5 | // Initial the plugin UI. 6 | figma.showUI(__html__); 7 | // Rezise the plugin UI to the desired size. 8 | figma.ui.resize(350, 370); 9 | 10 | /** 11 | * Removes duplicates and adds an array of unique guides to the given frame node. 12 | * @param frame Figma frame node 13 | * @param guides Array of guides including duplicate guides to be added in to the given frame. 14 | */ 15 | const addGuide = (frame: FrameNode, guides: Guide[]): void => { 16 | const guideString = ({ axis, offset }): string => `${axis}_${offset}`; 17 | const existingGuidesLookupMap = frame.guides.map(guideString); 18 | const filteredGuides = guides.filter( 19 | (guide): boolean => !existingGuidesLookupMap.includes(guideString(guide)), 20 | ); 21 | const { parent } = frame; 22 | const clone = frame.clone(); 23 | const isFrameSelected = clone.getPluginData('x-selected') === 'true'; 24 | const selectedInClone = clone.findOne( 25 | (node): boolean => node.getPluginData('x-selected') === 'true', 26 | ); 27 | clone.guides = guides.length === 0 ? [] : frame.guides.concat(filteredGuides); 28 | if (isFrameSelected) { 29 | figma.currentPage.selection = [clone as any]; 30 | clone.setPluginData('x-selected', 'false'); 31 | } else if (selectedInClone) { 32 | figma.currentPage.selection = [selectedInClone as any]; 33 | selectedInClone.setPluginData('x-selected', 'false'); 34 | } 35 | parent.appendChild(clone); 36 | frame.remove(); 37 | }; 38 | 39 | /** 40 | * Returns the frame node if found initially or recursively fetch the parent till frame 41 | * node is found. 42 | * @param node Current selection node. 43 | */ 44 | const findFrame = (node: BaseNode): FrameNode => { 45 | if (node.type === 'PAGE') { 46 | figma.ui.postMessage({ type: MessageTypes.NO_FRAME_ERROR }); 47 | return undefined; 48 | } 49 | if (node.type === 'FRAME') { 50 | return node; 51 | } 52 | return findFrame(node.parent); 53 | }; 54 | 55 | /** 56 | * Returns the current user selected node else returns the frame node when only one frame is found 57 | * in the tab. Throws an error when no frame or more than one frame is found in the tab. 58 | */ 59 | const getSelection = (): SceneNode | undefined => { 60 | const { selection } = figma.currentPage; 61 | 62 | if (selection.length === 0) { 63 | figma.ui.postMessage({ type: MessageTypes.NO_SELECTION_ERROR }); 64 | return undefined; 65 | } 66 | 67 | if (selection.length > 1) { 68 | figma.ui.postMessage({ type: MessageTypes.MULTI_SELECTION_ERROR }); 69 | return undefined; 70 | } 71 | 72 | const currentSelection = selection[0]; 73 | currentSelection.setPluginData('x-selected', 'true'); 74 | return currentSelection; 75 | }; 76 | 77 | /** 78 | * Adds guides based on the shortcut selection. 79 | * @param shortcut 80 | */ 81 | const handleShortcuts = (shortcut: ShortcutTypes): void => { 82 | const currentSelection = getSelection(); 83 | if (!currentSelection) return; 84 | const frame = findFrame(currentSelection); 85 | if (!frame) return; 86 | const { 87 | width, height, x: selectionX, y: selectionY, 88 | } = currentSelection; 89 | const isSelectedFrame = frame === currentSelection; 90 | const x = isSelectedFrame ? 0 : selectionX; 91 | const y = isSelectedFrame ? 0 : selectionY; 92 | let guide: Guide[]; 93 | 94 | switch (shortcut) { 95 | case ShortcutTypes.LEFT: 96 | guide = [{ axis: 'X', offset: x }]; 97 | break; 98 | case ShortcutTypes.RIGHT: 99 | guide = [{ axis: 'X', offset: x + width }]; 100 | break; 101 | case ShortcutTypes.VERTICAL_CENTER: 102 | guide = [{ axis: 'X', offset: x + (width / 2) }]; 103 | break; 104 | case ShortcutTypes.TOP: 105 | guide = [{ axis: 'Y', offset: y }]; 106 | break; 107 | case ShortcutTypes.HORIZONTAL_CENTER: 108 | guide = [{ axis: 'Y', offset: y + (height / 2) }]; 109 | break; 110 | case ShortcutTypes.BOTTOM: 111 | guide = [{ axis: 'Y', offset: y + height }]; 112 | break; 113 | case ShortcutTypes.CLEAR: 114 | guide = []; 115 | break; 116 | default: 117 | console.warn(`Unhandled shortcut: ${shortcut}`); 118 | } 119 | addGuide(frame, guide); 120 | }; 121 | 122 | /** 123 | * Computes and returns incremental guides width/length and their position with gutter space 124 | * considered. 125 | * @param count Total number of guide blocks to be added. 126 | * @param size Width or height of each guide block. 127 | * @param start Starting point of the first guide block. 128 | * @param gutter Gutter space between two guide blocks. 129 | * @param axis Axis to which the guide block should be added. 130 | */ 131 | const calculateGuideBlock = ( 132 | count: number, 133 | size: number, 134 | start: number, 135 | gutter: number, 136 | axis: 'X' | 'Y', 137 | ): Guide[] => { 138 | const guides = []; 139 | let nextStart = start + size; 140 | for (let i = 0; i < count; i++) { 141 | if (i === 1 || i === count) { 142 | continue; 143 | } 144 | guides.push({ 145 | axis, 146 | offset: nextStart, 147 | }); 148 | if (gutter) { 149 | guides.push({ 150 | axis, 151 | offset: nextStart + gutter, 152 | }); 153 | nextStart = nextStart + gutter + size; 154 | continue; 155 | } 156 | nextStart += size; 157 | } 158 | return guides; 159 | }; 160 | 161 | /** 162 | * Adds guides to the selected frame based on the user supplied form data. 163 | * @param formData User input object containing the form values. 164 | */ 165 | const handleAddGuides = (formData: FormValues[]): void => { 166 | const currentSelection = getSelection(); 167 | if (!currentSelection) return; 168 | const frame = findFrame(currentSelection); 169 | if (!frame) return; 170 | const { 171 | width, height, x: selectionX, y: selectionY, 172 | } = currentSelection; 173 | const isSelectedFrame = frame === currentSelection; 174 | const x = isSelectedFrame ? 0 : selectionX; 175 | const y = isSelectedFrame ? 0 : selectionY; 176 | const formDataObject = formData.reduce( 177 | (acc, { id, value }): FormValuesAccess => ({ ...acc, ...{ [id]: value } }), {}, 178 | ); 179 | const marginGuides = formData.map(({ id, value }): Guide => { 180 | switch (id) { 181 | case FormInputs.TOP_MARGIN: 182 | return { axis: 'Y', offset: y + value }; 183 | case FormInputs.BOTTOM_MARGIN: 184 | return { axis: 'Y', offset: y + (height - value) }; 185 | case FormInputs.LEFT_MARGIN: 186 | return { axis: 'X', offset: x + value }; 187 | case FormInputs.RIGHT_MARGIN: 188 | return { axis: 'X', offset: x + (width - value) }; 189 | default: 190 | return undefined; 191 | } 192 | }).filter(Boolean); 193 | 194 | const rowGutter = formDataObject[FormInputs.HORIZONTAL_GUTTER]; 195 | const columnGutter = formDataObject[FormInputs.VERTICAL_GUTTER]; 196 | const leftMargin = formDataObject[FormInputs.LEFT_MARGIN]; 197 | const rightMargin = formDataObject[FormInputs.RIGHT_MARGIN]; 198 | const topMargin = formDataObject[FormInputs.TOP_MARGIN]; 199 | const bottomMargin = formDataObject[FormInputs.BOTTOM_MARGIN]; 200 | const columns = formDataObject[FormInputs.NO_OF_COLUMNS]; 201 | const rows = formDataObject[FormInputs.NO_OF_ROWS]; 202 | const columnWidth = Math.round( 203 | (width - (leftMargin + rightMargin + ((columns - 1) * columnGutter))) / columns, 204 | ); 205 | const rowHeight = Math.round( 206 | (height - (topMargin + bottomMargin + ((rows - 1) * rowGutter))) / rows, 207 | ); 208 | const columnGuides = calculateGuideBlock(columns, columnWidth, x + leftMargin, columnGutter, 'X'); 209 | const rowGuides = calculateGuideBlock(rows, rowHeight, y + topMargin, rowGutter, 'Y'); 210 | 211 | addGuide(frame, [...marginGuides, ...columnGuides, ...rowGuides]); 212 | }; 213 | 214 | /** 215 | * Handles all messages from parent. 216 | */ 217 | figma.ui.onmessage = (msg: Message): void => { 218 | switch (msg.type) { 219 | case MessageTypes.SHORTCUTS: 220 | handleShortcuts(msg.data); 221 | break; 222 | case MessageTypes.ADD_GUIDES: 223 | handleAddGuides(msg.data); 224 | break; 225 | default: 226 | console.warn(`Unhandled message type: ${msg.type}`); 227 | } 228 | }; 229 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export enum ShortcutTypes { 2 | UNKNOWN = 0, 3 | LEFT = 1, 4 | VERTICAL_CENTER = 2, 5 | RIGHT = 3, 6 | TOP = 4, 7 | HORIZONTAL_CENTER = 5, 8 | BOTTOM = 6, 9 | CLEAR = 7, 10 | } 11 | 12 | export enum MessageTypes { 13 | UNKNOWN = 0, 14 | SHORTCUTS = 1, 15 | ADD_GUIDES = 2, 16 | NO_SELECTION_ERROR = 3, 17 | MULTI_SELECTION_ERROR = 4, 18 | NO_FRAME_ERROR = 5, 19 | } 20 | 21 | export enum FormInputs { 22 | UNKNOWN = 0, 23 | LEFT_MARGIN = 1, 24 | RIGHT_MARGIN = 2, 25 | TOP_MARGIN = 3, 26 | BOTTOM_MARGIN = 4, 27 | NO_OF_COLUMNS = 5, 28 | NO_OF_ROWS = 6, 29 | WIDTH = 7, 30 | HEIGHT = 8, 31 | VERTICAL_GUTTER = 9, 32 | HORIZONTAL_GUTTER = 10, 33 | } 34 | 35 | export interface FormValues { 36 | id: FormInputs; 37 | value: number; 38 | } 39 | 40 | export interface FormValuesAccess { 41 | [FormInputs: number]: string; 42 | } 43 | 44 | export interface Message { 45 | type: MessageTypes; 46 | data: any; 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/easy-shortcuts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, customElement, TemplateResult, html, css, CSSResult, 3 | } from 'lit-element'; 4 | 5 | import { ShortcutTypes, MessageTypes } from '../types'; 6 | 7 | const tippy = require('tippy.js').default; 8 | 9 | @customElement('x-easy-shortcuts') 10 | export default class EasyShortcuts extends LitElement { 11 | public static get styles(): CSSResult { 12 | return css` 13 | .shortcuts { 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | border-bottom: 1px solid #1f1f2d; 18 | } 19 | .shortcut { 20 | cursor: pointer; 21 | padding: var(--side-padding); 22 | outline: none; 23 | } 24 | .shortcut:hover { 25 | background: var(--border-color); 26 | } 27 | `; 28 | } 29 | 30 | /** 31 | * Posts message to parent with about the user selection. 32 | * @param event Mouse event 33 | */ 34 | private shortcutClick_(event: any): void { 35 | const { type } = event.currentTarget.dataset; 36 | window.parent.postMessage({ 37 | pluginMessage: { type: MessageTypes.SHORTCUTS, data: Number(type) }, 38 | }, '*'); 39 | } 40 | 41 | public firstUpdated(): void { 42 | // Initiate tippy tooltip. 43 | tippy(this.renderRoot.querySelectorAll('.shortcut'), { 44 | placement: 'bottom', 45 | delay: 100, 46 | arrow: true, 47 | }); 48 | } 49 | 50 | public render(): TemplateResult { 51 | return html` 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | s-remove 79 | 80 |
81 | `; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/ui/form-input.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, css, html, customElement, TemplateResult, property, CSSResult, 3 | } from 'lit-element'; 4 | 5 | import { FormValues } from '../types'; 6 | 7 | @customElement('x-form-input') 8 | export default class FormInput extends LitElement { 9 | @property({ type: String }) private placeholder = ''; 10 | 11 | @property({ type: Number }) public id: any; 12 | 13 | public static get styles(): CSSResult { 14 | return css` 15 | .form-input { 16 | display: flex; 17 | align-items: center; 18 | } 19 | input { 20 | width: 100%; 21 | background: var(--border-color); 22 | border: 1px solid var(--border-color); 23 | border-radius: 2px; 24 | padding: 5px; 25 | color: var(--font-color); 26 | } 27 | input:focus { 28 | outline: none; 29 | border-color: var(--primary-color); 30 | } 31 | .input-container { 32 | margin-left: 10px; 33 | } 34 | .icon-container { 35 | opacity: 1; 36 | } 37 | `; 38 | } 39 | 40 | /** 41 | * Return the value of the input and the id of this component. 42 | */ 43 | public getValue(): FormValues { 44 | const input = this.renderRoot.querySelector('input'); 45 | return { 46 | id: this.id, 47 | value: Number(input.value), 48 | }; 49 | } 50 | 51 | public render(): TemplateResult { 52 | return html` 53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 |
61 | `; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/form.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, html, customElement, css, TemplateResult, CSSResult, property, 3 | } from 'lit-element'; 4 | 5 | import { FormValues, FormInputs } from '../types'; 6 | // eslint-disable-next-line import/no-duplicates 7 | import './form-input'; 8 | // eslint-disable-next-line import/no-duplicates 9 | import FormInput from './form-input'; 10 | 11 | @customElement('x-form') 12 | export default class Form extends LitElement { 13 | @property({ type: String }) public id = '' 14 | 15 | public static get styles(): CSSResult { 16 | return css` 17 | .form { 18 | padding: var(--side-padding); 19 | } 20 | .row { 21 | margin: 0 calc(-1 * calc(var(--side-padding) / 2)); 22 | display: flex; 23 | margin-bottom: var(--side-padding); 24 | } 25 | .left, .right { 26 | display: flex; 27 | flex: 1; 28 | padding: 0 calc(var(--side-padding) / 2); 29 | } 30 | `; 31 | } 32 | 33 | /** 34 | * Returns the values of input fields used in the form as an array. 35 | */ 36 | public getValues(): FormValues[] { 37 | const formInputs = [...this.renderRoot.querySelectorAll('x-form-input')]; 38 | return formInputs.map((formInput): FormValues => formInput.getValue()); 39 | } 40 | 41 | public render(): TemplateResult { 42 | return html` 43 |
44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
55 |
56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |
66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 |
78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
87 |
88 |
89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
98 |
99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
107 |
108 |
109 |
110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 |
121 |
122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 |
133 |
134 |
135 | `; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | LitElement, customElement, html, TemplateResult, css, CSSResult, property, 3 | } from 'lit-element'; 4 | 5 | import { MessageTypes } from '../types'; 6 | import './easy-shortcuts'; 7 | // eslint-disable-next-line import/no-duplicates 8 | import './form'; 9 | // eslint-disable-next-line import/no-duplicates 10 | import Form from './form'; 11 | 12 | @customElement('x-guidemate') 13 | export default class extends LitElement { 14 | 15 | @property({ type: Number }) private errorType: 0; 16 | 17 | @property({ type: Number, attribute: false }) private timerId; 18 | 19 | public constructor() { 20 | super(); 21 | this.resetError = this.resetError.bind(this); 22 | } 23 | 24 | public static get styles(): CSSResult { 25 | return css` 26 | :host { 27 | font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif; 28 | color: var(--font-color); 29 | } 30 | 31 | .guidemate { 32 | margin-bottom: 20px; 33 | } 34 | 35 | .primary { 36 | width: 100%; 37 | height: 40px; 38 | display: flex; 39 | justify-content: center; 40 | align-items: center; 41 | background: var(--primary-color); 42 | border: none; 43 | width: calc(100% - calc(var(--side-padding) * 2)); 44 | bottom: 60px; 45 | color: #FFF; 46 | border-radius: 4px; 47 | margin: 20px auto; 48 | font-weight: 500; 49 | cursor: pointer; 50 | } 51 | 52 | .contribute { 53 | width: 100%; 54 | bottom: 20px; 55 | font-size: 12px; 56 | text-align: center; 57 | color: #474753; 58 | } 59 | 60 | .error-message-container { 61 | position: fixed; 62 | width: 250px; 63 | left: 35px; 64 | background-color: #DE5F5A; 65 | text-align: center; 66 | padding: 12px; 67 | color: var(--font-color); 68 | bottom: 0; 69 | transform: translate3d(0, 50px, 0); 70 | transition: all 0.2s ease; 71 | font-size: 12px; 72 | border-radius: 4px 4px 0 0; 73 | font-weight: 500; 74 | } 75 | 76 | .error-message-container.show { 77 | transform: translate3d(0, 0px, 0); 78 | } 79 | 80 | a { 81 | color: var(--primary-color); 82 | text-decoration: none; 83 | } 84 | `; 85 | } 86 | 87 | private resetError(): void { 88 | this.timerId = undefined; 89 | this.errorType = MessageTypes.UNKNOWN; 90 | } 91 | 92 | public firstUpdated(): void { 93 | window.onmessage = ({ data }: any): void => { 94 | const { type } = data.pluginMessage; 95 | if (type !== this.errorType) { 96 | if (!this.timerId) { 97 | this.errorType = type; 98 | } else { 99 | this.timerId = undefined; 100 | this.errorType = MessageTypes.UNKNOWN; 101 | window.setTimeout((): void => { 102 | this.errorType = type; 103 | }, 400); 104 | } 105 | this.timerId = window.setTimeout(this.resetError, 3000); 106 | } 107 | }; 108 | } 109 | 110 | /** 111 | * Retrieves the form value and pass a message to the figma main to add guides with the supplied 112 | * value. 113 | */ 114 | public addGuide(): void { 115 | const form = this.renderRoot.querySelector
('#form'); 116 | const formValue = form.getValues(); 117 | window.parent.postMessage({ 118 | pluginMessage: { type: MessageTypes.ADD_GUIDES, data: formValue }, 119 | }, '*'); 120 | } 121 | 122 | private getErrorMessage(): string { 123 | switch (this.errorType) { 124 | case MessageTypes.NO_SELECTION_ERROR: 125 | return 'No frame or shape selected.'; 126 | case MessageTypes.MULTI_SELECTION_ERROR: 127 | return 'Multi selection not supported.'; 128 | case MessageTypes.NO_FRAME_ERROR: 129 | return 'Selected item is not inside a frame.'; 130 | default: 131 | return null; 132 | } 133 | } 134 | 135 | public render(): TemplateResult { 136 | const errorMessage = this.getErrorMessage(); 137 | return html` 138 |
139 | 140 | 141 |
142 | ${errorMessage} 143 |
144 | 145 |
146 | If you enjoy Guide Mate, consider 147 | buying me a coffee :) 148 |
149 |
150 | `; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "experimentalDecorators": true, 5 | "moduleResolution": "node", 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); 3 | const HTMLWebpackPlugin = require('html-webpack-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const { 6 | CleanWebpackPlugin, 7 | } = require('clean-webpack-plugin'); 8 | 9 | const isProd = process.env.NODE_ENV === 'production'; 10 | 11 | module.exports = { 12 | entry: { 13 | index: path.join(__dirname, 'src', 'ui', 'index.ts'), 14 | plugin: path.join(__dirname, 'src', 'plugin.ts'), 15 | }, 16 | mode: isProd ? 'production' : 'development', 17 | devtool: isProd ? false : 'inline-source-map', 18 | output: { 19 | path: path.join(__dirname, 'plugin'), 20 | publicPath: '/', 21 | filename: '[name].js', 22 | }, 23 | resolve: { 24 | extensions: ['.js', '.ts'], 25 | }, 26 | module: { 27 | rules: [{ 28 | test: /\.ts$/, 29 | loader: 'ts-loader', 30 | }], 31 | }, 32 | plugins: [ 33 | new HTMLWebpackPlugin({ 34 | inject: 'head', 35 | inlineSource: '.(js)$', 36 | chunks: ['index'], 37 | minify: true, 38 | template: path.join(__dirname, 'index.html'), 39 | }), 40 | new HtmlWebpackInlineSourcePlugin(), 41 | new CopyWebpackPlugin([{ 42 | from: path.join(__dirname, 'manifest.json'), 43 | to: path.join(__dirname, 'plugin', 'manifest.json'), 44 | }]), 45 | ].concat( 46 | isProd ? new CleanWebpackPlugin({ 47 | cleanOnceBeforeBuildPatterns: path.join(__dirname, 'plugin'), 48 | cleanAfterEveryBuildPatterns: path.join(__dirname, 'plugin', 'index.js'), 49 | }) : [], 50 | ), 51 | }; 52 | --------------------------------------------------------------------------------