├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── README.md ├── dev-plugin ├── manifest.json ├── package-lock.json ├── package.json ├── src │ ├── figma.ts │ ├── frame.html │ ├── frame.tsx │ └── ui.html ├── tsconfig.json └── webpack.config.js ├── package-lock.json ├── package.json ├── src ├── browser │ ├── add-constraints.ts │ ├── border.ts │ ├── build-tree.ts │ ├── dom-utils.ts │ ├── element-to-figma.ts │ ├── html-to-figma.ts │ ├── index.ts │ ├── text-to-figma.ts │ └── utils.ts ├── figma │ ├── dropOffset.ts │ ├── getFont.ts │ ├── helpers.ts │ ├── images.ts │ ├── index.ts │ └── processLayer.ts ├── types.ts └── utils.ts ├── tests ├── __snapshots__ │ └── base.test.js.snap ├── base.test.js ├── page │ ├── index.ts │ └── stubs │ │ ├── base-button.html │ │ ├── borders.html │ │ ├── button-before-after.html │ │ ├── input.html │ │ ├── opacity.html │ │ └── shadows.html └── setup.js └── tsconfig.json /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js v12.13.1 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: v12.13.1 19 | - run: npm ci 20 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /build/ 4 | /dev-plugin/dist 5 | /dev-plugin/node_modules/ 6 | .cache -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry = http://registry.npmjs.org -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # html-figma 2 | 3 | **WORK IN PROGRESS** 4 | 5 | ![](https://s3-alpha-sig.figma.com/plugins/1005496056687344906/20022/399cc0cb-16e8-404b-b546-414cada784c8-cover?Expires=1630886400&Signature=Nbz0-5O19TeWqidtT42D9wSso8wXEqrhkY8oQ9cBE9aehp4plxzeEXTuHXlBEOi6~85psa1Fr~t6ofvgT1T2QzZLnqaCm6DOjHqdOtG05qXaniN8ptD0zNPWvzCWvEaTLcJvbEZ3hufcITGEOiO~kDg94r~zXKxDkOrKhnFS4YyBfIwd-wm54oHipTvbjhVqnSZwDUGk6ycFuv13ZWD5qAe8-p8qnkWZtu5K~bluHDMPPsD8iKzYoYYjJEBOU4M3NvP~gtNltqJxFTk8bvI3AUtsDKgdyvJY7aJwb1SGEqGq9B1MYxB0EKsIXg6cjgeeyHYgJJVpTWYheHB92b3Hgw__&Key-Pair-Id=APKAINTVSUGEWH5XD5UA) 6 | 7 | Converts DOM nodes to Figma nodes. 8 | 9 | Inspired by [figma-html](https://github.com/BuilderIO/figma-html). 10 | 11 | *DEMO*: https://www.figma.com/community/plugin/1005496056687344906/html-to-figma-DEV-plugin 12 | 13 | Example: `/dev-plugin` 14 | 15 | ```npm i html-figma``` 16 | 17 | ## USAGE 18 | 19 | ### Browser 20 | ```js 21 | import { htmlTofigma } from 'html-figma/browser'; 22 | 23 | const element = document.getElementById('#element-to-export'); 24 | 25 | const layersMeta = await htmlTofigma(element); 26 | ``` 27 | 28 | ### Figma 29 | ```js 30 | import { addLayersToFrame } from 'html-figma/figma'; 31 | 32 | const rootNode = figma.currentPage; 33 | 34 | await addLayersToFrame(layersMeta, rootNode); 35 | ``` 36 | -------------------------------------------------------------------------------- /dev-plugin/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-to-figma DEV-plugin", 3 | "id": "962455453431179906", 4 | "api": "1.0.0", 5 | "main": "./figma.js", 6 | "ui": "./index.html" 7 | } 8 | -------------------------------------------------------------------------------- /dev-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-figma-dev-plugin", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "src/index.ts", 6 | "scripts": { 7 | "start": "webpack serve", 8 | "build": "webpack build", 9 | "build:prod": "webpack build --mode=production", 10 | "serve:tests": "serve -s tests/page -p 3000", 11 | "test": "npm run build && jest tests/*.test.js" 12 | }, 13 | "author": "Sergei Savelev", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@figma/plugin-typings": "^1.19.3", 17 | "@types/jest": "^26.0.23", 18 | "@types/lodash.throttle": "^4.1.6", 19 | "@types/node": "^15.6.0", 20 | "copy-webpack-plugin": "^9.0.1", 21 | "css-loader": "^6.2.0", 22 | "cssnano": "^5.0.6", 23 | "html-webpack-inline-source-plugin": "^0.0.10", 24 | "html-webpack-plugin": "^5.3.2", 25 | "jest": "^26.6.3", 26 | "monaco-editor": "^0.26.1", 27 | "postcss": "^8.3.2", 28 | "puppeteer": "^9.1.1", 29 | "react-docgen-typescript": "^1.22.0", 30 | "style-loader": "^3.2.1", 31 | "ts-jest": "^26.5.6", 32 | "ts-loader": "^9.2.4", 33 | "typescript": "^4.2.4", 34 | "webpack": "^5.46.0", 35 | "webpack-cli": "^4.7.2", 36 | "webpack-dev-server": "^3.11.2" 37 | }, 38 | "prettier": { 39 | "tabWidth": 4, 40 | "singleQuote": true 41 | }, 42 | "dependencies": { 43 | "@types/react": "^17.0.15", 44 | "file-type": "^12.2.0", 45 | "jest-dev-server": "^5.0.3", 46 | "jest-puppeteer": "^5.0.4", 47 | "lodash.throttle": "^4.1.1" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /dev-plugin/src/figma.ts: -------------------------------------------------------------------------------- 1 | import { addLayersToFrame, defaultFont } from '../../src/figma'; 2 | import { PlainLayerNode } from '../../src/types'; 3 | 4 | 5 | //@ts-ignore 6 | figma.showUI(__html__, { 7 | width: 600, 8 | height: 600, 9 | }); 10 | 11 | const name = 'HTML-TO-FIGMA RESULT'; 12 | 13 | interface MsgData { 14 | layers: PlainLayerNode; 15 | } 16 | 17 | figma.ui.onmessage = async (msg) => { 18 | if (msg.type === 'import') { 19 | await figma.loadFontAsync(defaultFont); 20 | 21 | const { data } = msg; 22 | 23 | let { layers } = data as MsgData; 24 | 25 | let baseFrame: PageNode | FrameNode = figma.currentPage; 26 | let frameRoot: SceneNode = baseFrame as any; 27 | 28 | let x = 0, y = 0; 29 | let currentNode = figma.currentPage.findOne(n => n.name === name); 30 | 31 | if (currentNode) { 32 | x = currentNode.x; 33 | y = currentNode.y; 34 | } 35 | 36 | layers.x = x; 37 | layers.y = y; 38 | 39 | await addLayersToFrame([layers], baseFrame, ({ node, parent }) => { 40 | if (!parent) { 41 | frameRoot = node; 42 | node.name = name; 43 | } 44 | }); 45 | 46 | currentNode?.remove(); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /dev-plugin/src/frame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 |
42 |
43 | 44 | -------------------------------------------------------------------------------- /dev-plugin/src/frame.tsx: -------------------------------------------------------------------------------- 1 | import * as monaco from 'monaco-editor'; 2 | import { htmlToFigma, setContext } from '../../src/browser'; 3 | import throttle from 'lodash.throttle'; 4 | import { LayerNode } from '../../src/types'; 5 | 6 | const sendToFigma = (layers: LayerNode) => { 7 | window.parent.postMessage( 8 | { 9 | pluginMessage: { 10 | type: 'import', 11 | data: { 12 | layers, 13 | }, 14 | }, 15 | }, 16 | '*' 17 | ); 18 | }; 19 | 20 | // @ts-ignore 21 | self.MonacoEnvironment = { 22 | // @ts-ignore 23 | getWorkerUrl: function (moduleId, label) { 24 | if (label === 'json') { 25 | return './json.worker.js'; 26 | } 27 | if (label === 'css' || label === 'scss' || label === 'less') { 28 | return './css.worker.js'; 29 | } 30 | if (label === 'html' || label === 'handlebars' || label === 'razor') { 31 | return './html.worker.js'; 32 | } 33 | if (label === 'typescript' || label === 'javascript') { 34 | return './ts.worker.js'; 35 | } 36 | return './editor.worker.js'; 37 | }, 38 | }; 39 | 40 | document.addEventListener('DOMContentLoaded', function () { 41 | const editor = monaco.editor.create( 42 | document.getElementById('editor-container') as HTMLElement, 43 | { 44 | value: ` 45 | 46 | 51 | 52 |
53 | 54 |
55 | 56 | 57 | `.trim(), 58 | language: 'html', 59 | } 60 | ); 61 | 62 | const frame = document.getElementById( 63 | 'iframe-sandbox' 64 | ) as HTMLIFrameElement; 65 | 66 | if (!frame || !frame.contentWindow) return; 67 | 68 | const updateFigma = throttle(async () => { 69 | setContext(frame.contentWindow as Window); 70 | //@ts-ignore 71 | const res = await htmlToFigma('#root,#container'); 72 | 73 | sendToFigma(res); 74 | }, 500); 75 | 76 | frame?.contentDocument?.addEventListener('DOMContentLoaded', async () => { 77 | setTimeout(() => { 78 | updateFigma(); 79 | }, 1000); 80 | }); 81 | 82 | const updateSandbox = async () => { 83 | frame.srcdoc = editor.getValue(); 84 | setTimeout(updateFigma, 500); 85 | // updateFigma(); 86 | }; 87 | 88 | editor.onDidChangeModelContent((e) => { 89 | updateSandbox(); 90 | }); 91 | 92 | setTimeout(updateSandbox, 500); 93 | }); 94 | -------------------------------------------------------------------------------- /dev-plugin/src/ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | 11 | 16 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /dev-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /dev-plugin/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 2 | const CopyPlugin = require('copy-webpack-plugin'); 3 | const path = require('path'); 4 | 5 | module.exports = (env, argv) => { 6 | const outputDir = path.join( 7 | __dirname, 8 | argv.mode === 'production' ? '../dev-plugin-static' : 'dist' 9 | ); 10 | const publicPath = 11 | argv.mode === 'production' 12 | ? 'https://sergcen.github.io/html-to-figma/dev-plugin-static/' 13 | : '/'; 14 | const frameUrl = 15 | argv.mode === 'production' 16 | ? path.join(publicPath, 'frame.html') 17 | : 'http://localhost:5000/frame.html'; 18 | 19 | return { 20 | mode: argv.mode === 'production' ? 'production' : 'development', 21 | 22 | // This is necessary because Figma's 'eval' works differently than normal eval 23 | devtool: argv.mode === 'production' ? false : 'inline-source-map', 24 | 25 | entry: { 26 | frame: './src/frame.tsx', // The entry point for your plugin code 27 | figma: './src/figma.ts', // The entry point for your plugin code 28 | 'editor.worker': 'monaco-editor/esm/vs/editor/editor.worker.js', 29 | 'html.worker': 'monaco-editor/esm/vs/language/html/html.worker', 30 | 'css.worker': 'monaco-editor/esm/vs/language/css/css.worker', 31 | }, 32 | 33 | module: { 34 | rules: [ 35 | // Converts TypeScript code to JavaScript 36 | { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/ }, 37 | 38 | // Enables including CSS by doing "import './file.css'" in your TypeScript code 39 | { 40 | test: /\.css$/, 41 | use: ['style-loader', { loader: 'css-loader' }], 42 | }, 43 | 44 | // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI 45 | { test: /\.(png|jpg|gif|webp|svg)$/, loader: 'url-loader' }, 46 | ], 47 | }, 48 | 49 | // Webpack tries these extensions for you if you omit the extension like "import './file'" 50 | resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] }, 51 | 52 | output: { 53 | filename: (pathData) => { 54 | return pathData.chunk.name === 'figma' 55 | ? 'figma/[name].js' 56 | : '[name].js'; 57 | }, 58 | path: outputDir, // Compile into a folder called "dist" 59 | publicPath, 60 | }, 61 | devServer: { 62 | contentBase: outputDir, 63 | compress: true, 64 | port: 5000, 65 | proxy: { 66 | '/frame': { 67 | target: 'http://localhost:8100', 68 | pathRewrite: { '^/frame': '' }, 69 | }, 70 | }, 71 | }, 72 | // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it 73 | plugins: [ 74 | new HtmlWebpackPlugin({ 75 | template: './src/ui.html', 76 | filename: 'figma/index.html', 77 | inlineSource: '.(js)$', 78 | chunks: ['ui'], 79 | templateParameters: { frameUrl }, 80 | }), 81 | new HtmlWebpackPlugin({ 82 | template: './src/frame.html', 83 | filename: 'frame.html', 84 | inlineSource: '.(js)$', 85 | chunks: ['frame', 'editor.worker', 'html.worker', 'css.worker'], 86 | }), 87 | new CopyPlugin({ 88 | patterns: [ 89 | { 90 | from: 'manifest.json', 91 | to: path.join(outputDir, 'figma', 'manifest.json'), 92 | }, 93 | ], 94 | }), 95 | // argv.mode === 'production' && new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin), 96 | ].filter(Boolean), 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-figma", 3 | "version": "0.3.1", 4 | "description": "Convert DOM node to Figma Node", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/sergcen/html-to-figma" 8 | }, 9 | "scripts": { 10 | "start": "parcel tests/page/index.html", 11 | "publish:package": "npm run build:package && cd build && npm publish", 12 | "build:package": "tsc --project tsconfig.json && cp README.md ./build && cp package.json ./build", 13 | "build:tests": "parcel build tests/page/index.ts --no-minify && cp -R tests/page/stubs dist/", 14 | "serve:tests": "serve -s tests/page -p 3000", 15 | "test": "npm run build:tests && jest tests/*.test.js" 16 | }, 17 | "author": "Sergei Savelev", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@figma/plugin-typings": "^1.19.3", 21 | "@types/jest": "^26.0.23", 22 | "@types/node": "^15.6.0", 23 | "cssnano": "^5.0.6", 24 | "html-webpack-inline-source-plugin": "^0.0.10", 25 | "html-webpack-plugin": "^5.3.2", 26 | "jest": "^26.6.3", 27 | "postcss": "^8.3.2", 28 | "puppeteer": "^9.1.1", 29 | "react-docgen-typescript": "^1.22.0", 30 | "ts-jest": "^26.5.6", 31 | "ts-loader": "^9.2.4", 32 | "typescript": "^4.2.4", 33 | "webpack": "^5.46.0", 34 | "webpack-cli": "^4.7.2", 35 | "jest-dev-server": "^5.0.3", 36 | "jest-puppeteer": "^5.0.4" 37 | }, 38 | "prettier": { 39 | "tabWidth": 4, 40 | "singleQuote": true 41 | }, 42 | "dependencies": { 43 | "file-type": "^12.2.0" 44 | }, 45 | "jest": { 46 | "globalSetup": "./tests/setup.js", 47 | "preset": "jest-puppeteer" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/browser/add-constraints.ts: -------------------------------------------------------------------------------- 1 | import { LayerNode, MetaLayerNode } from '../types'; 2 | import { traverse } from '../utils'; 3 | import { context } from './utils'; 4 | 5 | function setData(node: any, key: string, value: string) { 6 | if (!(node as any).data) { 7 | (node as any).data = {}; 8 | } 9 | (node as any).data[key] = value; 10 | } 11 | 12 | export const addConstraintToLayer = (layer: MetaLayerNode, elem?: HTMLElement, pseudo?: string) => { 13 | // @ts-expect-error 14 | const { getComputedStyle, HTMLElement } = context.window; 15 | 16 | if (layer.type === 'SVG') { 17 | layer.constraints = { 18 | horizontal: 'CENTER', 19 | vertical: 'MIN', 20 | }; 21 | 22 | return; 23 | } 24 | 25 | if (!elem) { 26 | layer.constraints = { 27 | horizontal: 'SCALE', 28 | vertical: 'MIN', 29 | }; 30 | return; 31 | } 32 | 33 | const el = 34 | elem instanceof HTMLElement ? elem : elem.parentElement; 35 | const parent = el && el.parentElement; 36 | if (!el || !parent) return; 37 | 38 | const currentDisplay = el.style.display; 39 | // TODO 40 | // правильно посчитать фиксированную ширину и высоту 41 | el.style.setProperty('display', 'none', '!important'); 42 | let computed = getComputedStyle(el, pseudo); 43 | const hasFixedWidth = 44 | computed.width && computed.width.trim().endsWith('px'); 45 | const hasFixedHeight = 46 | computed.height && computed.height.trim().endsWith('px'); 47 | el.style.display = currentDisplay; 48 | // TODO END 49 | const parentStyle = getComputedStyle(parent); 50 | let hasAutoMarginLeft = computed.marginLeft === 'auto'; 51 | let hasAutoMarginRight = computed.marginRight === 'auto'; 52 | let hasAutoMarginTop = computed.marginTop === 'auto'; 53 | let hasAutoMarginBottom = computed.marginBottom === 'auto'; 54 | 55 | computed = getComputedStyle(el, pseudo); 56 | 57 | if (['absolute', 'fixed'].includes(computed.position!)) { 58 | setData(layer, 'position', computed.position!); 59 | } 60 | 61 | if (hasFixedHeight) { 62 | setData(layer, 'heightType', 'fixed'); 63 | } 64 | if (hasFixedWidth) { 65 | setData(layer, 'widthType', 'fixed'); 66 | } 67 | 68 | const isInline = computed.display && computed.display.includes('inline'); 69 | 70 | if (isInline) { 71 | const parentTextAlign = parentStyle.textAlign; 72 | if (parentTextAlign === 'center') { 73 | hasAutoMarginLeft = true; 74 | hasAutoMarginRight = true; 75 | } else if (parentTextAlign === 'right') { 76 | hasAutoMarginLeft = true; 77 | } 78 | 79 | if (computed.verticalAlign === 'middle') { 80 | hasAutoMarginTop = true; 81 | hasAutoMarginBottom = true; 82 | } else if (computed.verticalAlign === 'bottom') { 83 | hasAutoMarginTop = true; 84 | hasAutoMarginBottom = false; 85 | } 86 | 87 | setData(layer, 'widthType', 'shrink'); 88 | } 89 | const parentJustifyContent = 90 | parentStyle.display === 'flex' && 91 | ((parentStyle.flexDirection === 'row' && parentStyle.justifyContent) || 92 | (parentStyle.flexDirection === 'column' && parentStyle.alignItems)); 93 | 94 | if (parentJustifyContent === 'center') { 95 | hasAutoMarginLeft = true; 96 | hasAutoMarginRight = true; 97 | } else if ( 98 | parentJustifyContent && 99 | (parentJustifyContent.includes('end') || 100 | parentJustifyContent.includes('right')) 101 | ) { 102 | hasAutoMarginLeft = true; 103 | hasAutoMarginRight = false; 104 | } 105 | 106 | const parentAlignItems = 107 | parentStyle.display === 'flex' && 108 | ((parentStyle.flexDirection === 'column' && 109 | parentStyle.justifyContent) || 110 | (parentStyle.flexDirection === 'row' && parentStyle.alignItems)); 111 | if (parentAlignItems === 'center') { 112 | hasAutoMarginTop = true; 113 | hasAutoMarginBottom = true; 114 | } else if ( 115 | parentAlignItems && 116 | (parentAlignItems.includes('end') || 117 | parentAlignItems.includes('bottom')) 118 | ) { 119 | hasAutoMarginTop = true; 120 | hasAutoMarginBottom = false; 121 | } 122 | 123 | if (layer.type === 'TEXT') { 124 | if (computed.textAlign === 'center') { 125 | hasAutoMarginLeft = true; 126 | hasAutoMarginRight = true; 127 | } else if (computed.textAlign === 'right') { 128 | hasAutoMarginLeft = true; 129 | hasAutoMarginRight = false; 130 | } 131 | } 132 | 133 | layer.constraints = { 134 | horizontal: 135 | hasAutoMarginLeft && hasAutoMarginRight 136 | ? 'CENTER' 137 | : hasAutoMarginLeft 138 | ? 'MAX' 139 | : 'SCALE', 140 | vertical: 141 | hasAutoMarginBottom && hasAutoMarginTop 142 | ? 'CENTER' 143 | : hasAutoMarginTop 144 | ? 'MAX' 145 | : 'MIN', 146 | }; 147 | }; 148 | 149 | // export function addConstraints(layers: LayerNode[]) { 150 | // layers.forEach((layer) => { 151 | // traverse(layer, (child) => { 152 | // addConstraintToLayer(child); 153 | // }); 154 | // }); 155 | // } 156 | -------------------------------------------------------------------------------- /src/browser/border.ts: -------------------------------------------------------------------------------- 1 | import { LayerNode, MetaLayerNode, WithRef } from '../types'; 2 | import { capitalize, getRgb } from '../utils'; 3 | 4 | export const getBorder = ( 5 | computedStyle: CSSStyleDeclaration 6 | ) => { 7 | if (!computedStyle.border) { 8 | return; 9 | } 10 | const parsed = computedStyle.border.match(/^([\d\.]+)px\s*(\w+)\s*(.*)$/); 11 | if (!parsed) return; 12 | 13 | let [_match, width, type, color] = parsed; 14 | 15 | if (width && width !== '0' && type !== 'none' && color) { 16 | const rgb = getRgb(color); 17 | if (!rgb) return; 18 | 19 | return { 20 | strokes: [ 21 | { 22 | type: 'SOLID', 23 | color: { 24 | r: rgb.r, 25 | b: rgb.b, 26 | g: rgb.g, 27 | }, 28 | opacity: rgb.a || 1, 29 | }, 30 | ], 31 | strokeWeight: Math.round(parseFloat(width)), 32 | }; 33 | } 34 | }; 35 | 36 | export const getBorderPin = ( 37 | rect: ClientRect, 38 | computedStyle: CSSStyleDeclaration 39 | ) => { 40 | const directions = ['top', 'left', 'right', 'bottom']; 41 | const layers = []; 42 | 43 | for (const dir of directions) { 44 | const computed = computedStyle[('border' + capitalize(dir)) as any]; 45 | if (!computed) { 46 | continue; 47 | } 48 | 49 | const parsed = computed.match(/^([\d\.]+)px\s*(\w+)\s*(.*)$/); 50 | if (!parsed) continue; 51 | 52 | let [_match, borderWidth, type, color] = parsed; 53 | if (borderWidth && borderWidth !== '0' && type !== 'none' && color) { 54 | const rgb = getRgb(color); 55 | if (rgb) { 56 | const width = ['top', 'bottom'].includes(dir) 57 | ? rect.width 58 | : parseFloat(borderWidth); 59 | const height = ['left', 'right'].includes(dir) 60 | ? rect.height 61 | : parseFloat(borderWidth); 62 | layers.push({ 63 | type: 'RECTANGLE', 64 | x: 65 | dir === 'left' 66 | ? rect.left 67 | : dir === 'right' 68 | ? rect.right - width 69 | : rect.left, 70 | y: 71 | dir === 'top' 72 | ? rect.top - height 73 | : dir === 'bottom' 74 | ? rect.bottom 75 | : rect.top, 76 | width, 77 | height, 78 | children: [], 79 | fills: [ 80 | { 81 | type: 'SOLID', 82 | color: { 83 | r: rgb.r, 84 | b: rgb.b, 85 | g: rgb.g, 86 | }, 87 | opacity: rgb.a || 1, 88 | } as SolidPaint, 89 | ] as any, 90 | } as MetaLayerNode); 91 | } 92 | } 93 | } 94 | if (!layers.length) return; 95 | // return layers; 96 | return [{ 97 | type: 'FRAME', 98 | clipsContent: false, 99 | name: '::borders', 100 | x: Math.round(rect.left), 101 | y: Math.round(rect.top), 102 | width: Math.round(rect.width), 103 | height: Math.round(rect.height), 104 | children: layers, 105 | // @ts-expect-error 106 | fills: [] 107 | }] as MetaLayerNode[]; 108 | }; 109 | -------------------------------------------------------------------------------- /src/browser/build-tree.ts: -------------------------------------------------------------------------------- 1 | import { getBoundingClientRect } from './dom-utils'; 2 | import { hasChildren, traverse } from '../utils'; 3 | import { LayerNode, WithRef } from '../types'; 4 | import { context } from './utils'; 5 | 6 | function getParent(layer: LayerNode, root: WithRef) { 7 | let response: LayerNode | null = null; 8 | try { 9 | traverse(root, (child) => { 10 | if ( 11 | child && 12 | (child as any).children && 13 | (child as any).children.includes(layer) 14 | ) { 15 | response = child; 16 | // Deep traverse short circuit hack 17 | throw 'DONE'; 18 | } 19 | }); 20 | } catch (err) { 21 | if (err === 'DONE') { 22 | // Do nothing 23 | } else { 24 | console.error(err.message); 25 | } 26 | } 27 | return response; 28 | } 29 | 30 | function getParents(node: Element | Node): Element[] { 31 | let el: Element | null = 32 | node instanceof Node && node.nodeType === Node.TEXT_NODE 33 | ? node.parentElement 34 | : (node as Element); 35 | 36 | let parents: Element[] = []; 37 | while (el && (el = el.parentElement)) { 38 | parents.push(el); 39 | } 40 | return parents; 41 | } 42 | 43 | function getDepth(node: Element | Node) { 44 | return getParents(node).length; 45 | } 46 | 47 | // export function removeRefs(layers: LayerNode[], root: WithRef) { 48 | // layers.concat([root]).forEach((layer) => { 49 | // traverse(layer, (child) => { 50 | // delete child.ref; 51 | // // @ts-expect-error 52 | // delete child.zIndex; 53 | // }); 54 | // }); 55 | // } 56 | 57 | // export function makeTree(layers: LayerNode[], root: WithRef) { 58 | // // @ts-expect-error 59 | // const { getComputedStyle, Element } = context.window; 60 | 61 | // const refMap = new WeakMap(); 62 | // // маппинг слоя к дом элементам 63 | // layers.forEach((layer) => { 64 | // if (layer.ref) { 65 | // refMap.set(layer.ref, layer); 66 | // } 67 | // }); 68 | 69 | // let updated = true; 70 | // let iterations = 0; 71 | // while (updated) { 72 | // updated = false; 73 | // if (iterations++ > 10000) { 74 | // console.error('Too many tree iterations 1'); 75 | // break; 76 | // } 77 | 78 | // traverse(root, (layer, originalParent) => { 79 | // // const node = layer.ref!; 80 | // const node = layer.ref!; 81 | // let parentElement: Element | null = 82 | // (node as Element)?.parentElement || null; 83 | 84 | // do { 85 | // if (parentElement === context.document.body) { 86 | // break; 87 | // } 88 | // if (!parentElement) continue; 89 | // // Get least common demoninator shared parent and make a group 90 | // const parentLayer = refMap.get(parentElement); 91 | // if (parentLayer === originalParent) { 92 | // break; 93 | // } 94 | // if (!parentLayer || parentLayer === root) continue; 95 | 96 | // if (hasChildren(parentLayer)) { 97 | // if (originalParent) { 98 | // const index = (originalParent as any).children.indexOf( 99 | // layer 100 | // ); 101 | // (originalParent as any).children.splice(index, 1); 102 | // (parentLayer.children as Array).push(layer); 103 | // updated = true; 104 | // return; 105 | // } 106 | // } else { 107 | // let parentRef = parentLayer.ref as Element; 108 | // if ( 109 | // parentRef && 110 | // parentRef instanceof Node && 111 | // parentRef.nodeType === Node.TEXT_NODE 112 | // ) { 113 | // parentRef = parentRef.parentElement as Element; 114 | // } 115 | // const overflowHidden = 116 | // parentRef instanceof Element && 117 | // getComputedStyle(parentRef).overflow !== 'visible'; 118 | // const newParent: LayerNode = { 119 | // type: 'FRAME', 120 | // clipsContent: !!overflowHidden, 121 | // // type: 'GROUP', 122 | // x: parentLayer.x, 123 | // y: parentLayer.y, 124 | // width: parentLayer.width, 125 | // height: parentLayer.height, 126 | // ref: parentLayer.ref, 127 | // backgrounds: [] as any, 128 | // // @ts-expect-error 129 | // children: [parentLayer, layer] as LayerNode[], 130 | // }; 131 | 132 | // const parent = getParent(parentLayer, root); 133 | // if (!parent) { 134 | // console.warn( 135 | // '\n\nCANT FIND PARENT\n', 136 | // JSON.stringify({ 137 | // ...parentLayer, 138 | // ref: null, 139 | // }) 140 | // ); 141 | // continue; 142 | // } 143 | // if (originalParent) { 144 | // const index = (originalParent as any).children.indexOf( 145 | // layer 146 | // ); 147 | // (originalParent as any).children.splice(index, 1); 148 | // } 149 | // delete parentLayer.ref; 150 | // const newIndex = (parent as any).children.indexOf( 151 | // parentLayer 152 | // ); 153 | // refMap.set(parentElement, newParent); 154 | // (parent as any).children.splice(newIndex, 1, newParent); 155 | // updated = true; 156 | // return; 157 | // } 158 | // } while ( 159 | // parentElement && 160 | // (parentElement = parentElement.parentElement) 161 | // ); 162 | // }); 163 | // } 164 | // // Collect tree of depeest common parents and make groups 165 | // let secondUpdate = true; 166 | // let secondIterations = 0; 167 | // while (secondUpdate) { 168 | // if (secondIterations++ > 10000) { 169 | // console.error('Too many tree iterations 2'); 170 | // break; 171 | // } 172 | // secondUpdate = false; 173 | 174 | // traverse(root, (layer, parent) => { 175 | // if (secondUpdate) { 176 | // return; 177 | // } 178 | // if (layer.type === 'FRAME') { 179 | // // Final all child elements with layers, and add groups around any with a shared parent not shared by another 180 | // const ref = layer.ref as Element; 181 | // if (layer.children && layer.children.length > 2) { 182 | // const childRefs = 183 | // layer.children && 184 | // (layer.children as LayerNode[]).map( 185 | // (child) => child.ref! 186 | // ); 187 | 188 | // let lowestCommonDenominator = layer.ref!; 189 | // let lowestCommonDenominatorDepth = getDepth( 190 | // lowestCommonDenominator as Element 191 | // ); 192 | 193 | // // Find lowest common demoninator with greatest depth 194 | // for (const childRef of childRefs) { 195 | // const otherChildRefs = childRefs.filter( 196 | // // @ts-ignore 197 | // (item) => item !== childRef 198 | // ); 199 | // const childParents = getParents(childRef as Node); 200 | // for (const otherChildRef of otherChildRefs) { 201 | // const otherParents = getParents( 202 | // otherChildRef as Node 203 | // ); 204 | // for (const parent of otherParents) { 205 | // if ( 206 | // childParents.includes(parent) && 207 | // // @ts-expect-error 208 | // layer.ref!.contains(parent) 209 | // ) { 210 | // const depth = getDepth(parent); 211 | // if (depth > lowestCommonDenominatorDepth) { 212 | // lowestCommonDenominator = parent; 213 | // lowestCommonDenominatorDepth = depth; 214 | // } 215 | // } 216 | // } 217 | // } 218 | // } 219 | // if ( 220 | // lowestCommonDenominator && 221 | // lowestCommonDenominator !== layer.ref 222 | // ) { 223 | // // Make a group around all children elements 224 | // const newChildren = layer.children!.filter( 225 | // (item: any) => 226 | // ( 227 | // lowestCommonDenominator as HTMLElement 228 | // ).contains(item.ref) 229 | // ); 230 | 231 | // if (newChildren.length !== layer.children.length) { 232 | // const lcdRect = getBoundingClientRect( 233 | // lowestCommonDenominator as Element 234 | // ); 235 | 236 | // const overflowHidden = 237 | // lowestCommonDenominator instanceof Element && 238 | // getComputedStyle(lowestCommonDenominator as Element) 239 | // .overflow !== 'visible'; 240 | 241 | // const newParent: LayerNode = { 242 | // type: 'FRAME', 243 | // clipsContent: !!overflowHidden, 244 | // ref: lowestCommonDenominator as Element, 245 | // x: lcdRect.left, 246 | // y: lcdRect.top, 247 | // width: lcdRect.width, 248 | // height: lcdRect.height, 249 | // backgrounds: [] as any, 250 | // children: newChildren as any, 251 | // }; 252 | // refMap.set(lowestCommonDenominator, ref); 253 | // let firstIndex = layer.children.length - 1; 254 | // for (const child of newChildren) { 255 | // const childIndex = layer.children.indexOf( 256 | // child as any 257 | // ); 258 | // if ( 259 | // childIndex > -1 && 260 | // childIndex < firstIndex 261 | // ) { 262 | // firstIndex = childIndex; 263 | // } 264 | // } 265 | // (layer.children as any).splice( 266 | // firstIndex, 267 | // 0, 268 | // newParent 269 | // ); 270 | // for (const child of newChildren) { 271 | // const index = layer.children.indexOf(child); 272 | // if (index > -1) { 273 | // (layer.children as any).splice(index, 1); 274 | // } 275 | // } 276 | // secondUpdate = true; 277 | // } 278 | // } 279 | // } 280 | // } 281 | // }); 282 | // } 283 | // // Update all positions 284 | // traverse(root, (layer) => { 285 | // if (layer.type === 'FRAME' || layer.type === 'GROUP') { 286 | // const { x, y } = layer; 287 | // if (x || y) { 288 | // traverse(layer, (child) => { 289 | // if (child === layer) { 290 | // return; 291 | // } 292 | // child.x = child.x! - x!; 293 | // child.y = child.y! - y!; 294 | // }); 295 | // } 296 | // } 297 | // }); 298 | 299 | // return layers; 300 | // } 301 | -------------------------------------------------------------------------------- /src/browser/dom-utils.ts: -------------------------------------------------------------------------------- 1 | import { getImageFills, parseUnits } from '../utils'; 2 | import { LayerNode, SvgNode, Unit } from '../types'; 3 | import fileType from 'file-type'; 4 | import { context } from './utils'; 5 | 6 | export function getAggregateRectOfElements(elements: Element[]) { 7 | if (!elements.length) { 8 | return null; 9 | } 10 | 11 | const top = getBoundingClientRect( 12 | getDirectionMostOfElements('top', elements)! 13 | ).top; 14 | const left = getBoundingClientRect( 15 | getDirectionMostOfElements('left', elements)! 16 | ).left; 17 | const bottom = getBoundingClientRect( 18 | getDirectionMostOfElements('bottom', elements)! 19 | ).bottom; 20 | const right = getBoundingClientRect( 21 | getDirectionMostOfElements('right', elements)! 22 | ).right; 23 | const width = right - left; 24 | const height = bottom - top; 25 | return { 26 | top, 27 | left, 28 | bottom, 29 | right, 30 | width, 31 | height, 32 | }; 33 | } 34 | export function getBoundingClientRect( 35 | el: Element, 36 | pseudo?: string 37 | ): ClientRect { 38 | const { getComputedStyle } = context.window; 39 | 40 | const computed = getComputedStyle(el, pseudo); 41 | const display = computed.display; 42 | if (pseudo) { 43 | return getBoundingClientRectPseudo(el, pseudo, computed); 44 | } 45 | // if (display && display.includes('inline') && el.children.length) { 46 | // const elRect = el.getBoundingClientRect(); 47 | // const aggregateRect = getAggregateRectOfElements( 48 | // Array.from(el.children) 49 | // )!; 50 | 51 | // if (elRect.width > aggregateRect.width) { 52 | // return { 53 | // ...aggregateRect, 54 | // width: elRect.width, 55 | // left: elRect.left, 56 | // right: elRect.right, 57 | // }; 58 | // } 59 | // return aggregateRect; 60 | // } 61 | 62 | return el.getBoundingClientRect(); 63 | } 64 | 65 | export function getBoundingClientRectPseudo( 66 | el: Element, 67 | pseudo: string, 68 | style: CSSStyleDeclaration 69 | ): ClientRect { 70 | const dest: Record = {}; 71 | const copy = document.createElement('span'); 72 | 73 | for (let i = 0, l = style.length; i < l; i++) { 74 | const prop = style[i]; 75 | 76 | // @ts-ignore 77 | copy.style[prop] = style.getPropertyValue(prop) || style[prop]; 78 | } 79 | 80 | pseudo === 'after' ? el.append(copy) : el.prepend(copy); 81 | 82 | const rect = copy.getBoundingClientRect(); 83 | el.removeChild(copy); 84 | 85 | return rect; 86 | } 87 | 88 | export function getDirectionMostOfElements( 89 | direction: 'left' | 'right' | 'top' | 'bottom', 90 | elements: Element[] 91 | ) { 92 | if (elements.length === 1) { 93 | return elements[0]; 94 | } 95 | return elements.reduce((memo, value: Element) => { 96 | if (!memo) { 97 | return value; 98 | } 99 | 100 | if (direction === 'left' || direction === 'top') { 101 | if ( 102 | getBoundingClientRect(value)[direction] < 103 | getBoundingClientRect(memo)[direction] 104 | ) { 105 | return value; 106 | } 107 | } else { 108 | if ( 109 | getBoundingClientRect(value)[direction] > 110 | getBoundingClientRect(memo)[direction] 111 | ) { 112 | return value; 113 | } 114 | } 115 | return memo; 116 | }, null as Element | null); 117 | } 118 | 119 | export function getAppliedComputedStyles( 120 | element: Element, 121 | pseudo?: string 122 | ): { [key: string]: string } { 123 | // @ts-ignore 124 | const { getComputedStyle, HTMLElement, SVGElement } = context.window; 125 | 126 | if (!(element instanceof HTMLElement || element instanceof SVGElement)) { 127 | return {}; 128 | } 129 | 130 | const styles = getComputedStyle(element, pseudo); 131 | 132 | const list = [ 133 | 'opacity', 134 | 'backgroundColor', 135 | 'border', 136 | 'borderTop', 137 | 'borderLeft', 138 | 'borderRight', 139 | 'borderBottom', 140 | 'borderRadius', 141 | 'backgroundImage', 142 | 'borderColor', 143 | 'boxShadow', 144 | ]; 145 | 146 | const color = styles.color; 147 | 148 | const defaults: any = { 149 | transform: 'none', 150 | opacity: '1', 151 | borderRadius: '0px', 152 | backgroundImage: 'none', 153 | backgroundPosition: '0% 0%', 154 | backgroundSize: 'auto', 155 | backgroundColor: 'rgba(0, 0, 0, 0)', 156 | backgroundAttachment: 'scroll', 157 | border: '0px none ' + color, 158 | borderTop: '0px none ' + color, 159 | borderBottom: '0px none ' + color, 160 | borderLeft: '0px none ' + color, 161 | borderRight: '0px none ' + color, 162 | borderWidth: '0px', 163 | borderColor: color, 164 | borderStyle: 'none', 165 | boxShadow: 'none', 166 | fontWeight: '400', 167 | textAlign: 'start', 168 | justifyContent: 'normal', 169 | alignItems: 'normal', 170 | alignSelf: 'auto', 171 | flexGrow: '0', 172 | textDecoration: 'none solid ' + color, 173 | lineHeight: 'normal', 174 | letterSpacing: 'normal', 175 | backgroundRepeat: 'repeat', 176 | zIndex: 'auto', // TODO 177 | }; 178 | 179 | function pick( 180 | object: T, 181 | paths: (keyof T)[] 182 | ) { 183 | const newObject: Partial = {}; 184 | paths.forEach((path) => { 185 | if (object[path]) { 186 | if (object[path] !== defaults[path]) { 187 | newObject[path] = object[path]; 188 | } 189 | } 190 | }); 191 | return newObject; 192 | } 193 | 194 | return pick(styles, list as any) as any; 195 | } 196 | 197 | export function textNodesUnder(el: Element) { 198 | let n: Node | null = null; 199 | const a: Node[] = []; 200 | const walk = context.document.createTreeWalker( 201 | el, 202 | NodeFilter.SHOW_TEXT, 203 | null, 204 | false 205 | ); 206 | 207 | while ((n = walk.nextNode())) { 208 | a.push(n); 209 | } 210 | return a; 211 | } 212 | 213 | export const getUrl = (url: string) => { 214 | if (!url) { 215 | return ''; 216 | } 217 | let final = url.trim(); 218 | if (final.startsWith('//')) { 219 | final = 'https:' + final; 220 | } 221 | 222 | if (final.startsWith('/')) { 223 | final = 'https://' + window.location.host + final; 224 | } 225 | 226 | return final; 227 | }; 228 | 229 | export const prepareUrl = (url: string) => { 230 | if (url.startsWith('data:')) { 231 | return url; 232 | } 233 | const urlParsed = new URL(url); 234 | 235 | return urlParsed.toString(); 236 | }; 237 | 238 | export function isHidden(element: Element, pseudo?: string) { 239 | const { getComputedStyle } = context.window; 240 | 241 | let el: Element | null = element; 242 | do { 243 | const computed = getComputedStyle(el, pseudo); 244 | if ( 245 | computed.opacity === '0' || 246 | computed.display === 'none' || 247 | computed.visibility === 'hidden' 248 | ) { 249 | return true; 250 | } 251 | // Some sites hide things by having overflow: hidden and height: 0, e.g. dropdown menus that animate height in 252 | if ( 253 | computed.overflow !== 'visible' && 254 | el.getBoundingClientRect().height < 1 255 | ) { 256 | return true; 257 | } 258 | } while ((el = el.parentElement)); 259 | return false; 260 | } 261 | 262 | const BASE64_MARKER = ';base64,'; 263 | function convertDataURIToBinary(dataURI: string) { 264 | const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length; 265 | const base64 = dataURI.substring(base64Index); 266 | const raw = window.atob(base64); 267 | const rawLength = raw.length; 268 | const array = new Uint8Array(new ArrayBuffer(rawLength)); 269 | 270 | for (let i = 0; i < rawLength; i++) { 271 | array[i] = raw.charCodeAt(i); 272 | } 273 | return array; 274 | } 275 | 276 | const convertToSvg = (value: string, layer: LayerNode) => { 277 | const layerSvg = layer as SvgNode; 278 | layerSvg.type = 'SVG'; 279 | layerSvg.svg = value; 280 | 281 | if (typeof layerSvg.fills !== 'symbol') { 282 | layerSvg.fills = layerSvg?.fills?.filter( 283 | (item) => item.type !== 'IMAGE' 284 | ); 285 | } 286 | }; 287 | 288 | // TODO: CACHE! 289 | // const imageCache: { [key: string]: Uint8Array | undefined } = {}; 290 | export async function processImages(layer: LayerNode) { 291 | const images = getImageFills(layer as RectangleNode); 292 | 293 | return images 294 | ? Promise.all( 295 | images.map(async (image: any) => { 296 | try { 297 | if (image) { 298 | const url = image.url; 299 | if (url.startsWith('data:')) { 300 | const type = url.split(/[:,;]/)[1]; 301 | if (type.includes('svg')) { 302 | const svgValue = decodeURIComponent( 303 | url.split(',')[1] 304 | ); 305 | convertToSvg(svgValue, layer); 306 | return Promise.resolve(); 307 | } else { 308 | if (url.includes(BASE64_MARKER)) { 309 | image.intArr = 310 | convertDataURIToBinary(url); 311 | delete image.url; 312 | } else { 313 | console.info( 314 | 'Found data url that could not be converted', 315 | url 316 | ); 317 | } 318 | return; 319 | } 320 | } 321 | 322 | const isSvg = url.endsWith('.svg'); 323 | 324 | // Proxy returned content through Builder so we can access cross origin for 325 | // pulling in photos, etc 326 | const res = await fetch(url); 327 | 328 | const contentType = res.headers.get('content-type'); 329 | if ( 330 | isSvg || 331 | (contentType && contentType.includes('svg')) 332 | ) { 333 | const text = await res.text(); 334 | convertToSvg(text, layer); 335 | } else { 336 | const arrayBuffer = await res.arrayBuffer(); 337 | const type = fileType(arrayBuffer); 338 | if ( 339 | type && 340 | (type.ext.includes('svg') || 341 | type.mime.includes('svg')) 342 | ) { 343 | convertToSvg(await res.text(), layer); 344 | return; 345 | } else { 346 | const intArr = new Uint8Array(arrayBuffer); 347 | delete image.url; 348 | image.intArr = intArr; 349 | } 350 | } 351 | } 352 | } catch (err) { 353 | console.warn('Could not fetch image', layer, err); 354 | } 355 | }) 356 | ) 357 | : Promise.resolve([]); 358 | } 359 | 360 | export const getShadowEls = (el: Element): Element[] => 361 | Array.from( 362 | el.shadowRoot?.querySelectorAll('*') || ([] as Element[]) 363 | ).reduce((memo, el) => { 364 | memo.push(el); 365 | memo.push(...getShadowEls(el)); 366 | return memo; 367 | }, [] as Element[]); 368 | 369 | export enum ElemTypes { 370 | Textarea, 371 | Input, 372 | Image, 373 | Picture, 374 | Video, 375 | SVG, 376 | SubSVG, 377 | Element 378 | } 379 | 380 | export const getElemType = (el: Element): ElemTypes | undefined => { 381 | // @ts-expect-error 382 | if (el instanceof context.window.HTMLInputElement) { 383 | return ElemTypes.Input; 384 | } 385 | // @ts-expect-error 386 | if (el instanceof context.window.HTMLTextAreaElement) { 387 | return ElemTypes.Textarea; 388 | } 389 | // @ts-expect-error 390 | if (el instanceof context.window.HTMLPictureElement) { 391 | return ElemTypes.Picture; 392 | } 393 | // @ts-expect-error 394 | if (el instanceof context.window.HTMLImageElement) { 395 | return ElemTypes.Image; 396 | } 397 | // @ts-expect-error 398 | if (el instanceof context.window.HTMLVideoElement) { 399 | return ElemTypes.Video; 400 | } 401 | // @ts-expect-error 402 | if (el instanceof context.window.SVGSVGElement) { 403 | return ElemTypes.SVG; 404 | } 405 | // @ts-expect-error 406 | if (el instanceof context.window.SVGElement) { 407 | return ElemTypes.SubSVG; 408 | } 409 | 410 | // @ts-expect-error 411 | if (el instanceof context.window.HTMLElement) { 412 | return ElemTypes.Element; 413 | } 414 | }; 415 | 416 | export const isElemType = (el: Element, type: ElemTypes): boolean => { 417 | return getElemType(el) === type; 418 | } 419 | 420 | export const getLineHeight = (el: HTMLElement, computedStyles: CSSStyleDeclaration): Unit | null => { 421 | const computedLineHeight = parseUnits(computedStyles.lineHeight); 422 | 423 | if (computedLineHeight) { 424 | return computedLineHeight; 425 | } 426 | 427 | if (isElemType(el, ElemTypes.Input)) { 428 | return parseUnits(computedStyles.height); 429 | } 430 | 431 | const fontSize = parseUnits(computedStyles.fontSize)?.value; 432 | if (!fontSize) return null; 433 | 434 | return { value: Math.floor(fontSize * 1.2), unit: 'PIXELS' } 435 | } 436 | -------------------------------------------------------------------------------- /src/browser/element-to-figma.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isHidden, 3 | getBoundingClientRect, 4 | getUrl, 5 | prepareUrl, 6 | isElemType, 7 | ElemTypes, 8 | } from './dom-utils'; 9 | import { getRgb, parseUnits, parseBoxShadowValues, getOpacity } from '../utils'; 10 | import { MetaLayerNode, SvgNode, WithMeta } from '../types'; 11 | import { context, replaceSvgFill } from './utils'; 12 | import { textToFigma } from './text-to-figma'; 13 | import { getBorder, getBorderPin } from './border'; 14 | import { addConstraintToLayer } from './add-constraints'; 15 | 16 | export const elementToFigma = ( 17 | el: Element, 18 | pseudo?: string 19 | ): MetaLayerNode | undefined => { 20 | if (el.nodeType === Node.TEXT_NODE) { 21 | return textToFigma(el); 22 | } 23 | if (el.nodeType !== Node.ELEMENT_NODE) { 24 | return; 25 | } 26 | 27 | if ( 28 | el.nodeType !== Node.ELEMENT_NODE || 29 | isHidden(el, pseudo) || 30 | isElemType(el, ElemTypes.SubSVG) 31 | ) { 32 | return; 33 | } 34 | 35 | const { getComputedStyle } = context.window; 36 | 37 | if (el.parentElement && isElemType(el, ElemTypes.Picture)) { 38 | return; 39 | } 40 | 41 | const computedStyle = getComputedStyle(el, pseudo); 42 | 43 | if (isElemType(el, ElemTypes.SVG)) { 44 | const rect = el.getBoundingClientRect(); 45 | const fill = computedStyle.fill; 46 | 47 | return { 48 | type: 'SVG', 49 | ref: el, 50 | // add FILL to SVG to get right color in figma 51 | svg: replaceSvgFill(el.outerHTML, fill), 52 | x: Math.round(rect.left), 53 | y: Math.round(rect.top), 54 | width: Math.round(rect.width), 55 | height: Math.round(rect.height), 56 | } as WithMeta; 57 | } 58 | 59 | const rect = getBoundingClientRect(el, pseudo); 60 | 61 | if (rect.width < 1 || rect.height < 1) { 62 | return; 63 | } 64 | 65 | const fills: Paint[] = []; 66 | const color = getRgb(computedStyle.backgroundColor); 67 | 68 | if (color) { 69 | fills.push({ 70 | type: 'SOLID', 71 | color: { 72 | r: color.r, 73 | g: color.g, 74 | b: color.b, 75 | }, 76 | opacity: color.a || 1, 77 | } as SolidPaint); 78 | } 79 | const overflowHidden = computedStyle.overflow !== 'visible'; 80 | const rectNode = { 81 | type: 'FRAME', 82 | ref: el, 83 | x: Math.round(rect.left), 84 | y: Math.round(rect.top), 85 | width: Math.round(rect.width), 86 | height: Math.round(rect.height), 87 | clipsContent: !!overflowHidden, 88 | fills: fills as any, 89 | children: [], 90 | opacity: getOpacity(computedStyle), 91 | } as WithMeta; 92 | 93 | const zIndex = Number(computedStyle.zIndex); 94 | if (isFinite(zIndex)) { 95 | rectNode.zIndex = zIndex; 96 | } 97 | 98 | const stroke = getBorder(computedStyle); 99 | 100 | if (stroke) { 101 | rectNode.strokes = stroke.strokes as SolidPaint[]; 102 | rectNode.strokeWeight = stroke.strokeWeight; 103 | } else { 104 | rectNode.borders = getBorderPin(rect, computedStyle); 105 | } 106 | 107 | if ( 108 | computedStyle.backgroundImage && 109 | computedStyle.backgroundImage !== 'none' 110 | ) { 111 | const urlMatch = computedStyle.backgroundImage.match( 112 | /url\(['"]?(.*?)['"]?\)/ 113 | ); 114 | const url = urlMatch && urlMatch[1]; 115 | 116 | if (url) { 117 | fills.push({ 118 | url: prepareUrl(url), 119 | type: 'IMAGE', 120 | // TODO: backround size, position 121 | scaleMode: 122 | computedStyle.backgroundSize === 'contain' ? 'FIT' : 'FILL', 123 | imageHash: null, 124 | } as ImagePaint); 125 | } 126 | } 127 | // if (isElemType(el, ElemTypes.SVG)) { 128 | // const url = `data:image/svg+xml,${encodeURIComponent( 129 | // el.outerHTML.replace(/\s+/g, ' ') 130 | // )}`; 131 | // if (url) { 132 | // fills.push({ 133 | // url, 134 | // type: 'IMAGE', 135 | // // TODO: object fit, position 136 | // scaleMode: 'FILL', 137 | // imageHash: null, 138 | // } as ImagePaint); 139 | // } 140 | // } 141 | if (isElemType(el, ElemTypes.Image)) { 142 | const url = (el as HTMLImageElement).src; 143 | if (url) { 144 | fills.push({ 145 | url, 146 | type: 'IMAGE', 147 | // TODO: object fit, position 148 | scaleMode: 149 | computedStyle.objectFit === 'contain' ? 'FIT' : 'FILL', 150 | imageHash: null, 151 | } as ImagePaint); 152 | } 153 | } 154 | if (isElemType(el, ElemTypes.Picture)) { 155 | const firstSource = el.querySelector('source'); 156 | if (firstSource) { 157 | const src = getUrl(firstSource.srcset.split(/[,\s]+/g)[0]); 158 | // TODO: if not absolute 159 | if (src) { 160 | fills.push({ 161 | url: src, 162 | type: 'IMAGE', 163 | // TODO: object fit, position 164 | scaleMode: 165 | computedStyle.objectFit === 'contain' ? 'FIT' : 'FILL', 166 | imageHash: null, 167 | } as ImagePaint); 168 | } 169 | } 170 | } 171 | if (isElemType(el, ElemTypes.Video)) { 172 | const url = (el as HTMLVideoElement).poster; 173 | if (url) { 174 | fills.push({ 175 | url, 176 | type: 'IMAGE', 177 | // TODO: object fit, position 178 | scaleMode: 179 | computedStyle.objectFit === 'contain' ? 'FIT' : 'FILL', 180 | imageHash: null, 181 | } as ImagePaint); 182 | } 183 | } 184 | 185 | if (computedStyle.boxShadow && computedStyle.boxShadow !== 'none') { 186 | const parsed = parseBoxShadowValues(computedStyle.boxShadow); 187 | const hasShadowSpread = 188 | parsed.findIndex(({ spreadRadius }) => Boolean(spreadRadius)) !== 189 | -1; 190 | // figma requires clipsContent=true, without spreadRadius wont be applied 191 | if (hasShadowSpread) { 192 | rectNode.clipsContent = true; 193 | } 194 | rectNode.effects = parsed.map((shadow) => ({ 195 | color: shadow.color, 196 | type: 'DROP_SHADOW', 197 | radius: shadow.blurRadius, 198 | spread: shadow.spreadRadius, 199 | blendMode: 'NORMAL', 200 | visible: true, 201 | offset: { 202 | x: shadow.offsetX, 203 | y: shadow.offsetY, 204 | }, 205 | })) as ShadowEffect[]; 206 | } 207 | 208 | const borderTopLeftRadius = parseUnits( 209 | computedStyle.borderTopLeftRadius, 210 | rect.height 211 | ); 212 | if (borderTopLeftRadius) { 213 | rectNode.topLeftRadius = borderTopLeftRadius.value; 214 | } 215 | const borderTopRightRadius = parseUnits( 216 | computedStyle.borderTopRightRadius, 217 | rect.height 218 | ); 219 | if (borderTopRightRadius) { 220 | rectNode.topRightRadius = borderTopRightRadius.value; 221 | } 222 | const borderBottomRightRadius = parseUnits( 223 | computedStyle.borderBottomRightRadius, 224 | rect.height 225 | ); 226 | if (borderBottomRightRadius) { 227 | rectNode.bottomRightRadius = borderBottomRightRadius.value; 228 | } 229 | const borderBottomLeftRadius = parseUnits( 230 | computedStyle.borderBottomLeftRadius, 231 | rect.height 232 | ); 233 | if (borderBottomLeftRadius) { 234 | rectNode.bottomLeftRadius = borderBottomLeftRadius.value; 235 | } 236 | 237 | const result = rectNode; 238 | 239 | if (!pseudo && getComputedStyle(el, 'before').content !== 'none') { 240 | result.before = elementToFigma(el, 'before') as WithMeta; 241 | 242 | if (result.before) { 243 | addConstraintToLayer(result.before, el as HTMLElement, 'before'); 244 | result.before.name = '::before'; 245 | } 246 | } 247 | 248 | if (!pseudo && getComputedStyle(el, 'after').content !== 'none') { 249 | result.after = elementToFigma(el, 'after') as WithMeta; 250 | if (result.after) { 251 | addConstraintToLayer(result.after, el as HTMLElement, 'after'); 252 | result.after.name = '::after'; 253 | } 254 | } 255 | 256 | if (isElemType(el, ElemTypes.Input) || isElemType(el, ElemTypes.Textarea)) { 257 | result.textValue = textToFigma(el, { fromTextInput: true }); 258 | } 259 | 260 | return result; 261 | }; 262 | -------------------------------------------------------------------------------- /src/browser/html-to-figma.ts: -------------------------------------------------------------------------------- 1 | import { elementToFigma } from './element-to-figma'; 2 | import { LayerNode, MetaLayerNode, PlainLayerNode, WithMeta } from '../types'; 3 | import { addConstraintToLayer } from './add-constraints'; 4 | import { context } from './utils'; 5 | import { traverse, traverseMap } from '../utils'; 6 | import { ElemTypes, isElemType } from './dom-utils'; 7 | 8 | const removeMeta = (layerWithMeta: WithMeta): LayerNode | undefined => { 9 | const { 10 | textValue, 11 | before, 12 | after, 13 | borders, 14 | ref, 15 | type, 16 | zIndex, 17 | ...rest 18 | } = layerWithMeta; 19 | 20 | if (!type) return; 21 | 22 | return { type, ...rest } as PlainLayerNode; 23 | } 24 | 25 | const mapDOM = (root: Element): LayerNode => { 26 | const elems: WithMeta[] = []; 27 | const walk = context.document.createTreeWalker( 28 | root, 29 | NodeFilter.SHOW_ALL, 30 | null, 31 | false 32 | ); 33 | const refs = new Map(); 34 | 35 | let n: Node | null = walk.currentNode; 36 | 37 | do { 38 | if (!n.parentElement) continue; 39 | const figmaEl = elementToFigma(n as Element); 40 | 41 | if (figmaEl) { 42 | addConstraintToLayer(figmaEl, n as HTMLElement); 43 | 44 | const children = refs.get(n.parentElement) || []; 45 | refs.set(n.parentElement, [...children, figmaEl]); 46 | elems.push(figmaEl as WithMeta); 47 | } 48 | } while (n = walk.nextNode()); 49 | 50 | const result = elems[0]; 51 | 52 | for (let i = 0;i < elems.length; i++) { 53 | const elem = elems[i]; 54 | if (elem.type !== 'FRAME') continue; 55 | 56 | elem.children = elem.children || []; 57 | 58 | elem.before && elem.children.push(elem.before); 59 | 60 | const children = refs.get(elem.ref as Element) || []; 61 | 62 | children && elem.children.push(...children); 63 | // elements with text 64 | if (!elem.textValue) { 65 | elem.children = elem.children.filter(Boolean); 66 | } else { 67 | elem.children = [elem.textValue]; 68 | } 69 | // extends elements for show complex borders 70 | if (elem.borders) { 71 | elem.children = elem.children.concat(elem.borders); 72 | } 73 | elem.after && elem.children.push(elem.after); 74 | 75 | elem.children.sort((a, b) => (b.zIndex || 0) - (a.zIndex || 0)); 76 | } 77 | 78 | // @ts-expect-error 79 | const layersWithoutMeta = traverseMap>(result, (layer) => { 80 | return removeMeta(layer); 81 | }) as LayerNode; 82 | // Update all positions and clean 83 | traverse(layersWithoutMeta, (layer) => { 84 | if (layer.type === 'FRAME' || layer.type === 'GROUP') { 85 | const { x, y } = layer; 86 | if (x || y) { 87 | traverse(layer, (child) => { 88 | if (child === layer) { 89 | return; 90 | } 91 | child.x = child.x! - x!; 92 | child.y = child.y! - y!; 93 | }); 94 | } 95 | } 96 | }); 97 | 98 | return layersWithoutMeta; 99 | } 100 | 101 | export function htmlToFigma( 102 | selector: HTMLElement | string = 'body', 103 | ) { 104 | 105 | let layers: LayerNode[] = []; 106 | const el = 107 | isElemType(selector as HTMLElement, ElemTypes.Element) 108 | ? selector as HTMLElement 109 | : context.document.querySelectorAll(selector as string || 'body')[0]; 110 | 111 | if (!el) { 112 | throw Error(`Element not found`); 113 | } 114 | 115 | // Process SVG elements 116 | for (const use of Array.from( 117 | el.querySelectorAll('use') 118 | ) as SVGUseElement[]) { 119 | try { 120 | const symbolSelector = use.href.baseVal; 121 | const symbol: SVGSymbolElement | null = 122 | context.document.querySelector(symbolSelector); 123 | if (symbol) { 124 | use.outerHTML = symbol.innerHTML; 125 | } 126 | } catch (err) { 127 | console.warn('Error querying tag href', err); 128 | } 129 | } 130 | 131 | // const els = (Array.from(el.querySelectorAll('*')) as Element[]).reduce( 132 | // (memo, el) => { 133 | // memo.push(el); 134 | // memo.push(...getShadowEls(el)); 135 | 136 | // return memo; 137 | // }, 138 | // [] as Element[] 139 | // ); 140 | const data = mapDOM(el); 141 | 142 | return data ? data : []; 143 | } 144 | -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | export * from './html-to-figma'; 2 | export * from './utils'; -------------------------------------------------------------------------------- /src/browser/text-to-figma.ts: -------------------------------------------------------------------------------- 1 | import { MetaTextNode, PlainLayerNode } from '../types'; 2 | import { 3 | fastClone, 4 | parseUnits, 5 | getRgb, 6 | defaultPlaceholderColor, 7 | } from '../utils'; 8 | import { getLineHeight, isHidden } from './dom-utils'; 9 | import { context } from './utils'; 10 | 11 | export const textToFigma = (node: Element, { fromTextInput = false } = {}) => { 12 | const textValue = ( 13 | node.textContent || 14 | (node as HTMLInputElement).value || 15 | (node as HTMLInputElement).placeholder 16 | )?.trim(); 17 | 18 | if (!textValue) return; 19 | 20 | const { getComputedStyle } = context.window; 21 | 22 | const parent = node.parentElement as Element; 23 | 24 | if (isHidden(parent)) { 25 | return; 26 | } 27 | const computedStyles = getComputedStyle(fromTextInput ? node : parent); 28 | const range = context.document.createRange(); 29 | range.selectNode(node); 30 | const rect = fastClone(range.getBoundingClientRect()); 31 | 32 | const lineHeight = getLineHeight(node as HTMLElement, computedStyles); 33 | 34 | range.detach(); 35 | if (lineHeight && lineHeight.value && rect.height < lineHeight.value) { 36 | const delta = lineHeight.value - rect.height; 37 | rect.top -= delta / 2; 38 | rect.height = lineHeight.value; 39 | } 40 | if (rect.height < 1 || rect.width < 1) { 41 | return; 42 | } 43 | let x = Math.round(rect.left); 44 | let y = Math.round(rect.top); 45 | let width = Math.round(rect.width); 46 | let height = Math.round(rect.height); 47 | 48 | if (fromTextInput) { 49 | const borderLeftWidth = 50 | parseUnits(computedStyles.borderLeftWidth)?.value || 0; 51 | const borderRightWidth = 52 | parseUnits(computedStyles.borderRightWidth)?.value || 0; 53 | 54 | const paddingLeft = parseUnits(computedStyles.paddingLeft)?.value || 0; 55 | const paddingRight = 56 | parseUnits(computedStyles.paddingRight)?.value || 0; 57 | const paddingTop = parseUnits(computedStyles.paddingTop)?.value || 0; 58 | const paddingBottom = 59 | parseUnits(computedStyles.paddingBottom)?.value || 0; 60 | 61 | x = x + borderLeftWidth + (fromTextInput ? paddingLeft : 0); 62 | y = y + paddingTop; 63 | width = width - borderRightWidth - paddingRight; 64 | height = height - paddingTop - paddingBottom; 65 | } 66 | 67 | const textNode = { 68 | x, 69 | y, 70 | width, 71 | height, 72 | ref: node, 73 | type: 'TEXT', 74 | characters: textValue?.replace(/\s+/g, ' ') || '', 75 | } as MetaTextNode; 76 | 77 | const fills: SolidPaint[] = []; 78 | let rgb = getRgb(computedStyles.color); 79 | const isPlaceholder = 80 | fromTextInput && 81 | !(node as HTMLInputElement).value && 82 | (node as HTMLInputElement).placeholder; 83 | rgb = isPlaceholder ? defaultPlaceholderColor : rgb; 84 | 85 | if (rgb) { 86 | fills.push({ 87 | type: 'SOLID', 88 | color: { 89 | r: rgb.r, 90 | g: rgb.g, 91 | b: rgb.b, 92 | }, 93 | blendMode: 'NORMAL', 94 | visible: true, 95 | opacity: rgb.a || 1, 96 | } as SolidPaint); 97 | } 98 | 99 | if (fills.length) { 100 | textNode.fills = fills; 101 | } 102 | const letterSpacing = parseUnits(computedStyles.letterSpacing); 103 | if (letterSpacing) { 104 | textNode.letterSpacing = letterSpacing; 105 | } 106 | if (lineHeight) { 107 | textNode.lineHeight = lineHeight; 108 | } 109 | 110 | const { textTransform } = computedStyles; 111 | switch (textTransform) { 112 | case 'uppercase': { 113 | textNode.textCase = 'UPPER'; 114 | break; 115 | } 116 | case 'lowercase': { 117 | textNode.textCase = 'LOWER'; 118 | break; 119 | } 120 | case 'capitalize': { 121 | textNode.textCase = 'TITLE'; 122 | break; 123 | } 124 | } 125 | 126 | const fontSize = parseUnits(computedStyles.fontSize); 127 | if (fontSize) { 128 | textNode.fontSize = Math.round(fontSize.value); 129 | } 130 | if (computedStyles.fontFamily) { 131 | // const font = computedStyles.fontFamily.split(/\s*,\s*/); 132 | textNode.fontFamily = computedStyles.fontFamily; 133 | } 134 | 135 | if (computedStyles.textDecoration) { 136 | if ( 137 | computedStyles.textDecoration === 'underline' || 138 | computedStyles.textDecoration === 'strikethrough' 139 | ) { 140 | textNode.textDecoration = 141 | computedStyles.textDecoration.toUpperCase() as any; 142 | } 143 | } 144 | if (computedStyles.textAlign) { 145 | if ( 146 | ['left', 'center', 'right', 'justified'].includes( 147 | computedStyles.textAlign 148 | ) 149 | ) { 150 | textNode.textAlignHorizontal = 151 | computedStyles.textAlign.toUpperCase() as any; 152 | } 153 | } 154 | 155 | return textNode; 156 | }; 157 | -------------------------------------------------------------------------------- /src/browser/utils.ts: -------------------------------------------------------------------------------- 1 | import { parseUnits } from "../utils"; 2 | 3 | interface ExtendedWindow extends Window { 4 | HTMLInputElement: HTMLInputElement 5 | } 6 | interface FigmaToHtmlContext { 7 | window: ExtendedWindow; 8 | document: Document 9 | } 10 | 11 | export const context: FigmaToHtmlContext = { 12 | // @ts-expect-error 13 | window, 14 | document 15 | }; 16 | 17 | export const setContext = (window: Window) => { 18 | context.document = window.document; 19 | // @ts-expect-error 20 | context.window = window; 21 | }; 22 | 23 | export const replaceSvgFill = (svg: string, fillColor: string) => { 24 | const endTagIndex = svg.indexOf('>'); 25 | const mainTag = svg.slice(1, endTagIndex); 26 | const fillAttr = `fill="${fillColor}"`; 27 | const mainTagWithFill = mainTag.includes('fill=') ? mainTag.replace(/fill\=(.*?)\s/, `fill="${fillColor}" `) : mainTag + fillAttr; 28 | 29 | return `<${mainTagWithFill}>${svg.slice(endTagIndex)}`; 30 | } -------------------------------------------------------------------------------- /src/figma/dropOffset.ts: -------------------------------------------------------------------------------- 1 | interface DropOffsetParams { 2 | dropPosition: { clientX: number, clientY: number }, 3 | windowSize: { width: number, height: number }, 4 | offset: { x: number, y: number }, 5 | } 6 | 7 | export function getDropOffset(payload: DropOffsetParams) { 8 | const { dropPosition, windowSize, offset } = payload; 9 | 10 | const { bounds, zoom } = figma.viewport; 11 | const hasUI = Math.abs((bounds.width * zoom) / windowSize.width) < 0.99; 12 | const leftPaneWidth = windowSize.width - bounds.width * zoom - 240; 13 | const xFromCanvas = hasUI 14 | ? dropPosition.clientX - leftPaneWidth 15 | : dropPosition.clientX; 16 | const yFromCanvas = hasUI ? dropPosition.clientY - 40 : dropPosition.clientY; 17 | 18 | return { 19 | x: bounds.x + xFromCanvas / zoom - offset.x, 20 | y: bounds.y + yFromCanvas / zoom - offset.y 21 | } 22 | } -------------------------------------------------------------------------------- /src/figma/getFont.ts: -------------------------------------------------------------------------------- 1 | const fontCache: { [key: string]: FontName | undefined } = {}; 2 | 3 | const normalizeName = (str: string) => 4 | str.toLowerCase().replace(/[^a-z]/gi, ''); 5 | 6 | export const defaultFont = { family: 'Roboto', style: 'Regular' }; 7 | 8 | let cachedAvailableFonts: Font[] | null = null; 9 | 10 | const getAvailableFontNames = async () => { 11 | if (cachedAvailableFonts) { 12 | return cachedAvailableFonts; 13 | } else { 14 | return (await figma.listAvailableFontsAsync()).filter( 15 | (font: Font) => font.fontName.style === 'Regular' 16 | ); 17 | } 18 | } 19 | 20 | // TODO: keep list of fonts not found 21 | export async function getMatchingFont(fontStr: string) { 22 | const cached = fontCache[fontStr]; 23 | if (cached) { 24 | return cached; 25 | } 26 | 27 | const availableFonts = await getAvailableFontNames(); 28 | const familySplit = fontStr.split(/\s*,\s*/); 29 | 30 | for (const family of familySplit) { 31 | const normalized = normalizeName(family); 32 | for (const availableFont of availableFonts) { 33 | const normalizedAvailable = normalizeName( 34 | availableFont.fontName.family 35 | ); 36 | if (normalizedAvailable === normalized) { 37 | const cached = fontCache[normalizedAvailable]; 38 | if (cached) { 39 | return cached; 40 | } 41 | await figma.loadFontAsync(availableFont.fontName); 42 | fontCache[fontStr] = availableFont.fontName; 43 | fontCache[normalizedAvailable] = availableFont.fontName; 44 | return availableFont.fontName; 45 | } 46 | } 47 | } 48 | 49 | return defaultFont; 50 | } -------------------------------------------------------------------------------- /src/figma/helpers.ts: -------------------------------------------------------------------------------- 1 | const allPropertyNames = [ 2 | 'id', 3 | 'width', 4 | 'height', 5 | 'currentPage', 6 | 'cancel', 7 | 'origin', 8 | 'onmessage', 9 | 'center', 10 | 'zoom', 11 | 'fontName', 12 | 'name', 13 | 'visible', 14 | 'locked', 15 | 'constraints', 16 | 'relativeTransform', 17 | 'x', 18 | 'y', 19 | 'rotation', 20 | 'constrainProportions', 21 | 'layoutAlign', 22 | 'layoutGrow', 23 | 'opacity', 24 | 'blendMode', 25 | 'isMask', 26 | 'effects', 27 | 'effectStyleId', 28 | 'expanded', 29 | 'backgrounds', 30 | 'backgroundStyleId', 31 | 'fills', 32 | 'strokes', 33 | 'strokeWeight', 34 | 'strokeMiterLimit', 35 | 'strokeAlign', 36 | 'strokeCap', 37 | 'strokeJoin', 38 | 'dashPattern', 39 | 'fillStyleId', 40 | 'strokeStyleId', 41 | 'cornerRadius', 42 | 'cornerSmoothing', 43 | 'topLeftRadius', 44 | 'topRightRadius', 45 | 'bottomLeftRadius', 46 | 'bottomRightRadius', 47 | 'exportSettings', 48 | 'overflowDirection', 49 | 'numberOfFixedChildren', 50 | 'description', 51 | 'layoutMode', 52 | 'primaryAxisSizingMode', 53 | 'counterAxisSizingMode', 54 | 'primaryAxisAlignItems', 55 | 'counterAxisAlignItems', 56 | 'paddingLeft', 57 | 'paddingRight', 58 | 'paddingTop', 59 | 'paddingBottom', 60 | 'itemSpacing', 61 | 'layoutGrids', 62 | 'gridStyleId', 63 | 'clipsContent', 64 | 'guides', 65 | 'guides', 66 | 'selection', 67 | 'selectedTextRange', 68 | 'backgrounds', 69 | 'arcData', 70 | 'pointCount', 71 | 'pointCount', 72 | 'innerRadius', 73 | 'vectorNetwork', 74 | 'vectorPaths', 75 | 'handleMirroring', 76 | 'textAlignHorizontal', 77 | 'textAlignVertical', 78 | 'textAutoResize', 79 | 'paragraphIndent', 80 | 'paragraphSpacing', 81 | 'autoRename', 82 | 'textStyleId', 83 | 'fontSize', 84 | 'fontName', 85 | 'textCase', 86 | 'textDecoration', 87 | 'letterSpacing', 88 | 'lineHeight', 89 | 'characters', 90 | 'mainComponent', 91 | 'scaleFactor', 92 | 'booleanOperation', 93 | 'expanded', 94 | 'name', 95 | 'type', 96 | 'paints', 97 | 'type', 98 | 'fontSize', 99 | 'textDecoration', 100 | 'fontName', 101 | 'letterSpacing', 102 | 'lineHeight', 103 | 'paragraphIndent', 104 | 'paragraphSpacing', 105 | 'textCase', 106 | 'type', 107 | 'effects', 108 | 'type', 109 | 'layoutGrids', 110 | ]; 111 | 112 | type AnyStringMap = { [key: string]: any }; 113 | 114 | export function assign(a: BaseNode & AnyStringMap, b: AnyStringMap) { 115 | for (const key in b) { 116 | const value = b[key]; 117 | if (key === 'data' && value && typeof value === 'object') { 118 | const currentData = 119 | JSON.parse(a.getSharedPluginData('builder', 'data') || '{}') || 120 | {}; 121 | const newData = value; 122 | const mergedData = Object.assign({}, currentData, newData); 123 | // TODO merge plugin data 124 | a.setSharedPluginData( 125 | 'builder', 126 | 'data', 127 | JSON.stringify(mergedData) 128 | ); 129 | } else if ( 130 | typeof value != 'undefined' && 131 | ['width', 'height', 'type', 'ref', 'children', 'svg'].indexOf( 132 | key 133 | ) === -1 134 | ) { 135 | try { 136 | a[key] = b[key]; 137 | } catch (err) { 138 | console.warn(`Assign error for property "${key}"`, a, b, err); 139 | } 140 | } 141 | } 142 | } 143 | 144 | // The Figma nodes are hard to inspect at a glance because almost all properties are non enumerable 145 | // getters. This removes that wrapping for easier inspecting 146 | export const cloneObject = (obj: any, valuesSet = new Set()) => { 147 | if (!obj || typeof obj !== 'object') { 148 | return obj; 149 | } 150 | 151 | const newObj: any = Array.isArray(obj) ? [] : {}; 152 | 153 | for (const property of allPropertyNames) { 154 | const value = obj[property]; 155 | if (value !== undefined && typeof value !== 'symbol') { 156 | newObj[property] = obj[property]; 157 | } 158 | } 159 | 160 | return newObj; 161 | }; 162 | -------------------------------------------------------------------------------- /src/figma/images.ts: -------------------------------------------------------------------------------- 1 | import { getImageFills } from "../utils"; 2 | 3 | export async function processImages(layer: RectangleNode | TextNode) { 4 | const images = getImageFills(layer); 5 | return ( 6 | images && 7 | Promise.all( 8 | images.map(async (image: any) => { 9 | if (image && image.intArr) { 10 | image.imageHash = await figma.createImage(image.intArr) 11 | .hash; 12 | delete image.intArr; 13 | } 14 | }) 15 | ) 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/figma/index.ts: -------------------------------------------------------------------------------- 1 | import { LayerNode, PlainLayerNode } from '../types'; 2 | import { traverse, traverseAsync } from '../utils'; 3 | import { processLayer } from './processLayer'; 4 | 5 | interface LayerCbArgs { 6 | node: SceneNode; 7 | layer: LayerNode; 8 | parent: LayerNode | null; 9 | } 10 | 11 | export async function addLayersToFrame( 12 | layers: PlainLayerNode[], 13 | baseFrame: PageNode | FrameNode, 14 | onLayerProcess?: (args: LayerCbArgs) => void 15 | ) { 16 | for (const rootLayer of layers) { 17 | await traverseAsync(rootLayer, async (layer, parent) => { 18 | try { 19 | const node = await processLayer(layer, parent, baseFrame); 20 | 21 | onLayerProcess?.({ node, layer, parent }); 22 | } catch (err) { 23 | console.warn('Error on layer:', layer, err); 24 | } 25 | }); 26 | } 27 | } 28 | 29 | export * from './getFont'; 30 | export * from './dropOffset'; 31 | -------------------------------------------------------------------------------- /src/figma/processLayer.ts: -------------------------------------------------------------------------------- 1 | import { getImageFills } from "../utils"; 2 | import { processImages } from "./images"; 3 | import { getMatchingFont } from "./getFont"; 4 | import { assign } from "./helpers"; 5 | import { LayerNode, PlainLayerNode, WithRef } from "../types"; 6 | 7 | const processDefaultElement = ( 8 | layer: LayerNode, 9 | node: SceneNode 10 | ): SceneNode => { 11 | node.x = layer.x as number; 12 | node.y = layer.y as number; 13 | node.resize(layer.width || 1, layer.height || 1); 14 | assign(node, layer); 15 | // rects.push(frame); 16 | return node; 17 | }; 18 | 19 | const createNodeFromLayer = (layer: LayerNode) => { 20 | if (layer.type === 'FRAME' || layer.type === 'GROUP') { 21 | return figma.createFrame(); 22 | } 23 | 24 | if (layer.type === 'SVG' && layer.svg) { 25 | return figma.createNodeFromSvg(layer.svg); 26 | } 27 | 28 | if (layer.type === 'RECTANGLE') { 29 | return figma.createRectangle(); 30 | } 31 | 32 | if (layer.type === 'TEXT') { 33 | return figma.createText(); 34 | } 35 | 36 | if (layer.type === 'COMPONENT') { 37 | return figma.createComponent(); 38 | } 39 | }; 40 | 41 | const SIMPLE_TYPES = ['FRAME', 'GROUP', 'SVG', 'RECTANGLE', 'COMPONENT']; 42 | 43 | export const processLayer = async ( 44 | layer: PlainLayerNode, 45 | parent: WithRef | null, 46 | baseFrame: PageNode | FrameNode 47 | ) => { 48 | const parentFrame = (parent?.ref as FrameNode) || baseFrame; 49 | 50 | if (typeof layer.x !== 'number' || typeof layer.y !== 'number') { 51 | throw Error('Layer coords not defined'); 52 | } 53 | 54 | const node = createNodeFromLayer(layer); 55 | 56 | if (!node) { 57 | throw Error(`${layer.type} not implemented`); 58 | } 59 | 60 | if (SIMPLE_TYPES.includes(layer.type as string)) { 61 | parentFrame.appendChild(processDefaultElement(layer, node)); 62 | } 63 | // @ts-expect-error 64 | layer.ref = node; 65 | 66 | if (layer.type === 'RECTANGLE') { 67 | if (getImageFills(layer as RectangleNode)) { 68 | await processImages(layer as RectangleNode); 69 | } 70 | } 71 | 72 | if (layer.type === 'TEXT') { 73 | const text = node as TextNode; 74 | 75 | if (layer.fontFamily) { 76 | text.fontName = await getMatchingFont(layer.fontFamily); 77 | 78 | delete layer.fontFamily; 79 | } 80 | 81 | assign(text, layer); 82 | text.resize(layer.width || 1, layer.height || 1); 83 | 84 | text.textAutoResize = 'HEIGHT'; 85 | 86 | let adjustments = 0; 87 | if (layer.lineHeight) { 88 | text.lineHeight = layer.lineHeight; 89 | } 90 | // Adjust text width 91 | while ( 92 | typeof layer.height === 'number' && 93 | text.height > layer.height 94 | ) { 95 | 96 | if (adjustments++ > 5) { 97 | console.warn('Too many font adjustments', text, layer); 98 | 99 | break; 100 | } 101 | 102 | try { 103 | text.resize(text.width + 1, text.height); 104 | } catch (err) { 105 | console.warn('Error on resize text:', layer, text, err); 106 | } 107 | } 108 | 109 | parentFrame.appendChild(text); 110 | } 111 | 112 | return node; 113 | }; 114 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Unit { 2 | unit: "PIXELS"; 3 | value: number; 4 | } 5 | 6 | export interface SvgNode extends DefaultShapeMixin, ConstraintMixin { 7 | type: "SVG"; 8 | svg: string; 9 | } 10 | 11 | 12 | 13 | export type WithWriteChildren = Partial & { 14 | children: WithWriteChildren[] 15 | } 16 | 17 | export type WithRef = T & { 18 | ref?: SceneNode 19 | }; 20 | 21 | // export interface Layer { 22 | // ref: Element, 23 | // x: number, 24 | // y: number, 25 | // width: number, 26 | // height: number, 27 | // fills: 28 | // clipsContent: !!overflowHidden, 29 | // fills: fills as any, 30 | // children: [], 31 | // opacity: getOpacity(computedStyle), 32 | // zIndex: Number(computedStyle.zIndex), 33 | // } 34 | 35 | export type LayerNode = Partial; 36 | 37 | export type PlainLayerNode = Partial & { 38 | fontFamily?: string 39 | }; 40 | 41 | export type MetaLayerNode = WithMeta; 42 | export type MetaTextNode = WithMeta; 43 | 44 | export type WithMeta = Partial> & { 45 | ref?: SceneNode | Element | HTMLElement, 46 | zIndex?: number; 47 | fontFamily?: string; 48 | textValue?: WithMeta; 49 | before?: WithMeta; 50 | after?: WithMeta; 51 | borders?: WithMeta; 52 | children?: WithMeta[]; 53 | constraints?: FrameNode['constraints']; 54 | clipsContent?: FrameNode['clipsContent']; 55 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { LayerNode, Unit } from './types'; 2 | 3 | export const hasChildren = (node: T) => 4 | // @ts-expect-error 5 | node && Array.isArray(node.children); 6 | 7 | export function traverse( 8 | layer: LayerNode, 9 | cb: (layer: LayerNode, parent: LayerNode | null) => void, 10 | parent: LayerNode | null = null, 11 | ) { 12 | if (layer) { 13 | cb(layer, parent); 14 | if (hasChildren(layer)) { 15 | // @ts-expect-error 16 | layer.children.forEach((child) => 17 | traverse(child as LayerNode, cb, layer) 18 | ); 19 | } 20 | } 21 | } 22 | 23 | export function traverseMap( 24 | layer: T, 25 | cb: (layer: T, parent: T | null) => T | undefined, 26 | parent: T | null = null, 27 | ) { 28 | if (layer) { 29 | const newLayer = cb(layer, parent); 30 | // @ts-expect-error 31 | if (newLayer?.children?.length) { 32 | // @ts-expect-error 33 | newLayer.children = newLayer.children.map((child) => 34 | traverseMap(child, cb, layer) 35 | ); 36 | } 37 | return newLayer; 38 | } 39 | } 40 | 41 | export async function traverseAsync( 42 | layer: T, 43 | cb: (layer: T, parent: T | null) => void, 44 | parent: T | null = null, 45 | ) { 46 | if (layer) { 47 | await cb(layer, parent); 48 | if (hasChildren(layer)) { 49 | // @ts-ignore 50 | for (let child of layer.children.reverse()) { 51 | await traverseAsync(child as T, cb, layer) 52 | } 53 | } 54 | } 55 | } 56 | 57 | export function size(obj: object) { 58 | return Object.keys(obj).length; 59 | } 60 | 61 | export const capitalize = (str: string) => str[0].toUpperCase() + str.substring(1); 62 | 63 | interface ParsedColor { 64 | r: number; 65 | g: number; 66 | b: number; 67 | a: number; 68 | } 69 | 70 | export function getRgb(colorString?: string | null): ParsedColor | null { 71 | if (!colorString) { 72 | return null; 73 | } 74 | const [_1, r, g, b, _2, a] = (colorString!.match( 75 | /rgba?\(([\d\.]+), ([\d\.]+), ([\d\.]+)(, ([\d\.]+))?\)/ 76 | )! || []) as string[]; 77 | 78 | const none = a && parseFloat(a) === 0; 79 | 80 | if (r && g && b && !none) { 81 | return { 82 | r: parseInt(r) / 255, 83 | g: parseInt(g) / 255, 84 | b: parseInt(b) / 255, 85 | a: a ? parseFloat(a) : 1, 86 | }; 87 | } 88 | return null; 89 | } 90 | 91 | export const fastClone = (data: any) => 92 | typeof data === 'symbol' ? null : JSON.parse(JSON.stringify(data)); 93 | 94 | export const toNum = (v: string): number => { 95 | // if (!/px$/.test(v) && v !== '0') return v; 96 | if (!/px$/.test(v) && v !== '0') return 0; 97 | const n = parseFloat(v); 98 | // return !isNaN(n) ? n : v; 99 | return !isNaN(n) ? n : 0; 100 | }; 101 | 102 | export const toPercent = (v: string): number => { 103 | // if (!/px$/.test(v) && v !== '0') return v; 104 | if (!/%$/.test(v) && v !== '0') return 0; 105 | const n = parseInt(v); 106 | // return !isNaN(n) ? n : v; 107 | return !isNaN(n) ? n / 100 : 0; 108 | }; 109 | 110 | export const parseUnits = (str?: string | null, relative?: number): null | Unit => { 111 | if (!str) { 112 | return null; 113 | } 114 | let value = toNum(str); 115 | if (relative && !value) { 116 | const percent = toPercent(str); 117 | 118 | if (!percent) return null; 119 | 120 | value = relative * percent; 121 | } 122 | // const match = str.match(/([\d\.]+)px/); 123 | // const val = match && match[1]; 124 | if (value) { 125 | return { 126 | unit: 'PIXELS', 127 | value, 128 | }; 129 | } 130 | return null; 131 | }; 132 | 133 | const LENGTH_REG = /^[0-9]+[a-zA-Z%]+?$/; 134 | 135 | const isLength = (v: string) => v === '0' || LENGTH_REG.test(v); 136 | 137 | interface ParsedBoxShadow { 138 | inset: boolean; 139 | offsetX: number; 140 | offsetY: number; 141 | blurRadius: number; 142 | spreadRadius: number; 143 | color: ParsedColor; 144 | } 145 | 146 | const parseMultipleCSSValues = (str: string) => { 147 | const parts = []; 148 | let lastSplitIndex = 0; 149 | let skobka = false; 150 | 151 | for (let i = 0; i < str.length; i++) { 152 | if (str[i] === ',' && !skobka) { 153 | parts.push(str.slice(lastSplitIndex, i)); 154 | lastSplitIndex = i + 1; 155 | } else if(str[i] === '(') { 156 | skobka = true; 157 | } else if(str[i] === ')') { 158 | skobka = false; 159 | } 160 | } 161 | parts.push(str.slice(lastSplitIndex)); 162 | 163 | return parts.map(s => s.trim()); 164 | } 165 | 166 | export const parseBoxShadowValue = (str: string): ParsedBoxShadow => { 167 | // TODO: this is broken for multiple box shadows 168 | if (str.startsWith('rgb')) { 169 | // Werid computed style thing that puts the color in the front not back 170 | const colorMatch = str.match(/(rgba?\(.+?\))(.+)/); 171 | if (colorMatch) { 172 | str = (colorMatch[2] + ' ' + colorMatch[1]).trim(); 173 | } 174 | } 175 | 176 | const PARTS_REG = /\s(?![^(]*\))/; 177 | const parts = str.split(PARTS_REG); 178 | const inset = parts.includes('inset'); 179 | const last = parts.slice(-1)[0]; 180 | const color = !isLength(last) ? last : 'rgba(0, 0, 0, 1)'; 181 | 182 | const nums = parts 183 | .filter((n) => n !== 'inset') 184 | .filter((n) => n !== color) 185 | .map(toNum); 186 | 187 | const [offsetX, offsetY, blurRadius, spreadRadius] = nums; 188 | 189 | const parsedColor = getRgb(color); 190 | 191 | if (!parsedColor) { 192 | console.error('Parse color error: ' + color); 193 | } 194 | 195 | return { 196 | inset, 197 | offsetX, 198 | offsetY, 199 | blurRadius, 200 | spreadRadius, 201 | color: parsedColor || { r: 0, g: 0, b: 0, a: 1}, 202 | }; 203 | }; 204 | 205 | export const getOpacity = (styles: CSSStyleDeclaration) => { 206 | return Number(styles.opacity); 207 | } 208 | 209 | export const parseBoxShadowValues = (str: string): ParsedBoxShadow[] => { 210 | const values = parseMultipleCSSValues(str); 211 | 212 | return values.map(s => parseBoxShadowValue(s)); 213 | }; 214 | 215 | 216 | export function getImageFills(layer: RectangleNode | TextNode) { 217 | const images = 218 | Array.isArray(layer.fills) && 219 | layer.fills.filter((item) => item.type === 'IMAGE'); 220 | return images; 221 | } 222 | 223 | export const defaultPlaceholderColor = getRgb('rgba(178, 178, 178, 1)'); -------------------------------------------------------------------------------- /tests/__snapshots__/base.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Convert to figma button with padding 1`] = ` 4 | Object { 5 | "children": Array [ 6 | Object { 7 | "children": Array [ 8 | Object { 9 | "characters": "TEST", 10 | "constraints": Object { 11 | "horizontal": "CENTER", 12 | "vertical": "MIN", 13 | }, 14 | "data": Object { 15 | "heightType": "fixed", 16 | "widthType": "shrink", 17 | }, 18 | "fills": Array [ 19 | Object { 20 | "blendMode": "NORMAL", 21 | "color": Object { 22 | "b": 0, 23 | "g": 0, 24 | "r": 0, 25 | }, 26 | "opacity": 1, 27 | "type": "SOLID", 28 | "visible": true, 29 | }, 30 | ], 31 | "fontFamily": "Arial", 32 | "fontSize": 13, 33 | "height": 15, 34 | "lineHeight": Object { 35 | "unit": "PIXELS", 36 | "value": 15, 37 | }, 38 | "textAlignHorizontal": "CENTER", 39 | "type": "TEXT", 40 | "width": 34, 41 | "x": 12, 42 | "y": 12, 43 | }, 44 | ], 45 | "clipsContent": false, 46 | "constraints": Object { 47 | "horizontal": "SCALE", 48 | "vertical": "MIN", 49 | }, 50 | "data": Object { 51 | "heightType": "fixed", 52 | "widthType": "shrink", 53 | }, 54 | "fills": Array [ 55 | Object { 56 | "color": Object { 57 | "b": 0.9372549019607843, 58 | "g": 0.9372549019607843, 59 | "r": 0.9372549019607843, 60 | }, 61 | "opacity": 1, 62 | "type": "SOLID", 63 | }, 64 | ], 65 | "height": 39, 66 | "opacity": 1, 67 | "strokeWeight": 2, 68 | "strokes": Array [ 69 | Object { 70 | "color": Object { 71 | "b": 0.4627450980392157, 72 | "g": 0.4627450980392157, 73 | "r": 0.4627450980392157, 74 | }, 75 | "opacity": 1, 76 | "type": "SOLID", 77 | }, 78 | ], 79 | "type": "FRAME", 80 | "width": 58, 81 | "x": 0, 82 | "y": 0, 83 | }, 84 | ], 85 | "clipsContent": false, 86 | "constraints": Object { 87 | "horizontal": "SCALE", 88 | "vertical": "MIN", 89 | }, 90 | "data": Object { 91 | "heightType": "fixed", 92 | "widthType": "fixed", 93 | }, 94 | "fills": Array [], 95 | "height": 39, 96 | "opacity": 1, 97 | "type": "FRAME", 98 | "width": 784, 99 | "x": 8, 100 | "y": 8, 101 | } 102 | `; 103 | 104 | exports[`Convert to figma buttons with :before :after 1`] = ` 105 | Object { 106 | "children": Array [ 107 | Object { 108 | "children": Array [ 109 | Object { 110 | "characters": "TEST 1s", 111 | "constraints": Object { 112 | "horizontal": "CENTER", 113 | "vertical": "MIN", 114 | }, 115 | "data": Object { 116 | "heightType": "fixed", 117 | "widthType": "shrink", 118 | }, 119 | "fills": Array [ 120 | Object { 121 | "blendMode": "NORMAL", 122 | "color": Object { 123 | "b": 0, 124 | "g": 0, 125 | "r": 0, 126 | }, 127 | "opacity": 1, 128 | "type": "SOLID", 129 | "visible": true, 130 | }, 131 | ], 132 | "fontFamily": "Arial", 133 | "fontSize": 13, 134 | "height": 15, 135 | "lineHeight": Object { 136 | "unit": "PIXELS", 137 | "value": 15, 138 | }, 139 | "textAlignHorizontal": "CENTER", 140 | "type": "TEXT", 141 | "width": 52, 142 | "x": 10, 143 | "y": 10, 144 | }, 145 | Object { 146 | "bottomLeftRadius": 4, 147 | "bottomRightRadius": 4, 148 | "children": Array [], 149 | "clipsContent": false, 150 | "constraints": Object { 151 | "horizontal": "SCALE", 152 | "vertical": "MIN", 153 | }, 154 | "data": Object { 155 | "heightType": "fixed", 156 | "position": "absolute", 157 | "widthType": "fixed", 158 | }, 159 | "fills": Array [ 160 | Object { 161 | "color": Object { 162 | "b": 0, 163 | "g": 0.8, 164 | "r": 1, 165 | }, 166 | "opacity": 1, 167 | "type": "SOLID", 168 | }, 169 | ], 170 | "height": 35, 171 | "name": "::before", 172 | "opacity": 1, 173 | "topLeftRadius": 4, 174 | "topRightRadius": 4, 175 | "type": "FRAME", 176 | "width": 72, 177 | "x": 0, 178 | "y": 0, 179 | }, 180 | Object { 181 | "children": Array [], 182 | "clipsContent": false, 183 | "constraints": Object { 184 | "horizontal": "SCALE", 185 | "vertical": "MIN", 186 | }, 187 | "data": Object { 188 | "heightType": "fixed", 189 | "position": "absolute", 190 | "widthType": "fixed", 191 | }, 192 | "fills": Array [], 193 | "height": 35, 194 | "name": "::after", 195 | "opacity": 1, 196 | "type": "FRAME", 197 | "width": 72, 198 | "x": 0, 199 | "y": 0, 200 | }, 201 | ], 202 | "clipsContent": false, 203 | "constraints": Object { 204 | "horizontal": "SCALE", 205 | "vertical": "MIN", 206 | }, 207 | "data": Object { 208 | "heightType": "fixed", 209 | "widthType": "shrink", 210 | }, 211 | "fills": Array [], 212 | "height": 35, 213 | "opacity": 1, 214 | "type": "FRAME", 215 | "width": 72, 216 | "x": 0, 217 | "y": 0, 218 | }, 219 | Object { 220 | "children": Array [ 221 | Object { 222 | "characters": "TEST 2s", 223 | "constraints": Object { 224 | "horizontal": "CENTER", 225 | "vertical": "MIN", 226 | }, 227 | "data": Object { 228 | "heightType": "fixed", 229 | "widthType": "shrink", 230 | }, 231 | "fills": Array [ 232 | Object { 233 | "blendMode": "NORMAL", 234 | "color": Object { 235 | "b": 0, 236 | "g": 0, 237 | "r": 0, 238 | }, 239 | "opacity": 1, 240 | "type": "SOLID", 241 | "visible": true, 242 | }, 243 | ], 244 | "fontFamily": "Arial", 245 | "fontSize": 13, 246 | "height": 15, 247 | "lineHeight": Object { 248 | "unit": "PIXELS", 249 | "value": 15, 250 | }, 251 | "textAlignHorizontal": "CENTER", 252 | "type": "TEXT", 253 | "width": 52, 254 | "x": 10, 255 | "y": 10, 256 | }, 257 | Object { 258 | "bottomLeftRadius": 4, 259 | "bottomRightRadius": 4, 260 | "children": Array [], 261 | "clipsContent": false, 262 | "constraints": Object { 263 | "horizontal": "SCALE", 264 | "vertical": "MIN", 265 | }, 266 | "data": Object { 267 | "heightType": "fixed", 268 | "position": "absolute", 269 | "widthType": "fixed", 270 | }, 271 | "fills": Array [ 272 | Object { 273 | "color": Object { 274 | "b": 0, 275 | "g": 0.8, 276 | "r": 1, 277 | }, 278 | "opacity": 1, 279 | "type": "SOLID", 280 | }, 281 | ], 282 | "height": 35, 283 | "name": "::before", 284 | "opacity": 1, 285 | "topLeftRadius": 4, 286 | "topRightRadius": 4, 287 | "type": "FRAME", 288 | "width": 72, 289 | "x": 0, 290 | "y": 0, 291 | }, 292 | Object { 293 | "children": Array [], 294 | "clipsContent": false, 295 | "constraints": Object { 296 | "horizontal": "SCALE", 297 | "vertical": "MIN", 298 | }, 299 | "data": Object { 300 | "heightType": "fixed", 301 | "position": "absolute", 302 | "widthType": "fixed", 303 | }, 304 | "fills": Array [], 305 | "height": 35, 306 | "name": "::after", 307 | "opacity": 1, 308 | "type": "FRAME", 309 | "width": 72, 310 | "x": 0, 311 | "y": 0, 312 | }, 313 | ], 314 | "clipsContent": false, 315 | "constraints": Object { 316 | "horizontal": "SCALE", 317 | "vertical": "MIN", 318 | }, 319 | "data": Object { 320 | "heightType": "fixed", 321 | "widthType": "shrink", 322 | }, 323 | "fills": Array [], 324 | "height": 35, 325 | "opacity": 1, 326 | "type": "FRAME", 327 | "width": 72, 328 | "x": 76, 329 | "y": 0, 330 | }, 331 | ], 332 | "clipsContent": false, 333 | "constraints": Object { 334 | "horizontal": "SCALE", 335 | "vertical": "MIN", 336 | }, 337 | "data": Object { 338 | "heightType": "fixed", 339 | "widthType": "fixed", 340 | }, 341 | "fills": Array [], 342 | "height": 35, 343 | "opacity": 1, 344 | "type": "FRAME", 345 | "width": 784, 346 | "x": 8, 347 | "y": 8, 348 | } 349 | `; 350 | 351 | exports[`Convert to figma input and placeholder 1`] = ` 352 | Object { 353 | "children": Array [ 354 | Object { 355 | "children": Array [ 356 | Object { 357 | "children": Array [ 358 | Object { 359 | "characters": "Placeholder", 360 | "fills": Array [ 361 | Object { 362 | "blendMode": "NORMAL", 363 | "color": Object { 364 | "b": 0.6980392156862745, 365 | "g": 0.6980392156862745, 366 | "r": 0.6980392156862745, 367 | }, 368 | "opacity": 1, 369 | "type": "SOLID", 370 | "visible": true, 371 | }, 372 | ], 373 | "fontFamily": "Arial", 374 | "fontSize": 16, 375 | "height": 36, 376 | "lineHeight": Object { 377 | "unit": "PIXELS", 378 | "value": 36, 379 | }, 380 | "type": "TEXT", 381 | "width": 778, 382 | "x": 6, 383 | "y": 0, 384 | }, 385 | ], 386 | "clipsContent": false, 387 | "constraints": Object { 388 | "horizontal": "SCALE", 389 | "vertical": "MIN", 390 | }, 391 | "data": Object { 392 | "heightType": "fixed", 393 | "widthType": "shrink", 394 | }, 395 | "fills": Array [], 396 | "height": 36, 397 | "opacity": 1, 398 | "type": "FRAME", 399 | "width": 784, 400 | "x": 0, 401 | "y": 0, 402 | }, 403 | Object { 404 | "bottomLeftRadius": 3, 405 | "bottomRightRadius": 3, 406 | "children": Array [], 407 | "clipsContent": false, 408 | "constraints": Object { 409 | "horizontal": "SCALE", 410 | "vertical": "MIN", 411 | }, 412 | "data": Object { 413 | "heightType": "fixed", 414 | "position": "absolute", 415 | "widthType": "fixed", 416 | }, 417 | "fills": Array [ 418 | Object { 419 | "color": Object { 420 | "b": 0.9333333333333333, 421 | "g": 0.9333333333333333, 422 | "r": 0.9333333333333333, 423 | }, 424 | "opacity": 1, 425 | "type": "SOLID", 426 | }, 427 | ], 428 | "height": 36, 429 | "opacity": 1, 430 | "strokeWeight": 1, 431 | "strokes": Array [ 432 | Object { 433 | "color": Object { 434 | "b": 0.8, 435 | "g": 0.8, 436 | "r": 0.8, 437 | }, 438 | "opacity": 1, 439 | "type": "SOLID", 440 | }, 441 | ], 442 | "topLeftRadius": 3, 443 | "topRightRadius": 3, 444 | "type": "FRAME", 445 | "width": 784, 446 | "x": 0, 447 | "y": 0, 448 | }, 449 | ], 450 | "clipsContent": false, 451 | "constraints": Object { 452 | "horizontal": "SCALE", 453 | "vertical": "MIN", 454 | }, 455 | "data": Object { 456 | "heightType": "fixed", 457 | "widthType": "fixed", 458 | }, 459 | "fills": Array [], 460 | "height": 36, 461 | "opacity": 1, 462 | "type": "FRAME", 463 | "width": 784, 464 | "x": 0, 465 | "y": 0, 466 | }, 467 | Object { 468 | "children": Array [ 469 | Object { 470 | "children": Array [ 471 | Object { 472 | "characters": "Test Value", 473 | "fills": Array [ 474 | Object { 475 | "blendMode": "NORMAL", 476 | "color": Object { 477 | "b": 0, 478 | "g": 0, 479 | "r": 0, 480 | }, 481 | "opacity": 1, 482 | "type": "SOLID", 483 | "visible": true, 484 | }, 485 | ], 486 | "fontFamily": "Arial", 487 | "fontSize": 16, 488 | "height": 36, 489 | "lineHeight": Object { 490 | "unit": "PIXELS", 491 | "value": 36, 492 | }, 493 | "type": "TEXT", 494 | "width": 778, 495 | "x": 6, 496 | "y": 0, 497 | }, 498 | ], 499 | "clipsContent": false, 500 | "constraints": Object { 501 | "horizontal": "SCALE", 502 | "vertical": "MIN", 503 | }, 504 | "data": Object { 505 | "heightType": "fixed", 506 | "widthType": "shrink", 507 | }, 508 | "fills": Array [], 509 | "height": 36, 510 | "opacity": 1, 511 | "type": "FRAME", 512 | "width": 784, 513 | "x": 0, 514 | "y": 0, 515 | }, 516 | Object { 517 | "bottomLeftRadius": 3, 518 | "bottomRightRadius": 3, 519 | "children": Array [], 520 | "clipsContent": false, 521 | "constraints": Object { 522 | "horizontal": "SCALE", 523 | "vertical": "MIN", 524 | }, 525 | "data": Object { 526 | "heightType": "fixed", 527 | "position": "absolute", 528 | "widthType": "fixed", 529 | }, 530 | "fills": Array [ 531 | Object { 532 | "color": Object { 533 | "b": 0.9333333333333333, 534 | "g": 0.9333333333333333, 535 | "r": 0.9333333333333333, 536 | }, 537 | "opacity": 1, 538 | "type": "SOLID", 539 | }, 540 | ], 541 | "height": 36, 542 | "opacity": 1, 543 | "strokeWeight": 1, 544 | "strokes": Array [ 545 | Object { 546 | "color": Object { 547 | "b": 0.8, 548 | "g": 0.8, 549 | "r": 0.8, 550 | }, 551 | "opacity": 1, 552 | "type": "SOLID", 553 | }, 554 | ], 555 | "topLeftRadius": 3, 556 | "topRightRadius": 3, 557 | "type": "FRAME", 558 | "width": 784, 559 | "x": 0, 560 | "y": 0, 561 | }, 562 | ], 563 | "clipsContent": false, 564 | "constraints": Object { 565 | "horizontal": "SCALE", 566 | "vertical": "MIN", 567 | }, 568 | "data": Object { 569 | "heightType": "fixed", 570 | "widthType": "fixed", 571 | }, 572 | "fills": Array [], 573 | "height": 36, 574 | "opacity": 1, 575 | "type": "FRAME", 576 | "width": 784, 577 | "x": 0, 578 | "y": 46, 579 | }, 580 | ], 581 | "clipsContent": false, 582 | "constraints": Object { 583 | "horizontal": "SCALE", 584 | "vertical": "MIN", 585 | }, 586 | "data": Object { 587 | "heightType": "fixed", 588 | "widthType": "fixed", 589 | }, 590 | "fills": Array [], 591 | "height": 82, 592 | "opacity": 1, 593 | "type": "FRAME", 594 | "width": 784, 595 | "x": 8, 596 | "y": 8, 597 | } 598 | `; 599 | 600 | exports[`Convert to figma opacity 1`] = ` 601 | Object { 602 | "children": Array [ 603 | Object { 604 | "children": Array [], 605 | "clipsContent": false, 606 | "constraints": Object { 607 | "horizontal": "SCALE", 608 | "vertical": "MIN", 609 | }, 610 | "data": Object { 611 | "heightType": "fixed", 612 | "widthType": "fixed", 613 | }, 614 | "fills": Array [ 615 | Object { 616 | "color": Object { 617 | "b": 0.8, 618 | "g": 0.8, 619 | "r": 0.8, 620 | }, 621 | "opacity": 1, 622 | "type": "SOLID", 623 | }, 624 | ], 625 | "height": 100, 626 | "opacity": 0.5, 627 | "type": "FRAME", 628 | "width": 100, 629 | "x": 50, 630 | "y": 0, 631 | }, 632 | ], 633 | "clipsContent": false, 634 | "constraints": Object { 635 | "horizontal": "SCALE", 636 | "vertical": "MIN", 637 | }, 638 | "data": Object { 639 | "heightType": "fixed", 640 | "widthType": "fixed", 641 | }, 642 | "fills": Array [], 643 | "height": 100, 644 | "opacity": 1, 645 | "type": "FRAME", 646 | "width": 784, 647 | "x": 8, 648 | "y": 50, 649 | } 650 | `; 651 | 652 | exports[`Convert to figma shadows 1`] = ` 653 | Object { 654 | "children": Array [ 655 | Object { 656 | "children": Array [], 657 | "clipsContent": true, 658 | "constraints": Object { 659 | "horizontal": "SCALE", 660 | "vertical": "MIN", 661 | }, 662 | "data": Object { 663 | "heightType": "fixed", 664 | "widthType": "fixed", 665 | }, 666 | "effects": Array [ 667 | Object { 668 | "blendMode": "NORMAL", 669 | "color": Object { 670 | "a": 0.05, 671 | "b": 0, 672 | "g": 0, 673 | "r": 0, 674 | }, 675 | "offset": Object { 676 | "x": 0, 677 | "y": 0, 678 | }, 679 | "radius": 0, 680 | "spread": 1, 681 | "type": "DROP_SHADOW", 682 | "visible": true, 683 | }, 684 | Object { 685 | "blendMode": "NORMAL", 686 | "color": Object { 687 | "a": 0.05, 688 | "b": 0, 689 | "g": 0, 690 | "r": 0, 691 | }, 692 | "offset": Object { 693 | "x": 0, 694 | "y": 1, 695 | }, 696 | "radius": 0, 697 | "spread": 1, 698 | "type": "DROP_SHADOW", 699 | "visible": true, 700 | }, 701 | Object { 702 | "blendMode": "NORMAL", 703 | "color": Object { 704 | "a": 0.05, 705 | "b": 0, 706 | "g": 0, 707 | "r": 0, 708 | }, 709 | "offset": Object { 710 | "x": 0, 711 | "y": 4, 712 | }, 713 | "radius": 6, 714 | "spread": 0, 715 | "type": "DROP_SHADOW", 716 | "visible": true, 717 | }, 718 | ], 719 | "fills": Array [], 720 | "height": 100, 721 | "opacity": 1, 722 | "type": "FRAME", 723 | "width": 100, 724 | "x": 50, 725 | "y": 0, 726 | }, 727 | ], 728 | "clipsContent": false, 729 | "constraints": Object { 730 | "horizontal": "SCALE", 731 | "vertical": "MIN", 732 | }, 733 | "data": Object { 734 | "heightType": "fixed", 735 | "widthType": "fixed", 736 | }, 737 | "fills": Array [], 738 | "height": 100, 739 | "opacity": 1, 740 | "type": "FRAME", 741 | "width": 784, 742 | "x": 8, 743 | "y": 50, 744 | } 745 | `; 746 | -------------------------------------------------------------------------------- /tests/base.test.js: -------------------------------------------------------------------------------- 1 | 2 | describe('Convert to figma', () => { 3 | let htmlToFigma; 4 | beforeAll(async () => { 5 | htmlToFigma = async (name) => { 6 | await page.goto(`http://localhost:3000/stubs/${name}.html`); 7 | await page.addScriptTag({ url: '../index.js' }); 8 | 9 | return page.$eval(`#container`, (el) => window.__htmlToFigma(el)); 10 | } 11 | }); 12 | 13 | it('button with padding', async () => { 14 | expect(await htmlToFigma('base-button')).toMatchSnapshot(); 15 | }); 16 | 17 | it('buttons with :before :after', async () => { 18 | expect(await htmlToFigma('button-before-after')).toMatchSnapshot(); 19 | }); 20 | 21 | it('input and placeholder', async () => { 22 | expect(await htmlToFigma('input')).toMatchSnapshot(); 23 | }); 24 | 25 | it('shadows', async () => { 26 | expect(await htmlToFigma('shadows')).toMatchSnapshot(); 27 | }); 28 | 29 | it('opacity', async () => { 30 | expect(await htmlToFigma('opacity')).toMatchSnapshot(); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /tests/page/index.ts: -------------------------------------------------------------------------------- 1 | import { htmlToFigma } from '../../src/browser/html-to-figma'; 2 | // @ts-ignore 3 | window.__htmlToFigma = htmlToFigma; 4 | -------------------------------------------------------------------------------- /tests/page/stubs/base-button.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 |
9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /tests/page/stubs/borders.html: -------------------------------------------------------------------------------- 1 | 2 | 31 | 32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | 40 | -------------------------------------------------------------------------------- /tests/page/stubs/button-before-after.html: -------------------------------------------------------------------------------- 1 | 2 | 27 | 28 |
29 | 30 | 31 |
32 | 33 | -------------------------------------------------------------------------------- /tests/page/stubs/input.html: -------------------------------------------------------------------------------- 1 | 2 | 45 | 46 |
47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 |
58 | 59 | -------------------------------------------------------------------------------- /tests/page/stubs/opacity.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 |
16 |
17 |
18 | 19 | -------------------------------------------------------------------------------- /tests/page/stubs/shadows.html: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 |
15 |
16 |
17 | 18 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | const { setup: setupDevServer } = require('jest-dev-server'); 2 | const { setup: setupPuppeter } = require('jest-environment-puppeteer') 3 | const chalk = require('chalk'); 4 | const puppeteer = require('puppeteer'); 5 | const fs = require('fs'); 6 | const mkdirp = require('mkdirp'); 7 | const os = require('os'); 8 | const path = require('path'); 9 | 10 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup'); 11 | 12 | module.exports = async function () { 13 | await setupDevServer({ 14 | command: `npx serve dist -l 3000`, 15 | launchTimeout: 50000, 16 | port: 3000, 17 | }); 18 | await setupPuppeter(); 19 | }; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "lib": ["dom", "dom.iterable", "esnext", "es2016"], 5 | "outDir": "./build", 6 | "rootDirs": ["./src/browser", "./src/figma"], 7 | "declaration": true, 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": false, 20 | "experimentalDecorators": true, 21 | "jsx": "react-jsx", 22 | "typeRoots": ["node_modules/@types", "node_modules/@figma"] 23 | }, 24 | "include": ["src"] 25 | } 26 | --------------------------------------------------------------------------------