├── .npmrc
├── src
├── browser
│ ├── index.ts
│ ├── utils.ts
│ ├── border.ts
│ ├── html-to-figma.ts
│ ├── text-to-figma.ts
│ ├── add-constraints.ts
│ ├── element-to-figma.ts
│ ├── build-tree.ts
│ └── dom-utils.ts
├── figma
│ ├── images.ts
│ ├── dropOffset.ts
│ ├── index.ts
│ ├── getFont.ts
│ ├── processLayer.ts
│ └── helpers.ts
├── types.ts
└── utils.ts
├── .gitignore
├── dev-plugin
├── tsconfig.json
├── manifest.json
├── src
│ ├── ui.html
│ ├── figma.ts
│ ├── frame.tsx
│ └── frame.html
├── package.json
└── webpack.config.js
├── tests
├── page
│ ├── index.ts
│ └── stubs
│ │ ├── base-button.html
│ │ ├── opacity.html
│ │ ├── shadows.html
│ │ ├── button-before-after.html
│ │ ├── borders.html
│ │ └── input.html
├── setup.js
├── base.test.js
└── __snapshots__
│ └── base.test.js.snap
├── .github
└── workflows
│ └── main.yml
├── tsconfig.json
├── README.md
└── package.json
/.npmrc:
--------------------------------------------------------------------------------
1 | registry = http://registry.npmjs.org
--------------------------------------------------------------------------------
/src/browser/index.ts:
--------------------------------------------------------------------------------
1 | export * from './html-to-figma';
2 | export * from './utils';
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /dist/
3 | /build/
4 | /dev-plugin/dist
5 | /dev-plugin/node_modules/
6 | .cache
--------------------------------------------------------------------------------
/dev-plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tests/page/index.ts:
--------------------------------------------------------------------------------
1 | import { htmlToFigma } from '../../src/browser/html-to-figma';
2 | // @ts-ignore
3 | window.__htmlToFigma = htmlToFigma;
4 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/page/stubs/base-button.html:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/tests/page/stubs/opacity.html:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
18 |
19 |
--------------------------------------------------------------------------------
/tests/page/stubs/shadows.html:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
17 |
18 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/dev-plugin/src/ui.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
16 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/tests/page/stubs/button-before-after.html:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/tests/page/stubs/borders.html:
--------------------------------------------------------------------------------
1 |
2 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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 | }
--------------------------------------------------------------------------------
/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/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 | }
--------------------------------------------------------------------------------
/tests/page/stubs/input.html:
--------------------------------------------------------------------------------
1 |
2 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/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/frame.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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