├── src ├── ui.html ├── ignore.d.ts ├── code.ts ├── ui.tsx └── ui.scss ├── .gitignore ├── tsconfig.json ├── manifest.json ├── package.json ├── README.md └── webpack.config.js /src/ui.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /src/ignore.d.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | type ShowUIOptions = any; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "jsx": "react", 5 | "noEmit": false, 6 | "allowSyntheticDefaultImports": true, 7 | "typeRoots": ["./node_modules/@types", "./node_modules/@figma"] 8 | }, 9 | "types": ["webpack-env"], 10 | "include": ["src/**/*.ts", "src/**/*.tsx"] 11 | } 12 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Easometric", 3 | "id": "750743440401413268", 4 | "api": "1.0.0", 5 | "main": "dist/code.js", 6 | "ui": "dist/ui.html", 7 | "editorType": ["figma"], 8 | "menu": [ 9 | { 10 | "separator": true 11 | }, 12 | { 13 | "name": "Left", 14 | "command": "left" 15 | }, 16 | { 17 | "name": "Top Left", 18 | "command": "top-left" 19 | }, 20 | { 21 | "name": "Top Right", 22 | "command": "top-right" 23 | }, 24 | { 25 | "name": "Right", 26 | "command": "right" 27 | }, 28 | { 29 | "separator": true 30 | }, 31 | { 32 | "name": "Open Easometric", 33 | "command": "modal" 34 | } 35 | ], 36 | "networkAccess": { "allowedDomains": ["none"] } 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-easometric", 3 | "version": "1.0.0", 4 | "description": "With Easometric it is really easy to create isometric layers & groups", 5 | "main": "code.js", 6 | "scripts": { 7 | "dev": "webpack --mode=development --watch", 8 | "build": "webpack" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/aaroniker/figma-easometric.git" 13 | }, 14 | "author": "Aaron Iker", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "@figma/plugin-typings": "^1.39.0", 18 | "@types/node": "^16.11.8", 19 | "@types/react": "^17.0.35", 20 | "@types/react-dom": "^17.0.11", 21 | "css-loader": "^6.5.1", 22 | "html-webpack-plugin": "^5.5.0", 23 | "node-sass": "^6.0.1", 24 | "sass": "^1.43.4", 25 | "sass-loader": "^12.3.0", 26 | "style-loader": "^3.3.1", 27 | "ts-loader": "^9.2.6", 28 | "typescript": "^4.5.2", 29 | "url-loader": "^4.1.1", 30 | "webpack": "^5.64.1", 31 | "webpack-cli": "^4.9.1" 32 | }, 33 | "dependencies": { 34 | "react": "^17.0.2", 35 | "react-dev-utils": "^11.0.4", 36 | "react-dom": "^17.0.2", 37 | "transformation-matrix": "^2.1.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Figma Easometric 2 | 3 | ![Preview](https://aaroniker.me/easometric.gif) 4 | 5 | With Easometric it's really easy to create isometric layers & groups. 6 | 7 | This plugin using SSR30⁰, which is the most popular and flexible method of creating isometric artworks. With SSR30⁰ you can quickly create top, left and right isometric views. 8 | 9 | Simple as that you can either quick apply a top, left or right perspective or using the modal to modify your layer. 10 | 11 | ## Usage 12 | 13 | Download it on the Figma plugin library [figma.com/c/plugin/750743440401413268/Easometric](https://www.figma.com/c/plugin/750743440401413268/Easometric) 14 | 15 | ## Development 16 | 17 | First clone this repository 18 | ```shell 19 | git clone https://github.com/aaroniker/figma-easometric.git 20 | cd figma-easometric 21 | ``` 22 | 23 | Install dependencies & build files 24 | ```shell 25 | npm install 26 | npm run build 27 | # Or watch: npm run dev 28 | ``` 29 | 30 | After that open a project in Figma Desktop, select _Plugins -> Development -> New Plugin_. Click `Choose a manifest.json` and find the `manifest.json` file in this plugin directory. 31 | 32 | Done! Now _Plugins -> Development -> Easometric_ 33 | 34 | ## ToDo 35 | 36 | - [ ] Add SR45⁰ method 37 | - [ ] Live Preview 38 | - [ ] Node stay at same position after choose an angle 39 | 40 | ## Authors 41 | 42 | - Aaron Iker ([Twitter](https://twitter.com/aaroniker_me)) 43 | - Martin David ([Twitter](https://twitter.com/srioz)) 44 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | 4 | const path = require("path"); 5 | const webpack = require("webpack"); 6 | 7 | module.exports = (__, argv) => ({ 8 | mode: argv.mode === "production" ? "production" : "development", 9 | 10 | devtool: argv.mode === "production" ? false : "inline-source-map", 11 | 12 | entry: { 13 | ui: "./src/ui.tsx", 14 | code: "./src/code.ts", 15 | }, 16 | 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.tsx?$/, 21 | use: "ts-loader", 22 | exclude: /node_modules/, 23 | }, 24 | { 25 | test: /\.css$/, 26 | use: ["style-loader", "css-loader"], 27 | }, 28 | { 29 | test: /\.svg/, 30 | type: "asset/inline", 31 | }, 32 | { 33 | test: /\.s[ac]ss$/i, 34 | use: ["style-loader", "css-loader", "sass-loader"], 35 | }, 36 | ], 37 | }, 38 | resolve: { extensions: [".tsx", ".ts", ".jsx", ".js", ".scss"] }, 39 | output: { 40 | filename: "[name].js", 41 | path: path.resolve(__dirname, "dist"), 42 | }, 43 | plugins: [ 44 | new webpack.DefinePlugin({ 45 | global: {}, 46 | }), 47 | new HtmlWebpackPlugin({ 48 | inject: "body", 49 | template: "./src/ui.html", 50 | filename: "ui.html", 51 | chunks: ["ui"], 52 | }), 53 | new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]), 54 | ], 55 | }); 56 | -------------------------------------------------------------------------------- /src/code.ts: -------------------------------------------------------------------------------- 1 | const { skewDEG, rotateDEG, compose } = require("transformation-matrix"); 2 | 3 | let selection = figma.currentPage.selection; 4 | 5 | if (selection.length !== 1) { 6 | figma.closePlugin("Select a single node."); 7 | } 8 | 9 | function getOptions(direction) { 10 | switch (direction) { 11 | case "left": 12 | return { 13 | rotate: 0, 14 | skew: 30, 15 | degree: -30, 16 | }; 17 | break; 18 | case "right": 19 | return { 20 | rotate: 0, 21 | skew: -30, 22 | degree: 30, 23 | }; 24 | break; 25 | case "top-left": 26 | return { 27 | rotate: 0, 28 | skew: -30, 29 | degree: -30, 30 | }; 31 | break; 32 | case "top-right": 33 | return { 34 | rotate: 90, 35 | skew: -30, 36 | degree: 30, 37 | }; 38 | break; 39 | default: 40 | return { 41 | rotate: 0, 42 | skew: 0, 43 | degree: 0, 44 | }; 45 | break; 46 | } 47 | } 48 | 49 | function setIsomentric(node, direction) { 50 | let options = getOptions(direction), 51 | matrix = compose(rotateDEG(options.rotate), skewDEG(0, options.skew)), 52 | x = node.x, 53 | y = node.y; 54 | 55 | node.relativeTransform = [ 56 | [matrix.a, matrix.b, matrix.e], 57 | [matrix.c, matrix.d, matrix.f], 58 | ]; 59 | node.rotation = options.degree; 60 | 61 | node.x = x; 62 | node.y = y; 63 | 64 | node.setPluginData("direction", direction); 65 | 66 | return node; 67 | } 68 | 69 | function setActive(selection) { 70 | if (selection.length !== 1) { 71 | return false; 72 | } 73 | return selection[0].getPluginData("direction") || false; 74 | } 75 | 76 | if (figma.command == "modal") { 77 | figma.showUI(__html__, { 78 | width: 368, 79 | height: 368, 80 | themeColors: true, 81 | }); 82 | 83 | figma.on("selectionchange", () => { 84 | selection = figma.currentPage.selection; 85 | if (selection.length !== 1) { 86 | figma.closePlugin("Select a single node."); 87 | } 88 | figma.ui.postMessage({ 89 | type: "setActive", 90 | active: setActive(selection), 91 | }); 92 | }); 93 | 94 | figma.ui.postMessage({ 95 | type: "setActive", 96 | active: setActive(selection), 97 | }); 98 | 99 | figma.clientStorage.getAsync("easometricClose").then((bool) => { 100 | bool = bool === undefined ? true : bool; 101 | figma.ui.postMessage({ 102 | type: "setToggle", 103 | bool: bool, 104 | }); 105 | }); 106 | 107 | figma.ui.onmessage = (response) => { 108 | if (response.type == "set") { 109 | figma.clientStorage.getAsync("easometricClose").then((bool) => { 110 | bool = bool === undefined ? true : bool; 111 | setIsomentric(selection[0], response.direction); 112 | if (bool) { 113 | figma.closePlugin("Isometric set."); 114 | } 115 | }); 116 | } 117 | 118 | if (response.type == "toggle") { 119 | figma.clientStorage 120 | .setAsync("easometricClose", response.bool) 121 | .then(() => { 122 | figma.notify( 123 | response.bool 124 | ? "Modal will close after selection." 125 | : "Modal will stay after selection." 126 | ); 127 | }); 128 | } 129 | }; 130 | } else { 131 | setIsomentric(selection[0], figma.command); 132 | figma.closePlugin("Isometric set."); 133 | } 134 | -------------------------------------------------------------------------------- /src/ui.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, ChangeEvent, useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./ui.scss"; 4 | 5 | export const App: React.FC = () => { 6 | const [direction, setDirection] = useState(""); 7 | const [directionHover, setDirectionHover] = useState(""); 8 | const [checked, setChecked] = useState(false); 9 | 10 | const handleClick = (d: string) => { 11 | const newDirection = d === direction ? "none" : d; 12 | 13 | setDirection(newDirection); 14 | 15 | parent.postMessage( 16 | { 17 | pluginMessage: { 18 | type: "set", 19 | direction: newDirection, 20 | }, 21 | }, 22 | "*" 23 | ); 24 | }; 25 | 26 | const handleEnter = (d: string) => { 27 | setDirectionHover(d); 28 | }; 29 | 30 | const handleLeave = () => { 31 | setDirectionHover(""); 32 | }; 33 | 34 | useEffect(() => { 35 | window.onmessage = async (event) => { 36 | if (event.data.pluginMessage.type == "setToggle") { 37 | setChecked(event.data.pluginMessage.bool); 38 | } 39 | if (event.data.pluginMessage.type == "setActive") { 40 | if (event.data.pluginMessage.active) { 41 | setDirection(event.data.pluginMessage.active); 42 | } 43 | } 44 | }; 45 | }, []); 46 | 47 | return ( 48 |
49 |
50 |
handleClick("left")} 56 | onMouseEnter={() => handleEnter("left")} 57 | onMouseLeave={handleLeave} 58 | >
59 |
60 | handleClick("top-left")} 66 | onMouseEnter={() => handleEnter("top-left")} 67 | onMouseLeave={handleLeave} 68 | > 69 | 70 | 74 | 75 | 76 | handleClick("top-right")} 82 | onMouseEnter={() => handleEnter("top-right")} 83 | onMouseLeave={handleLeave} 84 | > 85 | 86 | 90 | 91 | 92 |
93 |
handleClick("right")} 99 | onMouseEnter={() => handleEnter("right")} 100 | onMouseLeave={handleLeave} 101 | >
102 |
103 | 159 | 181 |

182 | Follow me on{" "} 183 | 184 | 𝕏 (@aaroniker_me) 185 | {" "} 186 | for updates & more. 187 |

188 |
189 | ); 190 | }; 191 | 192 | ReactDOM.render(, document.getElementById("react-page")); 193 | -------------------------------------------------------------------------------- /src/ui.scss: -------------------------------------------------------------------------------- 1 | #easometric { 2 | --primary: var(--figma-color-bg-brand); 3 | --primary-pale: var(--figma-color-bg-brand-tertiary); 4 | --inactive: var(--figma-color-text-tertiary); 5 | --line: var(--figma-color-border); 6 | --border: var(--figma-color-border); 7 | font-family: "Inter", "Inter UI", Arial; 8 | padding: 4px 8px; 9 | 10 | .follow { 11 | border-top: 1px solid var(--line); 12 | padding: 8px 0 4px 8px; 13 | font-size: 13px; 14 | line-height: 28px; 15 | font-weight: 500; 16 | color: var(--inactive); 17 | margin: 12px -8px -4px -8px; 18 | 19 | a { 20 | color: var(--primary); 21 | text-decoration: none; 22 | } 23 | } 24 | 25 | nav { 26 | margin: 32px 0 20px 0; 27 | 28 | ul { 29 | padding: 0; 30 | margin: 0; 31 | list-style: none; 32 | display: grid; 33 | grid-template-columns: repeat(4, minmax(0, 1fr)); 34 | 35 | li { 36 | &:first-child { 37 | button { 38 | border-radius: 5px 0 0 5px; 39 | } 40 | } 41 | 42 | &:last-child { 43 | button { 44 | border-radius: 0 5px 5px 0; 45 | } 46 | } 47 | 48 | &:not(:first-child) { 49 | margin-left: -1px; 50 | } 51 | 52 | button { 53 | display: block; 54 | width: 100%; 55 | cursor: pointer; 56 | text-align: center; 57 | position: relative; 58 | padding: 12px 0; 59 | margin: 0; 60 | appearance: none; 61 | font: inherit; 62 | background: none; 63 | font-weight: 500; 64 | font-size: 14px; 65 | border: 1px solid var(--line); 66 | color: var(--inactive); 67 | transition: color 0.3s ease, border 0.3s ease, background-color 0.3s; 68 | 69 | &:before { 70 | content: ""; 71 | position: absolute; 72 | left: -1px; 73 | top: -1px; 74 | right: -1px; 75 | bottom: -1px; 76 | z-index: 1; 77 | pointer-events: none; 78 | border-radius: inherit; 79 | border: 1px solid var(--primary); 80 | opacity: 0; 81 | transition: opacity 0.3s ease; 82 | } 83 | 84 | &:hover, 85 | &.hover { 86 | color: var(--figma-color-text-secondary); 87 | } 88 | 89 | &.active { 90 | color: var(--primary); 91 | background-color: var(--primary-pale); 92 | 93 | &:before { 94 | opacity: 1; 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | .box { 103 | position: relative; 104 | height: 148px; 105 | margin: 28px 0 0 0; 106 | 107 | &>div { 108 | --r: 0deg; 109 | --s: 0deg; 110 | --x: 0px; 111 | --y: 0px; 112 | transform-origin: 0 0; 113 | width: 72px; 114 | height: 72px; 115 | top: 0; 116 | left: 50%; 117 | cursor: pointer; 118 | margin: -60px 0 0 -113px; 119 | position: absolute; 120 | transition: background-color 0.3s ease, border 0.3s ease; 121 | transform: rotate(var(--r)) skewX(var(--s)) translate(var(--x), var(--y)) scaleY(0.864); 122 | 123 | &:before, 124 | &:after { 125 | content: ""; 126 | position: absolute; 127 | left: 0; 128 | top: 0; 129 | right: 0; 130 | bottom: 0; 131 | z-index: 2; 132 | pointer-events: none; 133 | transition: border 0.3s ease, opacity 0.3s ease; 134 | } 135 | 136 | &:after { 137 | border: 1px dashed var(--border); 138 | } 139 | 140 | &.left { 141 | --s: -30deg; 142 | --r: 90deg; 143 | --x: 68px; 144 | --y: -113px; 145 | } 146 | 147 | &.top { 148 | --s: -30deg; 149 | --r: 210deg; 150 | --x: -199px; 151 | --y: -59px; 152 | cursor: default; 153 | overflow: hidden; 154 | 155 | &:after { 156 | border-top-color: transparent; 157 | border-left-color: transparent; 158 | top: -1px; 159 | left: -1px; 160 | } 161 | 162 | &.hover-top-left { 163 | &:after { 164 | border-right-color: var(--primary); 165 | border-top-color: var(--primary); 166 | } 167 | } 168 | 169 | &.hover-top-right { 170 | &:after { 171 | border-bottom-color: var(--primary); 172 | border-left-color: var(--primary); 173 | } 174 | } 175 | 176 | span { 177 | --x: 0px; 178 | --y: 0px; 179 | --s: 1; 180 | display: block; 181 | cursor: pointer; 182 | width: 100px; 183 | height: 100px; 184 | transform-origin: 50% 50%; 185 | transition: background 0.3s ease, transform 0.3s ease, color 0.3s ease; 186 | position: absolute; 187 | transform: rotate(var(--r)) translate(var(--x), var(--y)) scale(var(--s)); 188 | color: var(--inactive); 189 | 190 | svg { 191 | --r: 0deg; 192 | display: block; 193 | width: 24px; 194 | height: 24px; 195 | position: absolute; 196 | margin: -12px 0 0 -12px; 197 | transform: rotate(var(--r)); 198 | } 199 | 200 | &[data-direction="top-right"] { 201 | --r: 135deg; 202 | --x: 70px; 203 | left: 0; 204 | bottom: 0; 205 | 206 | svg { 207 | --r: -45deg; 208 | left: 17%; 209 | top: 55%; 210 | } 211 | } 212 | 213 | &[data-direction="top-left"] { 214 | --y: -70px; 215 | --r: 45deg; 216 | top: 0; 217 | right: 0; 218 | 219 | svg { 220 | --r: 135deg; 221 | left: 45%; 222 | top: 83%; 223 | } 224 | } 225 | 226 | &:hover, 227 | &.hover { 228 | background: var(--primary-pale); 229 | color: var(--primary); 230 | } 231 | 232 | &.active { 233 | background: var(--primary); 234 | color: #fff; 235 | } 236 | } 237 | } 238 | 239 | &.right { 240 | --s: -30deg; 241 | --r: -30deg; 242 | --x: 130px; 243 | --y: 172px; 244 | } 245 | 246 | &:not(.top) { 247 | 248 | &:hover, 249 | &.hover { 250 | background: var(--primary-pale); 251 | 252 | &:after { 253 | border-color: var(--primary); 254 | } 255 | 256 | span { 257 | color: var(--primary); 258 | } 259 | } 260 | 261 | &.active { 262 | background: var(--primary); 263 | } 264 | } 265 | } 266 | } 267 | } 268 | 269 | .switch { 270 | width: 100%; 271 | position: relative; 272 | display: flex; 273 | justify-content: space-between; 274 | align-items: center; 275 | 276 | span { 277 | display: block; 278 | list-style: 23px; 279 | font-size: 14px; 280 | font-weight: 500; 281 | color: var(--inactive); 282 | } 283 | 284 | input { 285 | display: none; 286 | 287 | &+div { 288 | --background: var(--line); 289 | width: 42px; 290 | height: 23px; 291 | cursor: pointer; 292 | border-radius: 12px; 293 | position: relative; 294 | background: var(--background); 295 | transition: background 0.4s ease; 296 | 297 | &:before { 298 | --x: 0; 299 | content: ""; 300 | transition: transform 0.4s cubic-bezier(0.175, 0.88, 0.32, 1.2); 301 | background: #fff; 302 | position: absolute; 303 | width: 15px; 304 | height: 15px; 305 | border-radius: 50%; 306 | left: 4px; 307 | top: 4px; 308 | transform: translateX(var(--x)); 309 | } 310 | } 311 | 312 | &:checked { 313 | &+div { 314 | --background: var(--primary); 315 | 316 | &:before { 317 | --x: 19px; 318 | } 319 | } 320 | } 321 | } 322 | } --------------------------------------------------------------------------------