├── .gitignore ├── LICENSE ├── README.md ├── manifest.json ├── media ├── button.svg └── hero.gif ├── package-lock.json ├── package.json ├── src ├── base.css ├── components │ ├── editor │ │ ├── editor.tsx │ │ ├── index.ts │ │ ├── style.css │ │ ├── thumb.tsx │ │ └── views.tsx │ ├── index.ts │ └── preset-menu │ │ ├── index.tsx │ │ ├── menu.tsx │ │ ├── nameInput.tsx │ │ └── style.css ├── hooks │ └── useFocus.ts ├── icons │ ├── curveIcon.tsx │ ├── index.ts │ ├── stepIcon.tsx │ └── svgIcon.tsx ├── main.ts ├── shared │ └── default_values.ts ├── ui.tsx └── utils │ ├── color.ts │ ├── create-class-name.ts │ ├── debounce.ts │ ├── gradient.ts │ ├── index.ts │ ├── node.ts │ ├── notification.ts │ ├── number.ts │ ├── selection.ts │ ├── storage.ts │ └── string.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | *.log 4 | *.css.d.ts 5 | build/ 6 | node_modules/ 7 | manifest.json 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexander Widua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Allows easing of gradient fills in Figma using custom cubic-bézier or step easing functions. 4 | 5 | [](https://www.figma.com/community/plugin/907899097995668330/Easing-Gradients) 6 | 7 | ## ♻️ Refactor 8 | 9 | This is a refactor of the original version of the plugin. Besides a frontend change from Vue to Preact, the plugin has other user-facing changes and QoL updates in its version 2.0.0: 10 | 11 | - Eased gradients can now be previewed directly in the canvas 12 | - It's now possible to save easing functions as presets 13 | - Improved feedback after user actions 14 | 15 | #### Caveat with in-canvas preview 16 | 17 | The in-canvas preview works by cloning the selected node and updating the cloned node's gradients on the fly. This comes with the caveat that the cloned node will enter the document's history and possibly pollute it. Even if the user closes the plugin without applying any easing function, CMD+Z'ing will restore the cloned node. However, when testing this feature it turned out that the benefit of a in-canvas easing preview outweighs the nuisance of this caveat. I'd like to hear more opinions on this. 18 | 19 | ## ✨ Usage 20 | 21 | 1. Go to _Plugins > Easing Gradients_ 22 | 1. Select a shape with at least one gradient fill 🎨 23 | 1. Use one of the easing function presets or drag the control handles for custom easing 🖐️ 24 | 1. Apply easing ✨ 25 | 26 | The plugin is 'gradient-agnostic' in that sense that it doesn't care about the type (linear, radial etc.) and orientation of the gradient. It takes the first and last color stop as parameters and will ease the gradient with the given easing function value. One caveat with this is that all other color steps in between are discarded. 27 | 28 | ## 🚧 Development 29 | 30 | 1. `npm i` — Install dependencies 31 | 1. `npm run watch` — Bundle the plugin and watch for changes 👁️ 32 | 33 | ## 💭 Motivation 34 | 35 | [@Matan Kushner's](https://github.com/matchai) [existing Figma plugin](https://www.figma.com/community/plugin/781591244449826498) does a great job but lacks an user interface and customizable easing functions. I took this as an opportunity to extend the plugin with a set of features. 36 | 37 | I'm grateful for [@Andreas Larsen](https://github.com/larsenwork) for putting out his work on [easing gradients](https://larsenwork.com/easing-gradients/) and [@Matan Kushner](https://github.com/matchai) for creating the [easing-gradient plugin](https://github.com/matchai/figma-easing-gradient) — his project was a great guidance how to initially tackle this project. 38 | 39 | ## 🌀 Misc 40 | 41 | This plugin uses the amazing [create-figma-plugin](https://github.com/yuanqing/create-figma-plugin) library. 42 | 43 | ## 📝 License 44 | 45 | [MIT](LICENSE) 46 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "api": "1.0.0", 3 | "editorType": [ 4 | "figma" 5 | ], 6 | "id": "907899097995668330", 7 | "name": "Easing Gradients", 8 | "main": "build/main.js", 9 | "ui": "build/ui.js", 10 | "relaunchButtons": [ 11 | { 12 | "name": "Re-apply gradient easing", 13 | "command": "ReapplyEasing" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /media/button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /media/hero.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwidua/figma-easing-gradients/2e35c821935b9da22c44a51185fd1b38bbb7d1c5/media/hero.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-easing-gradients", 3 | "version": "2.0.0", 4 | "description": "Easing functions for gradient fills in Figma.", 5 | "keywords": [ 6 | "figma-easing-gradients", 7 | "figma", 8 | "figma-plugin", 9 | "figma-plugins" 10 | ], 11 | "license": "MIT", 12 | "author": "Alexander Widua", 13 | "dependencies": { 14 | "@create-figma-plugin/ui": "^1.2.2", 15 | "@create-figma-plugin/utilities": "^1.2.2", 16 | "chroma-js": "^2.1.2", 17 | "easing-coordinates": "^2.0.2", 18 | "preact": "^10.5.14", 19 | "stylelint-config-idiomatic-order": "^8.1.0" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git://github.com/alexwidua/figma-easing-gradients/" 24 | }, 25 | "engines": { 26 | "node": ">=14" 27 | }, 28 | "devDependencies": { 29 | "@create-figma-plugin/build": "^1.2.2", 30 | "@create-figma-plugin/tsconfig": "^1.2.2", 31 | "@figma/plugin-typings": "^1", 32 | "@types/chroma-js": "^2.1.3", 33 | "typescript": "^4" 34 | }, 35 | "scripts": { 36 | "build": "build-figma-plugin --typecheck --minify", 37 | "watch": "build-figma-plugin --typecheck --watch" 38 | }, 39 | "figma-plugin": { 40 | "id": "907899097995668330", 41 | "name": "Easing Gradients", 42 | "main": "src/main.ts", 43 | "ui": "src/ui.tsx", 44 | "editorType": [ 45 | "figma" 46 | ], 47 | "relaunchButtons": { 48 | "ReapplyEasing": { 49 | "name": "Re-apply gradient easing", 50 | "main": "src/main.ts", 51 | "ui": "src/ui.tsx" 52 | } 53 | } 54 | }, 55 | "stylelint": { 56 | "extends": "stylelint-config-idiomatic-order" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/base.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Local variables, prefixed with local to avoid conflict with 3 | * @create-figma-plugin's UI library. 4 | */ 5 | 6 | :root { 7 | /* TODO */ 8 | } 9 | 10 | body { 11 | overflow: hidden !important; 12 | } 13 | 14 | /* @media screen and (-webkit-min-device-pixel-ratio: 1.5), 15 | screen and (min-resolution: 1.5dppx) { 16 | .menu { 17 | -webkit-font-smoothing: antialiased; 18 | } 19 | } */ 20 | -------------------------------------------------------------------------------- /src/components/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import { useState, useRef } from 'preact/hooks' 3 | import style from './style.css' 4 | import Thumb from './thumb' 5 | import { Curve, Steps } from './views' 6 | import { EasingType, Matrix, SkipOption } from '../../main' 7 | 8 | export type EditorChange = { 9 | type: EasingType 10 | thumb?: { index: EditorInputIndex; vector: number[] } 11 | steps?: number 12 | } 13 | type EditorInputIndex = -1 | 0 | 1 | 2 14 | 15 | const EasingEditor = ({ 16 | easingType, 17 | matrix, 18 | steps, 19 | jump, 20 | onEditorChange 21 | }: { 22 | easingType: EasingType 23 | matrix: Matrix 24 | steps: number 25 | jump: SkipOption 26 | onEditorChange: (change: EditorChange) => void 27 | }) => { 28 | // empty -1, thumbs 0..1, steps scrubbing 2 29 | const [currentIndex, setCurrentIndex] = useState(-1) 30 | const [initX, setInitX] = useState(0) 31 | const [initSteps, setInitSteps] = useState(steps) 32 | const scrubSensitivity: number = 0.1 // 1 => 1 step increase per 1 pixel 33 | 34 | const container = useRef(null) 35 | 36 | function handleMouseDown(index: EditorInputIndex, e?: MouseEvent): void { 37 | setCurrentIndex(index) 38 | if (e && index === 2) { 39 | // hold onto reference values for scrubbing 40 | setInitSteps(steps) 41 | setInitX(e.clientX) 42 | } 43 | } 44 | 45 | function handleMouseMove(e: MouseEvent): void { 46 | if (currentIndex === -1 || !container.current) return 47 | 48 | let value: EditorChange 49 | 50 | // dragging bézier thumbs 51 | if (currentIndex < 2) { 52 | const rect: ClientRect = container.current.getBoundingClientRect() 53 | 54 | // keep handles in viewbox bounds 55 | const x: number = 56 | e.clientX <= rect.left 57 | ? 0 58 | : e.clientX >= rect.right 59 | ? 1 60 | : (e.clientX - rect.left) / (rect.right - rect.left) 61 | const y: number = 62 | e.clientY <= rect.top 63 | ? 1 64 | : e.clientY >= rect.bottom 65 | ? 0 66 | : 1 - (e.clientY - rect.top) / (rect.bottom - rect.top) 67 | 68 | value = { 69 | type: 'CURVE', 70 | thumb: { index: currentIndex, vector: [x, y] } 71 | } 72 | } 73 | // scrubbing the step polyline 74 | else { 75 | const deltaX = e.clientX - initX 76 | const addFriction = Math.floor(deltaX * scrubSensitivity) 77 | const minSteps = Math.max(initSteps + addFriction, 2) 78 | 79 | value = { 80 | type: 'STEPS', 81 | steps: minSteps 82 | } 83 | } 84 | onEditorChange(value) 85 | } 86 | 87 | function cancelDragEvent(): void { 88 | setCurrentIndex(-1) 89 | } 90 | 91 | return ( 92 |
98 |
99 | {easingType === 'CURVE' && ( 100 |
101 | handleMouseDown(0)} 106 | /> 107 | handleMouseDown(1)} 112 | /> 113 | 114 |
115 | )} 116 | {easingType === 'STEPS' && ( 117 | handleMouseDown(2, e)} 121 | /> 122 | )} 123 |
124 |
125 | ) 126 | } 127 | 128 | export default EasingEditor 129 | -------------------------------------------------------------------------------- /src/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from './editor' 2 | 3 | export { Editor } 4 | -------------------------------------------------------------------------------- /src/components/editor/style.css: -------------------------------------------------------------------------------- 1 | /* Editor */ 2 | 3 | .wrapper { 4 | display: flex; 5 | width: 100%; 6 | align-items: center; 7 | justify-content: center; 8 | padding: 36px; 9 | border: 1px solid var(--color-black-6-translucent); 10 | border-radius: var(--border-radius-6); 11 | } 12 | 13 | .container { 14 | position: relative; 15 | width: 100%; 16 | height: 100%; 17 | background-image: radial-gradient(rgba(0, 0, 0, 0.15) 1px, transparent 0); 18 | background-position: 7px 7px; 19 | background-size: 12px 12px; 20 | transition: opacity 0.2s; 21 | user-select: none; 22 | } 23 | 24 | .wrapper:hover .path.connector, 25 | .wrapper:hover .thumb { 26 | opacity: 1; 27 | } 28 | 29 | /* Thumb */ 30 | 31 | .thumb { 32 | position: absolute; 33 | width: 14px; 34 | height: 14px; 35 | border: 2px solid var(--color-white); 36 | background: var(--color-black); 37 | border-radius: 100%; 38 | opacity: 0; 39 | transform: translate(-50%, -50%); 40 | transition: opacity 0.2s; 41 | } 42 | 43 | .thumb.dragged { 44 | background: var(--color-blue); 45 | } 46 | 47 | .thumb.zeroed { 48 | border: 1px solid var(--color-black); 49 | background: var(--color-white); 50 | } 51 | 52 | /* Views */ 53 | 54 | .viewbox { 55 | overflow: visible; 56 | } 57 | 58 | .path { 59 | fill: none; 60 | stroke: var(--color-blue); 61 | stroke-linecap: round; 62 | stroke-width: 2px; 63 | } 64 | 65 | .path.connector { 66 | opacity: 0; 67 | stroke: var(--color-black); 68 | transition: opacity 0.2s; 69 | } 70 | 71 | .dashed { 72 | stroke-dasharray: 4; 73 | } 74 | 75 | .diagonal { 76 | stroke: var(--color-silver); 77 | } 78 | 79 | .point { 80 | fill: var(--color-white); 81 | stroke: var(--color-black); 82 | stroke-width: 0.01; 83 | } 84 | -------------------------------------------------------------------------------- /src/components/editor/thumb.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import style from './style.css' 3 | 4 | const Thumb = ({ index, isDragged, matrix, onMouseDown }: any) => { 5 | const inlineThumb = { 6 | left: `${matrix[index][0] * 100}%`, 7 | top: `${100 - matrix[index][1] * 100}%` 8 | } 9 | 10 | return ( 11 |
20 | ) 21 | } 22 | 23 | export default Thumb 24 | -------------------------------------------------------------------------------- /src/components/editor/views.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import style from './style.css' 3 | import easingCoordinates from 'easing-coordinates' 4 | import { Matrix, SkipOption } from '../../main' 5 | import { createClassName } from '../../utils/create-class-name' 6 | 7 | const Curve = ({ 8 | matrix = [ 9 | [0.0, 0.0], 10 | [0.0, 0.0] 11 | ] 12 | }: { 13 | matrix: Matrix 14 | }) => { 15 | return ( 16 | 17 | 18 | {/* thumb[0] connector */} 19 | 27 | {/* thumb[1] connector --> */} 28 | 36 | {/* bézier curve */} 37 | 43 | {/* terminal points */} 44 | 45 | 46 | 47 | 48 | 49 | 50 | ) 51 | } 52 | 53 | const Steps = ({ 54 | steps = 6, 55 | jump = 'skip-none', 56 | onMouseDown 57 | }: { 58 | steps: number 59 | jump: SkipOption 60 | onMouseDown: h.JSX.MouseEventHandler 61 | }) => { 62 | const getPolyPoints = (): string => { 63 | const coords = easingCoordinates(`steps(${steps}, ${jump})`) 64 | return coords.map(pos => `${pos.x},${1 - pos.y}`).join(' ') 65 | } 66 | 67 | // display dashed line as visual guide for jump/skip values 68 | const getJumpHelper = [ 69 | `${easingCoordinates(`steps(${steps}, ${jump})`)[0].x}, ${ 70 | 1 - easingCoordinates(`steps(${steps}, ${jump})`)[0].y 71 | }`, 72 | `${easingCoordinates(`steps(${steps}, ${jump})`)[steps * 2 - 1].x}, ${ 73 | 1 - easingCoordinates(`steps(${steps}, ${jump})`)[steps * 2 - 1].y 74 | }` 75 | ] 76 | 77 | return ( 78 | 85 | {/* stepped polyline */} 86 | 87 | 92 | 93 | 94 | 99 | 104 | {/* terminal points */} 105 | 106 | 107 | 108 | 109 | 110 | ) 111 | } 112 | 113 | export { Curve, Steps } 114 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { Editor } from './editor' 2 | import { PresetMenu, PresetInput } from './preset-menu' 3 | 4 | export { Editor, PresetMenu, PresetInput } 5 | -------------------------------------------------------------------------------- /src/components/preset-menu/index.tsx: -------------------------------------------------------------------------------- 1 | import PresetMenu from './menu' 2 | import PresetInput from './nameInput' 3 | 4 | export { PresetMenu, PresetInput } 5 | -------------------------------------------------------------------------------- /src/components/preset-menu/menu.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact' 2 | import { Dropdown, DropdownOption } from '@create-figma-plugin/ui' 3 | import { useFocus } from '../../hooks/useFocus' 4 | import { PresetOption, PresetOptionKey } from '../../ui' 5 | 6 | const COPY_DEFAULT_OPTIONS_H1 = `Select a preset` 7 | const COPY_DEFAULT_OPTIONS_H2 = `Options` 8 | const COPY_MANAGE_PRESETS_H1 = `Select a preset to remove` 9 | const COPY_MANAGE_PRESETS_H2 = `Dangerzone` 10 | const COPY_MANAGE_PRESETS_RESET = `Remove all and reset to default` 11 | const COPY_MANAGE_PRESETS_ADD_PRESET = `Save current as preset` 12 | const COPY_MANAGE_PRESETS_ACTION = `Remove presets...` 13 | 14 | const MENU_OPTIONS: PresetOption[] = [ 15 | { separator: true }, 16 | { header: COPY_DEFAULT_OPTIONS_H2 }, 17 | { children: COPY_MANAGE_PRESETS_ADD_PRESET, value: 'ADD_PRESET' }, 18 | { 19 | children: COPY_MANAGE_PRESETS_ACTION, 20 | value: 'MANAGE_PRESETS' 21 | } 22 | ] 23 | 24 | const PresetMenu = ({ 25 | value, 26 | placeholder, 27 | showManagingPresetsDialog, 28 | presets, 29 | onValueChange 30 | }: { 31 | value: PresetOptionKey | null 32 | placeholder: string 33 | showManagingPresetsDialog: boolean 34 | presets: PresetOption[] 35 | onValueChange: Function 36 | }) => { 37 | const defaultOptions: PresetOption[] = [ 38 | { header: COPY_DEFAULT_OPTIONS_H1 }, 39 | ...presets, 40 | ...MENU_OPTIONS 41 | ] 42 | const managePresetsOptions: PresetOption[] = [ 43 | { header: COPY_MANAGE_PRESETS_H1 }, 44 | ...presets, 45 | { separator: true }, 46 | { header: COPY_MANAGE_PRESETS_H2 }, 47 | { children: COPY_MANAGE_PRESETS_RESET, value: 'RESET_DEFAULT' } 48 | ] 49 | 50 | function handlePresetInput(e: JSX.TargetedEvent): void { 51 | const value = e.currentTarget.value 52 | onValueChange(value) 53 | } 54 | 55 | return ( 56 | 67 | ) 68 | } 69 | 70 | export default PresetMenu 71 | -------------------------------------------------------------------------------- /src/components/preset-menu/nameInput.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import { 3 | Button, 4 | Columns, 5 | Textbox, 6 | VerticalSpace 7 | } from '@create-figma-plugin/ui' 8 | import style from './style.css' 9 | 10 | const COPY_INPUT_H1 = `Enter a name` 11 | const COPY_ADD_BUTTON = `Add` 12 | 13 | const PresetInput = ({ 14 | showInputDialog, 15 | value, 16 | placeholder, 17 | onInput, 18 | onApply 19 | }: { 20 | showInputDialog: boolean 21 | value: string 22 | placeholder: string 23 | onInput: 24 | | ((event: h.JSX.TargetedEvent) => void) 25 | | undefined 26 | onApply: h.JSX.MouseEventHandler | undefined 27 | }) => { 28 | return ( 29 |
32 |
{COPY_INPUT_H1}
33 | 34 | 39 | 40 | 41 | 44 | 45 |
46 | ) 47 | } 48 | 49 | export default PresetInput 50 | -------------------------------------------------------------------------------- /src/components/preset-menu/style.css: -------------------------------------------------------------------------------- 1 | .presetInput { 2 | position: absolute; 3 | z-index: 20; 4 | top: 0; 5 | right: 4px; 6 | min-width: 168px; 7 | padding: 16px; 8 | background-color: var(--color-hud); 9 | } 10 | 11 | .presetInput.isHidden { 12 | pointer-events: none; 13 | visibility: hidden; 14 | } 15 | 16 | .presetInput input { 17 | color: var(--color-white) !important; 18 | font-size: var(--font-size-12); 19 | } 20 | 21 | .presetInput input::placeholder { 22 | text-transform: capitalize; 23 | } 24 | 25 | .presetInput .header { 26 | color: var(--color-white-40-translucent); 27 | font-size: var(--font-size-12); 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useFocus.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Focuses elements of the ui library which input elements 3 | * cannot be referenced directly. This is a small tweak of the librarie's useInitialFocus hook. 4 | * @author yuanqing 5 | * @url https://github.com/yuanqing/create-figma-plugin/blob/main/packages/ui/src/hooks/use-initial-focus/use-initial-focus.ts 6 | */ 7 | 8 | import { useEffect } from 'preact/hooks' 9 | 10 | const FOCUS_DATA_ATTRIBUTE_NAME = 'data-focus' 11 | 12 | export type Focus = { 13 | [FOCUS_DATA_ATTRIBUTE_NAME]: true 14 | } 15 | 16 | export function useFocus(shouldFocus: boolean): Focus { 17 | useEffect( 18 | function (): void { 19 | const focusableElements = document.querySelectorAll( 20 | `[${FOCUS_DATA_ATTRIBUTE_NAME}]` 21 | ) 22 | if (focusableElements.length === 0) { 23 | throw new Error( 24 | `No element with attribute \`${FOCUS_DATA_ATTRIBUTE_NAME}\`` 25 | ) 26 | } 27 | if (shouldFocus) focusableElements[0].focus() 28 | }, 29 | [shouldFocus] 30 | ) 31 | return { 32 | [FOCUS_DATA_ATTRIBUTE_NAME]: true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/icons/curveIcon.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import { Matrix } from '../main' 3 | 4 | const CurveIcon = ({ size = 12, matrix }: { size: number; matrix: Matrix }) => { 5 | const viewbox = { 6 | overflow: 'visible', 7 | height: size, 8 | width: size 9 | } 10 | const path = { 11 | fill: 'none', 12 | strokeWidth: '1px', 13 | strokeLinecap: 'round', 14 | stroke: 'currentColor' 15 | } 16 | return ( 17 | 18 | 24 | 25 | ) 26 | } 27 | 28 | export default CurveIcon 29 | -------------------------------------------------------------------------------- /src/icons/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextboxMatrixIcon, 3 | TextboxStepIcon, 4 | DropdownJumpNoneIcon, 5 | DropdownJumpBothIcon, 6 | DropdownJumpStartIcon, 7 | DropdownJumpEndIcon 8 | } from './svgIcon' 9 | import CurveIcon from './curveIcon' 10 | import StepIcon from './stepIcon' 11 | 12 | export { 13 | TextboxMatrixIcon, 14 | TextboxStepIcon, 15 | DropdownJumpNoneIcon, 16 | DropdownJumpBothIcon, 17 | DropdownJumpStartIcon, 18 | DropdownJumpEndIcon, 19 | CurveIcon, 20 | StepIcon 21 | } 22 | -------------------------------------------------------------------------------- /src/icons/stepIcon.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | import easingCoordinates from 'easing-coordinates' 3 | import { SkipOption } from '../main' 4 | 5 | const StepIcon = ({ 6 | size = 12, 7 | steps = 6, 8 | jump = 'skip-none' 9 | }: { 10 | size: number 11 | steps: number 12 | jump: SkipOption 13 | }) => { 14 | const getPolyPoints = (): string => { 15 | const coords = easingCoordinates(`steps(${steps}, ${jump})`) 16 | return coords.map(pos => `${pos.x},${1 - pos.y}`).join(' ') 17 | } 18 | 19 | // display dashed line as visual guide for jump/skip values 20 | const getJumpHelper = [ 21 | `${easingCoordinates(`steps(${steps}, ${jump})`)[0].x}, ${ 22 | 1 - easingCoordinates(`steps(${steps}, ${jump})`)[0].y 23 | }`, 24 | `${easingCoordinates(`steps(${steps}, ${jump})`)[steps * 2 - 1].x}, ${ 25 | 1 - easingCoordinates(`steps(${steps}, ${jump})`)[steps * 2 - 1].y 26 | }` 27 | ] 28 | 29 | const viewbox = { 30 | overflow: 'visible', 31 | height: size, 32 | width: size 33 | } 34 | 35 | const path = { 36 | fill: 'none', 37 | strokeWidth: '1px', 38 | strokeLinecap: 'round', 39 | stroke: 'currentColor' 40 | } 41 | 42 | const dashed = { 43 | strokeDasharray: 4 44 | } 45 | 46 | return ( 47 | 48 | {/* stepped polyline */} 49 | 50 | 55 | 56 | 61 | 66 | 67 | ) 68 | } 69 | 70 | export default StepIcon 71 | -------------------------------------------------------------------------------- /src/icons/svgIcon.tsx: -------------------------------------------------------------------------------- 1 | import { h } from 'preact' 2 | 3 | export const TextboxMatrixIcon = ( 4 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | 18 | export const TextboxStepIcon = ( 19 | 26 | 32 | 33 | ) 34 | 35 | export const DropdownJumpNoneIcon = ( 36 | 43 | 49 | 55 | 56 | ) 57 | 58 | export const DropdownJumpBothIcon = ( 59 | 66 | 70 | 74 | 75 | ) 76 | 77 | export const DropdownJumpStartIcon = ( 78 | 85 | 91 | 95 | 96 | ) 97 | 98 | export const DropdownJumpEndIcon = ( 99 | 106 | 110 | 116 | 117 | ) 118 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | nodeIsGeometryMixin, 3 | isGradientFillWithMultipleStops, 4 | interpolateColorStops, 5 | validateSelection, 6 | getValueFromStoreOrInit, 7 | setValueToStorage, 8 | handleNotificationFromUI 9 | } from './utils' 10 | import { 11 | on, 12 | emit, 13 | showUI, 14 | cloneObject, 15 | insertAfterNode 16 | //collapseLayer 17 | } from '@create-figma-plugin/utilities' 18 | import { 19 | DEFAULT_PRESETS, 20 | DEFAULT_EASING_TYPE, 21 | DEFAULT_MATRIX, 22 | DEFAULT_STEPS, 23 | DEFAULT_SKIP 24 | } from './shared/default_values' 25 | import { PresetOptionValue } from './ui' 26 | 27 | type UISettings = { width: number; height: number } 28 | type StorageKey = `easing-gradients-${string | number}` 29 | 30 | export type EasingType = 'CURVE' | 'STEPS' 31 | export type Matrix = number[][] 32 | export type SkipOption = 'skip-none' | 'skip-both' | 'start' | 'end' 33 | export type EasingOptions = { 34 | type: EasingType 35 | matrix: Matrix 36 | steps: number 37 | skip: string 38 | } 39 | 40 | const KEY_PLUGIN_DATA = 'easing-gradients-v2-data' 41 | const KEY_PRESETS: StorageKey = 'easing-gradients-v2-presets' 42 | //const PREVIEW_ELEMENT_PREFIX = '[Preview]' 43 | 44 | export default async function () { 45 | const ui: UISettings = { width: 280, height: 388 } 46 | 47 | // See comment L87 48 | // await figma.loadFontAsync({ family: 'Roboto', style: 'Regular' }) 49 | 50 | let selectionRef: SceneNode | undefined 51 | let cloneRef: SceneNode | undefined 52 | let labelRef: GroupNode | undefined 53 | let state: EasingOptions = { 54 | type: DEFAULT_EASING_TYPE, 55 | matrix: DEFAULT_MATRIX, 56 | steps: DEFAULT_STEPS, 57 | skip: DEFAULT_SKIP 58 | } 59 | 60 | /** 61 | * Functions 62 | */ 63 | function updateGradientFill(node: SceneNode): void { 64 | if (!node) return console.error(`Couldn't get node.`) 65 | if (!nodeIsGeometryMixin(node)) 66 | return console.warn('Selected node is not a shape.') 67 | const fills = node.fills as Paint[] 68 | 69 | fills.forEach((fillProperty, index) => { 70 | if (!isGradientFillWithMultipleStops(fillProperty)) 71 | return console.warn( 72 | 'Selected node does not contain gradient fills.' 73 | ) 74 | 75 | const tempNode: any = cloneObject(node.fills) 76 | 77 | tempNode[index].gradientStops = interpolateColorStops( 78 | fillProperty, 79 | state 80 | ) 81 | node.fills = tempNode 82 | }) 83 | } 84 | 85 | /** 86 | * ⚠️ The preview label is causing some issues in Version 8, 87 | * but right now I don't have capacity to investigate. 88 | * Since this feature has no priority, I'll disable it for Version 9. 89 | */ 90 | 91 | // function createPreviewLabel(): GroupNode | void { 92 | // if (!cloneRef) return 93 | // let elements = [] 94 | // const baseHeight = 12 95 | // const baseWidth = 34 96 | // const zoom = figma.viewport.zoom / 1.6 // adjust viewport-relative scaling, guessed value 97 | // const width = baseWidth / zoom 98 | // const height = baseHeight / zoom 99 | // const fontSize = Math.max(8 / zoom, 1) 100 | 101 | // // label backdrop 102 | // const rect: RectangleNode = figma.createRectangle() 103 | // rect.resizeWithoutConstraints(width, height) 104 | // const rectColor = { r: 0.094, g: 0.627, b: 0.984 } // #18A0FB aka. Figma blue 105 | // rect.fills = [{ type: 'SOLID', color: rectColor }] 106 | // rect.cornerRadius = height / 8 107 | // // label text 108 | // const text: TextNode = figma.createText() 109 | // text.resizeWithoutConstraints(width, height) 110 | // const textColor = { r: 1, g: 1, b: 1 } //#fff 111 | // text.fills = [{ type: 'SOLID', color: textColor }] 112 | // text.fontSize = fontSize 113 | // text.textAlignHorizontal = 'CENTER' 114 | // text.textAlignVertical = 'CENTER' 115 | // text.characters = 'Preview' 116 | 117 | // elements.push(rect, text) 118 | // // label container 119 | // const group: GroupNode = figma.group(elements, figma.currentPage) 120 | // group.name = `${PREVIEW_ELEMENT_PREFIX} Label` 121 | // const margin = 2 / zoom 122 | // group.x = cloneRef.x 123 | // group.y = cloneRef.y - height - margin 124 | 125 | // collapseLayer(group) 126 | // return group 127 | // } 128 | 129 | function updateCanvasPreview(): void { 130 | if (!selectionRef || !cloneRef) return 131 | selectionRef.locked = true 132 | selectionRef.visible = false 133 | 134 | cloneRef.locked = true 135 | 136 | // see L87 137 | // if (!labelRef) { 138 | // labelRef = createPreviewLabel() || undefined 139 | // } else { 140 | // labelRef.locked = true 141 | 142 | // cloneRef.name = `${PREVIEW_ELEMENT_PREFIX} Easing Gradients` 143 | // insertAfterNode(cloneRef, selectionRef) 144 | // insertAfterNode(labelRef, selectionRef) 145 | // } 146 | 147 | updateGradientFill(cloneRef) 148 | } 149 | 150 | function cleanUpCanvasPreview(): void { 151 | if (!selectionRef || !cloneRef) return 152 | selectionRef.locked = false 153 | selectionRef.visible = true 154 | selectionRef = undefined 155 | 156 | cloneRef.remove() 157 | cloneRef = undefined 158 | 159 | if (labelRef) { 160 | labelRef.remove() 161 | labelRef = undefined 162 | } 163 | } 164 | 165 | /** 166 | * Event handlers 167 | */ 168 | function handleSelectionChange() { 169 | const selectionState = validateSelection(figma.currentPage.selection) 170 | if (selectionState !== 'VALID') { 171 | cleanUpCanvasPreview() 172 | } else { 173 | const selection = figma.currentPage.selection[0] 174 | if (cloneRef) { 175 | // handle user selecting preview node via layer menu 176 | if (selection.id === cloneRef.id) { 177 | cleanUpCanvasPreview() 178 | figma.notify(`Cannot select the preview element.`) 179 | } else { 180 | cleanUpCanvasPreview() 181 | selectionRef = selection 182 | cloneRef = selectionRef.clone() 183 | makeSureClonedNodeIsInPlace() 184 | updateCanvasPreview() 185 | } 186 | } else { 187 | selectionRef = selection 188 | cloneRef = selectionRef.clone() 189 | makeSureClonedNodeIsInPlace() 190 | updateCanvasPreview() 191 | } 192 | } 193 | const pluginData = checkIfExistingEasingData(selectionRef) 194 | emit('UPDATE_SELECTION_STATE', { selectionState, pluginData }) 195 | } 196 | 197 | function handleUpdate(options: EasingOptions) { 198 | state = { ...state, ...options } 199 | updateCanvasPreview() 200 | } 201 | 202 | /** 203 | * Makes sure that the cloneRef node has the same position as it's selectionRef master. 204 | */ 205 | function makeSureClonedNodeIsInPlace(): void { 206 | if (!selectionRef || !cloneRef) return 207 | insertAfterNode(cloneRef, selectionRef) 208 | if (selectionRef.parent?.type === 'GROUP') { 209 | cloneRef.x = selectionRef.x 210 | cloneRef.y = selectionRef.y 211 | } 212 | } 213 | 214 | /** 215 | * Handle preset getting/setting 216 | */ 217 | async function handleInitialPresetEmitToUI(): Promise { 218 | getValueFromStoreOrInit(KEY_PRESETS, DEFAULT_PRESETS) 219 | .then((response: PresetOptionValue) => { 220 | emit('INITIALLY_EMIT_PRESETS_TO_UI', response) 221 | }) 222 | .catch(() => { 223 | figma.notify( 224 | `Couldn't load user presets, default presets will be used.` 225 | ) 226 | }) 227 | } 228 | 229 | async function handleReceivePresetsFromUI(data: any): Promise { 230 | const { presets, message } = data 231 | setValueToStorage(KEY_PRESETS, presets) 232 | .then((response: PresetOptionValue) => { 233 | emit('RESPOND_TO_PRESETS_UPDATE', { response, message }) 234 | figma.notify( 235 | message === 'ADD' ? 'Added new preset.' : 'Removed preset.' 236 | ) 237 | }) 238 | .catch(() => { 239 | figma.notify(`Couldn't save preset, please try again.`) 240 | }) 241 | } 242 | 243 | async function handleResetPresetsToDefault(): Promise { 244 | setValueToStorage(KEY_PRESETS, DEFAULT_PRESETS) 245 | .then((response: PresetOptionValue) => { 246 | emit('RESPOND_TO_PRESETS_UPDATE', { 247 | response, 248 | message: 'RESET' 249 | }) 250 | figma.notify( 251 | 'Removed all presets and restored default presets.' 252 | ) 253 | }) 254 | .catch(() => { 255 | figma.notify(`Couldn't reset preset, please try again.`) 256 | }) 257 | } 258 | 259 | function applyEasingFunction(): void { 260 | if (!selectionRef) return 261 | selectionRef.setRelaunchData({ 262 | ReapplyEasing: `Re-applies gradient easing with respect to first and last color stop.` 263 | }) 264 | selectionRef.setPluginData(KEY_PLUGIN_DATA, JSON.stringify(state)) 265 | 266 | updateGradientFill(selectionRef) 267 | cleanUpCanvasPreview() 268 | figma.closePlugin() 269 | } 270 | 271 | function checkIfExistingEasingData(selection: any): any | undefined { 272 | if (!selection) return 273 | const pluginData = selection.getPluginData(KEY_PLUGIN_DATA) 274 | if (pluginData) { 275 | let data: any 276 | try { 277 | data = JSON.parse(pluginData) 278 | } catch (e) { 279 | return 280 | } 281 | return data 282 | } 283 | } 284 | 285 | /** 286 | * Event listeners 287 | */ 288 | on('UPDATE_FROM_UI', handleUpdate) 289 | on('APPLY_EASING_FUNCTION', applyEasingFunction) 290 | on('EMIT_PRESETS_TO_PLUGIN', handleReceivePresetsFromUI) 291 | on('EMIT_PRESET_RESET_TO_PLUGIN', handleResetPresetsToDefault) 292 | on('EMIT_NOTIFICATION_TO_PLUGIN', handleNotificationFromUI) 293 | figma.on('selectionchange', handleSelectionChange) 294 | figma.on('close', cleanUpCanvasPreview) 295 | 296 | /** 297 | * If plugin was launched via RelaunchButton 298 | */ 299 | if (figma.command === 'ReapplyEasing') { 300 | const selection = figma.currentPage.selection[0] 301 | const pluginData = checkIfExistingEasingData(selection) 302 | if (pluginData) { 303 | state = pluginData 304 | updateGradientFill(selection) 305 | figma.notify('Re-applied gradient easing.', { timeout: 3 }) 306 | figma.closePlugin() 307 | } 308 | } else { 309 | showUI(ui) 310 | handleInitialPresetEmitToUI() 311 | handleSelectionChange() 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/shared/default_values.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Easing default values, shared across plugin and ui. 3 | * Eventhough plugin and ui state should always be in sync, there would 4 | * be a brief flash of value changes before the initial event handler is 5 | * emitted. 6 | */ 7 | 8 | import { PresetOptionValue } from '../ui' 9 | import { EasingOptions, EasingType, Matrix, SkipOption } from '../main' 10 | 11 | export const DEFAULT_PRESETS: PresetOptionValue[] = [ 12 | { 13 | children: 'Ease-in-out', 14 | value: 'DEFAULT_EASE_IN_OUT', 15 | matrix: [ 16 | [0.42, 0.0], 17 | [0.58, 1.0] 18 | ] 19 | }, 20 | { 21 | children: 'Ease-in', 22 | value: 'DEFAULT_EASE_IN', 23 | matrix: [ 24 | [0.42, 0.0], 25 | [1.0, 1.0] 26 | ] 27 | }, 28 | { 29 | children: 'Ease-out', 30 | value: 'DEFAULT_EASE_OUT', 31 | matrix: [ 32 | [0.0, 0.0], 33 | [0.58, 1.0] 34 | ] 35 | }, 36 | { 37 | children: 'Ease', 38 | value: 'DEFAULT_EASE', 39 | matrix: [ 40 | [0.25, 0.1], 41 | [0.25, 1.0] 42 | ] 43 | } 44 | ] 45 | 46 | export const DEFAULT_EASING_TYPE: EasingType = 'CURVE' 47 | export const DEFAULT_MATRIX: Matrix = [ 48 | [0.65, 0.0], 49 | [0.35, 1.0] 50 | ] 51 | export const DEFAULT_STEPS: number = 8 52 | export const DEFAULT_SKIP: SkipOption = 'skip-none' 53 | -------------------------------------------------------------------------------- /src/ui.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX, ComponentChildren } from 'preact' 2 | import { useState, useEffect, useCallback, useRef } from 'preact/hooks' 3 | import { on, emit } from '@create-figma-plugin/utilities' 4 | import './base.css' 5 | import { 6 | render, 7 | Button, 8 | Container, 9 | Columns, 10 | Text, 11 | Textbox, 12 | TextboxNumeric, 13 | Dropdown, 14 | useMouseDownOutside, 15 | VerticalSpace, 16 | MiddleAlign 17 | } from '@create-figma-plugin/ui' 18 | import { 19 | debounce, 20 | showDecimals, 21 | getRandomString, 22 | describeCurveInAdjectives 23 | } from './utils' 24 | import { Editor, PresetMenu, PresetInput } from './components' 25 | import { 26 | TextboxMatrixIcon, 27 | TextboxStepIcon, 28 | DropdownJumpNoneIcon, 29 | DropdownJumpBothIcon, 30 | DropdownJumpStartIcon, 31 | DropdownJumpEndIcon, 32 | CurveIcon, 33 | StepIcon 34 | } from './icons' 35 | import { 36 | DEFAULT_EASING_TYPE, 37 | DEFAULT_MATRIX, 38 | DEFAULT_STEPS, 39 | DEFAULT_SKIP 40 | } from './shared/default_values' 41 | 42 | /** 43 | * Types 44 | */ 45 | import { DropdownOption } from '@create-figma-plugin/ui' 46 | import { SelectionKey, SelectionKeyMap } from './utils/' 47 | import { EasingOptions, EasingType, Matrix, SkipOption } from './main' 48 | import { EditorChange } from './components/editor/editor' 49 | 50 | export type PresetOption = 51 | | PresetOptionHeader 52 | | PresetOptionValue 53 | | PresetOptionSeparator 54 | export type PresetOptionHeader = { 55 | header: string 56 | } 57 | export type PresetOptionValue = { 58 | children?: string 59 | value: PresetOptionKey 60 | matrix?: Matrix 61 | } 62 | export type PresetOptionSeparator = { 63 | separator: boolean 64 | } 65 | export type PresetOptionKey = 66 | | 'ADD_PRESET' 67 | | 'MANAGE_PRESETS' 68 | | 'RESET_DEFAULT' 69 | | CustomPresetKey 70 | 71 | export type CustomPresetKey = `CUSTOM_${string}` | `DEFAULT_${string}` 72 | export type PresetMessage = 'ADD' | 'REMOVE' 73 | 74 | /** 75 | * Global constants 76 | */ 77 | const COPY_PLACEHOLDER_BEFORE_INTERACTION: string = 'Choose preset...' 78 | const COPY_PLACEHOLDER_AFTER_INTERACTION: string = 'Custom' 79 | const OPTION_NO_PRESETS: PresetOptionHeader[] = [{ header: 'No presets.' }] 80 | const OPTION_EASING_TYPE: DropdownOption[] = [ 81 | { children: 'Curve', value: 'CURVE' }, 82 | { children: 'Steps', value: 'STEPS' } 83 | ] 84 | const OPTIONS_JUMPS: { children: string; value: SkipOption }[] = [ 85 | { children: 'jump-none', value: 'skip-none' }, 86 | { children: 'jump-both', value: 'skip-both' }, 87 | { children: 'jump-start', value: 'start' }, 88 | { children: 'jump-end', value: 'end' } 89 | ] 90 | const JUMP_ICON: { [type in SkipOption]: ComponentChildren } = { 91 | 'skip-none': DropdownJumpNoneIcon, 92 | 'skip-both': DropdownJumpBothIcon, 93 | start: DropdownJumpStartIcon, 94 | end: DropdownJumpEndIcon 95 | } 96 | const BUTTON_STATE: SelectionKeyMap = { 97 | EMPTY: 'No element selected', 98 | MULTIPLE_ELEMENTS: 'Multiple elements', 99 | INVALID_TYPE: 'Invalid element', 100 | NO_GRADIENT_FILL: 'No gradient fill', 101 | VALID: 'Apply' 102 | } 103 | 104 | const Plugin = () => { 105 | /** 106 | * States 107 | */ 108 | 109 | const [presets, setPresets] = useState< 110 | PresetOptionValue[] | PresetOptionHeader[] 111 | >(OPTION_NO_PRESETS) 112 | 113 | // preset menu states 114 | const [showPresetInputDialog, setShowPresetInputDialog] = 115 | useState(false) 116 | const [showManagingPresetsDialog, setShowManagingPresetsDialog] = 117 | useState(false) 118 | const [hasInteractedWithPresetMenu, setHasInteractedWithPresetMenu] = 119 | useState(false) 120 | 121 | // temporary states that are passed between the different dialogs 122 | const [tempCustomPresetStore, setTempCustomPresetStore] = 123 | useState(null) 124 | const [tempCustomPresetName, setTempCustomPresetName] = useState('') 125 | const [tempCustomPresetPlaceholder, setTempCustomPresetPlaceholder] = 126 | useState('') 127 | 128 | // ui exposed states 129 | const [easingType, setEasingType] = 130 | useState(DEFAULT_EASING_TYPE) 131 | const [selectedPreset, setSelectedPreset] = 132 | useState(null) 133 | const [matrix, setMatrix] = useState(DEFAULT_MATRIX) 134 | const [steps, setSteps] = useState(DEFAULT_STEPS) 135 | const [jump, setJump] = useState(DEFAULT_SKIP) 136 | const [selectionState, setSelectionState] = 137 | useState('INVALID_TYPE') 138 | 139 | // data emitted to plugin 140 | const messageData: EasingOptions = { 141 | type: easingType, 142 | matrix, 143 | steps, 144 | skip: jump 145 | } 146 | 147 | /** 148 | * Register event listeners 149 | */ 150 | useEffect(() => { 151 | on('INITIALLY_EMIT_PRESETS_TO_UI', storedPresets => { 152 | if (storedPresets.length) { 153 | setPresets([...storedPresets]) 154 | } 155 | }) 156 | on('UPDATE_SELECTION_STATE', ({ selectionState, pluginData }) => { 157 | setSelectionState(selectionState) 158 | if (pluginData) { 159 | const { type, matrix, steps, skip } = pluginData 160 | if (!type || !matrix || !steps || !skip) return 161 | setEasingType(type) 162 | setMatrix(matrix) 163 | setSteps(steps) 164 | setJump(skip) 165 | emitNotificationToPlugin('Restored previous easing settings.') 166 | } 167 | }) 168 | on('RESPOND_TO_PRESETS_UPDATE', handleResponseFromPlugin) 169 | }, []) 170 | 171 | useEffect(() => { 172 | if (selectedPreset) { 173 | if (!hasInteractedWithPresetMenu) 174 | setHasInteractedWithPresetMenu(true) 175 | 176 | const matrix = ([...presets] as any).find( 177 | (el: PresetOptionValue) => el.value === selectedPreset 178 | ).matrix 179 | if (matrix) setMatrix(matrix) 180 | } 181 | }, [selectedPreset]) 182 | 183 | /** 184 | * Handle input events 185 | */ 186 | function handleMatrixInput(e: JSX.TargetedEvent): void { 187 | const value = e.currentTarget.value.split(', ').map(Number) 188 | const isValidValue = value.every(e => e >= 0 && e <= 1) 189 | 190 | if (value.length === 4 && isValidValue) { 191 | setMatrix([ 192 | [value[0], value[1]], 193 | [value[2], value[3]] 194 | ]) 195 | setSelectedPreset(null) 196 | } 197 | } 198 | 199 | function handleStepInput(e: JSX.TargetedEvent): void { 200 | const value = e.currentTarget.value 201 | if (parseFloat(value) > 1) { 202 | setSteps(parseFloat(value)) 203 | } 204 | } 205 | 206 | function handleEditorChange(value: EditorChange): void { 207 | if (value.type === 'CURVE' && value.thumb) { 208 | const { thumb } = value 209 | const prev = [...matrix] 210 | prev[thumb.index] = thumb.vector 211 | setMatrix(prev) 212 | setSelectedPreset(null) 213 | } else if (value.type === 'STEPS' && value.steps) { 214 | setSteps(value.steps) 215 | } 216 | } 217 | 218 | /** 219 | * Handle preset menu changes and custom preset input 220 | */ 221 | function handlePresetMenuChange(value: PresetOptionKey) { 222 | // page 2: remove or reset presets 223 | if (showManagingPresetsDialog) { 224 | if (value === 'RESET_DEFAULT') { 225 | emit('EMIT_PRESET_RESET_TO_PLUGIN') 226 | } 227 | // else remove preset 228 | else { 229 | let data: PresetOption[] 230 | const updatedPresets = ([...presets] as any).filter( 231 | (el: PresetOptionValue) => el.value !== value 232 | ) 233 | if (!updatedPresets.length) { 234 | data = [] 235 | } else { 236 | data = [...updatedPresets] 237 | } 238 | emitPresetUpdateToPlugin(data, 'REMOVE') 239 | } 240 | setShowManagingPresetsDialog(false) 241 | } 242 | // page 1: select or add preset 243 | else { 244 | if (value === 'ADD_PRESET') { 245 | const tempCustomPresetName: CustomPresetKey = `CUSTOM_${getRandomString()}` 246 | const newPreset: PresetOptionValue = { 247 | value: tempCustomPresetName, 248 | matrix: [...matrix] 249 | } 250 | const placeholder = describeCurveInAdjectives([...matrix]) 251 | setTempCustomPresetStore(newPreset) 252 | setTempCustomPresetPlaceholder(placeholder) 253 | setShowPresetInputDialog(true) 254 | } 255 | // switch to page 2 256 | else if (value === 'MANAGE_PRESETS') { 257 | setShowManagingPresetsDialog(true) 258 | setSelectedPreset(null) 259 | } 260 | // select existing preset 261 | else { 262 | setSelectedPreset(value) 263 | } 264 | } 265 | } 266 | 267 | function handleCustomPresetInput( 268 | e: JSX.TargetedEvent 269 | ): void { 270 | const value = e.currentTarget.value 271 | setTempCustomPresetName(value) 272 | } 273 | 274 | function handleCustomPresetDialogApply(): void { 275 | if (tempCustomPresetName.length > 24) { 276 | emitNotificationToPlugin( 277 | 'Enter a name with less than 24 characters.' 278 | ) 279 | return 280 | } 281 | 282 | let data: any 283 | const presetName = tempCustomPresetName || tempCustomPresetPlaceholder 284 | const newPreset = { children: presetName, ...tempCustomPresetStore } 285 | if (([...presets] as any).some((el: PresetOptionHeader) => el.header)) { 286 | data = [newPreset] 287 | } else { 288 | data = [...presets, newPreset] 289 | } 290 | emitPresetUpdateToPlugin(data, 'ADD') 291 | resetCustomPresetDialog() 292 | } 293 | 294 | function resetCustomPresetDialog(): void { 295 | setShowPresetInputDialog(false) 296 | setTempCustomPresetStore(null) 297 | setTempCustomPresetName('') 298 | } 299 | 300 | /** 301 | * Handle emits to plugin and response from plugin 302 | */ 303 | function emitPresetUpdateToPlugin( 304 | presets: PresetOption[], 305 | message: PresetMessage 306 | ): void { 307 | emit('EMIT_PRESETS_TO_PLUGIN', { presets, message }) 308 | } 309 | 310 | function emitNotificationToPlugin(message: string): void { 311 | emit('EMIT_NOTIFICATION_TO_PLUGIN', message) 312 | } 313 | 314 | function handleResponseFromPlugin(data: { 315 | response: PresetOptionValue[] 316 | message: PresetMessage 317 | }): void { 318 | const { response, message } = data 319 | if (response) { 320 | if (!response.length) { 321 | setPresets(OPTION_NO_PRESETS) 322 | } else { 323 | setPresets([...response]) 324 | if (message === 'ADD') { 325 | setSelectedPreset(response[response.length - 1].value) 326 | } 327 | } 328 | } 329 | } 330 | 331 | /** 332 | * Cancel dialogs by clicking outside the dropdown field 333 | */ 334 | const ref = useRef(null) 335 | useMouseDownOutside({ 336 | onMouseDownOutside: () => handleMouseDownOutside(), 337 | ref 338 | }) 339 | function handleMouseDownOutside(): void { 340 | setShowManagingPresetsDialog(false) 341 | resetCustomPresetDialog() 342 | } 343 | function handlePresetMenuKeyDown( 344 | e: JSX.TargetedKeyboardEvent 345 | ) { 346 | if (e.key === 'Escape' || e.key === 'Tab') { 347 | setShowManagingPresetsDialog(false) 348 | resetCustomPresetDialog() 349 | return 350 | } 351 | } 352 | 353 | /** 354 | * Debounce gradient updates that are emitted to the plugin 355 | */ 356 | const debounceWaitTime = 100 //ms 357 | 358 | useEffect(() => { 359 | debounceNumItemsChange(messageData) 360 | }, [easingType, matrix, steps, jump]) 361 | 362 | const debounceNumItemsChange = useCallback( 363 | debounce(data => emit('UPDATE_FROM_UI', data), debounceWaitTime), 364 | [] 365 | ) 366 | 367 | return ( 368 | 369 | 370 | 371 | 374 | setEasingType(e.currentTarget.value as EasingType) 375 | } 376 | icon={ 377 | (easingType as EasingType) === 'CURVE' ? ( 378 | 379 | ) : ( 380 | 381 | ) 382 | } 383 | options={OPTION_EASING_TYPE} 384 | /> 385 |
400 | 407 | 418 |
419 |
420 | 421 | 428 | 429 | {(easingType as EasingType) === 'CURVE' ? ( 430 | showDecimals(vec, 2)) 433 | .join(', ')} 434 | icon={TextboxMatrixIcon} 435 | onBlurCapture={handleMatrixInput} 436 | /> 437 | ) : ( 438 | 439 | 444 | 448 | setJump(e.currentTarget.value as SkipOption) 449 | } 450 | options={OPTIONS_JUMPS} 451 | /> 452 | 453 | )} 454 | 455 | 462 |
463 | ) 464 | } 465 | 466 | export default render(Plugin) 467 | -------------------------------------------------------------------------------- /src/utils/color.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Chroma utilitiy functions. 3 | */ 4 | import chroma, { Color } from 'chroma-js' 5 | 6 | /** 7 | * Transforms RGBA to GLSL vec3 value (normalizes 0..255 to 0..1) 8 | * @param {ColorStop} colorStop Figma color stop 9 | */ 10 | export function gl(colorStop: ColorStop): Color { 11 | return chroma.gl( 12 | colorStop.color.r, 13 | colorStop.color.g, 14 | colorStop.color.b, 15 | colorStop.color.a 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/create-class-name.ts: -------------------------------------------------------------------------------- 1 | export function createClassName(classNames: Array): string { 2 | return classNames 3 | .filter(function (className: null | string): boolean { 4 | return className !== null 5 | }) 6 | .join(' ') 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Debounce utility to limit the amount of messages emitted to the plugin. 3 | */ 4 | 5 | export const debounce = any>( 6 | callback: T, 7 | wait: number 8 | ) => { 9 | let timeout = 0 10 | return (...args: Parameters): ReturnType => { 11 | let result: any 12 | clearTimeout(timeout) 13 | timeout = setTimeout(() => { 14 | result = callback(...args) 15 | }, wait) 16 | return result 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/gradient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file This file contains interpolateColorStops(), which is the 'main 3 | * logic behind easing gradient fills. 4 | */ 5 | 6 | import easingCoordinates from 'easing-coordinates' 7 | import chroma from 'chroma-js' 8 | import { gl } from './color' 9 | import { EasingType, EasingOptions } from '../main' 10 | 11 | /** 12 | * Checks if given fill is a gradient fill with at least two color stops. 13 | * @returns false if fill isn't a gradient fill, ex. SolidPaint or ImagePaint 14 | */ 15 | export function isGradientFillWithMultipleStops( 16 | fill: Paint 17 | ): fill is GradientPaint { 18 | return 'gradientStops' in fill && fill?.gradientStops?.length > 1 19 | } 20 | 21 | /** 22 | * Interpolates two color stops with a given set of coordinates or number fo steps. 23 | * @returns Returns an array of color stops which represents the eased color gradient. 24 | */ 25 | export function interpolateColorStops( 26 | fill: GradientPaint, 27 | options: EasingOptions 28 | ): ColorStop[] { 29 | const { type, matrix, steps, skip } = options 30 | 31 | const stops = [ 32 | fill.gradientStops[0], 33 | fill.gradientStops[fill.gradientStops.length - 1] 34 | ] 35 | const stopColor = [gl(stops[0]), gl(stops[1])] 36 | const stopPosition = [stops[0].position, stops[1].position] 37 | 38 | // How many color stops are used to interpolate between first and last stop 39 | // TODO: Should this be an user-facing option? 40 | const granularity = 15 41 | 42 | const coordinates = 43 | (type as EasingType) == 'CURVE' 44 | ? easingCoordinates( 45 | `cubic-bezier( 46 | ${matrix[0][0]}, 47 | ${matrix[0][1]}, 48 | ${matrix[1][0]},${matrix![0][0]})`, 49 | granularity 50 | ) 51 | : easingCoordinates(`steps(${steps}, ${skip})`) 52 | 53 | return coordinates.map(position => { 54 | const [r, g, b, a] = chroma 55 | .mix(stopColor[0], stopColor[1], position.y, 'rgb') 56 | .gl() 57 | return { 58 | color: { r, g, b, a }, 59 | position: 60 | stopPosition[0] + 61 | position.x * (stopPosition[1] - stopPosition[0]) 62 | } 63 | }) 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { gl } from './color' 2 | import { debounce } from './debounce' 3 | import { 4 | isGradientFillWithMultipleStops, 5 | interpolateColorStops 6 | } from './gradient' 7 | import { nodeIsGeometryMixin, nodeHasGradientFill } from './node' 8 | import { showDecimals } from './number' 9 | import { validateSelection, SelectionKey, SelectionKeyMap } from './selection' 10 | import { getValueFromStoreOrInit, setValueToStorage } from './storage' 11 | import { getRandomString, describeCurveInAdjectives } from './string' 12 | import { handleNotificationFromUI } from './notification' 13 | 14 | export { 15 | gl, 16 | debounce, 17 | isGradientFillWithMultipleStops, 18 | interpolateColorStops, 19 | nodeIsGeometryMixin, 20 | nodeHasGradientFill, 21 | showDecimals, 22 | validateSelection, 23 | getValueFromStoreOrInit, 24 | setValueToStorage, 25 | getRandomString, 26 | describeCurveInAdjectives, 27 | handleNotificationFromUI 28 | } 29 | 30 | export type { SelectionKey, SelectionKeyMap } 31 | -------------------------------------------------------------------------------- /src/utils/node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions that raverse nodes or node properties. 3 | */ 4 | 5 | import { isGradientFillWithMultipleStops } from './gradient' 6 | 7 | /** 8 | * Checks if selection is a valid geometry node by looking for fill properties. 9 | * @returns false if node is not a geometry node, ex. SlideNodes 10 | */ 11 | export function nodeIsGeometryMixin( 12 | selection: any 13 | ): selection is GeometryMixin { 14 | return 'fills' in selection 15 | } 16 | 17 | /** 18 | * Traverses all fills and searches for atleast one gradient fill. 19 | * @returns true if node has atleast one gradient fill 20 | */ 21 | export function nodeHasGradientFill(node: GeometryMixin) { 22 | const fills = node.fills as Paint[] 23 | return fills.findIndex(fill => isGradientFillWithMultipleStops(fill)) > -1 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/notification.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * All-purpose handler to display notifications emitted from the ui. 3 | */ 4 | 5 | export function handleNotificationFromUI(message: string): NotificationHandler { 6 | return figma.notify(message) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Number utility functions. 3 | */ 4 | 5 | export function showDecimals(number: number, decimals: number): string { 6 | return (Math.round(number * 100) / 100).toFixed(decimals) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/selection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Validating the current selection is important because it 3 | * dictates several ui states and dictates the rendering of in-canvas previews. 4 | */ 5 | 6 | import { nodeHasGradientFill, nodeIsGeometryMixin } from './node' 7 | 8 | export type SelectionKey = 9 | | 'VALID' 10 | | 'INVALID_TYPE' 11 | | 'MULTIPLE_ELEMENTS' 12 | | 'NO_GRADIENT_FILL' 13 | | 'EMPTY' 14 | export type SelectionKeyMap = { [type in SelectionKey]: string } 15 | 16 | export function validateSelection( 17 | selection: ReadonlyArray 18 | ): SelectionKey { 19 | if (selection.length) { 20 | if (selection.length > 1) { 21 | return 'MULTIPLE_ELEMENTS' 22 | } 23 | const node: SceneNode = selection[0] 24 | if (!nodeIsGeometryMixin(node)) { 25 | return 'INVALID_TYPE' 26 | } else { 27 | if (!nodeHasGradientFill(node)) return 'NO_GRADIENT_FILL' 28 | else return 'VALID' 29 | } 30 | } else { 31 | return 'EMPTY' 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions for async figma.clientStorage read and write operations. 3 | */ 4 | 5 | /** 6 | * Tries to read value from figma.clientStorage, initializes and sets store 7 | * if no value has been set. 8 | * @returns 9 | */ 10 | export async function getValueFromStoreOrInit(key: string, initValue: any) { 11 | let value = initValue 12 | try { 13 | const tryFromClientStorage = await figma.clientStorage.getAsync(key) 14 | if (typeof tryFromClientStorage === 'undefined') { 15 | await figma.clientStorage.setAsync(key, value) 16 | } else { 17 | value = tryFromClientStorage 18 | } 19 | } catch (e) { 20 | console.error(`Couldn't get value from store.`, e) 21 | } 22 | return value 23 | } 24 | 25 | /** 26 | * Writes value to figma.clientStorage. 27 | * @returns supplied value if successful, returns undefined if failed 28 | */ 29 | export async function setValueToStorage(key: string, value: any) { 30 | let response = undefined 31 | try { 32 | await figma.clientStorage.setAsync(key, value) 33 | response = value 34 | } catch (e) { 35 | console.error(`Couldn't set user presets.`, e) 36 | } 37 | return response 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file String utility functions. 3 | */ 4 | 5 | import { Matrix } from '../main' 6 | 7 | export function getRandomString(): string { 8 | return (Math.random() + 1).toString(36).substring(7) 9 | } 10 | 11 | /** 12 | * @returns a very inaccurate and random description of a given matrix. 13 | * Used as placeholder for new presets in place of generic 'Custom 1' names 14 | */ 15 | export function describeCurveInAdjectives(matrix: Matrix): string { 16 | const { x1, y1, x2, y2 } = { 17 | x1: matrix[0][0], 18 | y1: matrix[0][1], 19 | x2: matrix[1][0], 20 | y2: matrix[1][1] 21 | } 22 | 23 | if (x1 == 0 && y1 == 0 && x2 == 1 && y2 == 1) { 24 | return `${getRandStrFromArr(LINEAR)} ` 25 | } 26 | // if smooth with litte deviation 27 | else if (Math.abs(x1 - y1) < 0.5 && Math.abs(x2 - y2) < 0.5) { 28 | return `${getRandStrFromArr(GENTLE)} ${getRandStrFromArr(CURVE)}` 29 | } 30 | // if inverse shaped 31 | else if (x1 >= y1 && x2 >= y2) { 32 | return `${getRandStrFromArr(CONCAVE)} ${getRandStrFromArr(CURVE)}` 33 | } 34 | // if convex shaped 35 | else if (x1 <= y1 && x2 <= y2) { 36 | return `${getRandStrFromArr(CONVEX)} ${getRandStrFromArr(CURVE)}` 37 | } 38 | // TODO: better wording than plane 39 | else if (x1 < y1 && x2 > y2) { 40 | return `${getRandStrFromArr(PLANAR)} ${getRandStrFromArr(CURVE)}` 41 | } 42 | // if steep incline 43 | else if (x1 > y1 && x2 < y2) { 44 | return `${getRandStrFromArr(STEEP)} ${getRandStrFromArr(CURVE)}` 45 | } else { 46 | return `Eased ${getRandStrFromArr(CURVE)}` 47 | } 48 | } 49 | 50 | function getRandStrFromArr(arr: string[]): string { 51 | return arr[Math.floor(Math.random() * arr.length)] 52 | } 53 | 54 | const CURVE = ['Curve', 'Slope', 'Incline', 'Bézier', 'Descent'] 55 | const LINEAR = ['Linear', 'Straight'] 56 | const GENTLE = ['Gentle', 'Mellow', 'Tame', 'Soft', 'Calm'] 57 | const CONCAVE = [ 58 | 'Gradual', 59 | 'Concave', 60 | 'Half-pipey', 61 | 'Acclivous', 62 | 'Ascending', 63 | 'Indented', 64 | 'Hollow' 65 | ] 66 | 67 | const CONVEX = [ 68 | 'Extened', 69 | 'Expanded', 70 | 'Bulbous', 71 | 'Inflated', 72 | 'Bloated', 73 | 'Protuberant', 74 | 'Bulged' 75 | ] 76 | const PLANAR = ['Smooth', 'Flattened', 'Planar', 'Eased'] 77 | const STEEP = ['Steep', 'Scarped', 'Precipitous', 'Vertiginous', 'Sheer'] 78 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@create-figma-plugin/tsconfig", 3 | "compilerOptions": { 4 | "typeRoots": ["node_modules/@figma", "node_modules/@types"] 5 | }, 6 | "include": ["src/**/*.ts", "src/**/*.tsx"] 7 | } 8 | --------------------------------------------------------------------------------