├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets └── context-menu.png ├── docs ├── detachment.md ├── faq.md ├── fills.md ├── lines.md ├── padding.md ├── spacing.md └── tooltips.md ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── src ├── App.tsx ├── components │ ├── ColorPicker.tsx │ ├── EmptyScreenImage.tsx │ ├── Input.tsx │ ├── QuestionMark.tsx │ ├── Toggle.tsx │ ├── Tooltip.tsx │ └── icons │ │ ├── DetachedIcon.tsx │ │ ├── EyeIcons.tsx │ │ ├── RefreshIcon.tsx │ │ ├── SpacingIcon.tsx │ │ ├── SuccessIcon.tsx │ │ ├── TooltipIcons.tsx │ │ ├── TrashIcon.tsx │ │ └── WarningIcon.tsx ├── main │ ├── fill.ts │ ├── helper.ts │ ├── index.ts │ ├── line.ts │ ├── padding.ts │ ├── spacing.ts │ ├── store.ts │ └── tooltip │ │ ├── index.ts │ │ └── types │ │ ├── index.ts │ │ └── parts │ │ ├── cornerRadius.ts │ │ ├── effects.ts │ │ ├── fills.ts │ │ ├── font-name.ts │ │ ├── height.ts │ │ ├── name.ts │ │ ├── opacity.ts │ │ ├── point-count.ts │ │ ├── strokes.ts │ │ ├── variant-properties.ts │ │ └── width.ts ├── shared │ ├── EventEmitter.ts │ ├── constants.ts │ ├── helpers.ts │ ├── index.ts │ ├── interfaces.ts │ └── style.ts ├── store │ └── index.tsx ├── style.ts ├── ui.css └── views │ ├── About │ ├── components │ │ ├── DetachmentImage.tsx │ │ ├── FillsImage.tsx │ │ ├── LinesImage.tsx │ │ ├── PaddingImage.tsx │ │ ├── SpacingImage.tsx │ │ └── TooltipsImage.tsx │ └── index.tsx │ ├── Home │ ├── components │ │ ├── CenterChooser.tsx │ │ ├── LineChooser.tsx │ │ └── Viewer │ │ │ ├── components │ │ │ ├── Line.tsx │ │ │ └── Spacing.tsx │ │ │ └── index.tsx │ └── index.tsx │ └── Settings │ ├── components │ └── DebugModal.tsx │ └── index.tsx ├── styled.d.ts ├── tsconfig.json └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | figma-measure/ 2 | Figma Measure/ 3 | Measure/ 4 | node_modules 5 | *.zip 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Philip Stapelfeldt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Figma Measure 2 | 3 | ![header](https://github.com/ph1p/figma-measure/assets/15351728/d5f7c98c-d198-491d-b7be-4fc24f9d1222) 4 | 5 | A plugin to add measurement lines to figma. 6 | 7 | You can find it here: https://www.figma.com/community/plugin/739918456607459153/Measure 8 | 9 | ### Preview 10 | 11 | You can find some videos inside the `./docs` folder. 12 | 13 | ### How to use? 14 | 15 | - Open Figma 16 | - Go to **Plugins** 17 | - Click on **Browse all plugins**. 18 | - Search for **Measure** and click install 19 | - Ready! 20 | 21 | You can now find this plugin in the **Plugins** section by right-clicking on your project. 22 | Clicking **Measure** opens a window. 23 | Now you can select one or more elements and click on the different alignments to add lines, tooltips, fills etc. 24 | 25 | Feel free to open a feature request: https://github.com/ph1p/figma-measure/issues 26 | 27 | ### Development 28 | 29 | ```bash 30 | git clone git@github.com:ph1p/figma-measure.git 31 | cd figma-measure 32 | npm install 33 | ``` 34 | 35 | ```bash 36 | npm run build 37 | ``` 38 | 39 | or 40 | 41 | ```bash 42 | npm run dev 43 | ``` 44 | 45 | - Open figma 46 | - Go to **Plugins** 47 | - Click the "+" next to **Development** 48 | - Choose the manifest.json inside `figma-measure/Figma-Measure` 49 | - Ready to develop 50 | -------------------------------------------------------------------------------- /assets/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ph1p/figma-measure/5d526939073bbe0c10234ac86ad05a933828c769/assets/context-menu.png -------------------------------------------------------------------------------- /docs/detachment.md: -------------------------------------------------------------------------------- 1 | ![detachment](https://user-images.githubusercontent.com/15351728/150683212-cf689bf0-b08d-468a-934a-50147f701b78.png) 2 | 3 | This feature allows you to detach all measurements from "Figma-Measure". Any measurement created in this mode will no longer be controlled by the plugin. 4 | 5 | https://user-images.githubusercontent.com/15351728/150694096-e20ada0d-4b75-4b1e-a3a1-5b6933032cb1.mp4 6 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | ![faq](https://user-images.githubusercontent.com/15351728/150683210-99e51a85-2cd0-4b96-aecc-699364c7e2f9.png) 2 | 3 | ### How are measurement groups created? 4 | 5 | The plugin searches for the nearest parent group/frame and places it there. 6 | 7 | ### May I rename/move the measurement groups? 8 | 9 | There are two different measurement groups attached groups (📐 Measurements) and detached groups (🔌 Measurements). 10 | 11 | With detached groups you can do anything you want. 12 | With attached groups I would not recommend it, because "Figma-Measure" searches for it and needs it to control the measurements. 13 | 14 | If "Figma-Measure" does not find one of these groups, it creates new ones. 15 | 16 | ### Help! Measurements are cut off 17 | 18 | First. Don't panic. This is a normal behavior of figma. When you create a frame, it is automatically created with clipped content. That's why it can look like this: 19 | 20 | ![clip](https://user-images.githubusercontent.com/15351728/150818512-e449696e-146f-48f0-aa25-084cb411d4d3.png) 21 | 22 | There are two solutions to this "problem": 23 | 24 | - Turn of the content clipping of you frame 25 | - Create a [detached](https://github.com/ph1p/figma-measure/blob/master/docs/detachment.md) measurement and move it to the parent node. 26 | -------------------------------------------------------------------------------- /docs/fills.md: -------------------------------------------------------------------------------- 1 | ![fills](https://user-images.githubusercontent.com/15351728/150770089-9eb66584-1a91-417e-b6f0-79ee071177f9.png) 2 | 3 | https://user-images.githubusercontent.com/15351728/150771703-adfa2172-28e4-4030-b5bf-58d2afb019b4.mp4 4 | -------------------------------------------------------------------------------- /docs/lines.md: -------------------------------------------------------------------------------- 1 | ![lines](https://user-images.githubusercontent.com/15351728/150683217-658a62c1-afa5-408f-8eaa-c0816b401b92.png) 2 | 3 | These two videos show you how to create line measurements and make additional configurations. 4 | 5 | https://user-images.githubusercontent.com/15351728/150694083-08d07152-6094-4140-9f2c-21b824cecb5e.mp4 6 | 7 | https://user-images.githubusercontent.com/15351728/150694091-eee9c50a-0921-4af5-b101-f7c404be8d87.mp4 8 | -------------------------------------------------------------------------------- /docs/padding.md: -------------------------------------------------------------------------------- 1 | ![padding](https://user-images.githubusercontent.com/15351728/150683207-1a5d92c7-b3da-4f27-a713-feb71f7cfd0a.png) 2 | 3 | You can make outer spacing for individual elements that have a parent element, such as a frame. 4 | Otherwise, mark two elements where one is inside the other. 5 | 6 | https://user-images.githubusercontent.com/15351728/150694090-63293fb5-a358-4389-bc88-c160c63f8ff3.mp4 7 | -------------------------------------------------------------------------------- /docs/spacing.md: -------------------------------------------------------------------------------- 1 | ![spacing](https://user-images.githubusercontent.com/15351728/150683216-4b984e0a-95d5-41fb-a112-74ad75a55ecc.png) 2 | 3 | Distance measurements are always only possible between exactly two elements. 4 | 5 | ## Remove 6 | 7 | Click on a single element with a distance measurement to remove it. 8 | 9 | https://user-images.githubusercontent.com/15351728/150694089-3b5d6782-2469-4154-8c27-28cd1e8db569.mp4 10 | -------------------------------------------------------------------------------- /docs/tooltips.md: -------------------------------------------------------------------------------- 1 | ![tooltips](https://user-images.githubusercontent.com/15351728/150683213-701fc5d5-71f2-44ec-a938-8cfbeec3e672.png) 2 | 3 | https://user-images.githubusercontent.com/15351728/150694102-0b9f41cf-c55b-49db-9f0a-1c7ed6643dda.mp4 4 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from '@eslint/js'; 2 | import tsParser from '@typescript-eslint/parser'; 3 | import importPlugin from 'eslint-plugin-import'; 4 | import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config({ 8 | extends: [ 9 | { 10 | files: ['src/**/*.{js,ts,tsx}'], 11 | }, 12 | { 13 | ignores: ['**/*.json', 'Measure/**/*'], 14 | }, 15 | eslint.configs.recommended, 16 | tseslint.configs.recommended, 17 | importPlugin.flatConfigs.recommended, 18 | eslintPluginPrettierRecommended, 19 | ], 20 | 21 | languageOptions: { 22 | globals: { 23 | figma: true, 24 | }, 25 | 26 | parser: tsParser, 27 | ecmaVersion: 5, 28 | sourceType: 'module', 29 | 30 | parserOptions: { 31 | project: 'tsconfig.json', 32 | }, 33 | }, 34 | 35 | settings: { 36 | 'import/resolver': { 37 | typescript: { 38 | alwaysTryTypes: true, 39 | project: ['tsconfig.json'], 40 | }, 41 | }, 42 | }, 43 | 44 | rules: { 45 | '@typescript-eslint/explicit-module-boundary-types': 0, 46 | '@typescript-eslint/no-explicit-any': 0, 47 | 'react/no-did-mount-set-state': 0, 48 | 'react-hooks/exhaustive-deps': 0, 49 | 50 | 'import/order': [ 51 | 'error', 52 | { 53 | pathGroups: [ 54 | { 55 | pattern: '^[a-zA-Z]', 56 | group: 'builtin', 57 | position: 'after', 58 | }, 59 | ], 60 | 61 | pathGroupsExcludedImportTypes: ['builtin'], 62 | groups: [['builtin', 'external'], 'internal', 'parent', 'sibling'], 63 | 'newlines-between': 'always', 64 | 65 | alphabetize: { 66 | order: 'asc', 67 | }, 68 | }, 69 | ], 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-measure", 3 | "version": "3.4.2", 4 | "description": "", 5 | "main": "code.js", 6 | "scripts": { 7 | "build": "webpack --mode=production && zip -r -X Measure.zip ./Measure/ && rm ./Measure/ui.js*", 8 | "dev": "DEBUG=* webpack --watch", 9 | "version": "conventional-changelog -p karma -i CHANGELOG.md -s -r 0 && git add .", 10 | "lint": "eslint . --fix" 11 | }, 12 | "author": "Philip Stapelfeldt ", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "@eslint/eslintrc": "^3.2.0", 16 | "@eslint/js": "^9.20.0", 17 | "@figma/plugin-typings": "^1.107.0", 18 | "@types/node": "^22.13.4", 19 | "@types/react-dom": "^19.0.4", 20 | "@types/react-router-dom": "^5.3.3", 21 | "@typescript-eslint/eslint-plugin": "^8.24.1", 22 | "@typescript-eslint/parser": "^8.24.1", 23 | "create-file-webpack": "^1.0.2", 24 | "css-loader": "^7.1.2", 25 | "esbuild-loader": "^4.3.0", 26 | "eslint": "^9.20.1", 27 | "eslint-import-resolver-typescript": "^3.8.2", 28 | "eslint-plugin-import": "^2.31.0", 29 | "eslint-plugin-prettier": "^5.2.3", 30 | "eslint-plugin-react": "^7.37.4", 31 | "html-webpack-plugin": "^5.6.3", 32 | "style-loader": "^4.0.0", 33 | "styled-components": "^6.1.15", 34 | "terser-webpack-plugin": "^5.3.11", 35 | "typescript": "^5.7.3", 36 | "typescript-eslint": "^8.24.1", 37 | "url-loader": "^4.1.1", 38 | "webpack": "^5.98.0", 39 | "webpack-cli": "^6.0.1" 40 | }, 41 | "figmaPlugin": { 42 | "documentAccess": "dynamic-page", 43 | "name": "Measure", 44 | "id": "739918456607459153", 45 | "api": "1.0.0", 46 | "main": "code.js", 47 | "ui": "ui.html", 48 | "enablePrivatePluginApi": true, 49 | "editorType": [ 50 | "figma", 51 | "dev" 52 | ], 53 | "networkAccess": { 54 | "allowedDomains": [ 55 | "https://rsms.me", 56 | "https://unavatar.io" 57 | ] 58 | }, 59 | "capabilities": [ 60 | "inspect" 61 | ], 62 | "relaunchButtons": [ 63 | { 64 | "command": "open", 65 | "name": "Open Measure" 66 | } 67 | ] 68 | }, 69 | "dependencies": { 70 | "@popperjs/core": "^2.11.8", 71 | "mobx": "^6.13.6", 72 | "mobx-react": "^9.2.0", 73 | "mobx-sync": "^3.0.0", 74 | "react": "^19.0.0", 75 | "react-dom": "^19.0.0", 76 | "react-popper": "^2.3.0", 77 | "react-router-dom": "^7.2.0" 78 | }, 79 | "prettier": { 80 | "singleQuote": true 81 | }, 82 | "packageManager": "pnpm@10.2.0+sha512.0d27364e0139c6aadeed65ada153135e0ca96c8da42123bd50047f961339dc7a758fc2e944b428f52be570d1bd3372455c1c65fa2e7aa0bfbf931190f9552001" 83 | } 84 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { reaction, toJS } from 'mobx'; 2 | import { observer } from 'mobx-react'; 3 | import React, { FunctionComponent, useEffect } from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { 6 | Link, 7 | LinkProps, 8 | Route, 9 | MemoryRouter as Router, 10 | Routes, 11 | useMatch, 12 | useResolvedPath, 13 | } from 'react-router-dom'; 14 | import styled, { ThemeProvider } from 'styled-components'; 15 | 16 | import EventEmitter from './shared/EventEmitter'; 17 | import { Alignments, NodeSelection, PluginNodeData } from './shared/interfaces'; 18 | import { StoreProvider, getStoreFromMain, trunk, useStore } from './store'; 19 | import { 20 | DEFAULT_COLOR, 21 | GlobalStyle, 22 | getColorByTypeAndSolidColor, 23 | theme, 24 | } from './style'; 25 | import About from './views/About'; 26 | import Home from './views/Home'; 27 | import Settings from './views/Settings'; 28 | 29 | import './ui.css'; 30 | 31 | const CustomLink = ({ children, to, ...props }: LinkProps) => { 32 | const resolved = useResolvedPath(to); 33 | const match = useMatch({ path: resolved.pathname, end: true }); 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | const App: FunctionComponent = observer(() => { 43 | const store = useStore(); 44 | 45 | useEffect(() => { 46 | // check selection 47 | EventEmitter.ask('current selection').then((data: NodeSelection) => 48 | store.setSelection(data.nodes), 49 | ); 50 | 51 | EventEmitter.on('selection', (data: NodeSelection) => { 52 | store.setSelection(data.nodes); 53 | }); 54 | 55 | return () => EventEmitter.remove('selection'); 56 | }, []); 57 | 58 | // set data from selection 59 | useEffect( 60 | () => 61 | reaction( 62 | () => store.selection.slice(), 63 | () => { 64 | const selection = toJS(store.selection); 65 | if (selection.length > 0) { 66 | try { 67 | const padding = selection[0]?.padding || {}; 68 | const data: Partial = selection[0]?.data || null; 69 | 70 | if (data && !store.detached) { 71 | // padding 72 | if (Object.keys(padding).length > 0) { 73 | if (!data.surrounding) { 74 | // @ts-expect-error it filled afterwarts 75 | data.surrounding = {}; 76 | } 77 | 78 | for (const direction of Object.keys(Alignments)) { 79 | data.surrounding[`${direction.toLowerCase()}Padding`] = 80 | !!padding[direction]; 81 | } 82 | } else { 83 | for (const direction of Object.keys(Alignments)) { 84 | data.surrounding[`${direction.toLowerCase()}Padding`] = 85 | false; 86 | } 87 | } 88 | 89 | if (data?.labelFontSize) { 90 | store.setLabelFontSize(data?.labelFontSize, true); 91 | } 92 | 93 | if (Object.keys(data?.surrounding).length > 0) { 94 | store.setSurrounding(data.surrounding, true); 95 | } else { 96 | store.resetSurrounding(); 97 | } 98 | 99 | store.setAllNodeMeasurementData(data); 100 | } 101 | } catch { 102 | store.resetSurrounding(); 103 | } 104 | } else { 105 | store.resetSurrounding(); 106 | } 107 | }, 108 | ), 109 | [], 110 | ); 111 | 112 | return ( 113 | 121 | 122 | 123 |
124 | 125 |
    126 |
  • 127 | Measurement 128 |
  • 129 |
  • 130 | Settings 131 |
  • 132 |
  • 133 | About 134 |
  • 135 |
136 |
137 | 138 | } /> 139 | } /> 140 | } /> 141 | 142 |
143 |
144 | ); 145 | }); 146 | 147 | const elementRoot = createRoot(document.getElementById('app')); 148 | 149 | getStoreFromMain().then((store) => 150 | trunk.init(store).then(() => 151 | elementRoot.render( 152 | 153 | 154 | 155 | 156 | , 157 | ), 158 | ), 159 | ); 160 | 161 | const Main = styled.div` 162 | display: flex; 163 | flex-direction: column; 164 | justify-content: space-between; 165 | `; 166 | 167 | const Navigation = styled.nav` 168 | position: sticky; 169 | top: 0px; 170 | z-index: 30; 171 | overflow: hidden; 172 | height: 42px; 173 | border-width: 0px 0px 1px; 174 | border-color: var(--figma-color-bg-secondary); 175 | border-style: solid; 176 | width: 100%; 177 | padding: 0px 12px; 178 | font-size: 11px; 179 | background: var(--figma-color-bg); 180 | ul { 181 | display: flex; 182 | list-style: none; 183 | margin: 0; 184 | padding: 0; 185 | height: 100%; 186 | li { 187 | align-self: center; 188 | a { 189 | color: var(--figma-color-text-tertiary); 190 | text-decoration: none; 191 | &:hover { 192 | color: var(--figma-color-text-secondary); 193 | } 194 | &.active { 195 | color: var(--figma-color-text); 196 | } 197 | } 198 | &:last-child { 199 | margin-left: auto; 200 | } 201 | &:first-child { 202 | margin-right: 21px; 203 | } 204 | } 205 | } 206 | `; 207 | -------------------------------------------------------------------------------- /src/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { useStore } from '../store'; 6 | import { theme } from '../style'; 7 | 8 | const ColorItem = styled.div<{ $color: string; $active: boolean }>` 9 | position: absolute; 10 | left: 4px; 11 | top: 4px; 12 | z-index: 0; 13 | width: 22px; 14 | height: 22px; 15 | border-radius: 6px; 16 | background-color: ${(props) => props.$color}; 17 | cursor: pointer; 18 | transition: all 0.3s; 19 | `; 20 | 21 | const ColorsWrapper = styled.div` 22 | position: absolute; 23 | display: flex; 24 | left: 12px; 25 | bottom: 12px; 26 | top: initial; 27 | opacity: 1; 28 | z-index: 4; 29 | 30 | .active-color { 31 | position: relative; 32 | z-index: 1; 33 | border: 1px solid var(--figma-color-bg-tertiary); 34 | background-color: transparent; 35 | /* background-color: #fff; 36 | border: 1px solid #e8e8e8; */ 37 | padding: 3px; 38 | border-radius: 10px; 39 | transition: all 0.3s; 40 | width: 30px; 41 | div { 42 | width: 22px; 43 | height: 22px; 44 | border-radius: 6px; 45 | transition: all 0.3s; 46 | background-color: ${(p) => p.theme.color}; 47 | z-index: 1; 48 | } 49 | } 50 | 51 | ${ColorItem} { 52 | opacity: 0; 53 | margin-right: 4px; 54 | &:last-child { 55 | margin-right: 0; 56 | } 57 | } 58 | 59 | &:hover { 60 | width: 196px; 61 | .active-color { 62 | width: 196px; 63 | } 64 | ${ColorItem} { 65 | opacity: 1; 66 | z-index: 2; 67 | &:nth-child(2) { 68 | transform: translate(35px, 0); 69 | } 70 | &:nth-child(3) { 71 | transform: translate(61px, 0); 72 | } 73 | &:nth-child(4) { 74 | transform: translate(87px, 0); 75 | } 76 | &:nth-child(5) { 77 | transform: translate(113px, 0); 78 | } 79 | &:nth-child(6) { 80 | transform: translate(139px, 0); 81 | } 82 | &:nth-child(7) { 83 | transform: translate(165px, 0); 84 | } 85 | } 86 | } 87 | `; 88 | 89 | export const Colors: FunctionComponent = observer(() => { 90 | const store = useStore(); 91 | 92 | return ( 93 | 94 |
95 |
96 |
97 | {theme.colors 98 | .filter((c) => c !== store.color) 99 | .map((color, i) => ( 100 | color !== store.color && store.setColor(color)} 105 | /> 106 | ))} 107 |
108 | ); 109 | }); 110 | -------------------------------------------------------------------------------- /src/components/EmptyScreenImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const EmptyScreenImage: FunctionComponent<{ color: string }> = ({ 4 | color = '#1745E8', 5 | }) => ( 6 | 13 | 23 | 31 | 32 | 33 | 34 | 35 | 36 | 40 | 44 | 45 | 46 | 55 | 56 | 62 | 63 | 64 | 68 | 73 | 79 | 80 | 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | -------------------------------------------------------------------------------- /src/components/Input.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useMemo } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { QuestionMark } from './QuestionMark'; 5 | 6 | export const Input: FunctionComponent< 7 | React.InputHTMLAttributes & { 8 | label?: string; 9 | description?: string | JSX.Element; 10 | width?: number; 11 | } 12 | > = (props) => { 13 | const id = useMemo( 14 | () => props.label.toLowerCase().replace(/\s/g, '-'), 15 | [props.label], 16 | ); 17 | 18 | return ( 19 | 20 | {props.label && } 21 | {props.description && {props.description}} 22 | 23 | 32 | 33 | ); 34 | }; 35 | 36 | const InputWrapper = styled.div.attrs(() => ({ 37 | className: 'input', 38 | }))` 39 | display: flex; 40 | width: 100%; 41 | align-items: center; 42 | label { 43 | margin-right: 10px; 44 | font-weight: 500; 45 | color: var(--figma-color-text); 46 | p { 47 | color: var(--figma-color-text-onbrand-secondary); 48 | font-size: 11px; 49 | font-weight: normal; 50 | margin: 0; 51 | } 52 | } 53 | input { 54 | margin-left: auto; 55 | width: 60px; 56 | background-color: transparent; 57 | border-color: var(--figma-color-bg-tertiary); 58 | color: var(--figma-color-text); 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/QuestionMark.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, PropsWithChildren } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import Tooltip from './Tooltip'; 5 | 6 | interface Props { 7 | hover?: boolean; 8 | } 9 | 10 | export const QuestionMark: FunctionComponent> = ( 11 | props, 12 | ) => ( 13 | ((_, ref) => ( 16 | ? 17 | ))} 18 | > 19 | {props.children} 20 | 21 | ); 22 | 23 | const Description = styled.div` 24 | width: 200px; 25 | strong { 26 | background-color: #fff; 27 | border-radius: 3px; 28 | padding: 1px 3px; 29 | color: #000; 30 | display: inline-block; 31 | } 32 | h3 { 33 | margin: 5px 0 8px; 34 | } 35 | `; 36 | 37 | const Handle = styled.div` 38 | background-color: var(--figma-color-bg-hover); 39 | position: relative; 40 | width: 16px; 41 | height: 16px; 42 | line-height: 16px; 43 | border-radius: 4px; 44 | color: var(--figma-color-bg-inverse); 45 | text-align: center; 46 | cursor: pointer; 47 | &:hover { 48 | background-color: ${(props) => props.theme.color}; 49 | color: ${(props) => props.theme.hoverColor}; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent, useMemo } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { QuestionMark } from './QuestionMark'; 5 | 6 | export const Toggle: FunctionComponent< 7 | React.InputHTMLAttributes & { 8 | description?: string; 9 | label?: string; 10 | inline?: boolean; 11 | } 12 | > = (props) => { 13 | const inputProps = { 14 | ...props, 15 | }; 16 | delete inputProps.label; 17 | delete inputProps.children; 18 | delete inputProps.inline; 19 | 20 | const id = useMemo( 21 | () => props.label.toLowerCase().replace(/\s/g, '-'), 22 | [props.label], 23 | ); 24 | 25 | return ( 26 | 27 | 28 | {props.label && } 29 | {props.description && ( 30 | {props.description} 31 | )} 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | const Wrapper = styled.div` 42 | p { 43 | font-size: 10px; 44 | color: #999; 45 | margin: 0; 46 | } 47 | `; 48 | 49 | const Flex = styled.div<{ inline?: boolean }>` 50 | display: flex; 51 | align-items: center; 52 | width: ${(props) => (props.inline ? 'auto' : '100%')}; 53 | 54 | label { 55 | font-weight: normal; 56 | user-select: none; 57 | cursor: pointer; 58 | margin-right: ${(props) => (props.inline ? 5 : 10)}px; 59 | } 60 | `; 61 | 62 | const InputWrapper = styled.div` 63 | width: 34px; 64 | height: 21px; 65 | box-sizing: border-box; 66 | position: relative; 67 | margin-left: auto; 68 | input { 69 | position: absolute; 70 | width: 100%; 71 | height: 100%; 72 | opacity: 0; 73 | cursor: pointer; 74 | &:checked { 75 | & + span { 76 | background-color: ${(props) => props.theme.color}; 77 | &:before { 78 | content: ''; 79 | transform: translateX(12px); 80 | } 81 | } 82 | } 83 | + span { 84 | transition: background-color 0.3s; 85 | display: block; 86 | pointer-events: none; 87 | position: absolute; 88 | height: 100%; 89 | width: 100%; 90 | content: ''; 91 | background-color: var(--figma-color-bg-hover); 92 | /* background-color: var(--figma-color-bg-disabled); */ 93 | border-radius: 14px; 94 | &:before { 95 | content: ''; 96 | transition: all 0.2s; 97 | position: absolute; 98 | background-color: #fff; 99 | border-radius: 100%; 100 | height: 13px; 101 | width: 13px; 102 | top: 4px; 103 | left: 4px; 104 | box-shadow: 0 0 1px rgba(0, 0, 0, 0.4); 105 | } 106 | } 107 | } 108 | `; 109 | -------------------------------------------------------------------------------- /src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { 3 | RefAttributes, 4 | useEffect, 5 | useImperativeHandle, 6 | useRef, 7 | useState, 8 | } from 'react'; 9 | import { usePopper } from 'react-popper'; 10 | import styled from 'styled-components'; 11 | 12 | interface Props { 13 | handler?: React.ForwardRefExoticComponent>; 14 | hover?: boolean; 15 | children: unknown; 16 | style?: unknown; 17 | offsetHorizontal?: number; 18 | placement?: 'top' | 'bottom'; 19 | padding?: number; 20 | borderRadius?: number; 21 | } 22 | 23 | type TooltipHandle = { 24 | hide: () => void; 25 | }; 26 | 27 | const Tooltip = observer( 28 | React.forwardRef>( 29 | (props, ref) => { 30 | const [isOpen, setIsOpen] = useState(false); 31 | const { handler: HandlerComp } = props; 32 | 33 | const wrapperRef = useRef(null); 34 | const handlerRef = useRef(null); 35 | 36 | const [popperElement, setPopperElement] = useState(null); 37 | const [arrowElement, setArrowElement] = useState(null); 38 | const { styles, attributes } = usePopper( 39 | handlerRef.current, 40 | popperElement, 41 | { 42 | placement: props.placement || 'top', 43 | strategy: 'fixed', 44 | modifiers: [ 45 | { 46 | name: 'arrow', 47 | options: { 48 | element: arrowElement, 49 | }, 50 | }, 51 | { 52 | name: 'offset', 53 | options: { 54 | offset: [0, props.padding || (props.hover ? 10 : 15)], 55 | }, 56 | }, 57 | { 58 | name: 'preventOverflow', 59 | options: { 60 | padding: props.offsetHorizontal || 10, 61 | }, 62 | }, 63 | ], 64 | }, 65 | ); 66 | 67 | useImperativeHandle(ref, () => ({ 68 | hide: () => setIsOpen(false), 69 | })); 70 | 71 | useEffect(() => { 72 | if (!props.hover) { 73 | const handleClick = (event) => { 74 | if (!wrapperRef.current?.contains(event.target)) { 75 | setIsOpen(false); 76 | } 77 | }; 78 | 79 | document.addEventListener('mousedown', handleClick); 80 | return () => document.removeEventListener('mousedown', handleClick); 81 | } 82 | }, [wrapperRef]); 83 | 84 | return ( 85 |
86 |
!props.hover && setIsOpen(!isOpen)} 88 | onMouseEnter={() => props.hover && setIsOpen(!isOpen)} 89 | onMouseLeave={() => props.hover && setIsOpen(!isOpen)} 90 | > 91 | 92 |
93 | 94 | {isOpen && ( 95 | 103 | 108 | {props.children} 109 | 110 | 115 | 116 | )} 117 |
118 | ); 119 | }, 120 | ), 121 | ); 122 | 123 | const TooltipContent = styled.div<{ $hover: boolean; $padding?: number }>` 124 | padding: ${(p) => p.$padding || (p.$hover ? '5px 10px' : 15)}; 125 | position: relative; 126 | z-index: 1; 127 | font-weight: normal; 128 | `; 129 | 130 | const Arrow = styled.div<{ $hover: boolean }>` 131 | position: absolute; 132 | width: 21px; 133 | height: 21px; 134 | pointer-events: none; 135 | &::before { 136 | content: ''; 137 | position: absolute; 138 | width: 21px; 139 | height: 21px; 140 | background-color: #000; 141 | transform: rotate(45deg); 142 | top: 0px; 143 | left: 0px; 144 | border-radius: 2px; 145 | z-index: -1; 146 | } 147 | `; 148 | 149 | const Wrapper = styled.div<{ 150 | $hover: boolean; 151 | $isOpen: boolean; 152 | $borderRadius?: number; 153 | }>` 154 | position: fixed; 155 | background-color: #000; 156 | border-radius: ${(p) => p.$borderRadius || (p.$hover ? 3 : 4)}px; 157 | pointer-events: ${(p) => (p.$isOpen ? 'all' : 'none')}; 158 | z-index: 99; 159 | color: #fff; 160 | font-weight: 500; 161 | 162 | &-enter { 163 | opacity: 0; 164 | } 165 | &-enter-active { 166 | opacity: 1; 167 | transition: 168 | opacity 200ms ease-in, 169 | transform 200ms ease-in; 170 | } 171 | &-exit { 172 | opacity: 1; 173 | } 174 | &-exit-active { 175 | opacity: 0; 176 | transition: 177 | opacity 200ms ease-in, 178 | transform 200ms ease-in; 179 | } 180 | 181 | &[data-popper-placement^='top'] { 182 | ${Arrow} { 183 | bottom: -1px; 184 | } 185 | } 186 | &[data-popper-placement^='bottom'] { 187 | ${Arrow} { 188 | top: -1px; 189 | } 190 | } 191 | &.place-left { 192 | &::after { 193 | margin-top: -10px; 194 | } 195 | } 196 | `; 197 | 198 | export default Tooltip; 199 | -------------------------------------------------------------------------------- /src/components/icons/DetachedIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const DetachedIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export const AttachedIcon: FunctionComponent = () => ( 19 | 26 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/components/icons/EyeIcons.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const EyeIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export const EyeClosedIcon: FunctionComponent = () => ( 19 | 26 | 30 | 31 | ); 32 | -------------------------------------------------------------------------------- /src/components/icons/RefreshIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const RefreshIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/components/icons/SpacingIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const SpacingIcon: FunctionComponent<{ remove?: boolean }> = (props: { 4 | remove?: boolean; 5 | }) => ( 6 | 13 | 14 | {!props.remove && ( 15 | <> 16 | 17 | 25 | 26 | )} 27 | 33 | 39 | 40 | ); 41 | -------------------------------------------------------------------------------- /src/components/icons/SuccessIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const SuccessIcon: FunctionComponent = () => ( 4 | 11 | 12 | 13 | 22 | 23 | ); 24 | -------------------------------------------------------------------------------- /src/components/icons/TooltipIcons.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const PointsIcon: FunctionComponent = () => ( 4 | 11 | 12 | 13 | ); 14 | 15 | export const CornerRadiusIcon: FunctionComponent = () => ( 16 | 23 | 30 | 31 | ); 32 | 33 | export const WidthIcon: FunctionComponent = () => ( 34 | 41 | 46 | 47 | ); 48 | 49 | export const HeightIcon: FunctionComponent = () => ( 50 | 57 | 62 | 63 | ); 64 | 65 | export const FontSizeIcon: FunctionComponent = () => ( 66 | 73 | 79 | 80 | ); 81 | 82 | export const FontStyleIcon = () => ( 83 | 90 | 91 | 92 | ); 93 | 94 | export const FontFamilyIcon = () => ( 95 | 102 | 106 | 110 | 116 | 117 | ); 118 | 119 | export const NameIcon = () => ( 120 | 127 | 131 | 132 | ); 133 | 134 | export const StrokeIcon = () => ( 135 | 142 | 143 | 144 | ); 145 | 146 | export const OpacityIcon = () => ( 147 | 154 | 160 | 161 | ); 162 | 163 | export const ColorIcon = () => ( 164 | 171 | 177 | 178 | ); 179 | -------------------------------------------------------------------------------- /src/components/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const TrashIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/components/icons/WarningIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const WarningIcon: FunctionComponent = () => ( 4 | 11 | 12 | 16 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/main/fill.ts: -------------------------------------------------------------------------------- 1 | import { FillTypes } from '../shared/interfaces'; 2 | 3 | import { hexToRgb, solidColor } from './helper'; 4 | 5 | export const createFill = ( 6 | node: SceneNode, 7 | { fill, opacity, color }: { fill: FillTypes; opacity: number; color: string }, 8 | ) => { 9 | if ( 10 | node.type !== 'WIDGET' && 11 | node.type !== 'CODE_BLOCK' && 12 | node.type !== 'EMBED' && 13 | node.type !== 'LINK_UNFURL' && 14 | node.type !== 'SLICE' && 15 | node.type !== 'STICKY' && 16 | node.type !== 'CONNECTOR' && 17 | node.type !== 'STAMP' && 18 | node.type !== 'SHAPE_WITH_TEXT' && 19 | node.type !== 'MEDIA' 20 | ) { 21 | let cloneNode: SceneNode; 22 | 23 | if ( 24 | node.type === 'FRAME' || 25 | node.type === 'TEXT' || 26 | node.type === 'COMPONENT' || 27 | node.type === 'INSTANCE' || 28 | node.type === 'GROUP' 29 | ) { 30 | cloneNode = figma.createRectangle(); 31 | cloneNode.resize(node.width, node.height); 32 | } else { 33 | cloneNode = node.clone(); 34 | } 35 | 36 | cloneNode.x = node.x; 37 | cloneNode.y = node.y; 38 | cloneNode.relativeTransform = node.absoluteTransform; 39 | 40 | cloneNode.fills = []; 41 | cloneNode.strokes = []; 42 | cloneNode.opacity = 1; 43 | cloneNode.locked = false; 44 | cloneNode.isMask = false; 45 | 46 | cloneNode.setPluginData('data', ''); 47 | cloneNode.setPluginData('spacing', ''); 48 | cloneNode.setPluginData('parent', ''); 49 | cloneNode.setPluginData('padding', ''); 50 | 51 | const { r, g, b } = hexToRgb(color); 52 | 53 | switch (fill) { 54 | case 'dashed': 55 | cloneNode.dashPattern = [4]; 56 | cloneNode.strokes = [].concat(solidColor(r, g, b)); 57 | cloneNode.strokeWeight = 1; 58 | break; 59 | case 'fill': 60 | cloneNode.fills = [].concat({ 61 | ...solidColor(r, g, b), 62 | opacity: opacity / 100, 63 | }); 64 | break; 65 | case 'fill-stroke': 66 | cloneNode.strokes = [].concat(solidColor(r, g, b)); 67 | cloneNode.strokeWeight = 1; 68 | 69 | cloneNode.fills = [].concat({ 70 | ...solidColor(r, g, b), 71 | opacity: opacity / 100, 72 | }); 73 | break; 74 | case 'stroke': 75 | cloneNode.strokes = [].concat(solidColor(r, g, b)); 76 | cloneNode.strokeWeight = 1; 77 | break; 78 | } 79 | 80 | return cloneNode; 81 | } else { 82 | figma.notify('Slices are currently not supported.', { 83 | error: true, 84 | }); 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/main/helper.ts: -------------------------------------------------------------------------------- 1 | import { GROUP_NAME_ATTACHED } from '../shared/constants'; 2 | 3 | export const isPartOfAttachedGroup = (node: SceneNode) => { 4 | if (!node) { 5 | return false; 6 | } 7 | 8 | const parent = node.parent; 9 | if (!parent || parent.type === 'DOCUMENT') { 10 | return false; 11 | } else if (parent.name === GROUP_NAME_ATTACHED && parent.type === 'GROUP') { 12 | return true; 13 | } else { 14 | return isPartOfAttachedGroup(parent as SceneNode); 15 | } 16 | }; 17 | 18 | export const getClosestAttachedGroup = ( 19 | node: SceneNode, 20 | isGlobalGroup = false, 21 | ) => { 22 | let parent; 23 | if (isGlobalGroup) { 24 | parent = figma.currentPage; 25 | } else { 26 | parent = getNearestParentNode({ 27 | node, 28 | isGroupSearch: true, 29 | }); 30 | } 31 | 32 | const foundGroup = parent.findChild( 33 | (n) => n.type === 'GROUP' && n.name === GROUP_NAME_ATTACHED, 34 | ); 35 | 36 | if (foundGroup) { 37 | return foundGroup; 38 | } 39 | 40 | return null; 41 | }; 42 | 43 | export const appendElementsToGroup = ({ 44 | node = null, 45 | nodes = null, 46 | name = GROUP_NAME_ATTACHED, 47 | locked = true, 48 | isGlobalGroup, 49 | }: { 50 | node: SceneNode; 51 | nodes: SceneNode[]; 52 | name?: string; 53 | locked?: boolean; 54 | isGlobalGroup?: boolean; 55 | }) => { 56 | if (nodes.length > 0) { 57 | let parent; 58 | if (isGlobalGroup) { 59 | parent = figma.currentPage; 60 | } else { 61 | parent = getNearestParentNode({ 62 | node, 63 | isGroupSearch: true, 64 | }); 65 | } 66 | 67 | let children = []; 68 | 69 | const foundGroup = parent.findChild( 70 | (n) => n.type === 'GROUP' && n.name === name, 71 | ); 72 | 73 | if (foundGroup) { 74 | children = foundGroup.children; 75 | } 76 | 77 | const group = figma.group([...nodes, ...children], parent); 78 | group.name = name; 79 | group.expanded = false; 80 | group.locked = locked; 81 | } 82 | }; 83 | 84 | export const getRenderBoundsOfRectangle = (node) => { 85 | let nodeBounds = null; 86 | 87 | const dummyRect = figma.createRectangle(); 88 | dummyRect.relativeTransform = node.absoluteTransform; 89 | dummyRect.resize(node.width, node.height); 90 | nodeBounds = dummyRect.absoluteBoundingBox; 91 | dummyRect.remove(); 92 | 93 | return nodeBounds; 94 | }; 95 | 96 | export const getNearestParentNode = ({ 97 | node, 98 | includingAutoLayout = false, 99 | isGroupSearch = false, 100 | }: { 101 | node: SceneNode; 102 | includingAutoLayout?: boolean; 103 | isGroupSearch?: boolean; 104 | }) => { 105 | const parent = node.parent; 106 | 107 | if (isGroupSearch && !isPartOfInstance(node) && !isPartOfAutoLayout(node)) { 108 | return parent; 109 | } else if ( 110 | !isGroupSearch && 111 | (!isPartOfAutoLayout(node) || includingAutoLayout) 112 | ) { 113 | return parent; 114 | } else { 115 | return getNearestParentNode({ 116 | node: parent as SceneNode, 117 | isGroupSearch, 118 | includingAutoLayout, 119 | }); 120 | } 121 | }; 122 | 123 | export const solidColor = (r = 255, g = 0, b = 0): Paint => ({ 124 | type: 'SOLID', 125 | color: { 126 | r: r / 255, 127 | g: g / 255, 128 | b: b / 255, 129 | }, 130 | }); 131 | 132 | export const hexToRgb = (hex: string) => { 133 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 134 | return result 135 | ? { 136 | r: parseInt(result[1], 16), 137 | g: parseInt(result[2], 16), 138 | b: parseInt(result[3], 16), 139 | } 140 | : null; 141 | }; 142 | 143 | export const rgbaToHex = (data) => { 144 | const rgba = data.replace(/rgba?\(|\s+|\)/g, '').split(','); 145 | return `#${( 146 | (1 << 24) + 147 | (parseInt(rgba[0]) << 16) + 148 | (parseInt(rgba[1]) << 8) + 149 | parseInt(rgba[2]) 150 | ) 151 | .toString(16) 152 | .slice(1)}`; 153 | }; 154 | 155 | export const getColor = (color: string) => { 156 | if (color) { 157 | const { r, g, b } = hexToRgb(color); 158 | return solidColor(r, g, b); 159 | } else { 160 | return solidColor(); 161 | } 162 | }; 163 | 164 | export const setTitleBold = (content) => { 165 | let chars = 0; 166 | for (const line of content.characters.split('\n')) { 167 | if (line && ~line.indexOf(':')) { 168 | const [label] = line.split(':'); 169 | 170 | content.setRangeFontName(chars, chars + label.length + 1, { 171 | family: 'Inter', 172 | style: 'Bold', 173 | }); 174 | chars += line.length + 1; 175 | } 176 | } 177 | }; 178 | 179 | export const colorString = (color, opacity) => { 180 | return `rgba(${Math.round(color.r * 255)}, ${Math.round( 181 | color.g * 255, 182 | )}, ${Math.round(color.b * 255)}, ${opacity})`; 183 | }; 184 | 185 | export const createTooltipTextNode = ({ fontColor, fontSize }) => { 186 | const text = figma.createText(); 187 | 188 | text.fontName = { 189 | family: 'Inter', 190 | style: 'Regular', 191 | }; 192 | 193 | text.textAlignHorizontal = 'LEFT'; 194 | const c = hexToRgb(fontColor); 195 | 196 | text.fills = [].concat(solidColor(c.r, c.g, c.b)); 197 | text.fontSize = fontSize; 198 | 199 | return text; 200 | }; 201 | 202 | // thanks to https://github.com/figma-plugin-helper-functions/figma-plugin-helpers/blob/master/src/helpers/isPartOfInstance.ts 203 | export const isPartOfInstance = (node: SceneNode | BaseNode): boolean => { 204 | const parent = node.parent; 205 | if (parent.type === 'INSTANCE') { 206 | return true; 207 | } else if (parent.type === 'PAGE') { 208 | return false; 209 | } else { 210 | return isPartOfInstance(parent as SceneNode); 211 | } 212 | }; 213 | 214 | export const isPartOfAutoLayout = (node: SceneNode | BaseNode): boolean => { 215 | const parent = node.parent; 216 | if ( 217 | (parent.type === 'FRAME' || parent.type === 'COMPONENT') && 218 | parent.layoutMode !== 'NONE' 219 | ) { 220 | return true; 221 | } else if (parent.type === 'PAGE') { 222 | return false; 223 | } else { 224 | return isPartOfAutoLayout(parent as SceneNode); 225 | } 226 | }; 227 | 228 | export const getFontNameData = async ( 229 | textNode: TextNode, 230 | ): Promise<(FontName & { style: []; fontSize: number[] })[]> => { 231 | const fontNameData = []; 232 | 233 | const loadFontAndPush = async (font: FontName) => { 234 | if ( 235 | fontNameData.some( 236 | (f) => f.family === font.family && !f.style.includes(font.style), 237 | ) 238 | ) { 239 | fontNameData.find((f) => f.family === font.family).style.push(font.style); 240 | } else { 241 | if (!fontNameData.some((f) => f.family === font.family)) { 242 | fontNameData.push({ 243 | ...font, 244 | style: [font.style], 245 | fontSize: getFontSizeData(textNode, font.family), 246 | }); 247 | } 248 | } 249 | }; 250 | 251 | if (textNode.fontName === figma.mixed) { 252 | const len = textNode.characters.length; 253 | for (let i = 0; i < len; i++) { 254 | const font = textNode.getRangeFontName(i, i + 1) as FontName; 255 | 256 | await loadFontAndPush(font); 257 | } 258 | } else { 259 | await loadFontAndPush(textNode.fontName); 260 | } 261 | 262 | return fontNameData; 263 | }; 264 | 265 | type FontFill = Paint & { 266 | styleId?: string | null; 267 | name?: string | null; 268 | }; 269 | 270 | export const getFillsByFillStyleId = async ( 271 | fillStyleId: string | typeof figma.mixed, 272 | ) => { 273 | const fills: FontFill[] = []; 274 | 275 | if (fillStyleId && fillStyleId !== figma.mixed) { 276 | const style = (await figma.getStyleByIdAsync(fillStyleId)) as PaintStyle; 277 | 278 | if (style && style.type === 'PAINT') { 279 | for (const paint of style.paints as FontFill[]) { 280 | if (!fills.find((f) => f.styleId === style.id)) { 281 | fills.push({ 282 | ...paint, 283 | name: style.name || null, 284 | styleId: style.id || null, 285 | }); 286 | } 287 | } 288 | } 289 | } 290 | 291 | return fills; 292 | }; 293 | 294 | export const getFontFillsAndStyles = async (textNode: TextNode) => { 295 | const fills: FontFill[] = []; 296 | const styles = []; 297 | 298 | const len = textNode.characters.length; 299 | for (let i = 0; i < len; i++) { 300 | const textStyleId = textNode.getRangeTextStyleId(i, i + 1); 301 | const fillStyleId = textNode.getRangeFillStyleId(i, i + 1); 302 | const fill = textNode.getRangeFills(i, i + 1); 303 | 304 | const fillStyles = await getFillsByFillStyleId(fillStyleId); 305 | 306 | for (const fillStyle of fillStyles) { 307 | if (!fills.find((f) => f.styleId === fillStyle.styleId)) { 308 | fills.push(fillStyle); 309 | } 310 | } 311 | 312 | if (!fillStyleId && fill !== figma.mixed && fill.length === 1) { 313 | if (!fills.find((f) => JSON.stringify(f) === JSON.stringify(fill[0]))) { 314 | fills.push(fill[0]); 315 | } 316 | } 317 | 318 | if ( 319 | textStyleId && 320 | textStyleId !== figma.mixed && 321 | !styles.some((s) => s.id === textStyleId) 322 | ) { 323 | const textStyle = (await figma.getStyleByIdAsync( 324 | textStyleId, 325 | )) as TextStyle; 326 | 327 | if (textStyle.type === 'TEXT') { 328 | const { id, name, fontName, fontSize, lineHeight, letterSpacing } = 329 | textStyle; 330 | 331 | styles.push({ 332 | id, 333 | name, 334 | fontName, 335 | fontSize, 336 | lineHeight, 337 | letterSpacing, 338 | }); 339 | } 340 | } 341 | } 342 | 343 | return { 344 | fills, 345 | styles, 346 | }; 347 | }; 348 | export const getFontSizeData = (textNode: TextNode, fontName: string) => { 349 | const fonts = {}; 350 | 351 | const len = textNode.characters.length; 352 | for (let i = 0; i < len; i++) { 353 | const font = (textNode.getRangeFontName(i, i + 1) as FontName).family; 354 | const fontSize = textNode.getRangeFontSize(i, i + 1) as number; 355 | 356 | if (fonts[font]) { 357 | fonts[font] = Array.from(new Set(fonts[font].concat(fontSize))); 358 | } else { 359 | fonts[font] = [fontSize]; 360 | } 361 | } 362 | 363 | return fontName ? fonts[fontName] : fonts; 364 | }; 365 | -------------------------------------------------------------------------------- /src/main/store.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from '../shared/EventEmitter'; 2 | import { STORAGE_KEY } from '../shared/constants'; 3 | 4 | export const getState = async () => 5 | JSON.parse(await figma.clientStorage.getAsync(STORAGE_KEY)); 6 | 7 | EventEmitter.on('storage', async (key, send) => { 8 | try { 9 | send('storage', await figma.clientStorage.getAsync(key)); 10 | } catch { 11 | send('storage', '{}'); 12 | } 13 | }); 14 | 15 | EventEmitter.on('storage set item', ({ key, value }, send) => { 16 | figma.clientStorage.setAsync(key, value); 17 | 18 | send('storage set item', true); 19 | }); 20 | 21 | EventEmitter.on('storage get item', async (key, send) => { 22 | try { 23 | const store = await figma.clientStorage.getAsync(key); 24 | 25 | send('storage get item', store[key]); 26 | } catch { 27 | send('storage get item', false); 28 | } 29 | }); 30 | 31 | EventEmitter.on('storage remove item', async (key, send) => { 32 | try { 33 | await figma.clientStorage.setAsync(key, undefined); 34 | 35 | send('storage remove item', true); 36 | } catch { 37 | send('storage remove item', false); 38 | } 39 | }); 40 | -------------------------------------------------------------------------------- /src/main/tooltip/index.ts: -------------------------------------------------------------------------------- 1 | import { SetTooltipOptions, TooltipPositions } from '../../shared/interfaces'; 2 | import { solidColor, hexToRgb } from '../helper'; 3 | 4 | import { addNode } from './types'; 5 | 6 | const createArrow = (tooltipFrame, settings, { horizontal, vertical }) => { 7 | const arrowFrame = figma.createFrame(); 8 | const arrow = figma.createRectangle(); 9 | 10 | const bg = hexToRgb(settings.backgroundColor); 11 | 12 | const ARROW_WIDTH = settings.fontSize; 13 | const ARROW_HEIGHT = settings.fontSize; 14 | const FRAME_WIDTH = ARROW_WIDTH / 2; 15 | 16 | // frame 17 | arrowFrame.name = 'Arrow'; 18 | arrowFrame.resize(FRAME_WIDTH, ARROW_HEIGHT); 19 | arrowFrame.x -= FRAME_WIDTH; 20 | arrowFrame.y = tooltipFrame.height / 2 - ARROW_HEIGHT / 2; 21 | arrowFrame.fills = []; 22 | 23 | // arrow 24 | arrow.resize(ARROW_WIDTH, ARROW_HEIGHT); 25 | arrow.fills = [].concat(solidColor(bg.r, bg.g, bg.b)); 26 | arrow.x = 0; 27 | arrow.y = arrowFrame.height / 2; 28 | arrow.rotation = 45; 29 | 30 | if (horizontal === TooltipPositions.LEFT) { 31 | arrowFrame.rotation = 180; 32 | arrowFrame.x += tooltipFrame.width + ARROW_WIDTH; 33 | arrowFrame.y = tooltipFrame.height / 2 + ARROW_HEIGHT / 2; 34 | } 35 | 36 | if (vertical === TooltipPositions.TOP) { 37 | arrowFrame.rotation = 90; 38 | arrowFrame.x = tooltipFrame.width / 2 - ARROW_WIDTH / 2; 39 | arrowFrame.y = tooltipFrame.height + ARROW_HEIGHT / 2; 40 | } 41 | 42 | if (vertical === TooltipPositions.BOTTOM) { 43 | arrowFrame.rotation = -90; 44 | arrowFrame.x = tooltipFrame.width / 2 + ARROW_WIDTH / 2; 45 | arrowFrame.y = -(ARROW_HEIGHT / 2); 46 | } 47 | 48 | arrowFrame.appendChild(arrow); 49 | 50 | return arrowFrame; 51 | }; 52 | 53 | const getTooltipFrame = (node): FrameNode => { 54 | let tooltipFrame; 55 | 56 | if (!tooltipFrame) { 57 | tooltipFrame = figma.createFrame(); 58 | } 59 | tooltipFrame.expanded = false; 60 | tooltipFrame.name = `Tooltip ${node.name}`; 61 | tooltipFrame.clipsContent = false; 62 | tooltipFrame.fills = []; 63 | 64 | return tooltipFrame; 65 | }; 66 | 67 | export const setTooltip = async ( 68 | options: SetTooltipOptions, 69 | specificNode = null, 70 | ) => { 71 | const data = { 72 | vertical: undefined, 73 | horizontal: undefined, 74 | backgroundColor: '#ffffff', 75 | fontColor: '#000000', 76 | fontSize: 11, 77 | ...options, 78 | }; 79 | 80 | switch (options.position) { 81 | case TooltipPositions.TOP: 82 | case TooltipPositions.BOTTOM: 83 | data.vertical = options.position; 84 | break; 85 | case TooltipPositions.LEFT: 86 | case TooltipPositions.RIGHT: 87 | data.horizontal = options.position; 88 | break; 89 | default: 90 | return; 91 | } 92 | 93 | if (figma.currentPage.selection.length === 1 || specificNode) { 94 | const node: SceneNode = specificNode || figma.currentPage.selection[0]; 95 | 96 | if (node.type === 'TEXT' && !node.characters.length) { 97 | figma.notify('Could not add tooltip to empty text node', { 98 | error: true, 99 | }); 100 | return; 101 | } 102 | 103 | if (node.type === 'BOOLEAN_OPERATION' || node.type === 'SLICE') { 104 | figma.notify('This type of element is not supported', { 105 | error: true, 106 | }); 107 | return; 108 | } 109 | 110 | const tooltipFrame = getTooltipFrame(node); 111 | const contentFrame = figma.createFrame(); 112 | tooltipFrame.appendChild(contentFrame); 113 | 114 | // auto-layout 115 | contentFrame.layoutMode = 'VERTICAL'; 116 | contentFrame.cornerRadius = 7; 117 | contentFrame.paddingTop = 12; 118 | contentFrame.paddingBottom = 12; 119 | contentFrame.paddingLeft = 10; 120 | contentFrame.paddingRight = 10; 121 | contentFrame.itemSpacing = 3; 122 | contentFrame.counterAxisSizingMode = 'AUTO'; 123 | 124 | // background 125 | const bg = hexToRgb(data.backgroundColor); 126 | contentFrame.backgrounds = [].concat(solidColor(bg.r, bg.g, bg.b)); 127 | 128 | //----- 129 | 130 | switch (node.type) { 131 | case 'GROUP': 132 | case 'INSTANCE': 133 | case 'COMPONENT': 134 | case 'VECTOR': 135 | case 'STAR': 136 | case 'LINE': 137 | case 'ELLIPSE': 138 | case 'FRAME': 139 | case 'POLYGON': 140 | case 'RECTANGLE': 141 | case 'TEXT': 142 | await addNode(contentFrame, node, data); 143 | break; 144 | default: 145 | tooltipFrame.remove(); 146 | break; 147 | } 148 | 149 | // ---- 150 | 151 | const arrow = createArrow(contentFrame, data, { 152 | horizontal: data.horizontal, 153 | vertical: data.vertical, 154 | }); 155 | 156 | if (arrow) { 157 | tooltipFrame.appendChild(arrow); 158 | } 159 | 160 | tooltipFrame.resize(contentFrame.width, contentFrame.height); 161 | // ---- 162 | const nodeBounds = (node as any).absoluteBoundingBox; 163 | tooltipFrame.x = nodeBounds.x; 164 | tooltipFrame.y = nodeBounds.y; 165 | 166 | if (data.vertical) { 167 | tooltipFrame.x -= contentFrame.width / 2 - nodeBounds.width / 2; 168 | 169 | switch (data.vertical) { 170 | case TooltipPositions.TOP: 171 | tooltipFrame.y += (contentFrame.height + data.offset) * -1; 172 | break; 173 | case TooltipPositions.BOTTOM: 174 | tooltipFrame.y += nodeBounds.height + data.offset; 175 | break; 176 | } 177 | } else { 178 | tooltipFrame.y += nodeBounds.height / 2 - contentFrame.height / 2; 179 | 180 | switch (data.horizontal) { 181 | case TooltipPositions.LEFT: 182 | tooltipFrame.x -= tooltipFrame.width + data.offset; 183 | break; 184 | case TooltipPositions.RIGHT: 185 | tooltipFrame.x += nodeBounds.width + data.offset; 186 | break; 187 | } 188 | } 189 | 190 | // shadow 191 | tooltipFrame.effects = [].concat({ 192 | offset: { 193 | x: 0, 194 | y: 2, 195 | }, 196 | visible: true, 197 | blendMode: 'NORMAL', 198 | type: 'DROP_SHADOW', 199 | color: { 200 | r: 0, 201 | g: 0, 202 | b: 0, 203 | a: 0.1, 204 | }, 205 | radius: 4, 206 | }); 207 | 208 | return tooltipFrame; 209 | } else { 210 | figma.notify('Please select only one element', { 211 | error: true, 212 | }); 213 | } 214 | }; 215 | -------------------------------------------------------------------------------- /src/main/tooltip/types/index.ts: -------------------------------------------------------------------------------- 1 | import { SetTooltipOptions, TooltipSettings } from '../../../shared/interfaces'; 2 | 3 | import { cornerRadius } from './parts/cornerRadius'; 4 | import { effects } from './parts/effects'; 5 | import { fills } from './parts/fills'; 6 | import { fontName } from './parts/font-name'; 7 | import { height } from './parts/height'; 8 | import { name } from './parts/name'; 9 | import { opacity } from './parts/opacity'; 10 | import { pointCount } from './parts/point-count'; 11 | import { strokes } from './parts/strokes'; 12 | import { variantProperties } from './parts/variant-properties'; 13 | import { width } from './parts/width'; 14 | 15 | export const addNode = async ( 16 | parent: SceneNode, 17 | node: SceneNode, 18 | settings: SetTooltipOptions, 19 | ) => { 20 | const flags: TooltipSettings = settings.flags; 21 | // Add content to parent 22 | if (flags.name) { 23 | name(node, parent, settings); 24 | } 25 | if (flags.variants) { 26 | variantProperties(node, parent, settings); 27 | } 28 | if (flags.width) { 29 | width(node, parent, settings); 30 | } 31 | if (flags.height) { 32 | height(node, parent, settings); 33 | } 34 | if (flags.color) { 35 | fills(node, parent, settings); 36 | } 37 | if (flags.cornerRadius) { 38 | cornerRadius(node, parent, settings); 39 | } 40 | if (flags.stroke) { 41 | await strokes(node, parent, settings); 42 | } 43 | if (flags.opacity) { 44 | opacity(node, parent, settings); 45 | } 46 | if (flags.fontName && node.type === 'TEXT') { 47 | await fontName(node, parent, flags.fontSize, settings.fontPattern); 48 | } 49 | if (flags.points) { 50 | pointCount(node, parent, settings); 51 | } 52 | if (flags.effects) { 53 | await effects(node, parent, settings); 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/cornerRadius.ts: -------------------------------------------------------------------------------- 1 | import { toFixed } from '../../../../shared/helpers'; 2 | import { createTooltipTextNode } from '../../../helper'; 3 | 4 | export const cornerRadius = ( 5 | node, 6 | parent, 7 | { fontColor = '', fontSize = 0 }, 8 | ) => { 9 | if (node?.cornerRadius) { 10 | const cornerRadiusIcon = figma.createNodeFromSvg( 11 | ``, 12 | ); 13 | const textNode = createTooltipTextNode({ 14 | fontColor, 15 | fontSize, 16 | }); 17 | textNode.x += 20; 18 | textNode.y += 1.5; 19 | 20 | if (node.cornerRadius !== figma.mixed) { 21 | textNode.characters += `${toFixed(node.cornerRadius, 2)}`; 22 | } else { 23 | textNode.characters += `${toFixed(node.topLeftRadius, 2)} ${toFixed( 24 | node.topRightRadius, 25 | 2, 26 | )} ${toFixed(node.bottomLeftRadius, 2)} ${toFixed( 27 | node.bottomRightRadius, 28 | 2, 29 | )}`; 30 | } 31 | 32 | const g = figma.group([cornerRadiusIcon, textNode], parent); 33 | g.expanded = false; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/effects.ts: -------------------------------------------------------------------------------- 1 | import { findAndReplaceNumberPattern } from '../../../../shared/helpers'; 2 | import { createTooltipTextNode, solidColor } from '../../../helper'; 3 | 4 | import { createColorNode } from './fills'; 5 | 6 | export const effects = async ( 7 | node, 8 | parent, 9 | { 10 | fontColor = '', 11 | fontSize = 0, 12 | labelPattern, 13 | flags: { onlyEffectStyle = false }, 14 | }, 15 | ) => { 16 | const iconNode = ` 17 | 18 | 19 | `; 20 | 21 | if (node.effects.length || node.effectStyleId) { 22 | let effects = node.effects.filter((e) => e.visible); 23 | let effectStyle = null; 24 | 25 | if (node.effectStyleId) { 26 | effectStyle = await figma.getStyleByIdAsync(node.effectStyleId); 27 | effects = effectStyle.effects; 28 | const textNode = createTooltipTextNode({ 29 | fontColor, 30 | fontSize, 31 | }); 32 | textNode.x += 20; 33 | textNode.y -= 2; 34 | textNode.characters += `${effectStyle.name}`; 35 | 36 | if (effectStyle.description) { 37 | textNode.characters += `\n${effectStyle.description}`; 38 | 39 | textNode.setRangeFontSize( 40 | effectStyle.name.length, 41 | textNode.characters.length, 42 | 10, 43 | ); 44 | textNode.setRangeFills( 45 | effectStyle.name.length, 46 | textNode.characters.length, 47 | [solidColor(153, 153, 153)], 48 | ); 49 | } 50 | 51 | const styleGroup = figma.group( 52 | [figma.createNodeFromSvg(iconNode), textNode], 53 | parent, 54 | ); 55 | styleGroup.expanded = false; 56 | } 57 | 58 | if (!onlyEffectStyle || !effectStyle) { 59 | effects.forEach((effect, index) => { 60 | let firstItem = null; 61 | 62 | if (node.effectStyleId || index > 0) { 63 | firstItem = figma.createRectangle(); 64 | firstItem.resize(16, 16); 65 | firstItem.fills = []; 66 | } else { 67 | firstItem = figma.createNodeFromSvg(iconNode); 68 | } 69 | 70 | const group: any[] = [firstItem]; 71 | let colorNodes = []; 72 | 73 | if (effect.type !== 'BACKGROUND_BLUR' && effect.type !== 'LAYER_BLUR') { 74 | colorNodes = createColorNode( 75 | { 76 | type: 'SOLID', 77 | opacity: effect.color.a, 78 | color: { 79 | r: effect.color.r, 80 | g: effect.color.g, 81 | b: effect.color.b, 82 | }, 83 | }, 84 | { fontColor, fontSize }, 85 | ); 86 | } 87 | 88 | const textNode = createTooltipTextNode({ 89 | fontColor, 90 | fontSize, 91 | }); 92 | textNode.x += 20; 93 | textNode.y -= 2; 94 | textNode.characters += `${effect.type.toLowerCase().replace('_', ' ')}`; 95 | 96 | const texts = []; 97 | 98 | if (effect.radius) { 99 | texts.push( 100 | `Radius: ${findAndReplaceNumberPattern( 101 | labelPattern, 102 | effect.radius, 103 | )}`, 104 | ); 105 | } 106 | 107 | if (effect.type !== 'BACKGROUND_BLUR' && effect.type !== 'LAYER_BLUR') { 108 | texts.push( 109 | `Offset: ${findAndReplaceNumberPattern( 110 | labelPattern, 111 | effect.offset.x, 112 | )} ${findAndReplaceNumberPattern(labelPattern, effect.offset.y)}`, 113 | ); 114 | 115 | if (effect.spread) { 116 | texts.push( 117 | `Spread: ${findAndReplaceNumberPattern( 118 | labelPattern, 119 | effect.spread, 120 | )}`, 121 | ); 122 | } 123 | } 124 | 125 | if (texts.length) { 126 | textNode.characters += `\n${texts.join('\n')}`; 127 | 128 | textNode.setRangeFontSize( 129 | effect.type.length, 130 | textNode.characters.length, 131 | 10, 132 | ); 133 | textNode.setRangeFills( 134 | effect.type.length, 135 | textNode.characters.length, 136 | [solidColor(153, 153, 153)], 137 | ); 138 | } 139 | 140 | group.push(textNode); 141 | 142 | colorNodes.forEach((node) => { 143 | node.y += textNode.height + 2; 144 | if (node.type === 'TEXT' && effects.length - 1 !== index) { 145 | node.resize(node.width, node.height + 5); 146 | } 147 | }); 148 | 149 | group.push(colorNodes); 150 | 151 | const g = figma.group(group.flat(), parent); 152 | g.expanded = false; 153 | }); 154 | } 155 | } 156 | }; 157 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/fills.ts: -------------------------------------------------------------------------------- 1 | import { toFixed } from '../../../../shared/helpers'; 2 | import { 3 | createTooltipTextNode, 4 | getFontFillsAndStyles, 5 | colorString, 6 | rgbaToHex, 7 | getFillsByFillStyleId, 8 | solidColor, 9 | } from '../../../helper'; 10 | 11 | const ICON = ` 12 | 13 | `; 14 | 15 | const fillTypeToName = (type: string) => 16 | ({ 17 | GRADIENT_LINEAR: 'Linear', 18 | GRADIENT_RADIAL: 'Radial', 19 | GRADIENT_ANGULAR: 'Angular', 20 | GRADIENT_DIAMOND: 'Diamond', 21 | IMAGE: 'Image', 22 | })[type] || ''; 23 | 24 | export const createColorNode = (fill, { fontColor = '', fontSize = 0 }) => { 25 | const elements = []; 26 | let name = ''; 27 | let styleId = ''; 28 | 29 | // if has name or id 30 | if (fill.styleId || fill.name) { 31 | name = fill.name; 32 | styleId = fill.styleId; 33 | 34 | delete fill.styleId; 35 | delete fill.name; 36 | } 37 | 38 | const textNode = createTooltipTextNode({ 39 | fontColor, 40 | fontSize, 41 | }); 42 | textNode.x += 40; 43 | textNode.y += 1.5; 44 | 45 | if (!name) { 46 | name = fillTypeToName(fill.type); 47 | } 48 | 49 | if (fill.type === 'SOLID') { 50 | textNode.characters += `${rgbaToHex( 51 | colorString(fill.color, fill.opacity), 52 | )} ${name ? '\n' : ''}`; 53 | } 54 | 55 | if (name) { 56 | const start = textNode.characters.length; 57 | textNode.characters += `${name}`; 58 | 59 | if (fill.type === 'SOLID') { 60 | textNode.setRangeFontSize(start, textNode.characters.length, 10); 61 | textNode.setRangeFills(start, textNode.characters.length, [ 62 | solidColor(153, 153, 153), 63 | ]); 64 | } 65 | } 66 | 67 | if (fill.opacity !== 1) { 68 | textNode.characters += ` · ${toFixed(fill.opacity * 100, 2)}%`; 69 | 70 | const colorRect = figma.createRectangle(); 71 | colorRect.resize(8, 16); 72 | colorRect.x += 20; 73 | colorRect.topLeftRadius = 3; 74 | colorRect.bottomLeftRadius = 3; 75 | colorRect.fills = [{ ...fill, opacity: 1 }]; 76 | elements.push(colorRect); 77 | 78 | const colorRectWithOpacity = figma.createRectangle(); 79 | colorRectWithOpacity.resize(8, 16); 80 | colorRectWithOpacity.x += 28; 81 | colorRectWithOpacity.topRightRadius = 3; 82 | colorRectWithOpacity.bottomRightRadius = 3; 83 | colorRectWithOpacity.fills = [fill]; 84 | elements.push(colorRectWithOpacity); 85 | } else { 86 | const styleRect = figma.createRectangle(); 87 | styleRect.resize(16, 16); 88 | styleRect.x += 20; 89 | styleRect.cornerRadius = 3; 90 | if (styleId) { 91 | styleRect.setFillStyleIdAsync(styleId); 92 | } else { 93 | styleRect.fills = [fill]; 94 | } 95 | elements.push(styleRect); 96 | } 97 | elements.push(textNode); 98 | 99 | return elements; 100 | }; 101 | 102 | export const fills = async (node, parent, { fontColor = '', fontSize = 0 }) => { 103 | let fills = null; 104 | if (node.type === 'TEXT') { 105 | const fontData = await getFontFillsAndStyles(node); 106 | 107 | fills = fontData.fills; 108 | } else { 109 | if (node.fillStyleId) { 110 | fills = await getFillsByFillStyleId(node.fillStyleId); 111 | } else { 112 | if (!fills && node?.fills !== figma.mixed) { 113 | fills = node.fills; 114 | } 115 | } 116 | } 117 | 118 | if (fills) { 119 | fills.forEach((fill) => { 120 | const fillIcon = figma.createNodeFromSvg(ICON); 121 | 122 | const g = figma.group( 123 | [fillIcon, ...createColorNode(fill, { fontColor, fontSize })], 124 | parent, 125 | ); 126 | g.expanded = false; 127 | }); 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/font-name.ts: -------------------------------------------------------------------------------- 1 | import { findAndReplaceNumberPattern } from '../../../../shared/helpers'; 2 | import { 3 | createTooltipTextNode, 4 | getFontNameData, 5 | solidColor, 6 | } from '../../../helper'; 7 | 8 | export const fontName = async (node, parent, showFontSize, fontPattern) => { 9 | const fontFamilyName = node?.fontName; 10 | 11 | if (fontFamilyName) { 12 | const fontData = await getFontNameData(node); 13 | 14 | const iconFrame = figma.createNodeFromSvg( 15 | ` 16 | 17 | 18 | 19 | `, 20 | ); 21 | 22 | const textNode = createTooltipTextNode({ 23 | fontColor: '#999999', 24 | fontSize: 10, 25 | }); 26 | 27 | textNode.x += 20; 28 | textNode.y += 1.5; 29 | 30 | const fontNamesRanges = []; 31 | let start = 0; 32 | 33 | fontData.forEach((font, i) => { 34 | let text = `${font.family}\n`; 35 | text += `${font.style.join(', ')}`; 36 | 37 | if (showFontSize && font.fontSize) { 38 | text += `\nSizes: ${ 39 | !font.fontSize.length 40 | ? findAndReplaceNumberPattern(fontPattern, +font.fontSize) 41 | : font.fontSize 42 | .map((fontSize) => 43 | findAndReplaceNumberPattern(fontPattern, +fontSize), 44 | ) 45 | .join(', ') 46 | }`; 47 | } 48 | text += `${i === fontData.length - 1 ? '' : '\n'}`; 49 | 50 | textNode.characters += text; 51 | 52 | fontNamesRanges.push({ 53 | from: start, 54 | to: start + font.family.length, 55 | }); 56 | 57 | start += text.length; 58 | }); 59 | 60 | for (const range of fontNamesRanges) { 61 | textNode.setRangeFontSize(range.from, range.to, 11); 62 | textNode.setRangeFills(range.from, range.to, [solidColor(0, 0, 0)]); 63 | } 64 | 65 | const g = figma.group([iconFrame, textNode], parent); 66 | g.expanded = false; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/height.ts: -------------------------------------------------------------------------------- 1 | import { findAndReplaceNumberPattern } from '../../../../shared/helpers'; 2 | import { createTooltipTextNode } from '../../../helper'; 3 | 4 | export const height = ( 5 | node, 6 | parent, 7 | { fontColor = '', fontSize = 0, labelPattern }, 8 | ) => { 9 | const iconNode = figma.createNodeFromSvg( 10 | ``, 11 | ); 12 | const textNode = createTooltipTextNode({ 13 | fontColor, 14 | fontSize, 15 | }); 16 | textNode.x += 20; 17 | textNode.y += 1.5; 18 | textNode.characters += findAndReplaceNumberPattern(labelPattern, node.height); 19 | 20 | const g = figma.group([iconNode, textNode], parent); 21 | g.expanded = false; 22 | }; 23 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/name.ts: -------------------------------------------------------------------------------- 1 | import { createTooltipTextNode } from '../../../helper'; 2 | 3 | export const name = (node, parent, { fontColor = '', fontSize = 0 }) => { 4 | if (!node.name) return; 5 | 6 | const iconNode = figma.createNodeFromSvg( 7 | ` `, 8 | ); 9 | const textNode = createTooltipTextNode({ 10 | fontColor, 11 | fontSize, 12 | }); 13 | textNode.x += 20; 14 | textNode.y += 1.5; 15 | textNode.characters = node.name; 16 | 17 | const g = figma.group([iconNode, textNode], parent); 18 | g.expanded = false; 19 | }; 20 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/opacity.ts: -------------------------------------------------------------------------------- 1 | import { toFixed } from '../../../../shared/helpers'; 2 | import { createTooltipTextNode } from '../../../helper'; 3 | 4 | export const opacity = (node, parent, { fontColor = '', fontSize = 0 }) => { 5 | if (node?.opacity !== 0) { 6 | const iconFrame = figma.createNodeFromSvg( 7 | ` 8 | 9 | `, 10 | ); 11 | const textNode = createTooltipTextNode({ 12 | fontColor, 13 | fontSize, 14 | }); 15 | textNode.x += 20; 16 | textNode.y += 1.5; 17 | textNode.characters += `${toFixed(node.opacity * 100, 2)}%`; 18 | 19 | const g = figma.group([iconFrame, textNode], parent); 20 | g.expanded = false; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/point-count.ts: -------------------------------------------------------------------------------- 1 | import { createTooltipTextNode } from '../../../helper'; 2 | 3 | export const pointCount = (node, parent, { fontColor = '', fontSize = 0 }) => { 4 | if (node?.pointCount) { 5 | const iconNode = figma.createNodeFromSvg( 6 | ` 7 | 8 | `, 9 | ); 10 | const textNode = createTooltipTextNode({ 11 | fontColor, 12 | fontSize, 13 | }); 14 | textNode.x += 20; 15 | textNode.y += 1.5; 16 | textNode.characters += `${node.pointCount}`; 17 | 18 | const g = figma.group([iconNode, textNode], parent); 19 | g.expanded = false; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/strokes.ts: -------------------------------------------------------------------------------- 1 | import { toFixed } from '../../../../shared/helpers'; 2 | import { 3 | createTooltipTextNode, 4 | getFillsByFillStyleId, 5 | solidColor, 6 | } from '../../../helper'; 7 | 8 | import { createColorNode } from './fills'; 9 | 10 | const ICON = ` 11 | 12 | `; 13 | 14 | export const strokes = async ( 15 | node, 16 | parent, 17 | { fontColor = '', fontSize = 0 }, 18 | ) => { 19 | const strokes = [ 20 | node.strokeTopWeight, 21 | node.strokeBottomWeight, 22 | node.strokeLeftWeight, 23 | node.strokeRightWeight, 24 | ]; 25 | // Stroke 26 | if (node?.strokes?.length && strokes.some(Boolean)) { 27 | const textNode = createTooltipTextNode({ 28 | fontColor, 29 | fontSize, 30 | }); 31 | textNode.x += 20; 32 | textNode.y += 1.5; 33 | 34 | if (strokes.every((s) => s === node.strokeWeight)) { 35 | textNode.characters += `all: ${toFixed(node.strokeWeight, 2)} · `; 36 | } else { 37 | if (node.strokeTopWeight) { 38 | textNode.characters += `top: ${toFixed(node.strokeTopWeight, 2)} · `; 39 | } 40 | if (node.strokeBottomWeight) { 41 | textNode.characters += `bottom: ${toFixed( 42 | node.strokeBottomWeight, 43 | 2, 44 | )} · `; 45 | } 46 | if (node.strokeLeftWeight) { 47 | textNode.characters += `left: ${toFixed(node.strokeLeftWeight, 2)} · `; 48 | } 49 | if (node.strokeRightWeight) { 50 | textNode.characters += `right: ${toFixed( 51 | node.strokeRightWeight, 52 | 2, 53 | )} · `; 54 | } 55 | } 56 | 57 | textNode.characters += `align: ${node.strokeAlign.toLowerCase()}`; 58 | 59 | let textPosition = 0; 60 | for (const characters of textNode.characters.split(' · ')) { 61 | const [label, size] = characters.split(': '); 62 | textNode.setRangeFontSize( 63 | textPosition, 64 | textPosition + label.length + 2, 65 | fontSize - 1, 66 | ); 67 | textNode.setRangeFills(textPosition, textPosition + label.length + 2, [ 68 | solidColor(153, 153, 153), 69 | ]); 70 | textNode.setRangeFontSize( 71 | textPosition + characters.length - size.length, 72 | textPosition + characters.length, 73 | fontSize, 74 | ); 75 | textPosition += characters.length + 3; 76 | } 77 | 78 | const gr = figma.group([figma.createNodeFromSvg(ICON), textNode], parent); 79 | gr.expanded = false; 80 | 81 | let fills = null; 82 | 83 | if (node.strokeStyleId) { 84 | fills = await getFillsByFillStyleId(node.strokeStyleId); 85 | } 86 | 87 | if (!fills) { 88 | fills = node.strokes.filter((s) => s.type === 'SOLID' && s.visible); 89 | } 90 | 91 | for (const fill of fills) { 92 | const g = figma.group( 93 | [ 94 | figma.createNodeFromSvg(ICON), 95 | ...createColorNode(fill, { fontColor, fontSize }), 96 | ], 97 | parent, 98 | ); 99 | g.expanded = false; 100 | } 101 | } 102 | }; 103 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/variant-properties.ts: -------------------------------------------------------------------------------- 1 | import { createTooltipTextNode, solidColor } from '../../../helper'; 2 | 3 | export const variantProperties = ( 4 | node, 5 | parent, 6 | { fontColor = '', fontSize = 0 }, 7 | ) => { 8 | if (node?.variantProperties && Object.keys(node.variantProperties).length) { 9 | const iconNode = figma.createNodeFromSvg( 10 | ` 11 | 12 | `, 13 | ); 14 | const textNode = createTooltipTextNode({ 15 | fontColor, 16 | fontSize, 17 | }); 18 | textNode.x += 20; 19 | textNode.y += 1.5; 20 | textNode.characters += `Variants\n`; 21 | 22 | const variantTexts = []; 23 | for (const [key, value] of Object.entries(node.variantProperties)) { 24 | variantTexts.push(`${key}: ${value}`); 25 | } 26 | 27 | textNode.characters += variantTexts.join('\n'); 28 | 29 | textNode.setRangeFontSize(9, textNode.characters.length, 10); 30 | textNode.setRangeFills(9, textNode.characters.length, [ 31 | solidColor(153, 153, 153), 32 | ]); 33 | 34 | const g = figma.group([iconNode, textNode], parent); 35 | g.expanded = false; 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/main/tooltip/types/parts/width.ts: -------------------------------------------------------------------------------- 1 | import { findAndReplaceNumberPattern } from '../../../../shared/helpers'; 2 | import { createTooltipTextNode } from '../../../helper'; 3 | 4 | export const width = ( 5 | node, 6 | parent, 7 | { fontColor = '', fontSize = 0, labelPattern }, 8 | ) => { 9 | const iconNode = figma.createNodeFromSvg( 10 | ` 11 | 12 | `, 13 | ); 14 | const textNode = createTooltipTextNode({ 15 | fontColor, 16 | fontSize, 17 | }); 18 | textNode.x += 20; 19 | textNode.y += 1.5; 20 | textNode.characters += findAndReplaceNumberPattern(labelPattern, node.width); 21 | 22 | const g = figma.group([iconNode, textNode], parent); 23 | g.expanded = false; 24 | }; 25 | -------------------------------------------------------------------------------- /src/shared/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An structured way to handle renderer and main messages 3 | */ 4 | class EventEmitter { 5 | messageEvent = new Map(); 6 | emit: ( 7 | name: string, 8 | data?: 9 | | T 10 | | Record 11 | | number 12 | | string 13 | | Uint8Array 14 | | boolean 15 | | unknown[], 16 | ) => void; 17 | 18 | constructor() { 19 | // MAIN PROCESS 20 | try { 21 | this.emit = (name, data) => { 22 | figma.ui.postMessage({ 23 | name, 24 | data: data || null, 25 | }); 26 | }; 27 | 28 | figma.ui.onmessage = (event) => { 29 | if (this.messageEvent.has(event.name)) { 30 | this.messageEvent.get(event.name)(event.data, this.emit); 31 | } 32 | }; 33 | } catch { 34 | // we ignore the error, because it only says, that "figma" is undefined 35 | // RENDERER PROCESS 36 | onmessage = (event) => { 37 | if (this.messageEvent.has(event.data.pluginMessage.name)) { 38 | this.messageEvent.get(event.data.pluginMessage.name)( 39 | event.data.pluginMessage.data, 40 | this.emit, 41 | ); 42 | } 43 | }; 44 | 45 | this.emit = (name = '', data = {}) => { 46 | parent.postMessage( 47 | { 48 | pluginMessage: { 49 | name, 50 | data: data || null, 51 | }, 52 | }, 53 | '*', 54 | ); 55 | }; 56 | } 57 | } 58 | 59 | /** 60 | * This method emits a message to main or renderer 61 | * @param name string 62 | * @param callback function 63 | */ 64 | on(name, callback) { 65 | this.messageEvent.set(name, callback); 66 | 67 | return () => this.remove(name); 68 | } 69 | 70 | /** 71 | * Listen to a message once 72 | * @param name 73 | * @param callback 74 | */ 75 | once(name, callback) { 76 | const remove = this.on(name, (data, emit) => { 77 | callback(data, emit); 78 | remove(); 79 | }); 80 | } 81 | 82 | /** 83 | * Ask for data 84 | * @param name 85 | */ 86 | ask(name, data = undefined) { 87 | this.emit(name, data); 88 | 89 | return new Promise((resolve) => this.once(name, resolve)); 90 | } 91 | 92 | /** 93 | * Answer data from "ask" 94 | * @param name 95 | * @param functionOrValue 96 | */ 97 | answer(name, functionOrValue) { 98 | this.on(name, (incomingData, emit) => { 99 | if (this.isAsyncFunction(functionOrValue)) { 100 | functionOrValue(incomingData).then((data) => emit(name, data)); 101 | } else if (typeof functionOrValue === 'function') { 102 | emit(name, functionOrValue(incomingData)); 103 | } else { 104 | emit(name, functionOrValue); 105 | } 106 | }); 107 | } 108 | 109 | /** 110 | * Remove and active listener 111 | * @param name 112 | */ 113 | remove(name) { 114 | if (this.messageEvent.has(name)) { 115 | this.messageEvent.delete(name); 116 | } 117 | } 118 | 119 | /** 120 | * This function checks if it is asynchronous or not 121 | * @param func 122 | */ 123 | isAsyncFunction(func) { 124 | func = func.toString().trim(); 125 | 126 | return ( 127 | func.match('__awaiter') || func.match('function*') || func.match('async') 128 | ); 129 | } 130 | } 131 | 132 | export default new EventEmitter(); 133 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const VERSION = 1; 2 | 3 | export const STORAGE_KEY = '__figma_mobx_sync__'; 4 | 5 | export const GROUP_NAME_DETACHED = '🔌 Measurements'; 6 | export const GROUP_NAME_ATTACHED = `📐 Measurements`; 7 | -------------------------------------------------------------------------------- /src/shared/helpers.ts: -------------------------------------------------------------------------------- 1 | export const toFixed = (number: string | number, decimalPlaces: number) => { 2 | if (!number) { 3 | return '0'; 4 | } 5 | number = parseFloat(number.toString()); 6 | return !decimalPlaces 7 | ? number.toFixed(decimalPlaces) 8 | : number.toFixed(decimalPlaces).replace(/\.?0+$/, ''); 9 | }; 10 | 11 | export const overlaps = (node1, node2) => { 12 | if (node1.x >= node2.x2 || node2.x >= node1.x2) return false; 13 | if (node1.y >= node2.y2 || node2.y >= node1.y2) return false; 14 | 15 | return true; 16 | }; 17 | 18 | export const findAndReplaceNumberPattern = (pattern: string, num: number) => { 19 | if (!pattern) { 20 | pattern = '($)px'; 21 | } 22 | 23 | let somethingReplaced = false; 24 | const regexWithoutCalc = /\((\$)(#*)\)/g; 25 | const regexFull = /\(((\$)(#*)(\/|\*)(\d+\.?\d*))\)/g; 26 | 27 | for (const [match, , decimalPlace] of Array.from( 28 | pattern.matchAll(regexWithoutCalc), 29 | )) { 30 | somethingReplaced = true; 31 | pattern = pattern.replace( 32 | match, 33 | toFixed(num, decimalPlace ? decimalPlace.length : 0), 34 | ); 35 | } 36 | 37 | for (const group of Array.from(pattern.matchAll(regexFull))) { 38 | somethingReplaced = true; 39 | const [match, , , _decimalPlace, operator, _modificator] = group; 40 | let result; 41 | 42 | const decimalPlace = _decimalPlace ? _decimalPlace.length : 0; 43 | const modificator = parseFloat(_modificator); 44 | 45 | if (operator === '/') { 46 | result = toFixed(num / modificator, decimalPlace); 47 | } else if (operator === '*') { 48 | result = toFixed(num * modificator, decimalPlace); 49 | } 50 | 51 | pattern = pattern.replace(match, result); 52 | } 53 | 54 | return somethingReplaced ? pattern : transformPixelToUnit(num, pattern); 55 | }; 56 | 57 | export const transformPixelToUnit = ( 58 | pixel: number, 59 | unit: string, 60 | precision?: number, 61 | ) => { 62 | if (typeof precision === 'undefined') { 63 | precision = 2; 64 | } 65 | 66 | const DPI_TO_PIXEL = { 67 | 72: 28.35, 68 | 100: 39, 69 | 150: 59, 70 | 300: 118, 71 | }; 72 | const INCH_IN_CM = 2.54; 73 | 74 | let result: string | number; 75 | const unitWithoutSpaces = unit.replace(/\s/g, ''); 76 | 77 | if (isNaN(pixel)) { 78 | pixel = parseFloat(pixel.toString()); 79 | } 80 | 81 | result = toFixed(pixel, precision); 82 | 83 | // cm 84 | if (unitWithoutSpaces === 'cm') { 85 | result = toFixed(pixel / DPI_TO_PIXEL[72], precision); 86 | } 87 | 88 | // mm 89 | if (unitWithoutSpaces === 'mm') { 90 | result = toFixed((pixel / DPI_TO_PIXEL[72]) * 10, precision); 91 | } 92 | 93 | // dp 94 | if (unitWithoutSpaces === 'dp' || unitWithoutSpaces === 'dip') { 95 | result = toFixed(pixel / (DPI_TO_PIXEL[72] / 160), precision); 96 | } 97 | 98 | // pt 99 | if (unitWithoutSpaces === 'pt') { 100 | result = toFixed((3 / 4) * pixel, precision); 101 | } 102 | 103 | // inch 104 | if ( 105 | unitWithoutSpaces === 'inch' || 106 | unitWithoutSpaces === 'in' || 107 | unitWithoutSpaces === '"' 108 | ) { 109 | result = toFixed(pixel / DPI_TO_PIXEL[72] / INCH_IN_CM, precision); 110 | } 111 | 112 | return result + unit; 113 | }; 114 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | -------------------------------------------------------------------------------- /src/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | export enum Alignments { 2 | TOP = 'TOP', 3 | BOTTOM = 'BOTTOM', 4 | LEFT = 'LEFT', 5 | RIGHT = 'RIGHT', 6 | CENTER = 'CENTER', 7 | } 8 | 9 | export interface SetTooltipOptions { 10 | flags: TooltipSettings; 11 | offset: number; 12 | labelPattern: string; 13 | fontPattern: string; 14 | position: TooltipPositions; 15 | vertical?: TooltipPositions; 16 | horizontal?: TooltipPositions; 17 | backgroundColor?: string; 18 | fontColor?: string; 19 | labelFontSize?: number; 20 | name?: string; 21 | } 22 | export interface LineParameterTypes { 23 | left: number; 24 | top: number; 25 | node: SceneNode; 26 | direction: string; 27 | name: string; 28 | txtVerticalAlign: Alignments; 29 | txtHorizontalAlign: Alignments; 30 | lineVerticalAlign: Alignments; 31 | lineHorizontalAlign: Alignments; 32 | strokeCap: string; 33 | strokeOffset: number; 34 | color: string; 35 | labels: boolean; 36 | labelsOutside: boolean; 37 | labelPattern: string; 38 | labelFontSize: number; 39 | } 40 | export interface TooltipSettings { 41 | width: boolean; 42 | height: boolean; 43 | fontName: boolean; 44 | fontSize: boolean; 45 | color: boolean; 46 | opacity: boolean; 47 | stroke: boolean; 48 | cornerRadius: boolean; 49 | points: boolean; 50 | name: boolean; 51 | variants: boolean; 52 | effects: boolean; 53 | onlyEffectStyle: boolean; 54 | } 55 | 56 | export interface PluginNodeData { 57 | version?: number; 58 | surrounding?: SurroundingSettings; 59 | connectedNodes?: string[]; 60 | strokeCap?: StrokeCap; 61 | strokeOffset?: number; 62 | unit?: string; 63 | color?: string; 64 | labels?: boolean; 65 | labelsOutside?: boolean; 66 | tooltipOffset: number; 67 | fill?: FillTypes; 68 | opacity?: number; 69 | labelPattern: string; 70 | fontPattern: string; 71 | tooltip: TooltipSettings; 72 | detached?: boolean; 73 | labelFontSize?: number; 74 | } 75 | 76 | export interface SurroundingSettings { 77 | labels: boolean; 78 | topBar: boolean; 79 | leftBar: boolean; 80 | rightBar: boolean; 81 | bottomBar: boolean; 82 | topPadding: boolean; 83 | leftPadding: boolean; 84 | rightPadding: boolean; 85 | bottomPadding: boolean; 86 | horizontalBar: boolean; 87 | verticalBar: boolean; 88 | center: boolean; 89 | tooltip: TooltipPositions; 90 | } 91 | 92 | export type FillTypes = 'dashed' | 'fill' | 'stroke' | 'fill-stroke'; 93 | 94 | export enum TooltipPositions { 95 | TOP = 'TOP', 96 | LEFT = 'LEFT', 97 | BOTTOM = 'BOTTOM', 98 | RIGHT = 'RIGHT', 99 | NONE = '', 100 | } 101 | 102 | export interface MainMeasurements { 103 | labels: boolean; 104 | color: string; 105 | fill: FillTypes; 106 | opacity: number; 107 | strokeCap: StrokeCap | 'STANDARD'; 108 | strokeOffset: number; 109 | surrounding: SurroundingSettings; 110 | tooltipOffset: number; 111 | tooltip: TooltipSettings; 112 | } 113 | 114 | export interface Store { 115 | labelsOutside: boolean; 116 | labels: boolean; 117 | color: string; 118 | selection: unknown[]; 119 | fill: FillTypes; 120 | opacity: number; 121 | labelPattern: string; 122 | fontPattern: string; 123 | strokeCap: StrokeCap | 'STANDARD'; 124 | strokeOffset: number; 125 | surrounding: SurroundingSettings; 126 | tooltipOffset: number; 127 | tooltip: TooltipSettings; 128 | detached?: boolean; 129 | labelFontSize: number; 130 | isGlobalGroup?: boolean; 131 | } 132 | 133 | export interface SelectionNode { 134 | id: string; 135 | type: NodeType; 136 | hasSpacing: boolean; 137 | data: Partial; 138 | padding: Record; 139 | spacing?: unknown; 140 | } 141 | export interface NodeSelection { 142 | nodes: SelectionNode[]; 143 | padding: Partial[]; 144 | spacing: Partial[]; 145 | } 146 | 147 | export interface ExchangeStoreValues 148 | extends Omit { 149 | surrounding?: SurroundingSettings; 150 | } 151 | -------------------------------------------------------------------------------- /src/shared/style.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const Content = styled.div` 4 | padding: 10px 10px 0 10px; 5 | `; 6 | 7 | export const Grid = styled.div<{ repeat?: number }>` 8 | display: grid; 9 | grid-gap: 10px; 10 | grid-template-columns: repeat(${(p) => p.repeat || 3}, 1fr); 11 | `; 12 | -------------------------------------------------------------------------------- /src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, toJS } from 'mobx'; 2 | import { AsyncTrunk, ignore } from 'mobx-sync'; 3 | import React, { FunctionComponent, useContext } from 'react'; 4 | 5 | import EventEmitter from '../shared/EventEmitter'; 6 | import { STORAGE_KEY } from '../shared/constants'; 7 | import { 8 | FillTypes, 9 | NodeSelection, 10 | PluginNodeData, 11 | SelectionNode, 12 | SurroundingSettings, 13 | TooltipPositions, 14 | TooltipSettings, 15 | } from '../shared/interfaces'; 16 | import { DEFAULT_COLOR } from '../style'; 17 | 18 | const DEFAULT_SURROUNDING_FLAGS: SurroundingSettings = { 19 | labels: false, 20 | topBar: false, 21 | leftBar: false, 22 | rightBar: false, 23 | bottomBar: false, 24 | topPadding: false, 25 | leftPadding: false, 26 | rightPadding: false, 27 | bottomPadding: false, 28 | horizontalBar: false, 29 | verticalBar: false, 30 | center: false, 31 | tooltip: TooltipPositions.NONE, 32 | }; 33 | class RootStore { 34 | constructor() { 35 | makeAutoObservable(this); 36 | } 37 | 38 | labelsOutside = false; 39 | labels = true; 40 | color = DEFAULT_COLOR; 41 | 42 | @ignore 43 | selection: SelectionNode[] = []; 44 | 45 | fill: FillTypes = 'stroke'; 46 | opacity = 50; 47 | strokeCap: StrokeCap | 'STANDARD' = 'STANDARD'; 48 | strokeOffset = 10; 49 | detached = false; 50 | labelPattern = ''; 51 | fontPattern = ''; 52 | 53 | isGlobalGroup = false; 54 | lockDetachedGroup = true; 55 | lockAttachedGroup = true; 56 | labelFontSize = 10; 57 | 58 | @ignore 59 | surrounding: SurroundingSettings = DEFAULT_SURROUNDING_FLAGS; 60 | 61 | tooltipOffset = 15; 62 | tooltip: TooltipSettings = { 63 | cornerRadius: true, 64 | points: true, 65 | width: true, 66 | height: true, 67 | fontName: true, 68 | fontSize: true, 69 | color: true, 70 | opacity: true, 71 | stroke: true, 72 | name: true, 73 | variants: true, 74 | effects: true, 75 | onlyEffectStyle: false, 76 | }; 77 | 78 | setLabelFontSize(fontSize: number | string, disableSync = false) { 79 | this.labelFontSize = +fontSize; 80 | if (+fontSize > 0 && !disableSync) { 81 | this.sendMeasurements(); 82 | } 83 | } 84 | 85 | toggleIsGlobalGroup() { 86 | this.isGlobalGroup = !this.isGlobalGroup; 87 | } 88 | 89 | toggleLockDetachedGroup() { 90 | this.lockDetachedGroup = !this.lockDetachedGroup; 91 | } 92 | 93 | toggleLockAttachedGroup() { 94 | this.lockAttachedGroup = !this.lockAttachedGroup; 95 | } 96 | 97 | setAllNodeMeasurementData(data: Partial) { 98 | this.strokeCap = data.strokeCap ?? this.strokeCap; 99 | this.strokeOffset = data.strokeOffset ?? this.strokeOffset; 100 | this.opacity = data.opacity ?? this.opacity; 101 | this.fill = data.fill ?? this.fill; 102 | this.color = data.color ?? this.color; 103 | this.tooltip = data.tooltip ?? this.tooltip; 104 | this.tooltipOffset = data.tooltipOffset ?? this.tooltipOffset; 105 | this.labelPattern = data.labelPattern ?? this.labelPattern; 106 | this.fontPattern = data.fontPattern ?? this.fontPattern; 107 | this.labelsOutside = data.labelsOutside ?? this.labelsOutside; 108 | this.labels = data.labels ?? this.labels; 109 | } 110 | 111 | setColor(color: string) { 112 | this.color = color; 113 | this.sendMeasurements(); 114 | } 115 | 116 | setDetached(detached: boolean) { 117 | this.detached = detached; 118 | 119 | if (this.detached) { 120 | this.resetSurrounding(); 121 | } 122 | 123 | EventEmitter.ask('current selection').then((data: NodeSelection) => { 124 | this.setSelection(data.nodes); 125 | this.sendMeasurements(); 126 | }); 127 | } 128 | 129 | setLabels(labels: boolean) { 130 | this.labels = labels; 131 | this.sendMeasurements(); 132 | } 133 | 134 | setLabelsOutside(labelsOutside: boolean) { 135 | this.labels = true; 136 | this.labelsOutside = labelsOutside; 137 | this.sendMeasurements(); 138 | } 139 | 140 | toggleTooltipSetting(key: keyof TooltipSettings) { 141 | const truthyFlags = Object.keys(this.tooltip).filter((settingsKey) => { 142 | if (typeof this.tooltip[settingsKey] === 'boolean') { 143 | return this.tooltip[settingsKey]; 144 | } 145 | }).length; 146 | 147 | if (key === 'fontSize' && !this.tooltip['fontName']) { 148 | return; 149 | } 150 | 151 | if (truthyFlags > 1 || !this.tooltip[key]) { 152 | this.tooltip = { 153 | ...this.tooltip, 154 | ...(key === 'fontName' && this.tooltip[key] ? { fontSize: false } : {}), 155 | [key]: !this.tooltip[key], 156 | }; 157 | } 158 | 159 | this.sendMeasurements(); 160 | } 161 | 162 | setTooltipOffset(tooltipOffset: number) { 163 | this.tooltipOffset = tooltipOffset; 164 | this.sendMeasurements(); 165 | } 166 | 167 | setTooltipSettings(settings) { 168 | this.tooltip = { 169 | ...this.tooltip, 170 | ...settings, 171 | }; 172 | } 173 | 174 | setLabelPattern(labelPattern: string) { 175 | this.labelPattern = labelPattern; 176 | this.sendMeasurements(); 177 | } 178 | 179 | setFontPattern(fontPattern: string) { 180 | this.fontPattern = fontPattern; 181 | this.sendMeasurements(); 182 | } 183 | 184 | setFill(fill) { 185 | this.fill = fill; 186 | this.sendMeasurements(); 187 | } 188 | 189 | setOpacity(distance: number) { 190 | this.opacity = distance; 191 | this.sendMeasurements(); 192 | } 193 | 194 | setStrokeCap(strokeCap: StrokeCap | 'STANDARD') { 195 | this.strokeCap = strokeCap; 196 | this.sendMeasurements(); 197 | } 198 | 199 | setStrokeOffset(strokeOffset: number) { 200 | this.strokeOffset = parseInt(strokeOffset.toString(), 0) || 0; 201 | this.sendMeasurements(); 202 | } 203 | 204 | setSurrounding(surrounding, disableTransfer = false) { 205 | if (this.selection.length > 0) { 206 | this.surrounding = surrounding; 207 | if (!disableTransfer) { 208 | this.sendMeasurements(); 209 | } 210 | } 211 | } 212 | 213 | resetSurrounding() { 214 | this.surrounding = DEFAULT_SURROUNDING_FLAGS; 215 | } 216 | 217 | @ignore 218 | selectionSpacing = []; 219 | setSelectionSpacing(selection = []) { 220 | this.selectionSpacing = selection; 221 | } 222 | 223 | @ignore 224 | selectionPaddings = []; 225 | setSelectionPaddings(selection = []) { 226 | this.selectionPaddings = selection; 227 | } 228 | 229 | setSelection(selection = []) { 230 | this.selection = selection; 231 | } 232 | 233 | sendMeasurements() { 234 | if (this.selection.length > 0) { 235 | EventEmitter.emit( 236 | 'set measurements', 237 | toJS({ 238 | labelsOutside: this.labelsOutside, 239 | labels: this.labels, 240 | color: this.color, 241 | fill: this.fill, 242 | opacity: this.opacity, 243 | strokeCap: this.strokeCap, 244 | strokeOffset: this.strokeOffset, 245 | surrounding: toJS(this.surrounding), 246 | tooltipOffset: this.tooltipOffset, 247 | tooltip: toJS(this.tooltip), 248 | labelPattern: this.labelPattern, 249 | fontPattern: this.fontPattern, 250 | detached: this.detached, 251 | labelFontSize: this.labelFontSize, 252 | }), 253 | ); 254 | } 255 | 256 | if (this.detached) { 257 | this.resetSurrounding(); 258 | } 259 | } 260 | } 261 | 262 | const rootStore = new RootStore(); 263 | 264 | export type TStore = RootStore; 265 | 266 | const StoreContext = React.createContext(null); 267 | 268 | export const StoreProvider: FunctionComponent = ({ children }) => ( 269 | {children} 270 | ); 271 | 272 | export const useStore = (): RootStore => { 273 | const store = useContext(StoreContext); 274 | if (!store) { 275 | throw new Error('useStore must be used within a StoreProvider.'); 276 | } 277 | return store; 278 | }; 279 | 280 | export const trunk = new AsyncTrunk(rootStore, { 281 | storageKey: STORAGE_KEY, 282 | storage: { 283 | getItem(key: string) { 284 | EventEmitter.emit('storage get item', key); 285 | return new Promise((resolve) => 286 | EventEmitter.once('storage get item', resolve), 287 | ); 288 | }, 289 | setItem(key: string, value: string) { 290 | EventEmitter.emit('storage set item', { 291 | key, 292 | value, 293 | }); 294 | return new Promise((resolve) => 295 | EventEmitter.once('storage set item', resolve), 296 | ); 297 | }, 298 | removeItem(key: string) { 299 | EventEmitter.emit('storage remove item', key); 300 | return new Promise((resolve) => 301 | EventEmitter.once('storage remove item', resolve), 302 | ); 303 | }, 304 | }, 305 | }); 306 | 307 | export const getStoreFromMain = (): Promise => { 308 | return new Promise((resolve) => { 309 | EventEmitter.emit('storage', STORAGE_KEY); 310 | EventEmitter.once('storage', (store) => { 311 | resolve(JSON.parse(store || '{}')); 312 | }); 313 | }); 314 | }; 315 | -------------------------------------------------------------------------------- /src/style.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export const TOKENS = { 4 | colors: { 5 | solid: { 6 | black: { 7 | value: '#000000', 8 | }, 9 | red: { 10 | value: '#ef5533', 11 | }, 12 | persian: { 13 | value: '#1745e8', 14 | }, 15 | violet: { 16 | value: '#7614f5', 17 | }, 18 | jade: { 19 | value: '#12b571', 20 | }, 21 | sun: { 22 | value: '#ffaa00', 23 | }, 24 | cerise: { 25 | value: '#e8178a', 26 | }, 27 | }, 28 | soft: { 29 | black: { 30 | value: '#CECECE', 31 | }, 32 | cerise: { 33 | value: '#dac0ce', 34 | }, 35 | persian: { 36 | value: '#c0c6da', 37 | }, 38 | jade: { 39 | value: '#c0d3cb', 40 | }, 41 | sun: { 42 | value: '#dcd2be', 43 | }, 44 | red: { 45 | value: '#dac8c4', 46 | }, 47 | violet: { 48 | value: '#ccc0db', 49 | }, 50 | }, 51 | hover: { 52 | black: { 53 | value: '#F8F8F8', 54 | }, 55 | sun: { 56 | value: '#fdf8e8', 57 | }, 58 | cerise: { 59 | value: '#fde8f7', 60 | }, 61 | red: { 62 | value: '#fdf6e8', 63 | }, 64 | jade: { 65 | value: '#e8fdf7', 66 | }, 67 | violet: { 68 | value: '#f1e8fd', 69 | }, 70 | persian: { 71 | value: '#e8ecfd', 72 | }, 73 | }, 74 | }, 75 | }; 76 | 77 | export const DEFAULT_COLOR = TOKENS.colors.solid.red.value; 78 | 79 | export const getColorByTypeAndSolidColor = ( 80 | color: string, 81 | type: keyof typeof TOKENS.colors = 'solid', 82 | ) => { 83 | const foundColor = Object.entries(TOKENS.colors.solid).find( 84 | ([, data]) => data.value.toLowerCase() === color.toLowerCase(), 85 | ); 86 | 87 | if (foundColor) { 88 | return TOKENS.colors[type][foundColor[0]].value; 89 | } else { 90 | return TOKENS.colors[type].persian.value; 91 | } 92 | }; 93 | 94 | export const theme = { 95 | tokens: TOKENS, 96 | colors: Object.keys(TOKENS.colors.solid).map( 97 | (color) => TOKENS.colors.solid[color].value, 98 | ), 99 | softColors: Object.keys(TOKENS.colors.soft).map( 100 | (color) => TOKENS.colors.soft[color].value, 101 | ), 102 | hoverColors: Object.keys(TOKENS.colors.hover).map( 103 | (color) => TOKENS.colors.hover[color].value, 104 | ), 105 | }; 106 | 107 | export const GlobalStyle = createGlobalStyle` 108 | body { 109 | font-family: Inter; 110 | font-size: 12px; 111 | margin: 0; 112 | } 113 | 114 | hr { 115 | border-width: 1px 0 0 0; 116 | border-color: var(--figma-color-bg-secondary); 117 | border-style: solid; 118 | margin: 11px 0 0; 119 | } 120 | 121 | h4 { 122 | margin: 0 0 12px; 123 | } 124 | 125 | .input { 126 | position: relative; 127 | input { 128 | border-radius: 6px; 129 | border: 1px solid var(--figma-color-bg-tertiary); 130 | outline: none; 131 | padding: 7px 10px; 132 | box-sizing: border-box; 133 | font-size: 12px; 134 | width: 100%; 135 | background-color: transparent; 136 | color: var(--figma-color-text); 137 | &:focus { 138 | border-color: ${(props) => props.theme.color}; 139 | } 140 | } 141 | &.icon { 142 | input { 143 | padding-left: 28px; 144 | & + div { 145 | display: flex; 146 | height: 100%; 147 | position: absolute; 148 | left: 9px; 149 | top: 0; 150 | svg { 151 | align-self: center; 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | ::-webkit-scrollbar { 159 | width: 7px; 160 | } 161 | 162 | ::-webkit-scrollbar:horizontal { 163 | height: 14px; 164 | } 165 | 166 | ::-webkit-scrollbar-track { 167 | background-color: transparent; 168 | } 169 | 170 | ::-webkit-scrollbar-thumb { 171 | border-radius: 35px; 172 | background-color: #3D434D; 173 | border-width: 2px; 174 | border-style: solid; 175 | border-color: transparent; 176 | background-clip: content-box; 177 | border-radius:32px; 178 | } 179 | `; 180 | -------------------------------------------------------------------------------- /src/ui.css: -------------------------------------------------------------------------------- 1 | /* Global styles */ 2 | * { 3 | box-sizing: border-box; 4 | } 5 | 6 | body { 7 | position: relative; 8 | box-sizing: border-box; 9 | font-family: 'Inter', sans-serif; 10 | margin: 0; 11 | padding: 0; 12 | background-color: var(--figma-color-bg); 13 | } 14 | 15 | /* FONTS */ 16 | @font-face { 17 | font-family: 'Inter'; 18 | font-weight: 400; 19 | font-style: normal; 20 | src: 21 | url('https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.7') 22 | format('woff2'), 23 | url('https://rsms.me/inter/font-files/Inter-Regular.woff?v=3.7') 24 | format('woff'); 25 | } 26 | 27 | @font-face { 28 | font-family: 'Inter'; 29 | font-weight: 500; 30 | font-style: normal; 31 | src: 32 | url('https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7') 33 | format('woff2'), 34 | url('https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7') 35 | format('woff'); 36 | } 37 | 38 | @font-face { 39 | font-family: 'Inter'; 40 | font-weight: 600; 41 | font-style: normal; 42 | src: 43 | url('https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7') 44 | format('woff2'), 45 | url('https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7') 46 | format('woff'); 47 | } 48 | 49 | button { 50 | cursor: pointer; 51 | } 52 | -------------------------------------------------------------------------------- /src/views/About/components/DetachmentImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const DetachmentImage = () => ( 4 | 11 | 17 | 25 | 34 | 42 | 50 | 60 | 67 | 68 | 74 | 80 | 86 | 92 | 98 | 104 | 110 | 111 | 115 | 116 | 120 | 121 | 129 | 130 | 131 | 132 | 140 | 141 | 142 | 143 | 151 | 152 | 153 | 154 | 162 | 163 | 164 | 165 | 173 | 174 | 175 | 176 | 184 | 185 | 186 | 187 | 195 | 196 | 197 | 198 | 206 | 207 | 208 | 209 | 217 | 218 | 219 | 220 | 228 | 229 | 230 | 231 | 232 | 233 | ); 234 | -------------------------------------------------------------------------------- /src/views/About/components/FillsImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const FillsImage = () => ( 4 | 11 | 20 | 29 | 30 | 38 | 47 | 55 | 56 | 64 | 65 | 73 | 74 | 75 | 76 | 84 | 85 | 86 | 87 | 95 | 96 | 97 | 98 | 106 | 107 | 108 | 109 | 110 | 111 | ); 112 | -------------------------------------------------------------------------------- /src/views/About/components/LinesImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const LinesImage = () => ( 4 | 11 | 15 | 16 | 25 | 34 | 35 | 43 | 44 | 52 | 53 | 54 | 55 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | -------------------------------------------------------------------------------- /src/views/About/components/PaddingImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const PaddingImage = () => ( 4 | 11 | 19 | 29 | 38 | 48 | 56 | 64 | 70 | 76 | 77 | 78 | 86 | 94 | 95 | 103 | 104 | 105 | 106 | 114 | 115 | 116 | 117 | 125 | 126 | 127 | 128 | 136 | 137 | 138 | 139 | 140 | 141 | ); 142 | -------------------------------------------------------------------------------- /src/views/About/components/SpacingImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const SpacingImage = () => ( 4 | 11 | 17 | 18 | 26 | 36 | 45 | 53 | 54 | 64 | 72 | 80 | 81 | 89 | 90 | 91 | 92 | 100 | 101 | 102 | 103 | 111 | 112 | 113 | 114 | 122 | 123 | 124 | 125 | 126 | 127 | ); 128 | -------------------------------------------------------------------------------- /src/views/About/components/TooltipsImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const TooltipsImage = () => ( 4 | 11 | 15 | 25 | 35 | 43 | 44 | 45 | 53 | 54 | 55 | 56 | 64 | 65 | 66 | 67 | 68 | 69 | ); 70 | -------------------------------------------------------------------------------- /src/views/About/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { DetachmentImage } from './components/DetachmentImage'; 6 | import { FillsImage } from './components/FillsImage'; 7 | import { LinesImage } from './components/LinesImage'; 8 | import { PaddingImage } from './components/PaddingImage'; 9 | import { SpacingImage } from './components/SpacingImage'; 10 | import { TooltipsImage } from './components/TooltipsImage'; 11 | 12 | const About: FunctionComponent = observer(() => { 13 | return ( 14 | 15 | 16 |

Hello there!

17 |

18 | Thank you for using Measure! Feel always free to let us know about 19 | bugs, ideas or what we should consider to change to improve the 20 | workflow, any feedback is welcome. 21 |
22 |
23 | And if we didn't mention it yet, Measure will be{' '} 24 | free and open-source forever. 25 |
26 |
27 | Happy measuring 28 |

29 | 30 | 31 | 32 | @phlp_ 33 | 34 | 35 | 36 | @crlhsr 37 | 38 | 39 |
40 | 41 | 42 |

Guides

43 | 47 | discussions {'->'} 48 | 49 |
50 | 51 | 52 | 57 | 58 | 59 | 64 | 65 | 66 | 71 | 72 | 73 | 78 | 79 | 80 | 85 | 86 | 87 | 92 | 93 | 94 | 95 |
96 | ); 97 | }); 98 | 99 | const Card = (props) => { 100 | const openLink = () => { 101 | window.open(props.link, '_blank'); 102 | }; 103 | 104 | return ( 105 | 106 |
{props.children}
107 | 111 |
112 | ); 113 | }; 114 | 115 | const CardWrapper = styled.div` 116 | width: 100%; 117 | height: 230px; 118 | border-radius: 8px; 119 | background-color: #c4c4c4; 120 | display: grid; 121 | grid-template-rows: 1fr 55px; 122 | overflow: hidden; 123 | cursor: pointer; 124 | 125 | div { 126 | align-self: center; 127 | justify-self: center; 128 | } 129 | footer { 130 | border-top: 1px solid rgb(255 255 255 / 6%); 131 | padding: 10px 15px; 132 | color: #fff; 133 | display: flex; 134 | flex-direction: column; 135 | justify-content: center; 136 | h5 { 137 | margin: 0 0 3px; 138 | font-size: 13px; 139 | font-weight: normal; 140 | } 141 | a { 142 | font-size: 12px; 143 | color: rgba(255, 255, 255, 0.5); 144 | } 145 | } 146 | &.line { 147 | background-color: #3d434d; 148 | footer { 149 | background-color: rgb(255 255 255 / 6%); 150 | } 151 | } 152 | &.tooltip { 153 | background-color: #93a8ac; 154 | footer { 155 | background-color: #9db2b6; 156 | } 157 | } 158 | &.padding { 159 | background-color: #e1e4dd; 160 | footer { 161 | border-top: 1px solid rgb(255 255 255 / 12%); 162 | background-color: rgb(0 0 0 / 6%); 163 | color: #000; 164 | a { 165 | color: rgba(0 0 0 / 50%); 166 | } 167 | } 168 | } 169 | &.spacing { 170 | background-color: #202229; 171 | footer { 172 | background-color: rgb(255 255 255 / 6%); 173 | 174 | a { 175 | color: rgba(255, 255, 255, 0.5); 176 | } 177 | } 178 | } 179 | &.detach { 180 | background-color: #ffc9c9; 181 | footer { 182 | border-top: 1px solid rgb(0 0 0 / 6%); 183 | background-color: rgb(0 0 0 / 6%); 184 | color: #000; 185 | a { 186 | color: rgba(0 0 0 / 50%); 187 | } 188 | } 189 | } 190 | &.fill { 191 | background-color: #d6d6d6; 192 | footer { 193 | border-top: 1px solid rgb(0 0 0 / 6%); 194 | background-color: rgb(0 0 0 / 6%); 195 | color: #000; 196 | a { 197 | color: rgba(0 0 0 / 50%); 198 | } 199 | } 200 | } 201 | `; 202 | 203 | const Users = styled.div` 204 | margin-top: 15px; 205 | display: flex; 206 | margin-bottom: 7px; 207 | a:first-child { 208 | margin-right: 11px; 209 | } 210 | `; 211 | 212 | const User = styled.a` 213 | display: flex; 214 | align-items: center; 215 | color: #000; 216 | background-color: #f1f1f1; 217 | border-radius: 51px; 218 | padding: 5px 12px 5px 6px; 219 | img { 220 | width: 24px; 221 | height: 24px; 222 | border-radius: 100%; 223 | } 224 | span { 225 | margin-left: 8px; 226 | } 227 | `; 228 | 229 | const Container = styled.div` 230 | overflow: auto; 231 | height: 520px; 232 | h3 { 233 | color: var(--figma-color-text); 234 | font-size: 13px; 235 | font-weight: 500; 236 | margin: 0; 237 | } 238 | a { 239 | text-decoration: none; 240 | &:hover { 241 | text-decoration: underline; 242 | } 243 | } 244 | `; 245 | 246 | const GuidesHeadline = styled.div` 247 | padding: 16px 15px 9px; 248 | a { 249 | display: block; 250 | margin-top: 2px; 251 | color: var(--figma-color-text-tertiary); 252 | font-size: 11px; 253 | } 254 | `; 255 | 256 | const Paragraph = styled.div` 257 | padding: 15px; 258 | color: var(--figma-color-text); 259 | p { 260 | margin: 10px 0 0; 261 | color: var(--figma-color-text-tertiary); 262 | font-size: 11px; 263 | line-height: 16px; 264 | } 265 | `; 266 | 267 | const Cards = styled.div` 268 | padding: 7px 0 7px 7px; 269 | display: grid; 270 | grid-gap: 7px; 271 | `; 272 | 273 | export default About; 274 | -------------------------------------------------------------------------------- /src/views/Home/components/CenterChooser.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { useStore } from '../../../store'; 6 | 7 | const CenterChooser: FunctionComponent = observer(() => { 8 | const store = useStore(); 9 | 10 | return ( 11 | 12 | 13 | store.setFill('stroke')} 21 | > 22 | 30 | 38 | 39 | 40 | store.setFill('dashed')} 48 | > 49 | 56 | 64 | 73 | 74 | 75 | 76 | store.setFill('fill-stroke')} 86 | > 87 | 95 | 105 | 106 | 107 | store.setFill('fill')} 115 | > 116 | 124 | 133 | 134 | 135 |
136 | store.setOpacity(+e.currentTarget.value)} 140 | /> 141 |
142 | 149 | 154 | 155 | 163 | 164 | 165 | 166 | 167 | 168 |
169 |
170 |
171 | ); 172 | }); 173 | 174 | const Icons = styled.div` 175 | display: flex; 176 | justify-content: space-between; 177 | width: 135px; 178 | `; 179 | 180 | const Container = styled.div` 181 | padding: 12px 14px; 182 | display: flex; 183 | align-items: center; 184 | justify-content: space-between; 185 | border-width: 1px 0 1px; 186 | border-color: var(--figma-color-bg-secondary); 187 | border-style: solid; 188 | input { 189 | width: 75px; 190 | } 191 | 192 | /* border: 1px solid var(--figma-color-bg-disabled); 193 | background-color: var(--figma-color-bg-hover); 194 | fill: var(--figma-color-text); */ 195 | 196 | .background { 197 | fill: var(--figma-color-bg-hover); 198 | } 199 | 200 | svg { 201 | cursor: pointer; 202 | 203 | rect { 204 | stroke: var(--figma-color-bg-disabled); 205 | } 206 | path { 207 | fill: var(--figma-color-bg-disabled); 208 | } 209 | 210 | &.fill { 211 | .background { 212 | stroke: var(--figma-color-bg-hover); 213 | fill: var(--figma-color-bg-hover); 214 | } 215 | } 216 | 217 | &:not(.active):hover { 218 | rect { 219 | stroke: ${(props) => props.theme.softColor}; 220 | } 221 | path { 222 | fill: ${(props) => props.theme.softColor}; 223 | } 224 | &.fill { 225 | .background { 226 | stroke: ${(props) => props.theme.softColor}; 227 | fill: ${(props) => props.theme.softColor}; 228 | } 229 | } 230 | &.fill-stroke { 231 | .background { 232 | stroke: ${(props) => props.theme.softColor}; 233 | fill: ${(props) => props.theme.hoverColor}; 234 | } 235 | } 236 | } 237 | 238 | &.active { 239 | rect { 240 | stroke: ${(props) => props.theme.color}; 241 | } 242 | 243 | path { 244 | fill: ${(props) => props.theme.color}; 245 | } 246 | 247 | &.fill { 248 | .background { 249 | stroke: ${(props) => props.theme.color}; 250 | fill: ${(props) => props.theme.color}; 251 | } 252 | } 253 | &.fill-stroke { 254 | .background { 255 | stroke: ${(props) => props.theme.softColor}; 256 | fill: ${(props) => props.theme.color}; 257 | } 258 | } 259 | } 260 | } 261 | `; 262 | 263 | export default CenterChooser; 264 | -------------------------------------------------------------------------------- /src/views/Home/components/LineChooser.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { useStore } from '../../../store'; 6 | 7 | const LineChooser: FunctionComponent = observer(() => { 8 | const store = useStore(); 9 | 10 | return ( 11 | 12 | 13 | store.setStrokeCap('ARROW_EQUILATERAL')} 21 | > 22 | 30 | 36 | 37 | 38 | store.setStrokeCap('NONE')} 46 | > 47 | 53 | 61 | 62 | 63 | store.setStrokeCap('ARROW_LINES')} 71 | > 72 | 80 | 86 | 87 | 88 | store.setStrokeCap('STANDARD')} 96 | > 97 | 105 | 109 | 110 | 111 |
112 | store.setStrokeOffset(+e.currentTarget.value)} 116 | /> 117 |
118 | 125 | 129 | 135 | 141 | 142 |
143 |
144 |
145 | ); 146 | }); 147 | 148 | const Icons = styled.div` 149 | display: flex; 150 | justify-content: space-between; 151 | width: 135px; 152 | `; 153 | 154 | const Container = styled.div` 155 | padding: 12px 14px; 156 | display: flex; 157 | align-items: center; 158 | justify-content: space-between; 159 | border-width: 1px 0 0; 160 | border-color: var(--figma-color-bg-secondary); 161 | border-style: solid; 162 | input { 163 | width: 75px; 164 | box-sizing: border-box; 165 | } 166 | 167 | svg { 168 | cursor: pointer; 169 | 170 | rect { 171 | stroke: var(--figma-color-bg-disabled); 172 | } 173 | path { 174 | fill: var(--figma-color-bg-disabled); 175 | } 176 | 177 | &:not(.active):hover { 178 | rect { 179 | stroke: ${(props) => props.theme.softColor}; 180 | } 181 | path { 182 | fill: ${(props) => props.theme.softColor}; 183 | } 184 | } 185 | 186 | &.active { 187 | rect { 188 | stroke: ${(props) => props.theme.color}; 189 | } 190 | path { 191 | fill: ${(props) => props.theme.color}; 192 | } 193 | } 194 | } 195 | `; 196 | 197 | export default LineChooser; 198 | -------------------------------------------------------------------------------- /src/views/Home/components/Viewer/components/Line.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface Props extends React.HTMLAttributes { 5 | $active?: boolean; 6 | $labels?: boolean | string; 7 | $labelsOutside?: boolean; 8 | } 9 | 10 | const Horizontal: FunctionComponent = (props) => ( 11 | 12 |
13 |
14 | ); 15 | 16 | const Vertical: FunctionComponent = (props) => ( 17 | 18 |
19 |
20 | ); 21 | 22 | const Corner: FunctionComponent = (props) => ( 23 | 24 |
25 |
26 | ); 27 | 28 | const VerticalLine = styled.div.attrs((props) => ({ 29 | className: props.$active ? 'active' : '', 30 | }))` 31 | background-color: transparent; 32 | border-radius: 5px; 33 | width: 11px; 34 | height: 60px; 35 | position: relative; 36 | cursor: pointer; 37 | margin: auto 0; 38 | z-index: 10; 39 | &:hover { 40 | z-index: 4; 41 | background-color: var(--figma-color-bg-hover); 42 | } 43 | div { 44 | position: absolute; 45 | top: 5px; 46 | left: 5px; 47 | height: calc(100% - 10px); 48 | width: 1px; 49 | background-color: var(--figma-color-bg-disabled); 50 | &::after { 51 | content: ''; 52 | display: ${(props) => (props.$labels ? 'block' : 'none')}; 53 | position: absolute; 54 | background-color: var(--figma-color-bg-disabled); 55 | width: 3px; 56 | left: ${(props) => (props.$labelsOutside ? 3 : -1)}; 57 | height: 29%; 58 | top: 50%; 59 | transform: translateY(-50%); 60 | } 61 | } 62 | &::after, 63 | &::before { 64 | content: ''; 65 | position: absolute; 66 | width: 5px; 67 | height: 1px; 68 | left: 3px; 69 | background-color: var(--figma-color-bg-disabled); 70 | } 71 | &::after { 72 | top: 5px; 73 | } 74 | &::before { 75 | bottom: 5px; 76 | } 77 | 78 | &.active { 79 | &::after, 80 | &::before, 81 | div, 82 | div::after { 83 | background-color: ${(props) => props.theme.color}; 84 | } 85 | } 86 | `; 87 | 88 | const HorizontalLine = styled(VerticalLine).attrs((props) => ({ 89 | className: props.$active ? 'active' : '', 90 | }))` 91 | border-radius: 5px; 92 | height: 11px; 93 | width: 60px; 94 | margin: 0 auto; 95 | div { 96 | height: 1px; 97 | width: calc(100% - 10px); 98 | &::after { 99 | height: 3px; 100 | top: ${(props) => (props.$labelsOutside ? 3 : -1)}; 101 | width: 29%; 102 | left: 50%; 103 | transform: translateX(-50%); 104 | } 105 | } 106 | 107 | &::after, 108 | &::before { 109 | height: 5px; 110 | width: 1px; 111 | left: inherit; 112 | top: 3px; 113 | } 114 | &::after { 115 | left: 5px; 116 | } 117 | &::before { 118 | right: 5px; 119 | } 120 | `; 121 | 122 | const CornerLine = styled.div.attrs((props) => ({ 123 | className: props.$active ? 'active' : '', 124 | }))` 125 | width: 17px; 126 | height: 17px; 127 | padding: 8px; 128 | position: relative; 129 | cursor: pointer; 130 | &:hover::after, 131 | &:hover div::after { 132 | background-color: var(--figma-color-bg-hover); 133 | } 134 | &::after { 135 | content: ''; 136 | position: absolute; 137 | width: 100%; 138 | left: 4px; 139 | top: 4px; 140 | height: 9px; 141 | z-index: -1; 142 | border-radius: 7px 5px 5px 0; 143 | } 144 | div { 145 | width: 9px; 146 | height: 9px; 147 | position: absolute; 148 | border-top-left-radius: 5px; 149 | border-width: 1px 0 0 1px; 150 | border-style: solid; 151 | border-color: var(--figma-color-bg-disabled); 152 | &::after { 153 | content: ''; 154 | display: 'block'; 155 | position: absolute; 156 | left: -5px; 157 | width: 9px; 158 | height: 14px; 159 | z-index: -1; 160 | top: -1px; 161 | border-radius: 0 0 4px 4px; 162 | } 163 | } 164 | &[data-direction='top-left'] { 165 | } 166 | &[data-direction='top-right'] { 167 | transform: rotate(90deg); 168 | } 169 | &[data-direction='right-bottom'] { 170 | transform: rotate(180deg); 171 | } 172 | &[data-direction='left-bottom'] { 173 | transform: rotate(-90deg); 174 | } 175 | 176 | &.active { 177 | div { 178 | border-color: ${(props) => props.theme.color}; 179 | } 180 | } 181 | `; 182 | 183 | export default { 184 | Horizontal, 185 | Vertical, 186 | Corner, 187 | }; 188 | -------------------------------------------------------------------------------- /src/views/Home/components/Viewer/components/Spacing.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent } from 'react'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | import EventEmitter from '../../../../../shared/EventEmitter'; 6 | import { NodeSelection } from '../../../../../shared/interfaces'; 7 | import { useStore } from '../../../../../store'; 8 | 9 | export const Spacing: FunctionComponent<{ 10 | showSpacing: boolean; 11 | hasSpacing: boolean; 12 | }> = observer((props) => { 13 | const store = useStore(); 14 | 15 | const refreshSelection = () => 16 | EventEmitter.ask('current selection').then((data: NodeSelection) => 17 | store.setSelection(data.nodes), 18 | ); 19 | 20 | const addSpacing = () => { 21 | if (store.selection.length > 1) { 22 | EventEmitter.emit('draw spacing', { 23 | color: store.color, 24 | labels: store.labels, 25 | strokeOffset: store.strokeOffset, 26 | labelsOutside: store.labelsOutside, 27 | labelPattern: store.labelPattern, 28 | strokeCap: store.strokeCap, 29 | }); 30 | refreshSelection(); 31 | } 32 | }; 33 | 34 | const removeSpacing = () => { 35 | if (props.showSpacing) { 36 | EventEmitter.emit('remove spacing'); 37 | refreshSelection(); 38 | } 39 | }; 40 | 41 | return ( 42 | (props.hasSpacing ? removeSpacing() : addSpacing())} 44 | enabled={props.showSpacing ? props.showSpacing.toString() : undefined} 45 | active={props.hasSpacing ? props.hasSpacing.toString() : undefined} 46 | > 47 |
48 |
49 |
50 |
51 |
52 | ); 53 | }); 54 | 55 | const ArrowTopBottom = css` 56 | left: calc(50% - 4.5px); 57 | 58 | &::after { 59 | border-left: 5px solid transparent; 60 | border-right: 5px solid transparent; 61 | border-bottom: 3px solid var(--figma-color-bg-disabled); 62 | } 63 | &::before { 64 | border-left: 5px solid transparent; 65 | border-right: 5px solid transparent; 66 | border-top: 3px solid var(--figma-color-bg-disabled); 67 | bottom: -9px; 68 | } 69 | `; 70 | 71 | const ArrowLeftRight = css` 72 | top: calc(50% - 4.5px); 73 | 74 | &::after { 75 | border-top: 5px solid transparent; 76 | border-bottom: 5px solid transparent; 77 | border-right: 3px solid var(--figma-color-bg-disabled); 78 | } 79 | &::before { 80 | border-top: 5px solid transparent; 81 | border-bottom: 5px solid transparent; 82 | border-left: 3px solid var(--figma-color-bg-disabled); 83 | right: -9px; 84 | } 85 | `; 86 | 87 | const Wrapper = styled.div.attrs<{ enabled?: boolean; active?: boolean }>( 88 | (p) => ({ 89 | className: `${p.enabled && 'enabled'} ${p.active && 'active'}`, 90 | }), 91 | )<{ enabled?: boolean | string; active?: boolean | string }>` 92 | position: absolute; 93 | left: -15px; 94 | top: -15px; 95 | border: 9px solid transparent; 96 | margin: 3px; 97 | border-radius: 20px; 98 | width: 127px; 99 | height: 127px; 100 | opacity: 0; 101 | &.enabled { 102 | opacity: 1; 103 | cursor: pointer; 104 | &:hover { 105 | border-color: var(--figma-color-bg-hover); 106 | } 107 | } 108 | &::after { 109 | content: ''; 110 | position: absolute; 111 | left: -5px; 112 | top: -5px; 113 | border: 1px dashed var(--figma-color-bg-disabled); 114 | width: calc(100% + 8px); 115 | height: calc(100% + 8px); 116 | border-radius: 17px; 117 | } 118 | div { 119 | position: absolute; 120 | &::after, 121 | &::before { 122 | content: ''; 123 | position: absolute; 124 | width: 0; 125 | height: 0; 126 | } 127 | } 128 | div:nth-child(1) { 129 | ${ArrowTopBottom} 130 | top: -9px; 131 | } 132 | div:nth-child(2) { 133 | ${ArrowLeftRight} 134 | left: -9px; 135 | } 136 | div:nth-child(3) { 137 | ${ArrowTopBottom} 138 | bottom: 0px; 139 | } 140 | div:nth-child(4) { 141 | ${ArrowLeftRight} 142 | right: 0; 143 | } 144 | 145 | &.active { 146 | &::after { 147 | border-color: ${(props) => props.theme.color}; 148 | } 149 | div:nth-child(1), 150 | div:nth-child(3) { 151 | &::after { 152 | border-bottom: 3px solid ${(props) => props.theme.color}; 153 | } 154 | &::before { 155 | border-top: 3px solid ${(props) => props.theme.color}; 156 | } 157 | } 158 | div:nth-child(2n) { 159 | &::after { 160 | border-right: 3px solid ${(props) => props.theme.color}; 161 | } 162 | &::before { 163 | border-left: 3px solid ${(props) => props.theme.color}; 164 | } 165 | } 166 | } 167 | `; 168 | -------------------------------------------------------------------------------- /src/views/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent, useMemo } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { Colors } from '../../components/ColorPicker'; 6 | import { EmptyScreenImage } from '../../components/EmptyScreenImage'; 7 | import { Input } from '../../components/Input'; 8 | import { 9 | AttachedIcon, 10 | DetachedIcon, 11 | } from '../../components/icons/DetachedIcon'; 12 | import { RefreshIcon } from '../../components/icons/RefreshIcon'; 13 | import { SuccessIcon } from '../../components/icons/SuccessIcon'; 14 | import { WarningIcon } from '../../components/icons/WarningIcon'; 15 | import { useStore } from '../../store'; 16 | 17 | import CenterChooser from './components/CenterChooser'; 18 | import LineChooser from './components/LineChooser'; 19 | import Viewer from './components/Viewer'; 20 | 21 | const Home: FunctionComponent = observer(() => { 22 | const store = useStore(); 23 | 24 | const labelDotIndex = useMemo(() => { 25 | if (!store.labels) { 26 | return 1; 27 | } else if (store.labelsOutside) { 28 | return 3; 29 | } 30 | return 2; 31 | }, [store.labels, store.labelsOutside]); 32 | 33 | const changeLabel = () => { 34 | switch (labelDotIndex) { 35 | case 2: 36 | store.setLabelsOutside(true); 37 | break; 38 | case 1: 39 | store.setLabelsOutside(false); 40 | break; 41 | case 3: 42 | store.setLabels(false); 43 | break; 44 | } 45 | }; 46 | 47 | return ( 48 | <> 49 | 50 | {store.selection.length === 0 && ( 51 | 52 | 53 | 54 | select an element to 55 |
56 | start measuring 57 |
58 |
59 | )} 60 | 61 | 62 | 63 | {!store.detached && ( 64 | 0} 67 | onClick={() => store.sendMeasurements()} 68 | > 69 | 70 | 71 | )} 72 | 73 | { 76 | store.setDetached(!store.detached); 77 | }} 78 | > 79 | {store.detached ? : } 80 |
81 | {store.detached ? : } 82 |
83 |
84 | 85 | 86 | 87 | { 91 | changeLabel(); 92 | }} 93 | > 94 |
95 |
96 | 97 | 98 | 99 |
100 |
101 |
102 | 103 | 104 | 105 | 106 | 107 | 113 | You can write a "complex" pattern like this{' '} 114 | ($###*2.5) or a simple one ($) 115 |

116 | $ represents the value, after that you can add 117 | a multiplier or divider (* or{' '} 118 | /) the repetition of the #{' '} 119 | symbol indicates the number of digits after the decimal point. 120 |
You can also fill the field with only one unit of 121 | measurement and everything will be automatically calculated 122 | based on 72dpi. (cm,mm,px,pt,dp,",in) 123 |

124 |

Example

125 |

126 | Imagine your base unit is 8px=1x. So when a square is 64px the 127 | measurement will be 8x as the result. 128 |

129 | ($###/8)x 130 | 131 | } 132 | value={store.labelPattern} 133 | onChange={(e) => store.setLabelPattern(e.currentTarget.value)} 134 | /> 135 |
136 | 137 | ); 138 | }); 139 | 140 | const InputContainer = styled.div` 141 | display: flex; 142 | align-items: center; 143 | justify-content: space-between; 144 | width: 100%; 145 | padding: 12px 14px; 146 | position: relative; 147 | `; 148 | 149 | const ViewerOverlay = styled.div` 150 | position: absolute; 151 | top: 0; 152 | left: 0; 153 | height: 100%; 154 | width: 100%; 155 | background-color: var(--figma-color-bg); 156 | display: flex; 157 | align-items: center; 158 | justify-content: center; 159 | text-align: center; 160 | font-weight: 500; 161 | z-index: 20; 162 | flex-direction: column; 163 | span { 164 | margin-top: 17px; 165 | color: #808080; 166 | font-weight: normal; 167 | } 168 | `; 169 | 170 | const Refresh = styled.div<{ $active?: boolean }>` 171 | position: absolute; 172 | right: 12px; 173 | top: 12px; 174 | cursor: pointer; 175 | border-radius: 11px; 176 | width: 30px; 177 | height: 30px; 178 | border: 1px solid var(--figma-color-bg-tertiary); 179 | overflow: hidden; 180 | opacity: ${(props) => (props.$active ? 1 : 0.5)}; 181 | display: flex; 182 | align-items: center; 183 | justify-content: center; 184 | background-color: transparent; 185 | 186 | > svg path { 187 | fill: var(--figma-color-text); 188 | } 189 | 190 | &:hover { 191 | border-color: ${(props) => props.theme.color}; 192 | > svg { 193 | path { 194 | fill: ${(props) => props.theme.color}; 195 | } 196 | } 197 | } 198 | &:active { 199 | border-color: ${(props) => props.theme.color}; 200 | } 201 | `; 202 | 203 | const LabelControl = styled(Refresh)<{ $index?: number }>` 204 | display: block; 205 | position: absolute; 206 | right: 12px; 207 | bottom: 12px; 208 | left: initial; 209 | top: initial; 210 | cursor: pointer; 211 | border-radius: 10px; 212 | opacity: 1; 213 | z-index: 2; 214 | .dots { 215 | display: flex; 216 | width: 85%; 217 | justify-content: space-evenly; 218 | margin: 0 auto; 219 | span { 220 | border-radius: 100%; 221 | width: 3px; 222 | height: 3px; 223 | background-color: var(--figma-color-bg-disabled); 224 | display: inline-block; 225 | 226 | &:nth-child(${(p) => p.$index}) { 227 | background-color: ${(p) => p.theme.color}; 228 | } 229 | } 230 | } 231 | .label { 232 | height: 18px; 233 | position: relative; 234 | z-index: 5; 235 | &::before, 236 | &::after { 237 | background-color: ${(p) => p.theme.color}; 238 | content: ''; 239 | position: absolute; 240 | } 241 | &::before { 242 | left: 4px; 243 | width: 20px; 244 | height: 1px; 245 | top: 10px; 246 | } 247 | &::after { 248 | content: ''; 249 | display: ${(p) => (p.$index === 1 ? 'none' : 'block')}; 250 | position: absolute; 251 | left: 10px; 252 | width: 8px; 253 | height: 3px; 254 | top: ${(p) => (p.$index === 3 ? 5 : 9)}px; 255 | } 256 | } 257 | `; 258 | 259 | const Detached = styled(Refresh)` 260 | top: initial; 261 | left: 12px; 262 | top: 12px; 263 | opacity: 1; 264 | z-index: 21; 265 | overflow: initial; 266 | svg { 267 | margin: 0; 268 | } 269 | .hint { 270 | position: absolute; 271 | right: -10px; 272 | top: -10px; 273 | } 274 | `; 275 | 276 | const ViewerContainer = styled.div` 277 | position: relative; 278 | height: 355px; 279 | display: flex; 280 | justify-content: center; 281 | align-items: center; 282 | 283 | svg { 284 | user-select: none; 285 | g { 286 | cursor: pointer; 287 | } 288 | } 289 | `; 290 | 291 | export default Home; 292 | -------------------------------------------------------------------------------- /src/views/Settings/components/DebugModal.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent, useEffect, useMemo, useState } from 'react'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | import EventEmitter from '../../../shared/EventEmitter'; 6 | 7 | export const DebugModal: FunctionComponent<{ close: () => void }> = observer( 8 | (props) => { 9 | const [isLoading, setLoading] = useState(true); 10 | const [activeNodeId, setActiveNodeId] = useState(''); 11 | const [measurements, setMeasurements] = useState([]); 12 | 13 | const groupedByPage = useMemo(() => { 14 | const pages = {}; 15 | 16 | for (const measurement of measurements) { 17 | if (!measurement.type.startsWith('GROUP_')) { 18 | if (!pages[measurement.pageId]) { 19 | pages[measurement.pageId] = { 20 | name: measurement.pageName, 21 | measurements: [], 22 | }; 23 | } 24 | pages[measurement.pageId].measurements.push(measurement); 25 | } 26 | } 27 | 28 | return pages; 29 | }, [measurements]); 30 | 31 | useEffect(() => { 32 | EventEmitter.ask('file measurements').then((data: any[]) => { 33 | setMeasurements(data); 34 | setLoading(false); 35 | }); 36 | }, []); 37 | 38 | return ( 39 | <> 40 | 41 | 42 | 43 | 50 | 54 | 55 | 56 | All measurements 57 | 58 |

59 | Listed elements still have Measure data. You can delete them here, 60 | but normally this is not necessary! 61 |

62 | {isLoading && ( 63 |
64 | loading elements ... 65 |
66 | (this could take a while) 67 |
68 | )} 69 | {!isLoading && Object.keys(groupedByPage).length === 0 && ( 70 |
No elements found
71 | )} 72 | {measurements.length > 0 && ( 73 |
    74 | {Object.keys(groupedByPage).map((pageId) => ( 75 |
    76 |

    {groupedByPage[pageId].name}

    77 | {groupedByPage[pageId].measurements.map((element) => ( 78 |
  • 79 | {element.name} 80 |
    81 | { 83 | setActiveNodeId( 84 | element.id === activeNodeId ? '' : element.id, 85 | ); 86 | EventEmitter.emit('focus node', element); 87 | }} 88 | > 89 | focus 90 | 91 | 92 | { 94 | const tempMeasurements = measurements.filter( 95 | (m) => 96 | m.pageId === pageId && m.id !== element.id, 97 | ); 98 | 99 | setMeasurements(tempMeasurements); 100 | 101 | EventEmitter.emit( 102 | 'remove node measurement', 103 | element.id, 104 | ); 105 | }} 106 | > 107 | remove 108 | 109 |
    110 |
  • 111 | ))} 112 |
    113 | ))} 114 |
115 | )} 116 |
117 |
118 | 119 | ); 120 | }, 121 | ); 122 | 123 | const Button = styled.button` 124 | display: inline-block; 125 | border: 0px; 126 | padding: 4px 7px; 127 | font-size: 11px; 128 | border-radius: 4px; 129 | text-align: center; 130 | color: #fff; 131 | `; 132 | 133 | const RemoveButton = styled(Button)` 134 | background-color: #444; 135 | `; 136 | 137 | const FocusButton = styled(Button)` 138 | margin-right: 7px; 139 | background-color: #c85555; 140 | `; 141 | 142 | const Headline = styled.h3` 143 | font-size: 13px; 144 | font-weight: 500; 145 | margin: 14px 14px 0; 146 | `; 147 | 148 | const DebugClose = styled.div` 149 | position: absolute; 150 | top: 14px; 151 | right: 14px; 152 | cursor: pointer; 153 | `; 154 | 155 | export const OverlayStyle = css` 156 | position: fixed; 157 | top: 0; 158 | left: 0; 159 | background-color: rgba(var(--figma-color-bg), 0.3); 160 | z-index: 31; 161 | height: 100%; 162 | width: 100%; 163 | `; 164 | 165 | const DebugOverlay = styled.div` 166 | ${OverlayStyle} 167 | `; 168 | 169 | const DebugList = styled.div` 170 | padding: 14px; 171 | p { 172 | color: #999; 173 | margin: 0 0 10px; 174 | padding-bottom: 10px; 175 | border-bottom: 1px solid #eee; 176 | } 177 | .loading, 178 | .empty { 179 | text-align: center; 180 | } 181 | .page { 182 | margin-bottom: 14px; 183 | &:last-child { 184 | margin-bottom: 0; 185 | } 186 | } 187 | ul { 188 | list-style: none; 189 | padding: 0; 190 | margin: 0; 191 | li { 192 | cursor: pointer; 193 | padding: 10px; 194 | font-size: 11px; 195 | background-color: #eee; 196 | border-radius: 5px; 197 | margin-bottom: 7px; 198 | span { 199 | display: block; 200 | margin-bottom: 5px; 201 | } 202 | &:last-child { 203 | margin-bottom: 0; 204 | } 205 | } 206 | } 207 | `; 208 | 209 | const DebugWrapper = styled.div` 210 | position: fixed; 211 | background-color: #fff; 212 | top: 14px; 213 | left: 14px; 214 | right: 14px; 215 | max-height: 60%; 216 | z-index: 32; 217 | font-size: 11px; 218 | box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); 219 | border-radius: 6px; 220 | overflow: auto; 221 | `; 222 | -------------------------------------------------------------------------------- /src/views/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import React, { FunctionComponent, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { Input } from '../../components/Input'; 6 | import { Toggle } from '../../components/Toggle'; 7 | import EventEmitter from '../../shared/EventEmitter'; 8 | import { useStore } from '../../store'; 9 | 10 | import { DebugModal, OverlayStyle } from './components/DebugModal'; 11 | 12 | const DebugWrapper = styled.div` 13 | position: fixed; 14 | background-color: #fff; 15 | top: 14px; 16 | left: 14px; 17 | right: 14px; 18 | max-height: 60%; 19 | z-index: 32; 20 | box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); 21 | border-radius: 6px; 22 | overflow: auto; 23 | `; 24 | 25 | const Loading = styled(DebugWrapper)` 26 | padding: 20px; 27 | & + div::after { 28 | content: ''; 29 | ${OverlayStyle} 30 | } 31 | `; 32 | 33 | const Settings: FunctionComponent = observer(() => { 34 | const store = useStore(); 35 | const [showDebug, setShowDebug] = useState(false); 36 | const [loading, setLoading] = useState(false); 37 | 38 | const removeAllMeasurements = async () => { 39 | if (confirm('Would your really remove all measurements?')) { 40 | setLoading(true); 41 | await EventEmitter.ask('remove all measurements'); 42 | setLoading(false); 43 | alert('All measurements removed'); 44 | } 45 | }; 46 | 47 | return ( 48 | <> 49 | {loading && ( 50 | 51 | Removing measurements... 52 |
53 |
This could take a while in large files. Just wait little bit 🫖 54 |
55 | )} 56 | {showDebug && setShowDebug(false)} />} 57 | 58 | Labels 59 | 60 | 61 |
62 | store.setLabelFontSize(+e.currentTarget.value)} 67 | /> 68 |
69 | 76 | 80 | 84 | 90 | 91 |
92 |
93 |
94 | 95 | 96 | 97 | Groups 98 | 99 | store.toggleIsGlobalGroup()} 104 | /> 105 | store.toggleLockAttachedGroup()} 109 | /> 110 | store.toggleLockDetachedGroup()} 114 | /> 115 | 116 | 117 | Tooltips 118 | 119 | 120 |
121 | store.setTooltipOffset(+e.currentTarget.value)} 126 | /> 127 |
128 | 135 | 136 | 142 | 143 |
144 |
145 |
146 | 147 | 148 | 149 | 150 | store.toggleTooltipSetting('name')} 154 | /> 155 | store.toggleTooltipSetting('variants')} 160 | /> 161 | 162 | 163 | store.toggleTooltipSetting('fontName')} 167 | /> 168 | store.toggleTooltipSetting('fontSize')} 173 | /> 174 | 175 | 181 | You can write a "complex" pattern like this{' '} 182 | ($###*2.5) or a simple one ($) 183 |

184 | $ represents the value, after that you can 185 | add a multiplier or divider (* or{' '} 186 | /) the repetition of the #{' '} 187 | symbol indicates the number of digits after the decimal point. 188 |
You can also fill the field with only one unit of 189 | measurement and everything will be automatically calculated 190 | based on 72dpi. (cm,mm,px,pt,dp,",in) 191 |

192 |

Example

193 |

194 | Imagine your base unit is 8px=1x. So when a square is 64px the 195 | measurement will be 8x as the result. 196 |

197 | ($###/8)x 198 | 199 | } 200 | value={store.fontPattern} 201 | onChange={(e) => store.setFontPattern(e.currentTarget.value)} 202 | /> 203 | 204 | 205 | 206 | store.toggleTooltipSetting('width')} 210 | /> 211 | store.toggleTooltipSetting('height')} 215 | /> 216 | 217 | 218 | 219 | store.toggleTooltipSetting('color')} 223 | /> 224 | store.toggleTooltipSetting('opacity')} 228 | /> 229 | store.toggleTooltipSetting('stroke')} 234 | /> 235 | store.toggleTooltipSetting('cornerRadius')} 239 | /> 240 | store.toggleTooltipSetting('points')} 245 | /> 246 | 247 | 248 | 249 | store.toggleTooltipSetting('effects')} 253 | /> 254 | 255 | store.toggleTooltipSetting('onlyEffectStyle')} 259 | /> 260 |
261 | 262 | 263 | Dangerzone 264 | 265 |

266 | This section lets you remove or display all measured elements. This 267 | could take a while in large files. 268 |

269 | 270 | Remove all measurements 271 | 272 | setShowDebug(true)}> 273 | All measured elements 274 | 275 |
276 |
277 | 278 | ); 279 | }); 280 | 281 | const DangerZone = styled.div` 282 | padding: 14px; 283 | p { 284 | margin-top: 0; 285 | color: var(--figma-color-text-tertiary); 286 | } 287 | `; 288 | 289 | const GroupSeperator = styled.div` 290 | height: 1px; 291 | width: 100%; 292 | margin-top: 10px; 293 | margin-bottom: 10px !important; 294 | background-color: var(--figma-color-bg-secondary); 295 | `; 296 | 297 | const Button = styled.button` 298 | display: block; 299 | border: 0; 300 | padding: 10px 12px; 301 | font-size: 12px; 302 | border-radius: 4px; 303 | width: 100%; 304 | text-align: center; 305 | `; 306 | 307 | const RemoveButton = styled(Button)` 308 | margin-bottom: 7px; 309 | color: #fff; 310 | background-color: #e03e1a; 311 | `; 312 | 313 | const DebugButton = styled(Button)` 314 | background-color: var(--figma-color-bg-hover); 315 | color: var(--figma-color-bg-inverse); 316 | `; 317 | 318 | const Headline = styled.h3` 319 | font-size: 13px; 320 | font-weight: 500; 321 | margin: 14px 14px 0; 322 | color: var(--figma-color-text); 323 | `; 324 | 325 | const Seperator = styled.div` 326 | width: 100%; 327 | height: 1px; 328 | background-color: var(--figma-color-bg-secondary); 329 | `; 330 | 331 | const LockSettings = styled.div` 332 | padding: 10px 5px 5px 14px; 333 | div:first-child { 334 | margin-bottom: 7px; 335 | } 336 | `; 337 | 338 | const DistanceSetting = styled.div` 339 | padding: 10px 5px 14px 14px; 340 | display: flex; 341 | justify-content: space-between; 342 | align-items: center; 343 | `; 344 | 345 | const Wrapper = styled.div` 346 | position: relative; 347 | overflow: auto; 348 | top: 0; 349 | height: 520px; 350 | font-size: 11px; 351 | color: var(--figma-color-text); 352 | `; 353 | 354 | const ToggleInputs = styled.div` 355 | padding: 12px 5px 12px 14px; 356 | > div { 357 | margin-bottom: 7px; 358 | &:last-child { 359 | margin-bottom: 0; 360 | } 361 | } 362 | `; 363 | 364 | export default Settings; 365 | -------------------------------------------------------------------------------- /styled.d.ts: -------------------------------------------------------------------------------- 1 | import 'styled-components'; 2 | import { TOKENS } from './src/style'; 3 | 4 | declare module 'styled-components' { 5 | export interface DefaultTheme { 6 | color: string; 7 | softColor: string; 8 | hoverColor: string; 9 | colors: string[]; 10 | softColors: string[]; 11 | hoverColors: string[]; 12 | tokens: typeof TOKENS; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "jsx": "react", 5 | "experimentalDecorators": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "resolveJsonModule": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "typeRoots": ["./node_modules/@figma"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | import CreateFileWebpack from 'create-file-webpack'; 2 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 3 | import path from 'path'; 4 | import TerserPlugin from 'terser-webpack-plugin'; 5 | import webpack from 'webpack'; 6 | 7 | import pkg from './package.json' with { type: 'json' }; 8 | 9 | const __dirname = path.dirname(new URL(import.meta.url).pathname); 10 | 11 | const webpackConfig = (env, argv) => ({ 12 | mode: argv.mode === 'production' ? 'production' : 'development', 13 | devtool: argv.mode === 'production' ? false : 'inline-source-map', 14 | devServer: { 15 | https: true, 16 | }, 17 | optimization: { 18 | minimize: argv.mode === 'production', 19 | minimizer: 20 | argv.mode === 'production' 21 | ? [ 22 | new TerserPlugin({ 23 | terserOptions: { 24 | mangle: true, 25 | }, 26 | }), 27 | ] 28 | : [], 29 | }, 30 | entry: { 31 | ui: './src/App.tsx', 32 | code: './src/main/index.ts', 33 | }, 34 | watchOptions: { 35 | ignored: ['node_modules/**'], 36 | }, 37 | module: { 38 | rules: [ 39 | { 40 | test: /\.tsx?$/, 41 | loader: 'esbuild-loader', 42 | options: { 43 | loader: 'tsx', // Or 'ts' if you don't need tsx 44 | target: 'es2015', 45 | }, 46 | }, 47 | { 48 | test: /\.css$/, 49 | use: [ 50 | { 51 | loader: 'style-loader', 52 | }, 53 | { 54 | loader: 'css-loader', 55 | }, 56 | ], 57 | }, 58 | { 59 | test: /\.(png|jpg|gif|webp|svg|zip|mp3)$/, 60 | loader: 'url-loader', 61 | }, 62 | ], 63 | }, 64 | resolve: { 65 | extensions: ['.tsx', '.ts', '.jsx', '.js'], 66 | }, 67 | output: { 68 | filename: '[name].js', 69 | path: path.resolve(__dirname, pkg.figmaPlugin.name.replace(/\s/g, '-')), 70 | }, 71 | plugins: [ 72 | new HtmlWebpackPlugin({ 73 | filename: 'ui.html', 74 | inlineSource: '.(js)$', 75 | chunks: ['ui'], 76 | inject: false, 77 | templateContent: ({ compilation, htmlWebpackPlugin }) => ` 78 | 79 | 80 |
81 | ${htmlWebpackPlugin.files.js.map( 82 | (jsFile) => 83 | ``, 86 | )} 87 | 88 | 89 | `, 90 | }), 91 | new webpack.DefinePlugin({ 92 | process: { 93 | env: { 94 | REACT_APP_SC_ATTR: JSON.stringify('data-styled-figma-measure'), 95 | SC_ATTR: JSON.stringify('data-styled-figma-measure'), 96 | REACT_APP_SC_DISABLE_SPEEDY: JSON.stringify('false'), 97 | }, 98 | }, 99 | }), 100 | new CreateFileWebpack({ 101 | path: path.resolve(__dirname, pkg.figmaPlugin.name.replace(/\s/g, '-')), 102 | fileName: 'manifest.json', 103 | content: JSON.stringify(pkg.figmaPlugin), 104 | }), 105 | ], 106 | }); 107 | 108 | export default webpackConfig; 109 | --------------------------------------------------------------------------------