├── src ├── app │ ├── index.html │ ├── assets │ │ └── logo.svg │ ├── index.tsx │ ├── components │ │ ├── CustomColorInput.tsx │ │ └── App.tsx │ └── styles │ │ └── ui.css ├── typings │ └── types.d.ts └── plugin │ └── controller.ts ├── .gitignore ├── imgs ├── Vector Fields Art.png └── vector-fields-logo.png ├── .prettierrc.yml ├── .github └── workflows │ └── ci.yml ├── manifest.json ├── tsconfig.json ├── LICENSE ├── package.json ├── README.md ├── webpack.config.js └── yarn.lock /src/app/index.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | settings.json 4 | yarn-error.log 5 | .vscode 6 | -------------------------------------------------------------------------------- /src/typings/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /imgs/Vector Fields Art.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/destefanis/vector-field-plugin/HEAD/imgs/Vector Fields Art.png -------------------------------------------------------------------------------- /imgs/vector-fields-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/destefanis/vector-field-plugin/HEAD/imgs/vector-fields-logo.png -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | trailingComma: es5 2 | singleQuote: true 3 | printWidth: 120 4 | tabWidth: 2 5 | bracketSpacing: true 6 | arrowParens: always 7 | -------------------------------------------------------------------------------- /src/app/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | - run: yarn 14 | - run: yarn build 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Vector Fields", 3 | "id": "1392692375323556258", 4 | "api": "1.0.0", 5 | "main": "dist/code.js", 6 | "ui": "dist/ui.html", 7 | "editorType": ["figma", "figjam"], 8 | "documentAccess": "dynamic-page", 9 | "networkAccess": { "allowedDomains": ["none"] } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './components/App'; 4 | 5 | document.addEventListener('DOMContentLoaded', function () { 6 | const container = document.getElementById('react-page'); 7 | const root = createRoot(container); 8 | root.render(); 9 | }); 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "outDir": "dist", 5 | "jsx": "react", 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "experimentalDecorators": true, 9 | "allowSyntheticDefaultImports": true, 10 | "removeComments": true, 11 | "noImplicitAny": false, 12 | "moduleResolution": "node", 13 | "typeRoots": ["./node_modules/@types", "./node_modules/@figma"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nir Hadassi 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-plugin-react-template", 3 | "version": "1.0.0", 4 | "description": "This plugin template uses Typescript. If you are familiar with Javascript, Typescript will look very familiar. In fact, valid Javascript code is already valid Typescript code.", 5 | "license": "ISC", 6 | "scripts": { 7 | "build": "webpack --mode=production", 8 | "build:watch": "webpack --mode=development --watch", 9 | "prettier:format": "prettier --write '**/*.{js,jsx,ts,tsx,css,json}' " 10 | }, 11 | "dependencies": { 12 | "@mantine/core": "^7.11.1", 13 | "@mantine/hooks": "^7.11.1", 14 | "class-variance-authority": "^0.7.0", 15 | "clsx": "^2.1.1", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@figma/plugin-typings": "^1.50.0", 21 | "@types/react": "^18.0.17", 22 | "@types/react-dom": "^18.0.6", 23 | "css-loader": "^6.7.1", 24 | "html-webpack-plugin": "^5.5.0", 25 | "husky": "^8.0.1", 26 | "lint-staged": "^13.0.3", 27 | "prettier": "^2.7.1", 28 | "react-dev-utils": "^12.0.1", 29 | "style-loader": "^3.3.1", 30 | "ts-loader": "^9.3.1", 31 | "typescript": "^4.7.4", 32 | "url-loader": "^4.1.1", 33 | "webpack": "^5.74.0", 34 | "webpack-cli": "^4.10.0" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged" 39 | } 40 | }, 41 | "lint-staged": { 42 | "src/**/*.{js,jsx,ts,tsx,css,json}": [ 43 | "prettier --write", 44 | "git add" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vector Fields Plugin 2 | 3 | Vector Fields is a generative tool that helps you play, tinker, and hack together patterns made from shapes. 4 | 5 | Make visually stunning graphics by playing with different vector field presets. 6 | 7 | Use dots, lines, arrows, triangles or even custom SVGS to create... stuff. 8 | 9 | ### Some tips: 10 | 11 | When you select a frame, the plugin will automatically adjust the SVG you're building to fit. If it has a background color, it will also use it! 12 | Selecting any vector on your page will use it in the pattern which makes it easy to create new ideas. 13 | Upload a custom SVG by selecting "custom shape" under the shape dropdown and paste your svg in. 14 | Use the reset button to undo any changes you've made. 15 | Play with the column and row counts, anything over 100 will make your computer chug so just be patient when rendering! 16 | 17 | 18 | ## To use this plugin 19 | 20 | - Run `yarn` to install dependencies. 21 | - Run `yarn build:watch` to start webpack in watch mode. 22 | - Open `Figma` -> `Plugins` -> `Development` -> `Import plugin from manifest...` and choose `manifest.json` file from this repo. 23 | - There are images in the `/imgs` directory if you need to publish internally. 24 | 25 | ⭐ To change the UI of your plugin (the react code), start editing [App.tsx](./src/app/components/App.tsx). 26 | ⭐ To interact with the Figma API edit [controller.ts](./src/plugin/controller.ts). 27 | ⭐ Read more on the [Figma API Overview](https://www.figma.com/plugin-docs/api/api-overview/). 28 | 29 | ## Toolings 30 | 31 | This repo is using: 32 | 33 | - React + Webpack 34 | - TypeScript 35 | - Prettier precommit hook 36 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin'); 3 | const path = require('path'); 4 | 5 | module.exports = (env, argv) => ({ 6 | mode: argv.mode === 'production' ? 'production' : 'development', 7 | 8 | // This is necessary because Figma's 'eval' works differently than normal eval 9 | devtool: argv.mode === 'production' ? false : 'inline-source-map', 10 | 11 | entry: { 12 | ui: './src/app/index.tsx', // The entry point for your UI code 13 | code: './src/plugin/controller.ts', // The entry point for your plugin code 14 | }, 15 | 16 | module: { 17 | rules: [ 18 | // Converts TypeScript code to JavaScript 19 | { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }, 20 | 21 | // Enables including CSS by doing "import './file.css'" in your TypeScript code 22 | { test: /\.css$/, use: ['style-loader', { loader: 'css-loader' }] }, 23 | 24 | // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI 25 | { test: /\.(png|jpg|gif|webp|svg)$/, loader: 'url-loader' }, 26 | ], 27 | }, 28 | 29 | // Webpack tries these extensions for you if you omit the extension like "import './file'" 30 | resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] }, 31 | 32 | output: { 33 | filename: '[name].js', 34 | path: path.resolve(__dirname, 'dist'), // Compile into a folder called "dist" 35 | }, 36 | 37 | // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it 38 | plugins: [ 39 | new HtmlWebpackPlugin({ 40 | template: './src/app/index.html', 41 | filename: 'ui.html', 42 | chunks: ['ui'], 43 | cache: false, 44 | }), 45 | new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]), 46 | ], 47 | }); 48 | -------------------------------------------------------------------------------- /src/app/components/CustomColorInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useCallback } from 'react'; 2 | import { ColorInput, Popover } from '@mantine/core'; 3 | 4 | const CustomColorInput = ({ label, value, onChange, ...props }) => { 5 | const [opened, setOpened] = useState(false); 6 | const inputRef = useRef(null); 7 | const containerRef = useRef(null); 8 | 9 | const handleSwatchClick = useCallback((event) => { 10 | event.preventDefault(); 11 | event.stopPropagation(); 12 | setOpened(true); 13 | if (inputRef.current) { 14 | inputRef.current.focus(); 15 | } 16 | }, []); 17 | 18 | const handleContainerClick = useCallback((event) => { 19 | // Prevent closing the popover when clicking on the input or swatch 20 | event.stopPropagation(); 21 | }, []); 22 | 23 | const inputContainer = useCallback((children) => ( 24 |
25 | {children} 26 |
40 |
41 | ), [handleSwatchClick, handleContainerClick]); 42 | 43 | return ( 44 | setOpened(false)} 47 | withinPortal={false} 48 | clickOutsideEvents={['mousedown', 'touchstart']} 49 | closeOnClickOutside={true} 50 | > 51 | 52 |
53 | setOpened(true)} 60 | popoverProps={{ 61 | opened, 62 | onClose: () => setOpened(false), 63 | withinPortal: false, 64 | ...props.popoverProps 65 | }} 66 | {...props} 67 | /> 68 |
69 |
70 |
71 | ); 72 | }; 73 | 74 | export default CustomColorInput; -------------------------------------------------------------------------------- /src/plugin/controller.ts: -------------------------------------------------------------------------------- 1 | figma.showUI(__html__, { width: 860, height: 600 }); 2 | 3 | let lastSelectedFrame = null; 4 | 5 | figma.ui.onmessage = (msg) => { 6 | if (msg.type === 'create-svg') { 7 | createSvgNode(msg.svg, msg.width, msg.height, msg.backgroundColor, msg.isCustomShape); 8 | } else if (msg.type === 'request-vector-selection') { 9 | requestVectorSelection(); 10 | } else if (msg.type === 'close-plugin') { 11 | figma.closePlugin(); 12 | } 13 | }; 14 | 15 | function sendFrameInfo(frame) { 16 | let backgroundColor = null; 17 | if (frame.fills && frame.fills.length > 0 && frame.fills[0].type === 'SOLID') { 18 | const color = frame.fills[0].color; 19 | backgroundColor = `#${Math.round(color.r * 255).toString(16).padStart(2, '0')}${Math.round(color.g * 255).toString(16).padStart(2, '0')}${Math.round(color.b * 255).toString(16).padStart(2, '0')}`; 20 | } 21 | figma.ui.postMessage({ 22 | type: 'frame-selected', 23 | width: frame.width, 24 | height: frame.height, 25 | backgroundColor: backgroundColor 26 | }); 27 | } 28 | 29 | async function sendVectorInfo(vector) { 30 | try { 31 | const svgString = await vector.exportAsync({ format: 'SVG_STRING' }); 32 | console.log("Exported SVG string:", svgString); 33 | figma.ui.postMessage({ 34 | type: 'vector-selected', 35 | svg: svgString, 36 | width: vector.width, 37 | height: vector.height 38 | }); 39 | } catch (error) { 40 | console.error('Error exporting vector:', error); 41 | figma.ui.postMessage({ 42 | type: 'vector-selection-error', 43 | message: 'Failed to process the selected vector' 44 | }); 45 | } 46 | } 47 | 48 | function requestVectorSelection() { 49 | const selection = figma.currentPage.selection; 50 | if (selection.length === 1 && (selection[0].type === 'VECTOR' || selection[0].type === 'SHAPE' || selection[0].type === 'BOOLEAN_OPERATION')) { 51 | sendVectorInfo(selection[0]); 52 | } else { 53 | figma.ui.postMessage({ 54 | type: 'vector-selection-error', 55 | message: 'Please select a single vector, shape, or boolean operation' 56 | }); 57 | } 58 | } 59 | 60 | function updateSelectedFrame() { 61 | const selection = figma.currentPage.selection; 62 | if (selection.length === 1 && selection[0].type === 'FRAME') { 63 | lastSelectedFrame = selection[0]; 64 | sendFrameInfo(lastSelectedFrame); 65 | } else { 66 | lastSelectedFrame = null; 67 | figma.ui.postMessage({ type: 'no-frame-selected' }); 68 | } 69 | } 70 | 71 | // Initial selection check 72 | updateSelectedFrame(); 73 | 74 | figma.on('selectionchange', () => { 75 | updateSelectedFrame(); 76 | 77 | const selection = figma.currentPage.selection; 78 | if (selection.length === 1 && selection[0].type === 'VECTOR') { 79 | sendVectorInfo(selection[0]); 80 | } 81 | }); 82 | 83 | function createSvgNode(svgString, width, height, backgroundColor, isCustomShape) { 84 | console.log('Creating SVG node with:', { width, height, backgroundColor }); 85 | 86 | const node = figma.createNodeFromSvg(svgString); 87 | node.name = "Vector Field Generator"; 88 | 89 | if (width && height) { 90 | node.resize(width, height); 91 | } 92 | 93 | if (backgroundColor) { 94 | const [r, g, b] = backgroundColor.match(/\w\w/g).map(x => parseInt(x, 16) / 255); 95 | node.fills = [{ type: 'SOLID', color: { r, g, b } }]; 96 | } 97 | 98 | // Check if a frame is currently selected 99 | const currentSelection = figma.currentPage.selection; 100 | const selectedFrame = currentSelection.length === 1 && currentSelection[0].type === 'FRAME' ? currentSelection[0] : null; 101 | 102 | if (selectedFrame) { 103 | selectedFrame.appendChild(node); 104 | } else if (lastSelectedFrame) { 105 | lastSelectedFrame.appendChild(node); 106 | } else { 107 | const { x, y } = findNonOverlappingPosition(node); 108 | node.x = x; 109 | node.y = y; 110 | figma.currentPage.appendChild(node); 111 | } 112 | 113 | figma.currentPage.selection = [node]; 114 | 115 | // Recursive function to process all children 116 | function processNode(node) { 117 | if (node.type === 'FRAME') { 118 | node.fills = []; 119 | node.clipsContent = false; 120 | } 121 | 122 | if ('children' in node) { 123 | node.children.forEach(child => processNode(child)); 124 | } 125 | } 126 | 127 | // Process all children of the main SVG node 128 | if (isCustomShape) { 129 | processNode(node); 130 | } 131 | 132 | figma.viewport.scrollAndZoomIntoView([node]); 133 | } 134 | 135 | function findNonOverlappingPosition(node) { 136 | const PADDING = 20; 137 | const nodes = figma.currentPage.children; 138 | 139 | let x = PADDING; 140 | let y = PADDING; 141 | 142 | const doesOverlap = (x, y, width, height) => { 143 | return nodes.some(existingNode => { 144 | const ex = existingNode.x; 145 | const ey = existingNode.y; 146 | const ew = existingNode.width; 147 | const eh = existingNode.height; 148 | 149 | return !(x + width < ex || x > ex + ew || y + height < ey || y > ey + eh); 150 | }); 151 | }; 152 | 153 | while (doesOverlap(x, y, node.width, node.height)) { 154 | x += PADDING + node.width; 155 | 156 | if (x + node.width > figma.viewport.bounds.width) { 157 | x = PADDING; 158 | y += PADDING + node.height; 159 | } 160 | } 161 | 162 | return { x, y }; 163 | } -------------------------------------------------------------------------------- /src/app/styles/ui.css: -------------------------------------------------------------------------------- 1 | .app { 2 | font-family: "Inter", sans-serif; 3 | display: flex; 4 | flex-flow: row; 5 | } 6 | 7 | .form label { 8 | font-size: 11px; 9 | margin-bottom: 8px; 10 | display: block; 11 | font-weight: 500; 12 | line-height: 18px; 13 | color: #000000f0; 14 | } 15 | 16 | .preview { 17 | /* border-radius: 16px; */ 18 | } 19 | 20 | .size-label { 21 | background-color: rgba(0, 0, 0, 0.4); 22 | color: #fff; 23 | font-weight: 400; 24 | line-height: 16px; 25 | padding: 0 4px; 26 | border-radius: 4px; 27 | position: absolute; 28 | z-index: 5; 29 | bottom: 8px; 30 | left: 8px; 31 | font-size: 11px; 32 | } 33 | 34 | .divider { 35 | display: block; 36 | margin: 16px -20px; 37 | border-bottom: 1px solid #e6e6e6; 38 | } 39 | 40 | /* .preview-svg { 41 | margin: 16px 0 16px 16px; 42 | border-radius: 16px; 43 | } */ 44 | 45 | .form { 46 | display: flex; 47 | flex-flow: column; 48 | width: 260px; 49 | height: 600px; 50 | border-left: 1px solid #e6e6e6; 51 | } 52 | 53 | .fields { 54 | padding: 20px 20px 24px; 55 | height: 600px; 56 | overflow: scroll; 57 | } 58 | 59 | .button-wrapper { 60 | display: flex; 61 | justify-content: flex-end; 62 | padding: 12px 20px; 63 | border-top: 1px solid #e6e6e6; 64 | gap: 8px; 65 | } 66 | 67 | .mantine-Button-root { 68 | height: 32px; 69 | border-radius: 6px; 70 | background-color: #0d99ff; 71 | overflow: visible; 72 | line-height: 32px; 73 | font-size: 11px; 74 | font-weight: 500; 75 | } 76 | 77 | .secondary-button { 78 | border: none; 79 | background-color: transparent; 80 | color: #000000e5; 81 | } 82 | 83 | .secondary-button:hover { 84 | background-color: transparent; 85 | color: #000; 86 | } 87 | 88 | .counter-wrapper { 89 | display: flex; 90 | flex-flow: row; 91 | margin-bottom: 16px; 92 | gap: 20px; 93 | } 94 | 95 | .mantine-Textarea-root { 96 | margin-bottom: 16px; 97 | } 98 | 99 | .mantine-InputWrapper-label { 100 | font-size: 11px; 101 | margin-bottom: 8px; 102 | font-weight: 500; 103 | line-height: 18px; 104 | color: #000000f0; 105 | } 106 | 107 | .value-span { 108 | margin-left: 4px; 109 | color: #000000e5; 110 | } 111 | 112 | .unit-span { 113 | color: #000000e5; 114 | margin-left: -2px; 115 | } 116 | 117 | .mantine-Input-root { 118 | border-color: #e6e6e6; 119 | } 120 | 121 | .mantine-ColorInput-root { 122 | margin-bottom: 16px; 123 | font-size: 13px; 124 | } 125 | 126 | .mantine-ColorInput-input { 127 | text-transform: uppercase; 128 | } 129 | 130 | .mantine-Input-input { 131 | font-size: 13px; 132 | } 133 | 134 | .mantine-NumberInput-root { 135 | border-color: #e6e6e6; 136 | } 137 | 138 | .mantine-Select-root { 139 | margin-bottom: 16px; 140 | border-color: #e6e6e6; 141 | } 142 | 143 | .mantine-Select-option { 144 | font-family: 'Inter'; 145 | height: 32px; 146 | padding: 0 16px; 147 | font-size: 12px; 148 | color: rgba(255, 255, 255, 1); 149 | border-radius: 0; 150 | } 151 | 152 | .mantine-Popover-dropdown { 153 | padding: 8px 0 8px; 154 | border-radius: 5px; 155 | background-color: #121212; 156 | font-size: 11px; 157 | box-shadow: 0 8px 11px rgba(0, 0, 0, 0.11); 158 | } 159 | 160 | .mantine-Select-option:hover { 161 | background-color: #0d99ff; 162 | } 163 | 164 | 165 | .mantine-Select-option[aria-selected="false"] { 166 | color: rgba(255, 255, 255, 1); 167 | } 168 | 169 | .mantine-Slider-root { 170 | margin-bottom: 16px; 171 | } 172 | 173 | .mantine-Slider-bar { 174 | background-color: #1d1d1d; 175 | } 176 | 177 | .mantine-Slider-thumb { 178 | background-color: #fff; 179 | border-color: #1d1d1d; 180 | } 181 | 182 | .mantine-Checkbox-root { 183 | margin-bottom: 8px; 184 | border-color: #e6e6e6; 185 | } 186 | 187 | .mantine-Checkbox-input:checked { 188 | background-color: #0d99ff; 189 | border-color: #0d99ff; 190 | } 191 | 192 | .slider-disabled { 193 | pointer-events: none; 194 | opacity: 0.5; 195 | } 196 | 197 | /* Css Reset */ 198 | 199 | /* http://meyerweb.com/eric/tools/css/reset/ 200 | v2.0 | 20110126 201 | License: none (public domain) 202 | */ 203 | 204 | html, 205 | body, 206 | div, 207 | span, 208 | applet, 209 | object, 210 | iframe, 211 | h1, 212 | h2, 213 | h3, 214 | h4, 215 | h5, 216 | h6, 217 | p, 218 | blockquote, 219 | pre, 220 | a, 221 | abbr, 222 | acronym, 223 | address, 224 | big, 225 | cite, 226 | code, 227 | del, 228 | dfn, 229 | em, 230 | img, 231 | ins, 232 | kbd, 233 | q, 234 | s, 235 | samp, 236 | small, 237 | strike, 238 | strong, 239 | sub, 240 | sup, 241 | tt, 242 | var, 243 | b, 244 | u, 245 | i, 246 | center, 247 | dl, 248 | dt, 249 | dd, 250 | ol, 251 | ul, 252 | li, 253 | fieldset, 254 | form, 255 | label, 256 | legend, 257 | table, 258 | caption, 259 | tbody, 260 | tfoot, 261 | thead, 262 | tr, 263 | th, 264 | td, 265 | article, 266 | aside, 267 | canvas, 268 | details, 269 | embed, 270 | figure, 271 | figcaption, 272 | footer, 273 | header, 274 | hgroup, 275 | menu, 276 | nav, 277 | output, 278 | ruby, 279 | section, 280 | summary, 281 | time, 282 | mark, 283 | audio, 284 | video { 285 | margin: 0; 286 | padding: 0; 287 | border: 0; 288 | font-size: 100%; 289 | font: inherit; 290 | vertical-align: baseline; 291 | } 292 | 293 | /* HTML5 display-role reset for older browsers */ 294 | article, 295 | aside, 296 | details, 297 | figcaption, 298 | figure, 299 | footer, 300 | header, 301 | hgroup, 302 | menu, 303 | nav, 304 | section { 305 | display: block; 306 | } 307 | 308 | body { 309 | line-height: 1; 310 | } 311 | 312 | ol, 313 | ul { 314 | list-style: none; 315 | } 316 | 317 | blockquote, 318 | q { 319 | quotes: none; 320 | } 321 | 322 | blockquote:before, 323 | blockquote:after, 324 | q:before, 325 | q:after { 326 | content: ''; 327 | content: none; 328 | } 329 | 330 | table { 331 | border-collapse: collapse; 332 | border-spacing: 0; 333 | } -------------------------------------------------------------------------------- /src/app/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useRef } from 'react'; 2 | import { MantineProvider } from '@mantine/core'; 3 | import '@mantine/core/styles.css'; 4 | import '../styles/ui.css'; 5 | import { NumberInput, Select, Slider, Checkbox, Button, ColorInput, Textarea } from '@mantine/core'; 6 | import CustomColorInput from './CustomColorInput'; 7 | 8 | const App = () => { 9 | const [rows, setRows] = useState(20); 10 | const [columns, setColumns] = useState(20); 11 | const [fieldType, setFieldType] = useState('magnetic'); 12 | const [direction, setDirection] = useState(0); 13 | const [intensity, setIntensity] = useState(1); 14 | const [shape, setShape] = useState('line'); 15 | const [roundedCorners, setRoundedCorners] = useState(true); 16 | const [spiral, setSpiral] = useState(true); 17 | const [spiralIntensity, setSpiralIntensity] = useState(0.1); 18 | const [gradientType, setGradientType] = useState('none'); 19 | const [shapeSize, setShapeSize] = useState(1); 20 | const [vectorScale, setVectorScale] = useState(1); 21 | const [lineThickness, setLineThickness] = useState(1); 22 | const [spacing, setSpacing] = useState(1); 23 | const [xSpacing, setXSpacing] = useState(1); 24 | const [ySpacing, setYSpacing] = useState(1); 25 | const [xOffset, setXOffset] = useState(0); 26 | const [yOffset, setYOffset] = useState(0); 27 | const [field, setField] = useState([]); 28 | const [fillParent, setFillParent] = useState(true); 29 | const [pastedSvg, setPastedSvg] = useState(''); 30 | const [customShapeSource, setCustomShapeSource] = useState(null); 31 | 32 | const [frameWidth, setFrameWidth] = useState(600); 33 | const [frameHeight, setFrameHeight] = useState(600); 34 | const [actualFrameWidth, setActualFrameWidth] = useState(600); 35 | const [actualFrameHeight, setActualFrameHeight] = useState(600); 36 | const [backgroundColor, setBackgroundColor] = useState('#1e1e1e'); 37 | const [shapeColor, setShapeColor] = useState('#ffffff'); 38 | const [customShape, setCustomShape] = useState(null); 39 | const [customShapeViewBox, setCustomShapeViewBox] = useState(null); 40 | const [userChangedColor, setUserChangedColor] = useState(false); 41 | const [isWaitingForVectorSelection, setIsWaitingForVectorSelection] = useState(false); 42 | const [userOverrideBgColor, setUserOverrideBgColor] = useState(false); 43 | 44 | const svgRef = useRef(null); 45 | 46 | const generateField = useCallback((rows, columns, type, direction, intensity, spiral, spiralIntensity, xSpacing, ySpacing, xOffsetPercent, yOffsetPercent) => { 47 | const newField = []; 48 | const cellWidth = frameWidth / (columns - 1); 49 | const cellHeight = frameHeight / (rows - 1); 50 | const centerX = frameWidth / 2; 51 | const centerY = frameHeight / 2; 52 | const seed = Math.random(); 53 | 54 | const xOffset = (xOffsetPercent / 100) * frameWidth; 55 | const yOffset = (yOffsetPercent / 100) * frameHeight; 56 | 57 | for (let y = 0; y < rows; y++) { 58 | for (let x = 0; x < columns; x++) { 59 | let vectorAngle = 0, length = 5; 60 | const posX = x * cellWidth * xSpacing; 61 | const posY = y * cellHeight * ySpacing; 62 | const dx = posX - centerX + xOffset; 63 | const dy = posY - centerY + yOffset; 64 | const r = Math.sqrt(dx * dx + dy * dy); 65 | 66 | switch (type) { 67 | case 'fluid': 68 | vectorAngle = (Math.sin((posX + xOffset) * 0.05 + seed) + Math.cos((posY + yOffset) * 0.05 + seed)) * Math.PI + direction * Math.PI / 180; 69 | length = 5 + Math.sin((posX + xOffset) * 0.01 + (posY + yOffset) * 0.01 + seed) * 2 * intensity; 70 | break; 71 | case 'magnetic': 72 | vectorAngle = Math.atan2(dy, dx) + Math.PI / 2 + direction * Math.PI / 180; 73 | length = 5 + r * 0.02 * intensity; 74 | break; 75 | case 'electric': 76 | vectorAngle = Math.atan2(dy, dx) + direction * Math.PI / 180; 77 | length = (10 - Math.min(r * 0.03, 8)) * intensity; 78 | break; 79 | case 'vortex': 80 | vectorAngle = Math.atan2(dy, dx) + Math.PI / 2 + direction * Math.PI / 180; 81 | length = (5 + Math.min(r * 0.05, 10)) * intensity; 82 | break; 83 | case 'sink': 84 | vectorAngle = Math.atan2(dy, dx) + Math.PI + direction * Math.PI / 180; 85 | length = (10 - Math.min(r * 0.05, 9)) * intensity; 86 | break; 87 | case 'source': 88 | vectorAngle = Math.atan2(dy, dx) + direction * Math.PI / 180; 89 | length = (10 - Math.min(r * 0.05, 9)) * intensity; 90 | break; 91 | case 'saddle': 92 | vectorAngle = Math.atan2(dy * dy - dx * dx, 2 * dx * dy) + direction * Math.PI / 180; 93 | length = Math.min(Math.sqrt(Math.abs(dx * dx - dy * dy)) * 0.1, 10) * intensity; 94 | break; 95 | case 'wind': 96 | vectorAngle = direction * Math.PI / 180 + Math.sin((posY + yOffset) * 0.1 + seed) * 0.5; 97 | length = (5 + Math.cos((posX + xOffset) * 0.1 + seed) * 2) * intensity; 98 | break; 99 | case 'grid': 100 | vectorAngle = direction * Math.PI / 180; 101 | length = 5 * intensity; 102 | break; 103 | case 'half-screen': 104 | if (posY >= frameHeight / 2) { 105 | vectorAngle = direction * Math.PI / 180; 106 | length = 5 * intensity; 107 | } else { 108 | continue; 109 | } 110 | break; 111 | default: 112 | console.warn(`Unknown field type: ${type}`); 113 | continue; 114 | } 115 | 116 | if (spiral && type !== 'grid' && type !== 'half-screen') { 117 | const spiralAngle = Math.atan2(dy, dx) + r * spiralIntensity * 0.1; 118 | vectorAngle = (vectorAngle + spiralAngle) / 2; 119 | } 120 | 121 | newField.push({ x: posX, y: posY, angle: vectorAngle, length, r }); 122 | } 123 | } 124 | return newField; 125 | }, [frameWidth, frameHeight]); 126 | 127 | const handleShapeChange = (value) => { 128 | setShape(value); 129 | if (value === 'custom') { 130 | setIsWaitingForVectorSelection(true); 131 | parent.postMessage({ pluginMessage: { type: 'request-vector-selection' } }, '*'); 132 | } 133 | }; 134 | 135 | const updateFrameDimensions = useCallback((width, height) => { 136 | if (fillParent) { 137 | const aspectRatio = width / height; 138 | let scaledWidth, scaledHeight; 139 | 140 | if (aspectRatio > 1) { 141 | scaledWidth = 600; 142 | scaledHeight = 600 / aspectRatio; 143 | } else { 144 | scaledHeight = 600; 145 | scaledWidth = 600 * aspectRatio; 146 | } 147 | 148 | setFrameWidth(scaledWidth); 149 | setFrameHeight(scaledHeight); 150 | } else { 151 | setFrameWidth(600); 152 | setFrameHeight(600); 153 | setActualFrameHeight(600); 154 | setActualFrameWidth(600); 155 | } 156 | }, [fillParent]); 157 | 158 | const getComplementaryColor = useCallback((hexColor) => { 159 | let r = parseInt(hexColor.slice(1, 3), 16); 160 | let g = parseInt(hexColor.slice(3, 5), 16); 161 | let b = parseInt(hexColor.slice(5, 7), 16); 162 | 163 | r /= 255; g /= 255; b /= 255; 164 | const max = Math.max(r, g, b), min = Math.min(r, g, b); 165 | let h, s, l = (max + min) / 2; 166 | 167 | if (max === min) { 168 | h = s = 0; 169 | } else { 170 | const d = max - min; 171 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 172 | switch (max) { 173 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 174 | case g: h = (b - r) / d + 2; break; 175 | case b: h = (r - g) / d + 4; break; 176 | } 177 | h /= 6; 178 | } 179 | 180 | h = (h + 0.5) % 1; 181 | l = l > 0.5 ? Math.max(0, l - 0.5) : Math.min(1, l + 0.5); 182 | s = Math.min(1, s + 0.3); 183 | 184 | const hue2rgb = (p, q, t) => { 185 | if (t < 0) t += 1; 186 | if (t > 1) t -= 1; 187 | if (t < 1 / 6) return p + (q - p) * 6 * t; 188 | if (t < 1 / 2) return q; 189 | if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; 190 | return p; 191 | }; 192 | 193 | const q = l < 0.5 ? l * (1 + s) : l + s - l * s; 194 | const p = 2 * l - q; 195 | r = hue2rgb(p, q, h + 1 / 3); 196 | g = hue2rgb(p, q, h); 197 | b = hue2rgb(p, q, h - 1 / 3); 198 | 199 | return `#${Math.round(r * 255).toString(16).padStart(2, '0')}${Math.round(g * 255).toString(16).padStart(2, '0')}${Math.round(b * 255).toString(16).padStart(2, '0')}`; 200 | }, []); 201 | 202 | useEffect(() => { 203 | const newField = generateField(rows, columns, fieldType, direction, intensity, spiral, spiralIntensity, xSpacing, ySpacing, xOffset, yOffset); 204 | setField(newField); 205 | }, [rows, columns, fieldType, direction, intensity, spiral, spiralIntensity, xSpacing, ySpacing, xOffset, yOffset, generateField]); 206 | 207 | useEffect(() => { 208 | window.onmessage = (event) => { 209 | const message = event.data.pluginMessage; 210 | if (message.type === "frame-selected") { 211 | const { width, height, backgroundColor: frameBgColor } = message; 212 | setActualFrameWidth(width); 213 | setActualFrameHeight(height); 214 | updateFrameDimensions(width, height); 215 | if (frameBgColor && !userOverrideBgColor) { 216 | setBackgroundColor(frameBgColor); 217 | const complementaryColor = getComplementaryColor(frameBgColor); 218 | setShapeColor(complementaryColor); 219 | } 220 | } else if (message.type === "vector-selected") { 221 | if (message.svg) { 222 | parseSvgString(message.svg, 'figma'); 223 | setIsWaitingForVectorSelection(false); 224 | } else { 225 | console.error('Received vector-selected message without SVG data'); 226 | } 227 | } else if (message.type === "no-frame-selected") { 228 | setActualFrameWidth(600); 229 | setActualFrameHeight(600); 230 | updateFrameDimensions(600, 600); 231 | setBackgroundColor('#1e1e1e'); 232 | setShapeColor('#ffffff'); 233 | } 234 | }; 235 | }, [updateFrameDimensions, getComplementaryColor, userOverrideBgColor]); 236 | 237 | const parseSvgString = (svgString, source) => { 238 | const parser = new DOMParser(); 239 | const svgDoc = parser.parseFromString(svgString, 'image/svg+xml'); 240 | const svgElement = svgDoc.querySelector('svg'); 241 | 242 | if (svgElement) { 243 | const viewBox = svgElement.getAttribute('viewBox'); 244 | setCustomShapeViewBox(viewBox); 245 | setCustomShape(svgString); 246 | setShape('custom'); 247 | setCustomShapeSource(source); 248 | setUserChangedColor(false); // Reset this when a new custom shape is set 249 | } else { 250 | console.error('No SVG element found in the parsed document'); 251 | } 252 | }; 253 | 254 | useEffect(() => { 255 | updateFrameDimensions(actualFrameWidth, actualFrameHeight); 256 | }, [fillParent, actualFrameWidth, actualFrameHeight, updateFrameDimensions]); 257 | 258 | useEffect(() => { 259 | const handleKeyDown = (event) => { 260 | if (event.key === 'Escape') { 261 | // Send a message to the plugin to close itself 262 | parent.postMessage({ pluginMessage: { type: 'close-plugin' } }, '*'); 263 | } 264 | }; 265 | window.addEventListener('keydown', handleKeyDown); 266 | }, []); 267 | 268 | const getGradientOpacity = useCallback((vector) => { 269 | const { x, y, r } = vector; 270 | const maxR = Math.sqrt(frameWidth * frameWidth / 4 + frameHeight * frameHeight / 4); 271 | switch (gradientType) { 272 | case 'radial': 273 | return Math.max(0, 1 - r / maxR); 274 | case 'angular': 275 | return (Math.atan2(y - frameHeight / 2, x - frameWidth / 2) + Math.PI) / (2 * Math.PI); 276 | case 'wave': 277 | return (Math.sin(r * 0.1) + 1) / 2; 278 | default: 279 | return 1; 280 | } 281 | }, [gradientType, frameWidth, frameHeight]); 282 | 283 | const renderCustomSvg = useCallback(() => { 284 | if (customShape && customShapeViewBox) { 285 | const parser = new DOMParser(); 286 | const svgDoc = parser.parseFromString(customShape, 'image/svg+xml'); 287 | const svgElement = svgDoc.documentElement; 288 | 289 | const updateColors = (element) => { 290 | if (customShapeSource === 'figma') { 291 | if (element.hasAttribute('fill') && element.getAttribute('fill') !== 'none') { 292 | element.setAttribute('fill', shapeColor); 293 | } 294 | if (element.hasAttribute('stroke') && element.getAttribute('stroke') !== 'none') { 295 | element.setAttribute('stroke', shapeColor); 296 | } 297 | } 298 | Array.from(element.children).forEach(updateColors); 299 | }; 300 | 301 | updateColors(svgElement); 302 | 303 | const serializer = new XMLSerializer(); 304 | const svgString = serializer.serializeToString(svgElement); 305 | 306 | return ( 307 | 308 | ); 309 | } 310 | return null; 311 | }, [customShape, customShapeViewBox, shapeColor, userChangedColor, customShapeSource]); 312 | 313 | 314 | const renderVector = useCallback((vector, index) => { 315 | const scaledLength = vector.length * vectorScale; 316 | const endX = vector.x + Math.cos(vector.angle) * scaledLength; 317 | const endY = vector.y + Math.sin(vector.angle) * scaledLength; 318 | const opacity = getGradientOpacity(vector); 319 | const color = `${shapeColor}${Math.round(opacity * 255).toString(16).padStart(2, '0')}`; 320 | 321 | if (shape === 'custom' && customShape && customShapeViewBox) { 322 | const [, , vbWidth, vbHeight] = customShapeViewBox.split(' ').map(Number); 323 | const aspectRatio = vbWidth / vbHeight; 324 | 325 | const cellSize = Math.min(frameWidth / columns, frameHeight / rows); 326 | const baseSize = cellSize * shapeSize; 327 | 328 | let scaledWidth, scaledHeight; 329 | if (aspectRatio > 1) { 330 | scaledWidth = baseSize; 331 | scaledHeight = baseSize / aspectRatio; 332 | } else { 333 | scaledHeight = baseSize; 334 | scaledWidth = baseSize * aspectRatio; 335 | } 336 | 337 | const parser = new DOMParser(); 338 | const svgDoc = parser.parseFromString(customShape, 'image/svg+xml'); 339 | const svgElement = svgDoc.documentElement; 340 | 341 | const updateColors = (element) => { 342 | if (customShapeSource === 'figma' || gradientType !== 'none') { 343 | if (element.hasAttribute('fill') && element.getAttribute('fill') !== 'none') { 344 | element.setAttribute('fill', color); 345 | } 346 | if (element.hasAttribute('stroke') && element.getAttribute('stroke') !== 'none') { 347 | element.setAttribute('stroke', color); 348 | } 349 | } 350 | Array.from(element.children).forEach(updateColors); 351 | }; 352 | 353 | updateColors(svgElement); 354 | 355 | const serializer = new XMLSerializer(); 356 | const svgString = serializer.serializeToString(svgElement); 357 | 358 | return ( 359 | 360 | 367 | 368 | ); 369 | } 370 | 371 | switch (shape) { 372 | case 'line': 373 | return ( 374 | 384 | ); 385 | case 'dot': 386 | return ( 387 | 394 | ); 395 | case 'arrow': 396 | const arrowSize = 2 * shapeSize; 397 | const angle = Math.atan2(endY - vector.y, endX - vector.x); 398 | return ( 399 | 400 | 408 | 412 | 413 | ); 414 | case 'triangle': 415 | const triangleSize = 3 * shapeSize; 416 | return ( 417 | 425 | ); 426 | default: 427 | return null; 428 | } 429 | }, [shape, shapeSize, vectorScale, lineThickness, getGradientOpacity, shapeColor, customShape, customShapeViewBox, columns, rows, frameWidth, frameHeight, renderCustomSvg, roundedCorners]); 430 | 431 | const handleSvgPaste = (event) => { 432 | const pastedValue = event.target.value; 433 | setPastedSvg(pastedValue); 434 | if (pastedValue) { 435 | parseSvgString(pastedValue, 'text'); 436 | } 437 | }; 438 | 439 | const handleColorChange = (color) => { 440 | setShapeColor(color); 441 | setUserChangedColor(true); 442 | }; 443 | 444 | const handleBackgroundColorChange = (color) => { 445 | setBackgroundColor(color); 446 | setUserOverrideBgColor(true); 447 | }; 448 | 449 | const resetState = () => { 450 | setRows(20); 451 | setColumns(20); 452 | setFieldType('magnetic'); 453 | setDirection(0); 454 | setIntensity(1); 455 | setShape('line'); 456 | setRoundedCorners(true); 457 | setSpiral(true); 458 | setSpiralIntensity(0.1); 459 | setGradientType('none'); 460 | setShapeSize(1); 461 | setVectorScale(1); 462 | setLineThickness(1); 463 | setSpacing(1); 464 | setXOffset(0); 465 | setYOffset(0); 466 | setXSpacing(1); 467 | setYSpacing(1); 468 | setFillParent(true); 469 | setCustomShape(null); 470 | setIsWaitingForVectorSelection(false); 471 | setUserOverrideBgColor(false); 472 | setUserChangedColor(false); 473 | setCustomShape(null); 474 | setCustomShapeViewBox(null); 475 | setPastedSvg(''); 476 | }; 477 | 478 | const sendSvgToFigma = () => { 479 | if (svgRef.current) { 480 | const svgElement = svgRef.current.cloneNode(true); 481 | const scaleX = actualFrameWidth / frameWidth; 482 | const scaleY = actualFrameHeight / frameHeight; 483 | 484 | // Set the viewBox to match the original preview dimensions 485 | svgElement.setAttribute('viewBox', `0 0 ${frameWidth} ${frameHeight}`); 486 | svgElement.setAttribute('width', actualFrameWidth); 487 | svgElement.setAttribute('height', actualFrameHeight); 488 | 489 | // We won't apply any scaling to individual elements 490 | // Instead, we'll let Figma handle the scaling based on the new width and height 491 | 492 | const svgData = new XMLSerializer().serializeToString(svgElement); 493 | const cleanedSvgData = svgData.replace(/xmlns="[^"]*"/, ''); 494 | 495 | console.log('Sending data to Figma:', { 496 | svg: cleanedSvgData, 497 | width: actualFrameWidth, 498 | height: actualFrameHeight, 499 | backgroundColor, 500 | frameWidth, 501 | frameHeight, 502 | scaleX, 503 | scaleY 504 | }); 505 | 506 | parent.postMessage({ 507 | pluginMessage: { 508 | type: 'create-svg', 509 | svg: cleanedSvgData, 510 | width: actualFrameWidth, 511 | height: actualFrameHeight, 512 | backgroundColor: backgroundColor, 513 | scaleX: scaleX, 514 | scaleY: scaleY, 515 | isCustomShape: shape === 'custom' 516 | }, 517 | }, '*'); 518 | } else { 519 | console.error('SVG ref is null'); 520 | } 521 | }; 522 | 523 | const renderControl = (label, value, setValue, min, max, step, unit = '', disabled = false) => ( 524 |
525 | 528 | 536 |
537 | ); 538 | 539 | return ( 540 | 541 |
542 |
543 | 557 | {field.map((vector, index) => renderVector(vector, index))} 558 | 559 |
560 | {Math.round(actualFrameWidth)}x{Math.round(actualFrameHeight)} 561 |
562 |
563 |
564 |
565 |