├── .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 | 
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