├── .gitignore ├── .stylelintrc ├── LICENSE ├── README.md ├── media ├── button.svg └── hero.png ├── package-lock.json ├── package.json ├── src ├── components │ ├── Preview │ │ ├── index.tsx │ │ ├── item.tsx │ │ └── style.css │ ├── Slider │ │ ├── index.tsx │ │ └── style.css │ └── index.ts ├── main.ts ├── style.css ├── types.d.ts ├── ui.tsx ├── utils │ ├── debounce.ts │ ├── error.ts │ ├── node.ts │ ├── selection.ts │ └── transform.ts └── vars.css └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | *.log 4 | *.css.d.ts 5 | build/ 6 | node_modules/ 7 | manifest.json 8 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@zilahir/stylelint-config-rational-order" 4 | ] 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Create radial interfaces and patterns in Figma with instantiated, rotated copies of elements. 4 | 5 | [](https://www.figma.com/community/plugin/1003651932772848456/Copy-%26-Rotate) 6 | 7 | ## 🚧 Development 8 | 9 | 1. `npm i` — Install dependencies 10 | 1. `npm run watch` — Bundle the plugin and watch for changes 👁️ 11 | 12 | ## 🌀 Misc 13 | 14 | This plugin uses the amazing [create-figma-plugin](https://github.com/yuanqing/create-figma-plugin) library. 15 | 16 | ## 📝 License 17 | 18 | [MIT](LICENSE) 19 | -------------------------------------------------------------------------------- /media/button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /media/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwidua/figma-copy-and-rotate/d28ed169acb3a7dcc19dc98ba00ffc32268e88ea/media/hero.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-copy-and-rotate", 3 | "version": "1.0.0", 4 | "description": "Create rotated copies of Figma nodes.", 5 | "keywords": [ 6 | "figma-copy-and-rotate", 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 | "preact": "^10.5.14" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git://github.com/alexwidua/figma-copy-and-rotate" 21 | }, 22 | "engines": { 23 | "node": ">=14" 24 | }, 25 | "devDependencies": { 26 | "@create-figma-plugin/build": "^1.2.2", 27 | "@create-figma-plugin/tsconfig": "^1.2.2", 28 | "@figma/plugin-typings": "^1", 29 | "@zilahir/stylelint-config-rational-order": "^0.1.5", 30 | "typescript": "^4" 31 | }, 32 | "scripts": { 33 | "build": "build-figma-plugin --typecheck --minify", 34 | "watch": "build-figma-plugin --typecheck --watch" 35 | }, 36 | "figma-plugin": { 37 | "id": "1003651932772848456", 38 | "name": "Copy & Rotate", 39 | "main": "src/main.ts", 40 | "ui": "src/ui.tsx" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Preview/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file UI preview that previews the current rotation. The preview is interactable 3 | * and allows the user to skip instances by clicking on the individual items. 4 | * The preview is naïve in that sense that is just represents the boundaries of a 5 | * node, ex. a triangle node will still be displayed as a square. 6 | */ 7 | 8 | import { h } from 'preact' 9 | import style from './style.css' 10 | import { baseDeg } from '../../utils/transform' 11 | import Item from './item' 12 | 13 | const Preview = ({ 14 | uiWidth, 15 | selectionState, 16 | selectionWidth, 17 | selectionHeight, 18 | selectionRotation, 19 | selectionType, 20 | numItems, 21 | radius, 22 | skipSelect, 23 | skipSpecific, 24 | skipEvery, 25 | alignRadially, 26 | isSweeping, 27 | sweepAngle, 28 | showRadiusHelper, 29 | showNumBadge, 30 | onInstanceClick, 31 | children 32 | }: PreviewProps) => { 33 | const previewPadding: number = 60 34 | 35 | const length: number = numItems 36 | const width: LayoutMixin['width'] = selectionWidth 37 | const height: LayoutMixin['height'] = selectionHeight 38 | const isWiderOrSquare: boolean = width >= height 39 | const diameter: number = radius * 2 40 | 41 | // Scale items down if item size + radius exceed preview container bounds 42 | const factor: number = isWiderOrSquare 43 | ? (width * 2 + diameter) / (uiWidth - previewPadding) 44 | : (height * 2 + diameter) / (uiWidth - previewPadding) 45 | 46 | // Proportional height, width and radius 47 | const propHeight: number = height / factor 48 | const propWidth: number = width / factor 49 | const propRadius: number = radius / factor 50 | const d = propRadius * 2 51 | 52 | /** 53 | * Map items radially 54 | */ 55 | const circle = Array.from({ length }, (e, i) => { 56 | // We subtract 1 from numItems to account for the sweep offset, see ./Slider 57 | const deg: number = baseDeg + (sweepAngle / (numItems - 1)) * i 58 | const rad: number = deg * (Math.PI / 180) 59 | 60 | // Normalize shape if item is oblongular 61 | const diff: number = Math.abs(propWidth - propHeight) 62 | const normalizeShape: number = 63 | propWidth >= propHeight 64 | ? -((diff / 2) * Math.cos(Math.abs(deg) * (Math.PI / 180))) 65 | : (diff / 2) * Math.cos(Math.abs(deg) * (Math.PI / 180)) 66 | 67 | const normRadian: number = selectionRotation * (Math.PI / 180) 68 | const normalizeRadius: number = 69 | propWidth === propHeight 70 | ? 0 71 | : propWidth > propHeight 72 | ? (-diff / 2) * Math.sin(Math.abs(normRadian)) 73 | : (diff / 2) * Math.sin(Math.abs(normRadian)) 74 | 75 | const x: number = 76 | (propRadius + propWidth / 2 - normalizeRadius) * Math.cos(rad) + 77 | (d + propWidth) / 2 - 78 | propWidth / 2 + 79 | normalizeShape 80 | 81 | const y: number = 82 | (propRadius + propHeight / 2 - normalizeRadius) * Math.sin(rad) + 83 | (d + propHeight) / 2 - 84 | propHeight / 2 85 | 86 | return ( 87 | onInstanceClick(i)} 104 | /> 105 | ) 106 | }) 107 | 108 | const inlineWrapper: h.JSX.CSSProperties = { 109 | width: uiWidth, 110 | height: uiWidth 111 | } 112 | 113 | const inlineContainer: h.JSX.CSSProperties = { 114 | height: d + propHeight, 115 | width: d + propWidth, 116 | pointerEvents: isSweeping ? 'none' : 'all' 117 | } 118 | 119 | const inlineCircumference: h.JSX.CSSProperties = { 120 | height: isWiderOrSquare ? d + propWidth : d + propHeight, 121 | width: isWiderOrSquare ? d + propWidth : d + propHeight 122 | } 123 | 124 | const inlineRadius: h.JSX.CSSProperties = { 125 | opacity: showRadiusHelper ? 1 : 0 126 | } 127 | 128 | const inlineDistance: h.JSX.CSSProperties = { 129 | height: `${radius / factor}px`, 130 | top: `${(radius / factor) * -1}px` 131 | } 132 | 133 | return ( 134 |
135 | {children} 136 |
137 | {circle} 138 |
139 |
140 | {radius} 141 | 142 |
143 |
144 |
145 | ) 146 | } 147 | 148 | export default Preview 149 | -------------------------------------------------------------------------------- /src/components/Preview/item.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file The item component is part of the UI preview and represents a node 3 | * that gets mapped radially. Each item holds different visual states, such as 4 | * being highlighted, being skipped or being heighlighted when the radius is changed. 5 | * Must be a child of ./Preview. 6 | */ 7 | 8 | import { h } from 'preact' 9 | import style from './style.css' 10 | 11 | interface ItemProps extends Partial { 12 | index: number 13 | x: number 14 | y: number 15 | itemHeight: number 16 | itemWidth: number 17 | angle: number 18 | elevateClick: Function 19 | } 20 | 21 | const Item = ({ 22 | index, 23 | x, 24 | y, 25 | itemHeight, 26 | itemWidth, 27 | angle, 28 | selectionState, 29 | selectionRotation, 30 | selectionType, 31 | skipSelect, 32 | skipSpecific = [-1], 33 | skipEvery = 0, 34 | alignRadially, 35 | showRadiusHelper, 36 | showNumBadge, 37 | elevateClick 38 | }: ItemProps) => { 39 | const isValidSelection: boolean = selectionState === 'VALID' 40 | const rotation: LayoutMixin['rotation'] = selectionRotation || 0 41 | const isSkipped: boolean = 42 | (skipSelect === 'EVERY' && skipEvery && !((index + 1) % skipEvery)) || 43 | (skipSelect === 'SPECIFIC' && skipSpecific.includes(index + 1)) 44 | 45 | // Styles 46 | const inlineItem: h.JSX.CSSProperties = { 47 | width: itemWidth, 48 | height: itemHeight, 49 | top: y, 50 | left: x, 51 | transform: `rotate(${ 52 | alignRadially ? angle + (rotation + 90) * -1 : rotation * -1 53 | }deg)`, 54 | borderRadius: 55 | selectionType === 'ELLIPSE' ? '100%' : 'var(--border-radius-2)' 56 | } 57 | 58 | const inlineIndexBadge: h.JSX.CSSProperties = { 59 | transform: `rotate(${ 60 | alignRadially ? angle * -1 + rotation + 90 : 90 61 | }deg)` 62 | } 63 | 64 | return ( 65 |
elevateClick()} 67 | class={`${style.item} 68 | ${index === 0 && style.isInitial} 69 | ${isSkipped && style.isSkipped} 70 | ${showRadiusHelper && style.isRadiusHighlight} 71 | ${showNumBadge! > 0 && style.showIndex} 72 | 73 | `} 74 | style={inlineItem}> 75 | 78 | {index + 1} 79 | 80 |
81 | ) 82 | } 83 | 84 | export default Item 85 | -------------------------------------------------------------------------------- /src/components/Preview/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Preview wrapper component 3 | * Class names are camcelCased because hyphenated class names are ignored? Bug? 4 | */ 5 | .wrapper { 6 | position: relative; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | background-color: var(--local-color-container-bg); 11 | } 12 | 13 | .container { 14 | position: relative; 15 | display: block; 16 | background: var(--local-color-container-bg); 17 | } 18 | 19 | /** 20 | * Radius helper 21 | */ 22 | .radiusContainer { 23 | position: absolute; 24 | top: 50%; 25 | left: 50%; 26 | display: block; 27 | transform: translateX(-50%) translateY(-50%); 28 | } 29 | 30 | .distanceLine { 31 | position: absolute; 32 | width: 0.1px; 33 | background: var(--color-red); 34 | } 35 | 36 | .radiusBadge { 37 | position: absolute; 38 | top: 50%; 39 | left: var(--space-extra-small); 40 | padding: 2px 4px; 41 | color: var(--local-color-white); 42 | background: var(--color-red); 43 | border-radius: var(--local-border-radius-3); 44 | transform: translateY(-50%); 45 | } 46 | 47 | .originIcon { 48 | position: absolute; 49 | bottom: calc(var(--local-space-extra-extra-small) / -2); 50 | left: calc(var(--local-space-extra-extra-small) / -2); 51 | width: var(--local-space-extra-extra-small); 52 | height: var(--local-space-extra-extra-small); 53 | } 54 | 55 | .originIcon::before, 56 | .originIcon::after { 57 | position: absolute; 58 | top: 0; 59 | left: calc(var(--local-space-extra-extra-small) / 2); 60 | display: block; 61 | width: 1px; 62 | height: 100%; 63 | background: var(--color-red); 64 | content: ''; 65 | } 66 | 67 | .originIcon::before { 68 | transform: rotate(45deg); 69 | } 70 | 71 | .originIcon::after { 72 | transform: rotate(-45deg); 73 | } 74 | 75 | /** 76 | * Item component 77 | */ 78 | .item { 79 | position: absolute; 80 | display: flex; 81 | align-items: center; 82 | justify-content: center; 83 | background: var(--local-color-item-fill-60); 84 | border: 1px dashed var(--computed-color-accent); 85 | box-shadow: var(--local-box-shadow); 86 | transition: box-shadow 0.2s, border-radius 0.2s; 87 | } 88 | 89 | .item:hover { 90 | z-index: 100; 91 | box-shadow: var(--local-box-shadow-hover); 92 | cursor: pointer; 93 | } 94 | 95 | .item:hover .indexBadge { 96 | opacity: 1 !important; 97 | } 98 | 99 | .item.isInitial { 100 | background: var(--local-color-item-fill-100); 101 | /* --computed-color-accent is set via inline styles in ./ui.tsx */ 102 | border: 1px solid var(--computed-color-accent); 103 | } 104 | 105 | .item.isInitial.isRadiusHighlight { 106 | border: var(--local-item-border-radius) !important; 107 | } 108 | 109 | .item.isInitial:hover { 110 | z-index: 0 !important; 111 | box-shadow: var(--local-box-shadow); 112 | } 113 | 114 | .item.isInitial .indexBadge { 115 | color: var(--computed-color-accent); 116 | background: #fff !important; 117 | border: 1px solid var(--computed-color-accent); 118 | } 119 | 120 | .item.isSkipped { 121 | background: none; 122 | border: var(--local-item-border-deselected); 123 | } 124 | 125 | .item.isRadiusHighlight { 126 | border: var(--local-item-border-radius); 127 | } 128 | 129 | .item.showIndex .indexBadge { 130 | opacity: 1; 131 | } 132 | 133 | .indexBadge { 134 | display: block; 135 | width: var(--space-medium); 136 | min-width: var(--space-medium); 137 | height: var(--space-medium); 138 | min-height: var(--space-medium); 139 | color: var(--local-color-white); 140 | text-align: center; 141 | background: var(--computed-color-accent); 142 | border-radius: 100%; 143 | cursor: pointer; 144 | opacity: 0; 145 | } 146 | 147 | .indexBadge.isSkipped { 148 | background: var(--local-color-item-inactive); 149 | } 150 | 151 | .container:hover .indexBadge { 152 | opacity: 1; 153 | } 154 | -------------------------------------------------------------------------------- /src/components/Slider/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Radial slider that allows the user to change the sweep angle of the 3 | * current rotation. 4 | * Contrary to what you might expect, the slider does not rotate 360 degrees. 5 | * The reason is that technically the circle is not rotated 360 degrees, 6 | * but 360 - (360/ { 16 | // We subtract the aforementioned offset to limit the rotation of the slider 17 | const offset: number = 360 / numItems 18 | 19 | const slider = useRef(null) 20 | const [isMouseDown, setIsMouseDown] = useState(false) 21 | const [sweepAngle, setSweepAngle] = useState(360 - offset) 22 | 23 | const sin: number = -Math.sin(sweepAngle * (Math.PI / 180)) 24 | const cos: number = -Math.cos(sweepAngle * (Math.PI / 180)) 25 | 26 | /** 27 | * Hooks 28 | */ 29 | 30 | const sweepPercentage: string = useMemo(() => { 31 | return ((sweepAngle / (360 - offset)) * 100).toFixed(1) 32 | }, [sweepAngle]) 33 | 34 | useEffect(() => { 35 | // Preserve sweep percentage after changing num items 36 | const temp: number = 360 - offset 37 | const preserve: number = 38 | temp - temp * ((100 - parseInt(sweepPercentage)) / 100) 39 | 40 | setSweepAngle(preserve) 41 | }, [numItems]) 42 | 43 | useEffect(() => { 44 | onSweepChange(sweepAngle) 45 | }, [sweepAngle]) 46 | 47 | useEffect(() => { 48 | onSweep(isMouseDown) 49 | }, [isMouseDown]) 50 | 51 | /** 52 | * Styles 53 | */ 54 | const inlineBar: h.JSX.CSSProperties = { 55 | background: ` 56 | conic-gradient( 57 | ${ 58 | isMouseDown 59 | ? 'var(--local-color-accent)' 60 | : 'var(--local-color-container-bg)' 61 | } 0deg, 62 | ${ 63 | isMouseDown 64 | ? 'var(--local-color-accent)' 65 | : 'var(--local-color-container-bg)' 66 | } ${sweepAngle}deg, 67 | var(--local-color-container-bg) ${sweepAngle + 0.01}deg, 68 | var(--local-color-container-bg) 360deg 69 | ) 70 | ` 71 | } 72 | 73 | // Make sure that badge stays centered during rotation 74 | const badgeHeight: number = 20 75 | const paddingLR: number = 4 76 | const inset: number = 20 77 | const inlineBadge: h.JSX.CSSProperties = { 78 | transform: ` 79 | rotate(${-sweepAngle}deg) 80 | translateX(calc(${50 * cos}% + ${inset * sin}px)) 81 | translateY(calc(${inset * sin}% + ${ 82 | (paddingLR + badgeHeight + badgeHeight / 2) * sin 83 | }px)) 84 | `, 85 | left: '50%', 86 | top: `${inset}%`, 87 | height: `${badgeHeight}px`, 88 | lineHeight: `${badgeHeight}px`, 89 | padding: `0px ${paddingLR}px` 90 | } 91 | 92 | const inlineHelper: h.JSX.CSSProperties = { 93 | transform: `rotate(${sweepAngle}deg) translateX(-50%)` 94 | } 95 | 96 | const inlineHandle: h.JSX.CSSProperties = { 97 | transform: `rotate(${sweepAngle}deg) translateX(-50%)` 98 | } 99 | 100 | /** 101 | * Event handlers 102 | */ 103 | function handleMouseDown(): void { 104 | setIsMouseDown(true) 105 | } 106 | 107 | function handleMouseUp(): void { 108 | setIsMouseDown(false) 109 | } 110 | 111 | function handleMouseMove(e: MouseEvent) { 112 | if (isMouseDown) { 113 | const rect = slider.current?.getBoundingClientRect() 114 | 115 | if (rect) { 116 | const origin: Vector = { 117 | x: rect.width / 2 + rect.left, 118 | y: rect.height / 2 + rect.top 119 | } 120 | const absolute: Vector = { 121 | x: origin.x - e.clientX, 122 | y: origin.y - e.clientY 123 | } 124 | let theta: number = 125 | Math.atan2(absolute.y, absolute.x) * (180 / Math.PI) - 90 126 | 127 | // Normalize sweepAngle from -180..180 to 0..360 128 | const normalizeDeg: number = (theta + 360) % 360 129 | const diff: number = Math.abs(normalizeDeg - sweepAngle) 130 | 131 | // Prevent handle from going beyond 0 / 360-offset 132 | if (diff < 180 && normalizeDeg <= 360 - offset) { 133 | //Snap to steps on shiftDown 134 | if (e.shiftKey) { 135 | const steps: number = 10 136 | const snapValue: number = 137 | Math.round(normalizeDeg / steps) * steps 138 | setSweepAngle(snapValue) 139 | } else { 140 | // Snap handle to 360..270..180..90..0 deg 141 | const treshold = 5 142 | if (normalizeDeg < 0 + treshold && normalizeDeg > 0) { 143 | setSweepAngle(0) 144 | } else if ( 145 | normalizeDeg < 360 - offset && 146 | normalizeDeg > 360 - offset - treshold 147 | ) { 148 | setSweepAngle(360 - offset) 149 | } else if ( 150 | normalizeDeg < 270 + treshold && 151 | normalizeDeg > 270 - treshold 152 | ) { 153 | setSweepAngle(270) 154 | } else if ( 155 | normalizeDeg < 180 + treshold && 156 | normalizeDeg > 180 - treshold 157 | ) { 158 | setSweepAngle(180) 159 | } else if ( 160 | normalizeDeg < 90 + treshold && 161 | normalizeDeg > 90 - treshold 162 | ) { 163 | setSweepAngle(90) 164 | } else { 165 | setSweepAngle(Math.round(normalizeDeg)) 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | 173 | return ( 174 |
178 |
179 |
180 |
181 |
185 | 190 | Sweep {sweepPercentage}% 191 | 192 |
193 |
194 |
195 | ) 196 | } 197 | 198 | export default Slider 199 | -------------------------------------------------------------------------------- /src/components/Slider/style.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | --local-basePadding: 8px; 3 | --local-strokeWidth: 10px; 4 | 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | width: 100%; 9 | height: 100%; 10 | padding: var(--local-basePadding); 11 | } 12 | 13 | /* Slider container */ 14 | .slider { 15 | position: relative; 16 | width: 100%; 17 | height: 100%; 18 | } 19 | 20 | /* Radial progress bar */ 21 | .bar { 22 | width: 100%; 23 | height: 100%; 24 | border: 4px solid var(--local-color-container-bg); 25 | border-radius: 100%; 26 | } 27 | .bar::after { 28 | position: absolute; 29 | top: 50%; 30 | left: 50%; 31 | width: calc(100% - var(--local-strokeWidth)); 32 | height: calc(100% - var(--local-strokeWidth)); 33 | background: var(--local-color-container-bg); 34 | border-radius: 100%; 35 | transform: translateX(-50%) translateY(-50%); 36 | content: ''; 37 | } 38 | 39 | .handle { 40 | position: absolute; 41 | top: 0; 42 | left: 50%; 43 | z-index: 9999; 44 | width: var(--space-extra-large); 45 | height: 50%; 46 | transform-origin: left bottom; 47 | cursor: pointer; 48 | pointer-events: none; 49 | } 50 | 51 | .handle::after { 52 | position: absolute; 53 | top: 0px; 54 | left: 50%; 55 | display: block; 56 | width: var(--space-extra-small); 57 | height: var(--space-extra-small); 58 | background: var(--local-color-white); 59 | border: 1px solid var(--local-color-accent); 60 | border-radius: 100%; 61 | transform: translateX(-50%); 62 | content: ''; 63 | pointer-events: all; 64 | } 65 | 66 | .handle:hover .badge { 67 | opacity: 1; 68 | } 69 | 70 | /* Connects the handle to the last item of the circle */ 71 | .helper { 72 | position: absolute; 73 | top: 0; 74 | left: calc(50% - 1px); 75 | width: 2px; 76 | height: 50%; 77 | border-left: 0.1px dashed var(--local-color-container-assistive); 78 | transform-origin: left bottom; 79 | cursor: pointer; 80 | pointer-events: none; 81 | } 82 | 83 | /* Badge that indicates the sweep percentage while sweeping */ 84 | .badge { 85 | position: absolute; 86 | color: var(--local-color-white); 87 | white-space: nowrap; 88 | background: var(--local-color-accent); 89 | border-radius: var(--local-border-radius-3); 90 | opacity: 0; 91 | pointer-events: none; 92 | } 93 | 94 | .badgeActive { 95 | opacity: 1; 96 | } 97 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import Preview from './Preview' 2 | import Slider from './Slider' 3 | 4 | export { Preview, Slider } 5 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | on, 3 | once, 4 | emit, 5 | showUI, 6 | insertAfterNode, 7 | insertBeforeNode, 8 | collapseLayer 9 | } from '@create-figma-plugin/utilities' 10 | import { instantiateAndRotate } from './utils/transform' 11 | import { createComponentInPlace } from './utils/node' 12 | import { validateSelection } from './utils/selection' 13 | import { handleErrorNotification } from './utils/error' 14 | 15 | export default function () { 16 | /** 17 | * Initial data that is sent to UI on plugin startup 18 | */ 19 | const ui: UISettings = { width: 280, height: 538 } 20 | const initialData: { selection: SelectionProperties; ui: UISettings } = { 21 | selection: { 22 | width: figma.currentPage.selection[0]?.width || 100, 23 | height: figma.currentPage.selection[0]?.height || 100, 24 | rotation: figma.currentPage.selection[0]?.rotation || 0, 25 | type: 'RECTANGLE' 26 | }, 27 | ui 28 | } 29 | 30 | // Internal plugin state 31 | let FLAG_TRANSFORM_SUCCESS = false 32 | let FLAG_SHOW_PREVIEW = true 33 | 34 | let state: TransformOptions = { 35 | numItems: 8, 36 | radius: 50, 37 | skipSelect: 'SPECIFIC', 38 | skipSpecific: [], 39 | skipEvery: 0, 40 | alignRadially: true, 41 | sweepAngle: 315 42 | } 43 | 44 | // Carbon copy of the selected node, is used to restore node on deselect or plugin close 45 | let selectionRef: SceneNode | undefined 46 | // Holds the group of instances used for the in-canvas/live preview 47 | let groupRef: GroupNode | undefined 48 | // Holds the componentized selected node, is discarded on deselect/plugin close 49 | let componentRef: ComponentNode | undefined 50 | 51 | /** 52 | * Handles selection changes and instructs circle (re)-renders if preview is enabled. 53 | */ 54 | function handleSelectionChange(): void { 55 | let msg: SelectionMessage 56 | const str: SelectionState = validateSelection( 57 | figma.currentPage.selection 58 | ) 59 | console.log(str) 60 | 61 | if (FLAG_SHOW_PREVIEW) { 62 | if ( 63 | str.match( 64 | /^(EMPTY|INVALID|HAS_COMPONENT_CHILD|IS_WITHIN_COMPONENT|IS_WITHIN_INSTANCE|MULTIPLE)$/ 65 | ) 66 | ) { 67 | removeRefs() 68 | } else { 69 | if (selectionRef && componentRef && groupRef) { 70 | // Handle user trying to select preview group via layer list 71 | if (figma.currentPage.selection[0].id === groupRef.id) { 72 | insertAfterNode(selectionRef, groupRef) 73 | } 74 | // Handle user trying to select preview component via layer list 75 | else if ( 76 | figma.currentPage.selection[0].id === componentRef.id 77 | ) { 78 | insertBeforeNode(componentRef, selectionRef) 79 | } 80 | // Handle if user selects a different node without clearing the selection 81 | else { 82 | removeRefs() 83 | componentizeNode(figma.currentPage.selection[0]) 84 | updateCanvasPreview() 85 | } 86 | } else { 87 | componentizeNode(figma.currentPage.selection[0]) 88 | updateCanvasPreview() 89 | } 90 | } 91 | msg = { 92 | state: str, 93 | properties: { 94 | width: componentRef?.width, 95 | height: componentRef?.height, 96 | rotation: componentRef?.rotation, 97 | type: figma.currentPage.selection[0]?.type 98 | } 99 | } 100 | } else { 101 | msg = { 102 | state: str, 103 | properties: { 104 | width: figma.currentPage.selection[0]?.width, 105 | height: figma.currentPage.selection[0]?.height, 106 | rotation: figma.currentPage.selection[0]?.rotation, 107 | type: figma.currentPage.selection[0]?.type 108 | } 109 | } 110 | } 111 | emit('EMIT_SELECTION_CHANGE_TO_UI', msg) 112 | } 113 | 114 | /** 115 | * Componentizes the supplied node (should be current selection). 116 | * This is required because the InCanvasPreview expects a ComponentNode. 117 | * @param selection 118 | * @returns - Returns NotificationHandler on error. 119 | */ 120 | function componentizeNode( 121 | selection: SceneNode 122 | ): NotificationHandler | undefined { 123 | if (!selection) { 124 | return figma.notify('Something went wrong with the selection.') 125 | } 126 | selectionRef = selection.clone() 127 | insertAfterNode(selectionRef, selection) 128 | selectionRef.visible = false 129 | 130 | if (selection.type === 'COMPONENT') { 131 | componentRef = selection 132 | } else { 133 | componentRef = createComponentInPlace(selection) 134 | } 135 | componentRef.name = 'Preview' 136 | insertAfterNode(componentRef, selectionRef) 137 | } 138 | 139 | /** 140 | * Updates the in canvas preview after selection or UI input changes. 141 | */ 142 | function updateCanvasPreview(): void { 143 | if (!selectionRef || !componentRef) { 144 | return console.error( 145 | `Couldn't update transformation. References are missing.` 146 | ) 147 | } 148 | const circle: Array = instantiateAndRotate( 149 | componentRef, 150 | state.numItems, 151 | state.radius, 152 | state.alignRadially, 153 | state.sweepAngle 154 | ) 155 | const parent = componentRef.parent || figma.currentPage 156 | groupRef = figma.group(circle, parent) 157 | insertAfterNode(groupRef, selectionRef) 158 | collapseLayer(groupRef) 159 | 160 | // Account for offset caused by grouping 161 | const alignX: number = componentRef.x - circle[0].x 162 | const alignY: number = componentRef.y - circle[0].y 163 | groupRef.x = groupRef.x + alignX 164 | groupRef.y = groupRef.y + alignY 165 | 166 | setPreviewProperties(true) 167 | 168 | if (state.skipSpecific.length || state.skipEvery > 1) { 169 | if (state.skipSelect === 'SPECIFIC') { 170 | groupRef.children.forEach((el, i) => { 171 | if (state.skipSpecific.includes(i + 1)) { 172 | el.remove() 173 | } 174 | }) 175 | } else if (state.skipSelect === 'EVERY') { 176 | groupRef.children.forEach((el, i) => { 177 | if (i === 0) return 178 | else if ((i + 1) % state.skipEvery == 0) { 179 | el.remove() 180 | } 181 | }) 182 | } 183 | } 184 | } 185 | 186 | /** 187 | * Applies the in canvas preview by appending componentRef to groupRef 188 | * and doing some additional cleanup. 189 | */ 190 | function applyTransformation(): void { 191 | if (!FLAG_SHOW_PREVIEW) { 192 | componentizeNode(figma.currentPage.selection[0]) 193 | updateCanvasPreview() 194 | } 195 | 196 | if (!selectionRef || !groupRef || !componentRef) { 197 | return console.error( 198 | `Couldn't apply transformation. References are missing.` 199 | ) 200 | } 201 | 202 | // Replace the first instance and append the component to the group 203 | const getFirstChild: SceneNode = groupRef.children[0] 204 | componentRef.rotation = getFirstChild.rotation 205 | componentRef.x = getFirstChild.x 206 | componentRef.y = getFirstChild.y 207 | componentRef.name = selectionRef.name 208 | getFirstChild.remove() 209 | groupRef.insertChild(0, componentRef) 210 | 211 | // Discard our backup carbon copy and change the visual properties of groupRef 212 | selectionRef.remove() 213 | setPreviewProperties(false) 214 | 215 | // Set flag to avoid preview cleanup on close 216 | FLAG_TRANSFORM_SUCCESS = true 217 | figma.closePlugin() 218 | } 219 | 220 | function handleClose(): void { 221 | if (FLAG_TRANSFORM_SUCCESS) return 222 | else if (selectionRef && groupRef && componentRef) { 223 | removeRefs() 224 | } 225 | } 226 | 227 | /** 228 | * Re-renders the circle on UI input change. 229 | */ 230 | function handleUpdateFromUI(data: any): void { 231 | state = { ...state, ...data } 232 | if (groupRef && componentRef) { 233 | groupRef.remove() 234 | groupRef = undefined 235 | updateCanvasPreview() 236 | } 237 | } 238 | 239 | /** 240 | * Enable or disable the in canvas live preview. 241 | * @param value - Checkbox state emitted from the UI window 242 | */ 243 | function handlePreviewChange(value: boolean): void { 244 | const str: SelectionState = validateSelection( 245 | figma.currentPage.selection 246 | ) 247 | if (str === 'VALID' && !FLAG_SHOW_PREVIEW) { 248 | componentizeNode(figma.currentPage.selection[0]) 249 | updateCanvasPreview() 250 | } else { 251 | removeRefs() 252 | } 253 | FLAG_SHOW_PREVIEW = value 254 | } 255 | 256 | /** 257 | * Utility function that toggles the group refs visual properties. 258 | * @param isPreview 259 | */ 260 | function setPreviewProperties(isPreview: boolean): void { 261 | if (!groupRef) { 262 | return console.error( 263 | `Couldn't set groupRef preview properties. Reference is missing.` 264 | ) 265 | } 266 | groupRef.opacity = isPreview ? 0.3 : 1 267 | groupRef.locked = isPreview ? true : false 268 | groupRef.name = isPreview 269 | ? '[Preview] Rotated Instances' 270 | : 'Rotated Instances' 271 | } 272 | 273 | /** 274 | * Utility function that removes and unbinds referenced nodes. 275 | */ 276 | function removeRefs(): void { 277 | if (!selectionRef || !groupRef || !componentRef) { 278 | return 279 | } 280 | groupRef.remove() 281 | componentRef.remove() 282 | selectionRef.visible = true 283 | groupRef = undefined 284 | componentRef = undefined 285 | selectionRef = undefined 286 | } 287 | 288 | /** 289 | * Event listeners 290 | */ 291 | on('APPLY_TRANSFORMATION', applyTransformation) 292 | on('EMIT_INPUT_TO_PLUGIN', handleUpdateFromUI) 293 | on('EMIT_PREVIEW_CHANGE_TO_PLUGIN', handlePreviewChange) 294 | on('UI_ERROR', handleErrorNotification) 295 | once('EMIT_UI_READY_TO_PLUGIN', handleSelectionChange) 296 | figma.on('selectionchange', handleSelectionChange) 297 | figma.on('close', handleClose) 298 | 299 | /** 300 | * Run plugin, run 301 | */ 302 | showUI(ui, initialData) 303 | if (figma.currentPage.selection.length) { 304 | const selection = figma.currentPage.selection[0] 305 | state = { ...state, radius: (selection.width + selection.height) / 4 } 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | .checkboxContainer { 2 | display: flex; 3 | justify-content: flex-end; 4 | margin-top: calc(var(--space-small) * -1); 5 | padding: var(--space-small); 6 | background: var(--local-color-container-bg); 7 | } 8 | 9 | .checkboxContainer .checkbox { 10 | display: inline-block; 11 | padding: var(--space-extra-small); 12 | background: #fff; 13 | border-radius: var(--local-border-radius-3); 14 | box-shadow: var(--local-box-shadow-subtle); 15 | transition: box-shadow 0.2s; 16 | } 17 | 18 | .checkboxContainer .checkbox:hover { 19 | box-shadow: var(--local-box-shadow); 20 | } 21 | 22 | /* Wrap the ui library's component to display a 'clear' icon after value input */ 23 | .textboxContainer { 24 | --local-icon-stroke-width: 1px; 25 | --local-icon-size: var(--space-small); 26 | --local-icon-padding-right: var(--space-extra-small); 27 | 28 | position: relative; 29 | width: 100%; 30 | height: 100%; 31 | } 32 | 33 | .textboxClear { 34 | position: absolute; 35 | top: 50%; 36 | right: var(--local-icon-padding-right); 37 | z-index: 100; 38 | width: var(--local-icon-size); 39 | height: var(--local-icon-size); 40 | background: var(--local-color-accent); 41 | border-radius: 100%; 42 | transform: translateY(-50%); 43 | cursor: pointer; 44 | } 45 | 46 | .textboxClear::before { 47 | position: absolute; 48 | top: 50%; 49 | left: 50%; 50 | width: var(--local-icon-stroke-width); 51 | height: calc(var(--local-icon-size) / 2); 52 | background: #fff; 53 | transform: translateY(-50%) translateX(-50%) rotate(-45deg); 54 | content: ''; 55 | } 56 | 57 | .textboxClear::after { 58 | position: absolute; 59 | top: 50%; 60 | left: 50%; 61 | width: var(--local-icon-stroke-width); 62 | height: calc(var(--local-icon-size) / 2); 63 | background: #fff; 64 | transform: translateY(-50%) translateX(-50%) rotate(45deg); 65 | content: ''; 66 | } 67 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Data that is applied to the plugin's UI window. 3 | */ 4 | interface UISettings { 5 | readonly height: number 6 | readonly width: number 7 | } 8 | 9 | /** 10 | * Message that is emitted to the UI containing the current selection's state 11 | * and properties to update the UI preview. 12 | */ 13 | interface SelectionMessage { 14 | state: SelectionState 15 | properties: SelectionProperties 16 | } 17 | 18 | /** 19 | * State of the current selection, see: ./utils/selection.ts 20 | */ 21 | type SelectionState = 22 | | 'MULTIPLE' 23 | | 'VALID' 24 | | 'INVALID' 25 | | 'HAS_COMPONENT_CHILD' 26 | | 'IS_WITHIN_COMPONENT' 27 | | 'IS_WITHIN_INSTANCE' 28 | | 'EMPTY' 29 | 30 | /** 31 | * Selection properties that are emitted to the UI to update the UI preview. 32 | */ 33 | type SelectionProperties = { 34 | readonly width: LayoutMixin['width'] | undefined 35 | readonly height: LayoutMixin['height'] | undefined 36 | readonly rotation: LayoutMixin['rotation'] | undefined 37 | readonly type: NodeType | undefined 38 | } 39 | 40 | /** 41 | * Displays button text that corresponds to the current SelecionState 42 | */ 43 | type SelectionStateMap = { [type in SelectionState]: string } 44 | 45 | /** 46 | * Options for the UI's skip dropdown menu. 47 | * 'SPECIFIC' refers to 'Skip (specific) instances', 'EVERY' to 'Skip every (Nth)' 48 | */ 49 | type SkipType = 'SPECIFIC' | 'EVERY' 50 | 51 | /** 52 | * Transform options that are emitted to the plugin and instruct the radial transformation. 53 | */ 54 | type TransformOptions = { 55 | readonly numItems: number 56 | readonly radius: number 57 | readonly skipSelect: SkipType 58 | readonly skipSpecific: Array 59 | readonly skipEvery: number 60 | readonly alignRadially: boolean 61 | readonly sweepAngle: number 62 | } 63 | 64 | /** 65 | * Interface for the preview component, which is the visual preview and control in the UI window. 66 | */ 67 | interface PreviewProps extends TransformOptions { 68 | readonly uiWidth: number 69 | readonly selectionState: SelectionState 70 | readonly selectionHeight: number 71 | readonly selectionWidth: number 72 | readonly selectionRotation: number 73 | readonly selectionType: NodeType 74 | readonly alignRadially: boolean 75 | readonly isSweeping: boolean 76 | readonly showRadiusHelper: boolean 77 | readonly showNumBadge: number 78 | readonly onInstanceClick: Function 79 | readonly children: import('preact').ComponentChildren 80 | } 81 | 82 | /** 83 | * Interface for the slider component, which controls the circle's sweep. 84 | */ 85 | interface SliderProp { 86 | readonly onSweepChange: Function 87 | readonly onSweep: Function 88 | readonly numItems: number 89 | } 90 | 91 | /** 92 | * Plugin error message that is displayed to the user. 93 | */ 94 | type PluginError = 'CANT_SKIP_FIRST_INDEX' | 'CANT_SKIP_ALL' 95 | /** 96 | * Maps the error message to a more human-readable text message. 97 | */ 98 | type PluginErrorMap = { [type in PluginError]: string } 99 | -------------------------------------------------------------------------------- /src/ui.tsx: -------------------------------------------------------------------------------- 1 | import { h, JSX } from 'preact' 2 | import { useState, useEffect, useCallback } from 'preact/hooks' 3 | import { on, emit } from '@create-figma-plugin/utilities' 4 | import { 5 | render, 6 | Container, 7 | Columns, 8 | Divider, 9 | VerticalSpace, 10 | Text, 11 | Button, 12 | Textbox, 13 | TextboxNumeric, 14 | Dropdown, 15 | DropdownOption, 16 | Checkbox 17 | } from '@create-figma-plugin/ui' 18 | import { Preview, Slider } from './components' 19 | import { debounce } from './utils/debounce' 20 | import './vars.css' 21 | import style from './style.css' 22 | 23 | const Plugin = ({ selection, ui }: any) => { 24 | /** 25 | * UI options 26 | */ 27 | const skipSelectOptions: Array = [ 28 | { children: 'Skip instances', value: 'SPECIFIC' }, 29 | { children: 'Skip every', value: 'EVERY' } 30 | ] 31 | const buttonMap: SelectionStateMap = { 32 | EMPTY: 'No element selected', 33 | INVALID: '❌ Element type not supported', 34 | HAS_COMPONENT_CHILD: `Can't copy groups containing components`, 35 | IS_WITHIN_COMPONENT: 'Select the parent component', 36 | IS_WITHIN_INSTANCE: 'Select the parent instance', 37 | MULTIPLE: 'Group multiple elements before rotation', 38 | VALID: 'Apply rotation' 39 | } 40 | const initSelection: SelectionProperties = { 41 | height: selection.height, 42 | width: selection.width, 43 | rotation: selection.rotation, 44 | type: selection.type 45 | } 46 | const adaptiveRadius: number = (selection.width + selection.height) / 4 47 | const radiusToString: string = adaptiveRadius.toFixed(0) 48 | 49 | /** 50 | * States 51 | */ 52 | 53 | // UI exposed states 54 | const [numItems, setNumItems] = useState('8') 55 | const [radius, setRadius] = useState(radiusToString) 56 | const [skipSelect, setSkipSelect] = useState('SPECIFIC') 57 | const [skipSpecific, setSkipSpecific] = useState('') 58 | const [skipEvery, setSkipEvery] = useState('') 59 | const [alignRadially, setAlignRadially] = useState(true) 60 | const [inCanvasPreview, setInCanvasPreview] = useState(true) 61 | const [sweepAngle, setSweepAngle] = useState(360) 62 | 63 | // Internal states 64 | const [selectionProps, setSelectionProps] = 65 | useState(initSelection) 66 | const [selectionState, setSelectionState] = 67 | useState('EMPTY') 68 | const [showRadiusHelper, setshowRadiusHelper] = useState(false) 69 | const [showNumBadge, setShowNumBadge] = useState(0) 70 | const [isSweeping, setIsSweeping] = useState(false) 71 | 72 | useEffect(() => { 73 | on('EMIT_SELECTION_CHANGE_TO_UI', handleSelectionChange) 74 | emit('EMIT_UI_READY_TO_PLUGIN') 75 | }, []) 76 | 77 | /** 78 | * Input handlers 79 | */ 80 | 81 | function handleNumItemsInput(e: JSX.TargetedEvent): void { 82 | const value = e.currentTarget.value 83 | if (parseInt(value) > 1 && parseFloat(value) % 1 == 0) { 84 | setNumItems(e.currentTarget.value) 85 | const data: Partial = { 86 | numItems: parseInt(e.currentTarget.value) 87 | } 88 | debounceNumItemsChange(data) 89 | } 90 | } 91 | 92 | function handleRadiusInput(e: JSX.TargetedEvent): void { 93 | const value = e.currentTarget.value 94 | if (parseFloat(value) >= 0) { 95 | setRadius(e.currentTarget.value) 96 | const data: Partial = { 97 | radius: parseInt(e.currentTarget.value) 98 | } 99 | debounceRadiusChange(data) 100 | } 101 | setshowRadiusHelper(true) 102 | } 103 | 104 | function handleSkipMenu(e: JSX.TargetedEvent): void { 105 | setSkipSelect(e.currentTarget.value as SkipType) 106 | const data: Partial = { 107 | skipSelect: e.currentTarget.value as SkipType 108 | } 109 | emit('EMIT_INPUT_TO_PLUGIN', data) 110 | } 111 | 112 | function handleSkipSpecificInput( 113 | e: JSX.TargetedEvent 114 | ): void { 115 | const value = e.currentTarget.value 116 | const map = e.currentTarget.value.split(',').map(Number) 117 | if (map.length > parseInt(numItems) - 2) { 118 | emit('EMIT_INPUT_TO_PLUGIN', { 119 | skipSelect: 'SPECIFIC', 120 | skipSpecific: [] 121 | }) 122 | emitError('CANT_SKIP_ALL') 123 | return setSkipSpecific('') 124 | } 125 | setSkipSpecific(value) 126 | const data: Partial = { 127 | skipSpecific: map 128 | } 129 | emit('EMIT_INPUT_TO_PLUGIN', data) 130 | } 131 | 132 | function handleSkipEveryInput( 133 | e: JSX.TargetedEvent 134 | ): void { 135 | const value = e.currentTarget.value 136 | setSkipEvery(value) 137 | const data: Partial = { 138 | skipEvery: parseInt(e.currentTarget.value) 139 | } 140 | emit('EMIT_INPUT_TO_PLUGIN', data) 141 | } 142 | 143 | function clearSkipInputs() { 144 | setSkipEvery('') 145 | setSkipSpecific('') 146 | const data: Partial = { 147 | skipEvery: 0, 148 | skipSpecific: [] 149 | } 150 | emit('EMIT_INPUT_TO_PLUGIN', data) 151 | } 152 | 153 | function handleAlignRadially(e: JSX.TargetedEvent): void { 154 | const value = e.currentTarget.checked 155 | setAlignRadially(value) 156 | const data: Partial = { 157 | alignRadially: e.currentTarget.checked 158 | } 159 | emit('EMIT_INPUT_TO_PLUGIN', data) 160 | } 161 | 162 | function handleInCanvasPreview( 163 | e: JSX.TargetedEvent 164 | ): void { 165 | const value = e.currentTarget.checked 166 | setInCanvasPreview(value) 167 | emit('EMIT_PREVIEW_CHANGE_TO_PLUGIN', value) 168 | } 169 | 170 | function handleInstanceClick(index: number): void { 171 | // index 0 is the original node and not skippable 172 | if (index === 0) { 173 | return emitError('CANT_SKIP_FIRST_INDEX') 174 | } 175 | let map: Array = [] 176 | if (skipSpecific) { 177 | map = skipSpecific.split(',').map(Number) 178 | } 179 | if (map.length > parseInt(numItems) - 3) { 180 | emit('EMIT_INPUT_TO_PLUGIN', { 181 | skipSelect: 'SPECIFIC', 182 | skipSpecific: [] 183 | }) 184 | emitError('CANT_SKIP_ALL') 185 | return setSkipSpecific('') 186 | } 187 | const isAlreadySelected = map.indexOf(index + 1) 188 | if (isAlreadySelected > -1) { 189 | map.splice(isAlreadySelected, 1) 190 | } else { 191 | map.push(index + 1) 192 | } 193 | const stringified: string = map.toString() 194 | setSkipSelect('SPECIFIC') 195 | setSkipSpecific(stringified) 196 | 197 | const data: Partial = { 198 | skipSelect: 'SPECIFIC', 199 | skipSpecific: map 200 | } 201 | emit('EMIT_INPUT_TO_PLUGIN', data) 202 | } 203 | 204 | function handleSweepChange(sweepAngle: number): void { 205 | setSweepAngle(sweepAngle) 206 | 207 | const data: Partial = { 208 | sweepAngle 209 | } 210 | debounceSweepChange(data) 211 | } 212 | 213 | function handleSweep(isSweeping: boolean): void { 214 | setIsSweeping(isSweeping) 215 | } 216 | 217 | function handleButtonClick(): void { 218 | emit('APPLY_TRANSFORMATION') 219 | } 220 | 221 | function handleSelectionChange({ 222 | state, 223 | properties 224 | }: SelectionMessage): void { 225 | setSelectionState(state) 226 | const { width, height, rotation, type } = properties 227 | setSelectionProps({ 228 | width: width, 229 | height: height, 230 | rotation: rotation, 231 | type: type 232 | }) 233 | } 234 | 235 | /** 236 | * Debounce events 237 | */ 238 | 239 | const debounceWaitTime = 200 240 | 241 | const debounceNumItemsChange = useCallback( 242 | debounce((data) => emitInputChange(data), debounceWaitTime), 243 | [] 244 | ) 245 | 246 | const debounceRadiusChange = useCallback( 247 | debounce((data) => emitInputChange(data), debounceWaitTime), 248 | [] 249 | ) 250 | 251 | const debounceSweepChange = useCallback( 252 | debounce((data) => emitInputChange(data), debounceWaitTime), 253 | [] 254 | ) 255 | 256 | /** 257 | * Emit to UI handlers 258 | */ 259 | function emitInputChange(data: any) { 260 | emit('EMIT_INPUT_TO_PLUGIN', data) 261 | } 262 | 263 | function emitError(error: PluginError) { 264 | emit('UI_ERROR', error) 265 | } 266 | 267 | /** 268 | * Input validators 269 | */ 270 | 271 | function validateMinValue( 272 | value: null | number, 273 | min: number 274 | ): null | number | boolean { 275 | return value !== null && value >= min 276 | } 277 | 278 | function validateSkipSpecific(value: string): string | boolean { 279 | const split = value.split(',').map(Number) 280 | const temp: any = split.filter( 281 | (e, i) => e > 1 && e % 1 == 0 && split.indexOf(e) == i 282 | ) 283 | return temp.toString() 284 | } 285 | 286 | function validateSkipEvery(value: string): string | boolean { 287 | return ( 288 | (parseFloat(value) > 1 && parseFloat(value) % 1 == 0) || 289 | value === '' 290 | ) 291 | } 292 | 293 | /** 294 | * Parsed vals 295 | */ 296 | 297 | const parsedNumItems = parseInt(numItems) 298 | const parsedRadius = parseInt(radius) 299 | const parsedSkipEvery = parseInt(skipEvery) 300 | const mappedSkipSpecific = skipSpecific.split(',').map(Number) 301 | 302 | return ( 303 |
309 | 327 | 332 | 333 |
334 |
335 | 338 | Canvas preview 339 | 340 |
341 |
342 | 343 | 344 | 345 | 354 | 360 | 366 | 367 | } 368 | maximum={9999} 369 | value={numItems} 370 | validateOnBlur={(e) => validateMinValue(e, 2)} 371 | onFocusCapture={() => setShowNumBadge(1)} 372 | onBlurCapture={() => setShowNumBadge(0)} 373 | /> 374 | validateMinValue(e, 0)} 379 | onFocusCapture={() => setshowRadiusHelper(true)} 380 | onBlurCapture={() => setshowRadiusHelper(false)} 381 | /> 382 | 383 | 384 | 385 | 386 | Advanced 387 | 388 | 389 | 394 | {skipSelect === 'SPECIFIC' && ( 395 |
396 | setShowNumBadge(2)} 402 | onBlurCapture={() => setShowNumBadge(0)} 403 | style={{ 404 | paddingRight: 405 | 'calc(var(--local-icon-size) * 2)' 406 | }} 407 | /> 408 | {skipSpecific && ( 409 | 413 | )} 414 |
415 | )} 416 | {skipSelect === 'EVERY' && ( 417 |
418 | th item'} 423 | onFocusCapture={() => setShowNumBadge(2)} 424 | onBlurCapture={() => setShowNumBadge(0)} 425 | style={{ 426 | paddingRight: 427 | 'calc(var(--local-icon-size) * 2)' 428 | }} 429 | /> 430 | {skipEvery && ( 431 | 435 | )} 436 |
437 | )} 438 |
439 | 440 | 441 | Align copies radially 442 | 443 | 444 | 461 |
462 |
463 | ) 464 | } 465 | 466 | export default render(Plugin) 467 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions that concern the plugin's UI. 3 | */ 4 | 5 | /** 6 | * Debounce utility, used here to debounce input changes which render expensive transforms. 7 | * @param callback - Input func 8 | * @param wait - Wait for x ms before triggering input func 9 | */ 10 | export const debounce = any>( 11 | callback: T, 12 | wait: number 13 | ) => { 14 | let timeout = 0 15 | return (...args: Parameters): ReturnType => { 16 | let result: any 17 | clearTimeout(timeout) 18 | timeout = setTimeout(() => { 19 | result = callback(...args) 20 | }, wait) 21 | return result 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | export function handleErrorNotification(error: PluginError) { 2 | const errorMap: PluginErrorMap = { 3 | CANT_SKIP_FIRST_INDEX: `You can't skip the first instance.`, 4 | CANT_SKIP_ALL: `You can't skip all elements.` 5 | } 6 | figma.notify(errorMap[error]) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions that concern node property changes. 3 | */ 4 | 5 | /** 6 | * Componentize selection and apply the selection's transformations. 7 | * @param selection 8 | * @returns {ComponentNode} 9 | */ 10 | export function createComponentInPlace(selection: SceneNode): ComponentNode { 11 | let node: ComponentNode = figma.createComponent() 12 | const w: LayoutMixin['width'] = selection.width 13 | const h: LayoutMixin['height'] = selection.height 14 | 15 | // Deal with line and vector nodes with width/height of 0 16 | const isHairline: boolean = h === 0 || w === 0 17 | const isWiderOrSquare: boolean = w >= h 18 | const inherit: number = isWiderOrSquare ? w : h 19 | 20 | if (isHairline) { 21 | node.resizeWithoutConstraints(inherit, inherit) 22 | } else { 23 | node.resizeWithoutConstraints(selection.width, selection.height) 24 | } 25 | 26 | selection.parent?.appendChild(node) 27 | node.appendChild(selection) 28 | 29 | // Store selection transformation, reset component and apply transformation 30 | const tempX: LayoutMixin['x'] = selection.x 31 | const tempY: LayoutMixin['y'] = selection.y 32 | const tempDeg: LayoutMixin['rotation'] = selection.rotation 33 | selection.x = 0 34 | selection.y = 0 35 | selection.rotation = 0 36 | node.x = tempX 37 | node.y = tempY 38 | node.rotation = tempDeg 39 | 40 | if (isHairline) { 41 | const rad: number = node.rotation * (Math.PI / 180) 42 | if (isWiderOrSquare) { 43 | selection.y = selection.x + inherit / 2 44 | node.x = node.x - (inherit / 2) * Math.sin(rad) 45 | node.y = node.y - (inherit / 2) * Math.cos(rad) 46 | } else { 47 | selection.x = selection.x + inherit / 2 48 | node.x = node.x - (inherit / 2) * Math.cos(rad) 49 | node.y = node.y + (inherit / 2) * Math.sin(rad) 50 | } 51 | } 52 | 53 | constrainChildren(node) 54 | return node 55 | } 56 | 57 | /** 58 | * Recursively search for child nodes and constraint them to CENTER 59 | * to preserve the rotation when scaling after the transformation has been applied. 60 | * @param node 61 | */ 62 | function constrainChildren(node: ChildrenMixin): void { 63 | node.children.forEach((e) => { 64 | if (e.type === 'GROUP') { 65 | constrainChildren(e) 66 | } else if ('constraints' in e) { 67 | e.constraints = { horizontal: 'SCALE', vertical: 'SCALE' } 68 | } 69 | }) 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/selection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions that concern page selection queries. 3 | */ 4 | 5 | /** 6 | * Checks if current selection is empty, multiple, valid or updateable. 7 | * @param selection - Current page selection 8 | * @returns {SelectionType} 9 | */ 10 | export function validateSelection( 11 | selection: ReadonlyArray 12 | ): SelectionState { 13 | const validNodeTypes: Array = [ 14 | 'BOOLEAN_OPERATION', 15 | 'COMPONENT', 16 | 'ELLIPSE', 17 | 'FRAME', 18 | 'GROUP', 19 | 'INSTANCE', 20 | 'LINE', 21 | 'POLYGON', 22 | 'RECTANGLE', 23 | 'STAR', 24 | 'TEXT', 25 | 'VECTOR' 26 | ] 27 | 28 | if (selection.length) { 29 | if (selection.length > 1) { 30 | return 'MULTIPLE' 31 | } 32 | const node: SceneNode = selection[0] 33 | if (validNodeTypes.indexOf(node.type) >= 0) { 34 | if (isWithinNodeType(node, 'COMPONENT')) { 35 | return 'IS_WITHIN_COMPONENT' 36 | } else if (isWithinNodeType(node, 'INSTANCE')) { 37 | return 'IS_WITHIN_INSTANCE' 38 | } else if (node.type === 'GROUP' && hasComponentChild(node)) { 39 | return 'HAS_COMPONENT_CHILD' 40 | } else { 41 | return 'VALID' 42 | } 43 | } else { 44 | return 'INVALID' 45 | } 46 | } else { 47 | return 'EMPTY' 48 | } 49 | } 50 | 51 | /** 52 | * Search group for component child nodes, that would throw a re-componentizing error. 53 | * @param selection 54 | * @returns - truthy value if component child has been found 55 | */ 56 | export function hasComponentChild(selection: SceneNode): boolean | undefined { 57 | let hasComponent 58 | if (selection.type === 'COMPONENT') { 59 | return true 60 | } else if (selection.type !== 'GROUP') { 61 | return false 62 | } 63 | selection.children.some( 64 | (child) => (hasComponent = hasComponentChild(child)) 65 | ) 66 | return hasComponent 67 | } 68 | 69 | /** 70 | * Check if node is parented under certain node type. 71 | * @param node 72 | * @param type 73 | * @returns 74 | */ 75 | export function isWithinNodeType(node: SceneNode, type: NodeType): boolean { 76 | const parent = node.parent 77 | if ( 78 | parent === null || 79 | parent.type === 'DOCUMENT' || 80 | parent.type === 'PAGE' 81 | ) { 82 | return false 83 | } 84 | if (parent.type === type) { 85 | return true 86 | } 87 | return isWithinNodeType(parent, type) 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/transform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Utility functions that concern node transformations. 3 | */ 4 | 5 | /** 6 | * Base rotation that is applied to transformation. 7 | * -90 because Figma sets origin (0°) to 3 o'clock. 8 | */ 9 | export const baseDeg: number = -90 10 | 11 | /** 12 | * Instantiates and rotates a node by a given amount and radius. Assumes node is already componentized. 13 | * @param node - Node that will be instantiated and rotated 14 | * @param numItems - Number of copies/ instances 15 | * @param radius 16 | * @returns {Array { 25 | const collection: Array = [] 26 | 27 | Array.from({ length: numItems }, () => { 28 | const clonedNode: InstanceNode = node.createInstance() 29 | collection.push(clonedNode) 30 | }) 31 | 32 | collection.forEach((e, i) => { 33 | const initX: LayoutMixin['x'] = node.x 34 | const initY: LayoutMixin['y'] = node.y 35 | const initDeg: LayoutMixin['rotation'] = Math.round(node.rotation) * -1 36 | const initRad: number = initDeg * (Math.PI / 180) 37 | 38 | const w: LayoutMixin['width'] = node.width / 2 39 | const h: LayoutMixin['height'] = node.height / 2 40 | const r = radius 41 | const d: number = r * 2 42 | 43 | // (items - 1) to account for the offset of the sweep slider 44 | const deg: number = baseDeg + (sweepAngle / (numItems - 1)) * i 45 | const rad: number = deg * (Math.PI / 180) 46 | 47 | // Normalize shape and radius if node is oblong 48 | const diff: number = Math.abs(w - h) 49 | const normalizeShape: number = 50 | w >= h 51 | ? -(diff * Math.cos(Math.abs(deg) * (Math.PI / 180))) 52 | : diff * Math.cos(Math.abs(deg) * (Math.PI / 180)) 53 | const normalizeRadius: number = 54 | w === h 55 | ? 0 56 | : w > h 57 | ? -diff * Math.sin(Math.abs(initRad)) 58 | : diff * Math.sin(Math.abs(initRad)) 59 | 60 | // Rotate instances in circle 61 | const translate: Vector = { 62 | x: 63 | (r + w - normalizeRadius) * Math.cos(rad) + 64 | w * Math.sin(rad) + 65 | (d + w * 2) / 2 + 66 | normalizeShape, 67 | y: 68 | (r + h - normalizeRadius) * Math.sin(rad) - 69 | h * Math.cos(rad) + 70 | (d + h * 2) / 2 71 | } 72 | 73 | const radialTransform: Transform = [ 74 | [Math.cos(rad), -Math.sin(rad), translate.x], 75 | [Math.sin(rad), Math.cos(rad), translate.y] 76 | ] 77 | 78 | e.relativeTransform = radialTransform 79 | e.rotation = e.rotation + baseDeg 80 | 81 | // Shift circle down so that original node becomes position 0 (12:00) 82 | const x: LayoutMixin['x'] = e.x + initX 83 | const y: LayoutMixin['y'] = e.y + initY 84 | const sin: number = Math.sin(initRad) 85 | const cos: number = Math.cos(initRad) 86 | e.x = x + w * (1 - sin) - w * (1 - cos) 87 | e.y = y + h * (1 - sin) - h * (1 - cos) + h * sin 88 | 89 | // Restore the original nodes rotation by applying it to all instances 90 | // We need some extra math here to preserve the radius 91 | const newX: LayoutMixin['x'] = e.x 92 | const newY: LayoutMixin['y'] = e.y 93 | 94 | const baseDegRad: number = baseDeg * -1 * (Math.PI / 180) 95 | const newRad: number = alignRadially 96 | ? rad + initRad + baseDegRad 97 | : initRad 98 | 99 | const rotateAroundOrigin: Vector = { 100 | x: 101 | w - 102 | w * Math.cos(newRad) + 103 | h * Math.sin(newRad) - 104 | w * Math.sin(rad), 105 | y: 106 | h - 107 | w * Math.sin(newRad) - 108 | h * Math.cos(newRad) + 109 | h * Math.cos(rad) 110 | } 111 | 112 | const restoreInitRotation: Transform = [ 113 | [Math.cos(newRad), -Math.sin(newRad), rotateAroundOrigin.x], 114 | [Math.sin(newRad), Math.cos(newRad), rotateAroundOrigin.y] 115 | ] 116 | 117 | e.relativeTransform = restoreInitRotation 118 | e.x = e.x + newX 119 | e.y = e.y + newY 120 | e.name = `Rotated Instance ${i + 1}` 121 | }) 122 | 123 | return collection 124 | } 125 | -------------------------------------------------------------------------------- /src/vars.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 | --local-color-white: var(--color-white); 8 | --local-color-accent: var(--color-blue); 9 | --local-color-purple: var(--color-purple); 10 | --local-color-darkpurple: #351fa0; 11 | --local-color-red: var(--color-red); 12 | --local-color-disabled: rgb(110, 110, 110); 13 | 14 | --local-color-container-bg: #f3f3f3; 15 | --local-color-container-assistive: #cacaca; 16 | 17 | --local-color-item-fill-100: rgba(255, 255, 255, 1); 18 | --local-color-item-fill-60: rgba(255, 255, 255, 0.9); 19 | --local-color-item-border: var(--local-color-white); 20 | --local-color-item-inactive: #c7c7c7; 21 | 22 | --local-item-border-inactive: 1px solid var(--local-color-white); 23 | --local-item-border-deselected: 1px dashed 24 | var(--local-color-container-assistive); 25 | --local-item-border-radius: 0.1px solid var(--color-red); 26 | 27 | --local-border-radius-3: 3px; 28 | 29 | --local-space-extra-extra-small: 6px; 30 | 31 | --local-box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px; 32 | --local-box-shadow-hover: rgba(149, 157, 165, 0.4) 0px 8px 24px; 33 | --local-box-shadow-subtle: rgba(149, 157, 165, 0.1) 0px 8px 24px; 34 | } 35 | 36 | body { 37 | overflow: hidden; 38 | } 39 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------