0
Fork 0
mirror of https://github.com/penpot/penpot-exporter-figma-plugin.git synced 2024-12-22 05:33:02 -05:00

Update packages and add linters (#1)

* Configure linters

* Move to older eslint config, add stylelint, correct configuration for editorconfig

* simplify packages

* improve eslint config

* Fix typescript

* remove line

* github workflow

* package-lock.json

* lint fix

* make plugin compatible with Figma

* Add stylelint to ci

---------

Co-authored-by: Alex Sánchez <alejandro@runroom.com>
This commit is contained in:
Jordi Sala Morales 2024-04-08 11:43:30 +02:00 committed by GitHub
parent 7dc730994f
commit 308fa93625
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 9757 additions and 1850 deletions

9
.editorconfig Normal file
View file

@ -0,0 +1,9 @@
root = true
[*]
end_of_line = lf
indent_style = space
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_size = 2

3
.eslintignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
src/penpot.js

22
.eslintrc.cjs Normal file
View file

@ -0,0 +1,22 @@
module.exports = {
root: true,
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module'
},
env: { browser: true, node: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'plugin:@figma/figma-plugins/recommended'
],
settings: {
react: {
version: 'detect'
}
}
};

34
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
name: Build ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.x']
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: npm run build
lint:
name: Lint ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20.x']
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- uses: bahmutov/npm-install@v1
- run: npm run lint

1
.gitignore vendored
View file

@ -1,3 +1,2 @@
node_modules node_modules
package-lock.json
dist dist

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
node_modules
dist
src/penpot.js
LICENSE

View file

@ -1,39 +1,40 @@
# Developer guide # Developer guide
The plugin relies in [penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js) The plugin relies in
library. It contains a subset of Penpot frontend app, transpiled into [penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js) library.
javascript to be used from js code (this was easy since the frontend is written It contains a subset of Penpot frontend app, transpiled into javascript to be used from js code
in ClojureScript, that has direct translation to javascript). (this was easy since the frontend is written in ClojureScript, that has direct translation to
javascript).
Basically, it exports the `createFile` function and the `File` data type, that Basically, it exports the `createFile` function and the `File` data type, that represents a Penpot
represents a Penpot file as it resides in memory inside the frontend app. It file as it resides in memory inside the frontend app. It has function to create pages and their
has function to create pages and their content, and also an `export` function content, and also an `export` function that generates and downloads a .zip archive with the Penpot
that generates and downloads a .zip archive with the Penpot file as svg file as svg documents in Penpot annotated format, that you can import directly into Penpot.
documents in Penpot annotated format, that you can import directly into Penpot.
You can see the [source of the library at Penpot repo](https://github.com/penpot/penpot/tree/develop/frontend/src/app/libs). You can see the
[source of the library at Penpot repo](https://github.com/penpot/penpot/tree/develop/frontend/src/app/libs).
To see a general description of the data types used in the functions you can To see a general description of the data types used in the functions you can see
see [the data model](https://help.penpot.app/technical-guide/data-model/). [the data model](https://help.penpot.app/technical-guide/data-model/). Their full specifications are
Their full specifications are in the [common types module](https://github.com/penpot/penpot/tree/develop/common/src/app/common/types). in the
[common types module](https://github.com/penpot/penpot/tree/develop/common/src/app/common/types).
Those types are defined in [Clojure spec](https://clojure.org/guides/spec) format. Those types are defined in [Clojure spec](https://clojure.org/guides/spec) format. For those
For those unfamiliar with the syntax, here is a small basic guide: unfamiliar with the syntax, here is a small basic guide:
```clojure ```clojure
(s/def ::name string?) (s/def ::name string?)
(s/def ::id uuid?) (s/def ::id uuid?)
``` ```
A parameter or attribute called `name` is of type string, and `id` is an *uuid* A parameter or attribute called `name` is of type string, and `id` is an _uuid_ string (e.g.
string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843"). "000498f3-27fc-8000-8988-ca7d52f46843").
```clojure ```clojure
(s/def ::stroke-alignment #{:center :inner :outer}) (s/def ::stroke-alignment #{:center :inner :outer})
``` ```
`stroke-alignment` is of an enumerated type, and the valid values are "center", `stroke-alignment` is of an enumerated type, and the valid values are "center", "inner" and "outer".
"inner" and "outer".
```clojure ```clojure
(ns app.common.types.shape (ns app.common.types.shape
@ -44,17 +45,17 @@ string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843").
(s/def ::line-height ::us/safe-number) (s/def ::line-height ::us/safe-number)
``` ```
`line-height` is of the type `safe-number`, defined in `app.common.spec` `line-height` is of the type `safe-number`, defined in `app.common.spec` namespace, that here is
namespace, that here is imported with the name `us` (a *safe number* is a imported with the name `us` (a _safe number_ is a integer or floating point number, with a value not
integer or floating point number, with a value not too big or small). too big or small).
```clojure ```clojure
(s/def ::page (s/def ::page
(s/keys :req-un [::id ::name ::objects ::options])) (s/keys :req-un [::id ::name ::objects ::options]))
``` ```
`page` is an object with four required arguments: `id`, `name`, `objects` and `page` is an object with four required arguments: `id`, `name`, `objects` and `options`, whose types
`options`, whose types have to be defined above. have to be defined above.
```clojure ```clojure
(s/def ::column (s/def ::column
@ -66,8 +67,8 @@ integer or floating point number, with a value not too big or small).
::gutter])) ::gutter]))
``` ```
`column` has one required attribute `color` and five optional ones `size`, `column` has one required attribute `color` and five optional ones `size`, `type`, `item-length`,
`type`, `item-length`, `margin` and `gutter`. `margin` and `gutter`.
```clojure ```clojure
(s/def ::children (s/def ::children
@ -76,8 +77,8 @@ integer or floating point number, with a value not too big or small).
:min-count 1)) :min-count 1))
``` ```
`children` is a collection (implemented as a vector) of objects of type `children` is a collection (implemented as a vector) of objects of type `content`, and must have a
`content`, and must have a minimun lenght of 1. minimun lenght of 1.
```clojure ```clojure
(defmulti animation-spec :animation-type) (defmulti animation-spec :animation-type)
@ -102,11 +103,9 @@ integer or floating point number, with a value not too big or small).
(s/multi-spec animation-spec ::animation-type)) (s/multi-spec animation-spec ::animation-type))
``` ```
This is probably the most complex construct. `animation` is a multi schema This is probably the most complex construct. `animation` is a multi schema object. It has an
object. It has an attribute called `animation-type` and the rest of the fields attribute called `animation-type` and the rest of the fields depend on its value. For example, if
depend on its value. For example, if `animation-type` is "dissolve", the object `animation-type` is "dissolve", the object must also have a `duration` and an `easing` attribute.
must also have a `duration` and an `easing` attribute.
Other constructs should be more or less auto explicative with this guide
and the clojure.spec manual linked above.
Other constructs should be more or less auto explicative with this guide and the clojure.spec manual
linked above.

121
README.md
View file

@ -1,4 +1,3 @@
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0 [uri_license]: https://www.mozilla.org/en-US/MPL/2.0
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg [uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
@ -25,9 +24,10 @@
![](penpotexporter.gif) ![](penpotexporter.gif)
This is a **very early-stage** Figma plugin to export Figma files to Penpot format. For now is little more than a proof of concept, or a first scaffolding, not a fully functional exporter. This is a **very early-stage** Figma plugin to export Figma files to Penpot format. For now is
little more than a proof of concept, or a first scaffolding, not a fully functional exporter.
## Table of contents ## ## Table of contents
- [Table of contents](#table-of-contents) - [Table of contents](#table-of-contents)
- [Why a Penpot exporter](#why-a-penpot-exporter) - [Why a Penpot exporter](#why-a-penpot-exporter)
@ -42,70 +42,97 @@ This is a **very early-stage** Figma plugin to export Figma files to Penpot form
- [Contributing](#contributing) - [Contributing](#contributing)
- [License](#license) - [License](#license)
## Why a Penpot exporter ## ## Why a Penpot exporter
The aim of this plugin is to help people migrate their files from Figma to [Penpot](https://penpot.app/). Migrating work from one design tool to another was never an easy task due to the abundance of closed and non-standard formats, and this is not a different case. Our approach to better solve this situation is to release a code skeleton for the minimum version of a Figma plugin that can convert a Figma file into a Penpot annotated SVG file. The aim of this plugin is to help people migrate their files from Figma to
[Penpot](https://penpot.app/). Migrating work from one design tool to another was never an easy task
due to the abundance of closed and non-standard formats, and this is not a different case. Our
approach to better solve this situation is to release a code skeleton for the minimum version of a
Figma plugin that can convert a Figma file into a Penpot annotated SVG file.
There is a sense of urgency for this capability because there is a feeling that Adobe might force Figma to limit exports and interoperability via plugins very soon. There is a sense of urgency for this capability because there is a feeling that Adobe might force
Figma to limit exports and interoperability via plugins very soon.
## Getting started
## Getting started ## This plugin makes use of npm, webpack and react, and is written on TypeScript. It also includes a
Penpot file builder library.
This plugin makes use of npm, webpack and react, and is written on TypeScript. It also includes a Penpot file builder library.
### Pre-requisites ### Pre-requisites
To use this plugin, you will need to have `node` and `npm` installed on your computer. If you don't already have these, you can download and install them from the official website ([https://nodejs.org/en/](https://nodejs.org/en/)). To use this plugin, you will need to have `node` and `npm` installed on your computer. If you don't
already have these, you can download and install them from the official website
([https://nodejs.org/en/](https://nodejs.org/en/)).
Once you have `node` and `npm` installed, you will need to download the source code for this plugin.
You can do this by clicking the "Clone or download" button on the GitHub page for this project and
then selecting "Download ZIP". Extract the ZIP file to a location on your computer.
Once you have `node` and `npm` installed, you will need to download the source code for this plugin. You can do this by clicking the "Clone or download" button on the GitHub page for this project and then selecting "Download ZIP". Extract the ZIP file to a location on your computer.
### Building ### Building
#### For Windows users: #### For Windows users:
1. Open the terminal by searching for "Command Prompt" in the start menu. 1. Open the terminal by searching for "Command Prompt" in the start menu.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`. 2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin. example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
#### For Mac users: #### For Mac users:
1. Open the terminal by searching for "Terminal" in the Launchpad or by using the `Command + Space` keyboard shortcut and searching for "Terminal". 1. Open the terminal by searching for "Terminal" in the Launchpad or by using the `Command + Space`
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`. keyboard shortcut and searching for "Terminal".
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin. 2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
#### For Linux users: #### For Linux users:
1. Open the terminal by using the `Ctrl + Alt + T` keyboard shortcut. 1. Open the terminal by using the `Ctrl + Alt + T` keyboard shortcut.
2. Use the `cd` command to navigate to the folder where the repository has been extracted. For example, if the repository is located in the `Downloads` folder, you can use the following command: `cd Downloads/penpot-exporter-figma-plugin`. 2. Use the `cd` command to navigate to the folder where the repository has been extracted. For
3. Once you are in the correct folder, you can run the `npm install` command to install the dependencies, and then the `npm run build` command to build the plugin. example, if the repository is located in the `Downloads` folder, you can use the following
command: `cd Downloads/penpot-exporter-figma-plugin`.
3. Once you are in the correct folder, you can run the `npm install` command to install the
dependencies, and then the `npm run build` command to build the plugin.
### Add to Figma ### Add to Figma
`Figma menu` > `Plugins` > `Development` > `Import plugin from manifest…`
To add the plugin to Figma, open Figma and go to the `Plugins` menu. Select `Development` and then choose `Import plugin from manifest…`. `Figma menu` > `Plugins` > `Development` > `Import plugin from manifest…` To add the plugin to
Figma, open Figma and go to the `Plugins` menu. Select `Development` and then choose
`Import plugin from manifest…`.
<img src="resources/Import plugin from manifest.png" alt='Screenshot of the Plugins > Development menus open showing the, "Import plugin from manifest" option.'> <img src="resources/Import plugin from manifest.png" alt='Screenshot of the Plugins > Development menus open showing the, "Import plugin from manifest" option.'>
Select the `manifest.json` file that is located in the folder where you extracted the source code for the plugin. Select the `manifest.json` file that is located in the folder where you extracted the source code
for the plugin.
### To use the plugin ### To use the plugin
1. Select what you want to export 1. Select what you want to export
2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter` 2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter` go to the `Plugins` menu in Figma
go to the `Plugins` menu in Figma and select `Development` followed by `Penpot Exporter`. and select `Development` followed by `Penpot Exporter`.
3. This will generate a .zip file that you can import into Penpot. 3. This will generate a .zip file that you can import into Penpot.
## Call to the community
## Call to the community ## Answering to the interest expressed by community members to build the plugin by themselves, at the
Penpot team we decided to help solve the need without having to depend on our current product
Answering to the interest expressed by community members to build the plugin by themselves, at the Penpot team we decided to help solve the need without having to depend on our current product priorities. That is why we have published this bare minimum version of the plugin, unsatisfactory in itself, but it unlocks the possibility for others to continue the task. priorities. That is why we have published this bare minimum version of the plugin, unsatisfactory in
itself, but it unlocks the possibility for others to continue the task.
Yes, we are asking for help. 🤗 Yes, we are asking for help. 🤗
We have explained this approach in a [community post](https://community.penpot.app/t/figma-file-importer/1684). Feel free to join the conversation there. We have explained this approach in a
[community post](https://community.penpot.app/t/figma-file-importer/1684). Feel free to join the
conversation there.
## What can this plugin currently import? ## ## What can this plugin currently import?
As mentioned above, this plugin gets you to a starting point. Things that are currently included in the import are: As mentioned above, this plugin gets you to a starting point. Things that are currently included in
the import are:
- **Basic shapes** (rectangles, ellipses). - **Basic shapes** (rectangles, ellipses).
- **Frames** (Boards in Penpot). - **Frames** (Boards in Penpot).
@ -114,20 +141,27 @@ As mentioned above, this plugin gets you to a starting point. Things that are cu
- **Texts** (basic support. Only fonts available on Google fonts). - **Texts** (basic support. Only fonts available on Google fonts).
- **Images** (basic support) - **Images** (basic support)
## Limitations ## ## Limitations
The obvious limitations are the features that are in Figma but not in Penpot or work differently in both tools so they can not be easily converted. We leave some comments below about the ones that are commonly considered more important:
- **Autolayout**: Not in Penpot yet but in a very advanced state of development. There will be news soon. The obvious limitations are the features that are in Figma but not in Penpot or work differently in
- **Components**: Currently very different from their counterparts at Figma. However, Penpot components are under a rework that we expect will make the conversion easier. both tools so they can not be easily converted. We leave some comments below about the ones that are
- **Variants**: Not expected in the short term. Also, we are thinking of different solutions to solve component states, things that eventually could make it difficult to import. commonly considered more important:
- **Autolayout**: Not in Penpot yet but in a very advanced state of development. There will be news
soon.
- **Components**: Currently very different from their counterparts at Figma. However, Penpot
components are under a rework that we expect will make the conversion easier.
- **Variants**: Not expected in the short term. Also, we are thinking of different solutions to
solve component states, things that eventually could make it difficult to import.
## Contributing
## Contributing ## If you want to make many people very happy and help us build this code skeleton for the minimum
version of the Figma plugin, a further effort will be needed to have a satisfactory import
If you want to make many people very happy and help us build this code skeleton for the minimum version of the Figma plugin, a further effort will be needed to have a satisfactory import experience. experience.
For instance, it will be interesting to add: For instance, it will be interesting to add:
- Strokes - Strokes
- Fills with radial gradients - Fills with radial gradients
- Paths - Paths
@ -137,10 +171,11 @@ For instance, it will be interesting to add:
- Constraints - Constraints
- ... - ...
Motivated to contribute? Take a look at our [Contributing Guide](https://help.penpot.app/contributing-guide/) that explains our guidelines (they're for the Penpot Core, but are mostly of application here too). Motivated to contribute? Take a look at our
[Contributing Guide](https://help.penpot.app/contributing-guide/) that explains our guidelines
(they're for the Penpot Core, but are mostly of application here too).
## License
## License ##
``` ```
This Source Code Form is subject to the terms of the Mozilla Public This Source Code Form is subject to the terms of the Mozilla Public
@ -149,4 +184,6 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC Copyright (c) KALEIDOS INC
``` ```
Penpot and the Penpot exporter plugin are Kaleidos [open source projects](https://kaleidos.net/products)
Penpot and the Penpot exporter plugin are Kaleidos
[open source projects](https://kaleidos.net/products)

7654
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,37 +1,52 @@
{ {
"name": "penpot-exporter", "name": "penpot-exporter",
"id": "1161652283781700708",
"version": "0.0.1", "version": "0.0.1",
"description": "Penpot exporter", "description": "Penpot exporter",
"type": "module",
"main": "code.js", "main": "code.js",
"scripts": { "scripts": {
"build": "webpack", "build": "webpack",
"watch": "webpack watch" "watch": "webpack watch",
"lint": "run-p lint:*",
"lint:eslint": "eslint .",
"lint:stylelint": "stylelint src/**.css",
"lint:prettier": "prettier --check .",
"lint:tsc": "tsc --noEmit --pretty false",
"fix-lint": "run-p fix-lint:*",
"fix-lint:eslint": "eslint . --fix",
"fix-lint:stylelint": "stylelint src/**.css --fix",
"fix-lint:prettier": "prettier --write ."
}, },
"author": "Kaleidos", "author": "Kaleidos",
"license": "MPL2.0", "license": "MPL2.0",
"devDependencies": {
"@figma/plugin-typings": "*",
"@types/node": "^16.11.64",
"css-loader": "^6.2.0",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^5.3.2",
"style-loader": "^3.2.1",
"ts-loader": "^9.2.5",
"typescript": "^4.3.5",
"url-loader": "^4.1.1",
"webpack": "^5.51.1",
"webpack-cli": "^4.8.0"
},
"dependencies": { "dependencies": {
"@figma-plugin/helpers": "^0.15.2", "react": "^18.2",
"@types/react": "^17.0.19", "react-dom": "^18.2",
"@types/react-dom": "^17.0.9", "slugify": "^1.6"
"crypto": "^1.0.1", },
"crypto-browserify": "^3.12.0", "devDependencies": {
"react": "^17.0.2", "@figma/eslint-plugin-figma-plugins": "^0.15",
"react-dev-utils": "^12.0.1", "@figma/plugin-typings": "^1.88",
"react-dom": "^17.0.2", "@trivago/prettier-plugin-sort-imports": "^4.3",
"slugify": "^1.6.5" "@types/react": "^18.2",
"@types/react-dom": "^18.2",
"css-loader": "^6.10",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1",
"eslint-plugin-prettier": "^5.1",
"eslint-plugin-react": "^7.34",
"html-webpack-inline-source-plugin": "0.0.10",
"html-webpack-plugin": "^5.6",
"npm-run-all2": "^6.1",
"prettier": "^3.2",
"react-dev-utils": "^12.0",
"style-loader": "^3.3",
"stylelint": "^16.3",
"stylelint-config-standard": "^36.0",
"ts-loader": "^9.5",
"typescript": "^5.4",
"typescript-eslint": "^7.4",
"webpack": "^5.91",
"webpack-cli": "^5.1"
} }
} }

19
prettier.config.mjs Normal file
View file

@ -0,0 +1,19 @@
/** @type {import("prettier").Config} */
const config = {
arrowParens: 'avoid',
bracketSpacing: true,
printWidth: 100,
proseWrap: 'always',
quoteProps: 'consistent',
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'none',
useTabs: false,
plugins: ['@trivago/prettier-plugin-sort-imports'],
importOrder: ['^[./]'],
importOrderSeparation: true,
importOrderSortSpecifiers: true
};
export default config;

View file

@ -1,80 +1,83 @@
import { NodeData, TextData } from './interfaces';
interface Signatures {
let fileData = []; [key: string]: string;
type NodeData = {
id: string
name: string,
type: string,
children: Node[],
x: number,
y: number,
width: number,
height: number,
fills: any
} }
const signatures = { const signatures: Signatures = {
R0lGODdh: "image/gif", 'R0lGODdh': 'image/gif',
R0lGODlh: "image/gif", 'R0lGODlh': 'image/gif',
iVBORw0KGgo: "image/png", 'iVBORw0KGgo': 'image/png',
"/9j/": "image/jpg" '/9j/': 'image/jpg'
}; };
function detectMimeType(b64) { function detectMimeType(b64: string) {
for (var s in signatures) { for (const s in signatures) {
if (b64.indexOf(s) === 0) { if (b64.indexOf(s) === 0) {
return signatures[s]; return signatures[s];
} }
} }
} }
function traverse(node): NodeData { function traverse(node: BaseNode): NodeData | TextData {
let children:any[] = []; const children: (NodeData | TextData)[] = [];
if ("children" in node) { if ('children' in node) {
if (node.type !== "INSTANCE") { if (node.type !== 'INSTANCE') {
for (const child of node.children) { for (const child of node.children) {
children.push (traverse(child)) children.push(traverse(child));
} }
} }
} }
let result = { const result = {
id: node.id, id: node.id,
type: node.type, type: node.type,
name: node.name, name: node.name,
children: children, children: children,
x: node.x, x: 'x' in node ? node.x : 0,
y: node.y, y: 'y' in node ? node.y : 0,
width: node.width, width: 'width' in node ? node.width : 0,
height: node.height, height: 'height' in node ? node.height : 0,
fills: node.fills === figma.mixed ? [] : node.fills //TODO: Support mixed fills fills: 'fills' in node ? (node.fills === figma.mixed ? [] : node.fills) : [] // TODO: Support mixed fills
} };
if (node.fills && Array.isArray(node.fills)){
if (result.fills) {
// Find any fill of type image // Find any fill of type image
const imageFill = node.fills.find(fill => fill.type === "IMAGE"); const imageFill = result.fills.find(fill => fill.type === 'IMAGE');
if (imageFill) { if (imageFill) {
// An "image" in Figma is a shape with one or more image fills, potentially blended with other fill // An "image" in Figma is a shape with one or more image fills, potentially blended with other fill
// types. Given the complexity of mirroring this exactly in Penpot, which treats images as first-class // types. Given the complexity of mirroring this exactly in Penpot, which treats images as first-class
// objects, we're going to simplify this by exporting this shape as a PNG image. // objects, we're going to simplify this by exporting this shape as a PNG image.
node.exportAsync({format: "PNG"}).then((value) => { 'exportAsync' in node &&
node.exportAsync({ format: 'PNG' }).then(value => {
const b64 = figma.base64Encode(value); const b64 = figma.base64Encode(value);
figma.ui.postMessage({type: "IMAGE", data: { figma.ui.postMessage({
type: 'IMAGE',
data: {
id: node.id, id: node.id,
value: "data:" + detectMimeType(b64) + ";base64," + b64 value: 'data:' + detectMimeType(b64) + ';base64,' + b64
}}); }
});
}); });
} }
} }
if (node.type == "TEXT") { if (node.type == 'TEXT') {
const styledTextSegments = node.getStyledTextSegments(["fontName", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textCase", "textDecoration", "fills"]); const styledTextSegments = node.getStyledTextSegments([
'fontName',
'fontSize',
'fontWeight',
'lineHeight',
'letterSpacing',
'textCase',
'textDecoration',
'fills'
]);
if (styledTextSegments[0]) { if (styledTextSegments[0]) {
let font = { const font = {
...result,
fontName: styledTextSegments[0].fontName, fontName: styledTextSegments[0].fontName,
fontSize: styledTextSegments[0].fontSize.toString(), fontSize: styledTextSegments[0].fontSize.toString(),
fontWeight: styledTextSegments[0].fontWeight.toString(), fontWeight: styledTextSegments[0].fontWeight.toString(),
@ -88,25 +91,25 @@ function traverse(node): NodeData {
textAlignVertical: node.textAlignVertical, textAlignVertical: node.textAlignVertical,
children: styledTextSegments children: styledTextSegments
}; };
result = {...result, ...font};
return font as TextData;
} }
} }
return result; return result as NodeData;
} }
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 }); figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
let root: NodeData = traverse(figma.root) // start the traversal at the root const root: NodeData | TextData = traverse(figma.root); // start the traversal at the root
figma.ui.postMessage({type: "FIGMAFILE", data: root}); figma.ui.postMessage({ type: 'FIGMAFILE', data: root });
figma.ui.onmessage = (msg) => { figma.ui.onmessage = msg => {
if (msg.type === "cancel") { if (msg.type === 'cancel') {
figma.closePlugin(); figma.closePlugin();
} }
if (msg.type === "resize") { if (msg.type === 'resize') {
figma.ui.resize(msg.width, msg.height); figma.ui.resize(msg.width, msg.height);
} }
}; };

43
src/interfaces.ts Normal file
View file

@ -0,0 +1,43 @@
export type NodeData = {
id: string;
name: string;
type: string;
children: NodeData[];
x: number;
y: number;
width: number;
height: number;
fills: readonly Paint[];
};
export type TextDataChildren = Pick<
StyledTextSegment,
| 'fills'
| 'characters'
| 'start'
| 'end'
| 'fontSize'
| 'fontName'
| 'fontWeight'
| 'textDecoration'
| 'textCase'
| 'lineHeight'
| 'letterSpacing'
>;
export type TextData = Pick<
NodeData,
'id' | 'name' | 'type' | 'x' | 'y' | 'width' | 'height' | 'fills'
> & {
fontName: FontName;
fontSize: string;
fontWeight: string;
characters: string;
lineHeight: LineHeight;
letterSpacing: LetterSpacing;
textCase: TextCase;
textDecoration: TextDecoration;
textAlignHorizontal: 'CENTER' | 'LEFT' | 'RIGHT' | 'JUSTIFIED';
textAlignVertical: 'CENTER' | 'TOP' | 'BOTTOM';
children: TextDataChildren[];
};

18
src/penpot.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export interface PenpotFile {
asMap(): any;
export(): void;
addPage(name: string): void;
closePage(): void;
addArtboard(artboard: any): void;
closeArtboard(): void;
addGroup(group: any): void;
closeGroup(): void;
createRect(rect: any): void;
createCircle(circle: any): void;
createText(text: any): void;
createImage(image: any): void;
}
export function createFile(name: string): PenpotFile;

View file

@ -23,8 +23,8 @@ main {
body, body,
input, input,
button { button {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 'Open Sans', 'Helvetica Neue', sans-serif;
font-size: 1rem; font-size: 1rem;
text-align: center; text-align: center;
} }
@ -43,16 +43,20 @@ button {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
} }
button:hover { button:hover {
background-color: var(--color-bg-hover); background-color: var(--color-bg-hover);
} }
button:active { button:active {
background-color: var(--color-bg-active); background-color: var(--color-bg-active);
} }
button:focus-visible { button:focus-visible {
border: none; border: none;
outline-color: var(--color-border-focus); outline-color: var(--color-border-focus);
} }
button.brand { button.brand {
--color-bg: var(--color-bg-brand); --color-bg: var(--color-bg-brand);
--color-text: var(--color-text-brand); --color-text: var(--color-text-brand);
@ -75,7 +79,7 @@ input:focus-visible {
} }
svg { svg {
stroke: var(--color-icon, rgba(0, 0, 0, 0.9)); stroke: var(--color-icon, rgb(0 0 0 / 90%));
} }
main { main {
@ -96,6 +100,7 @@ section {
section > * + * { section > * + * {
margin-top: 0.5rem; margin-top: 0.5rem;
} }
footer > * + * { footer > * + * {
margin-left: 0.5rem; margin-left: 0.5rem;
} }

View file

@ -1,120 +1,113 @@
import * as React from "react"; import * as React from 'react';
import * as ReactDOM from "react-dom"; import { createRoot } from 'react-dom/client';
import "./ui.css"; import slugify from 'slugify';
import * as penpot from "./penpot.js";
import { extractLinearGradientParamsFromTransform } from "@figma-plugin/helpers"; import { NodeData, TextData, TextDataChildren } from './interfaces';
import slugify from "slugify"; import { PenpotFile, createFile } from './penpot';
import './ui.css';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare function require(path: string): any; declare function require(path: string): any;
// Open resources/gfonts.json and create a set of matched font names // Open resources/gfonts.json and create a set of matched font names
const gfonts = new Set(); const gfonts = new Set();
require("./gfonts.json").forEach((font) => gfonts.add(font)); require('./gfonts.json').forEach((font: string) => gfonts.add(font));
type PenpotExporterProps = {
}
type FigmaImageData = { type FigmaImageData = {
value: string, value: string;
width: number, width: number;
height: number height: number;
} };
type PenpotExporterState = { type PenpotExporterState = {
isDebug: boolean, isDebug: boolean;
penpotFileData: string penpotFileData: string;
missingFonts: Set<string> missingFonts: Set<string>;
figmaFileData: string figmaFileData: string;
figmaRootNode: NodeData figmaRootNode: NodeData | null;
images: { [id: string] : FigmaImageData; }; images: { [id: string]: FigmaImageData };
} };
export default class PenpotExporter extends React.Component<PenpotExporterProps, PenpotExporterState> { export default class PenpotExporter extends React.Component<unknown, PenpotExporterState> {
state: PenpotExporterState = { state: PenpotExporterState = {
isDebug: false, isDebug: false,
penpotFileData: "", penpotFileData: '',
figmaFileData: "", figmaFileData: '',
missingFonts: new Set(), missingFonts: new Set(),
figmaRootNode: null, figmaRootNode: null,
images: {} images: {}
}; };
componentDidMount = () => { componentDidMount = () => {
window.addEventListener("message", this.onMessage); window.addEventListener('message', this.onMessage);
} };
componentDidUpdate = () => { componentDidUpdate = () => {
this.setDimensions(); this.setDimensions();
} };
componentWillUnmount = () => { componentWillUnmount = () => {
window.removeEventListener('message', this.onMessage); window.removeEventListener('message', this.onMessage);
} };
rgbToHex = (color) => { rgbToHex = (color: RGB) => {
const r = Math.round(255 * color.r); const r = Math.round(255 * color.r);
const g = Math.round(255 * color.g); const g = Math.round(255 * color.g);
const b = Math.round(255 * color.b); const b = Math.round(255 * color.b);
const rgb = (r << 16) | (g << 8) | (b << 0); const rgb = (r << 16) | (g << 8) | (b << 0);
return '#' + (0x1000000 + rgb).toString(16).slice(1); return '#' + (0x1000000 + rgb).toString(16).slice(1);
} };
translateSolidFill(fill){ translateSolidFill(fill: SolidPaint) {
return { return {
fillColor: this.rgbToHex(fill.color), fillColor: this.rgbToHex(fill.color),
fillOpacity: fill.opacity fillOpacity: fill.visible === false ? 0 : fill.opacity
} };
} }
translateGradientLinearFill(fill, width, height){ translateGradientLinearFill(fill: GradientPaint /*, width: number, height: number*/) {
const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform); // const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform);
return { return {
fillColorGradient: { fillColorGradient: {
type: Symbol.for("linear"), type: Symbol.for('linear'),
width: 1, width: 1,
startX: points.start[0] / width, // startX: points.start[0] / width,
startY: points.start[1] / height, // startY: points.start[1] / height,
endX: points.end[0] / width, // endX: points.end[0] / width,
endY: points.end[1] / height, // endY: points.end[1] / height,
stops: [{ stops: [
{
color: this.rgbToHex(fill.gradientStops[0].color), color: this.rgbToHex(fill.gradientStops[0].color),
offset: fill.gradientStops[0].position, offset: fill.gradientStops[0].position,
opacity: fill.gradientStops[0].color.a * fill.opacity opacity: fill.gradientStops[0].color.a * (fill.opacity ?? 1)
}, },
{ {
color: this.rgbToHex(fill.gradientStops[1].color), color: this.rgbToHex(fill.gradientStops[1].color),
offset: fill.gradientStops[1].position, offset: fill.gradientStops[1].position,
opacity: fill.gradientStops[1].color.a * fill.opacity opacity: fill.gradientStops[1].color.a * (fill.opacity ?? 1)
} }
] ]
} },
} fillOpacity: fill.visible === false ? 0 : undefined
};
} }
translateFill(fill, width, height){ translateFill(fill: Paint /*, width: number, height: number*/) {
if (fill.type === 'SOLID') {
if (fill.type === "SOLID"){
return this.translateSolidFill(fill); return this.translateSolidFill(fill);
} else if (fill.type === "GRADIENT_LINEAR"){ } else if (fill.type === 'GRADIENT_LINEAR') {
return this.translateGradientLinearFill(fill, width, height); return this.translateGradientLinearFill(fill /*, width, height*/);
} else { } else {
console.error('Color type ' + fill.type + ' not supported yet'); console.error('Color type ' + fill.type + ' not supported yet');
return null; return null;
} }
} }
translateFills(fills, width, height){ translateFills(fills: readonly Paint[] /*, width: number, height: number*/) {
let penpotFills = []; const penpotFills = [];
let penpotFill = null; let penpotFill = null;
for (var fill of fills){ for (const fill of fills) {
penpotFill = this.translateFill(fill, width, height); penpotFill = this.translateFill(fill /*, width, height*/);
// Penpot does not track fill visibility, so if the Figma fill is invisible we
// force opacity to 0
if (fill.visible === false){
penpotFill.fillOpacity = 0;
}
if (penpotFill !== null) { if (penpotFill !== null) {
penpotFills.unshift(penpotFill); penpotFills.unshift(penpotFill);
@ -123,127 +116,141 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
return penpotFills; return penpotFills;
} }
addFontWarning(font){ addFontWarning(font: string) {
const newMissingFonts = this.state.missingFonts; const newMissingFonts = this.state.missingFonts;
newMissingFonts.add(font); newMissingFonts.add(font);
this.setState(_ => ({missingFonts: newMissingFonts })); this.setState(() => ({ missingFonts: newMissingFonts }));
} }
createPenpotPage(file, node){ createPenpotPage(file: PenpotFile, node: NodeData) {
file.addPage(node.name); file.addPage(node.name);
for (var child of node.children){ for (const child of node.children) {
this.createPenpotItem(file, child, 0, 0); this.createPenpotItem(file, child, 0, 0);
} }
file.closePage(); file.closePage();
} }
createPenpotBoard(file, node, baseX, baseY){ createPenpotBoard(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.addArtboard({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height, file.addArtboard({
fills: this.translateFills(node.fills, node.width, node.height) name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: this.translateFills(node.fills /*, node.width, node.height*/)
}); });
for (var child of node.children){ for (const child of node.children) {
this.createPenpotItem(file, child, node.x + baseX, node.y + baseY); this.createPenpotItem(file, child, node.x + baseX, node.y + baseY);
} }
file.closeArtboard(); file.closeArtboard();
} }
createPenpotGroup(file, node, baseX, baseY){ createPenpotGroup(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.addGroup({ name: node.name }); file.addGroup({ name: node.name });
for (var child of node.children){ for (const child of node.children) {
this.createPenpotItem(file, child, baseX, baseY); this.createPenpotItem(file, child, baseX, baseY);
} }
file.closeGroup(); file.closeGroup();
} }
createPenpotRectangle(file, node, baseX, baseY){ createPenpotRectangle(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.createRect({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height, file.createRect({
fills: this.translateFills(node.fills, node.width, node.height), name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: this.translateFills(node.fills /*, node.width, node.height*/)
}); });
} }
createPenpotCircle(file, node, baseX, baseY){ createPenpotCircle(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
file.createCircle({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: node.width, height: node.height, file.createCircle({
fills: this.translateFills(node.fills, node.width, node.height), name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: node.width,
height: node.height,
fills: this.translateFills(node.fills /*, node.width, node.height*/)
}); });
} }
translateHorizontalAlign(align: string) { translateHorizontalAlign(align: string) {
if (align === "RIGHT"){ if (align === 'RIGHT') {
return Symbol.for("right"); return Symbol.for('right');
} }
if (align === "CENTER"){ if (align === 'CENTER') {
return Symbol.for("center"); return Symbol.for('center');
} }
return Symbol.for("left") return Symbol.for('left');
} }
translateVerticalAlign(align: string) { translateVerticalAlign(align: string) {
if (align === "BOTTOM"){ if (align === 'BOTTOM') {
return Symbol.for("bottom"); return Symbol.for('bottom');
} }
if (align === "CENTER"){ if (align === 'CENTER') {
return Symbol.for("center"); return Symbol.for('center');
} }
return Symbol.for("top") return Symbol.for('top');
} }
translateFontStyle(style: string) { translateFontStyle(style: string) {
return style.toLowerCase().replace(/\s/g, "") return style.toLowerCase().replace(/\s/g, '');
} }
getTextDecoration(node){ getTextDecoration(node: TextData | TextDataChildren) {
const textDecoration = node.textDecoration; const textDecoration = node.textDecoration;
if (textDecoration === "STRIKETHROUGH"){ if (textDecoration === 'STRIKETHROUGH') {
return "line-through"; return 'line-through';
} }
if (textDecoration === "UNDERLINE"){ if (textDecoration === 'UNDERLINE') {
return "underline"; return 'underline';
} }
return "none"; return 'none';
} }
getTextTransform(node){ getTextTransform(node: TextData | TextDataChildren) {
const textCase = node.textCase; const textCase = node.textCase;
if (textCase === "UPPER"){ if (textCase === 'UPPER') {
return "uppercase"; return 'uppercase';
} }
if (textCase === "LOWER"){ if (textCase === 'LOWER') {
return "lowercase"; return 'lowercase';
} }
if (textCase === "TITLE"){ if (textCase === 'TITLE') {
return "capitalize"; return 'capitalize';
} }
return "none"; return 'none';
} }
validateFont(fontName) { validateFont(fontName: FontName) {
const name = slugify(fontName.family.toLowerCase()); const name = slugify(fontName.family.toLowerCase());
if (!gfonts.has(name)) { if (!gfonts.has(name)) {
this.addFontWarning(name); this.addFontWarning(name);
} }
} }
createPenpotText(file, node, baseX, baseY){ createPenpotText(file: PenpotFile, node: TextData, baseX: number, baseY: number) {
const children = node.children.map(val => {
const children = node.children.map((val) => {
this.validateFont(val.fontName); this.validateFont(val.fontName);
return { return {
lineHeight: val.lineHeight, lineHeight: val.lineHeight,
fontStyle: "normal", fontStyle: 'normal',
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal), textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
fontId: "gfont-" + slugify(val.fontName.family.toLowerCase()), fontId: 'gfont-' + slugify(val.fontName.family.toLowerCase()),
fontSize: val.fontSize.toString(), fontSize: val.fontSize.toString(),
fontWeight: val.fontWeight.toString(), fontWeight: val.fontWeight.toString(),
fontVariantId: this.translateFontStyle(val.fontName.style), fontVariantId: this.translateFontStyle(val.fontName.style),
textDecoration: this.getTextDecoration(val), textDecoration: this.getTextDecoration(val),
textTransform: this.getTextTransform(val), textTransform: this.getTextTransform(val),
letterSpacing: val.letterSpacing, letterSpacing: val.letterSpacing,
fills: this.translateFills(val.fills, node.width, node.height), fills: this.translateFills(val.fills /*, node.width, node.height*/),
fontFamily: val.fontName.family, fontFamily: val.fontName.family,
text: val.characters } text: val.characters
};
}); });
this.validateFont(node.fontName); this.validateFont(node.fontName);
@ -255,34 +262,49 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
width: node.width, width: node.width,
height: node.height, height: node.height,
rotation: 0, rotation: 0,
type: Symbol.for("text"), type: Symbol.for('text'),
content: { content: {
type: "root", type: 'root',
verticalAlign: this.translateVerticalAlign(node.textAlignVertical), verticalAlign: this.translateVerticalAlign(node.textAlignVertical),
children: [{ children: [
type: "paragraph-set", {
children: [{ type: 'paragraph-set',
children: [
{
lineHeight: node.lineHeight, lineHeight: node.lineHeight,
fontStyle: "normal", fontStyle: 'normal',
children: children, children: children,
textTransform: this.getTextTransform(node), textTransform: this.getTextTransform(node),
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal), textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
fontId: "gfont-" + slugify(node.fontName.family.toLowerCase()), fontId: 'gfont-' + slugify(node.fontName.family.toLowerCase()),
fontSize: node.fontSize.toString(), fontSize: node.fontSize.toString(),
fontWeight: node.fontWeight.toString(), fontWeight: node.fontWeight.toString(),
type: "paragraph", type: 'paragraph',
textDecoration: this.getTextDecoration(node), textDecoration: this.getTextDecoration(node),
letterSpacing: node.letterSpacing, letterSpacing: node.letterSpacing,
fills: this.translateFills(node.fills, node.width, node.height), fills: this.translateFills(node.fills /*, node.width, node.height*/),
fontFamily: node.fontName.family fontFamily: node.fontName.family
}] }
}] ]
}
]
} }
}); });
} }
createPenpotImage(file, node, baseX, baseY, image){ createPenpotImage(
file.createImage({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: image.width, height: image.height, file: PenpotFile,
node: NodeData,
baseX: number,
baseY: number,
image: FigmaImageData
) {
file.createImage({
name: node.name,
x: node.x + baseX,
y: node.y + baseY,
width: image.width,
height: image.height,
metadata: { metadata: {
width: image.width, width: image.width,
height: image.height height: image.height
@ -291,12 +313,12 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
}); });
} }
calculateAdjustment(node){ calculateAdjustment(node: NodeData) {
// For each child, check whether the X or Y position is less than 0 and less than the // For each child, check whether the X or Y position is less than 0 and less than the
// current adjustment. // current adjustment.
let adjustedX = 0; let adjustedX = 0;
let adjustedY = 0; let adjustedY = 0;
for (var child of node.children){ for (const child of node.children) {
if (child.x < adjustedX) { if (child.x < adjustedX) {
adjustedX = child.x; adjustedX = child.x;
} }
@ -307,49 +329,50 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
return [adjustedX, adjustedY]; return [adjustedX, adjustedY];
} }
createPenpotItem(file, node, baseX, baseY){ createPenpotItem(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
// We special-case images because an image in figma is a shape with one or many // We special-case images because an image in figma is a shape with one or many
// image fills. Given that handling images in Penpot is a bit different, we // image fills. Given that handling images in Penpot is a bit different, we
// rasterize a figma shape with any image fills to a PNG and then add it as a single // rasterize a figma shape with any image fills to a PNG and then add it as a single
// Penpot image. Implication is that any node that has an image fill will only be // Penpot image. Implication is that any node that has an image fill will only be
// treated as an image, so we skip node type checks. // treated as an image, so we skip node type checks.
const hasImageFill = node.fills?.some(fill => fill.type === "IMAGE"); const hasImageFill = node.fills?.some((fill: Paint) => fill.type === 'IMAGE');
if (hasImageFill) { if (hasImageFill) {
// If the nested frames extended the bounds of the rasterized image, we need to // If the nested frames extended the bounds of the rasterized image, we need to
// account for this both in position on the canvas and the calculated width and // account for this both in position on the canvas and the calculated width and
// height of the image. // height of the image.
const [adjustedX, adjustedY] = this.calculateAdjustment(node); const [adjustedX, adjustedY] = this.calculateAdjustment(node);
const width = node.width + Math.abs(adjustedX);
const height = node.height + Math.abs(adjustedY);
this.createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY, this.state.images[node.id]); this.createPenpotImage(
} file,
else if (node.type == "PAGE"){ node,
baseX + adjustedX,
baseY + adjustedY,
this.state.images[node.id]
);
} else if (node.type == 'PAGE') {
this.createPenpotPage(file, node); this.createPenpotPage(file, node);
} } else if (node.type == 'FRAME') {
else if (node.type == "FRAME"){
this.createPenpotBoard(file, node, baseX, baseY); this.createPenpotBoard(file, node, baseX, baseY);
} } else if (node.type == 'GROUP') {
else if (node.type == "GROUP"){
this.createPenpotGroup(file, node, baseX, baseY); this.createPenpotGroup(file, node, baseX, baseY);
} } else if (node.type == 'RECTANGLE') {
else if (node.type == "RECTANGLE"){
this.createPenpotRectangle(file, node, baseX, baseY); this.createPenpotRectangle(file, node, baseX, baseY);
} } else if (node.type == 'ELLIPSE') {
else if (node.type == "ELLIPSE"){
this.createPenpotCircle(file, node, baseX, baseY); this.createPenpotCircle(file, node, baseX, baseY);
} } else if (node.type == 'TEXT') {
else if (node.type == "TEXT"){ this.createPenpotText(file, node as unknown as TextData, baseX, baseY);
this.createPenpotText(file, node, baseX, baseY);
} }
} }
createPenpotFile() { createPenpotFile() {
let node = this.state.figmaRootNode; const node = this.state.figmaRootNode;
const file = penpot.createFile(node.name);
for (var page of node.children){ if (node === null) {
throw new Error('No Figma file data found');
}
const file = createFile(node.name);
for (const page of node.children) {
this.createPenpotItem(file, page, 0, 0); this.createPenpotItem(file, page, 0, 0);
} }
return file; return file;
@ -358,53 +381,58 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
onCreatePenpot = () => { onCreatePenpot = () => {
const file = this.createPenpotFile(); const file = this.createPenpotFile();
const penpotFileMap = file.asMap(); const penpotFileMap = file.asMap();
this.setState(state => ({penpotFileData: JSON.stringify(penpotFileMap, (key, value) => (value instanceof Map ? [...value] : value), 4)})); this.setState(() => ({
file.export() penpotFileData: JSON.stringify(
penpotFileMap,
(key, value) => (value instanceof Map ? [...value] : value),
4
)
}));
file.export();
}; };
onCancel = () => { onCancel = () => {
parent.postMessage({ pluginMessage: { type: "cancel" } }, "*"); parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
}; };
onMessage = (event) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
if (event.data.pluginMessage.type == "FIGMAFILE") { onMessage = (event: any) => {
this.setState(state => ({ if (event.data.pluginMessage.type == 'FIGMAFILE') {
figmaFileData: JSON.stringify(event.data.pluginMessage.data, (key, value) => (value instanceof Map ? [...value] : value), 4), this.setState(() => ({
figmaRootNode: event.data.pluginMessage.data})); figmaFileData: JSON.stringify(
} event.data.pluginMessage.data,
else if (event.data.pluginMessage.type == "IMAGE") { (key, value) => (value instanceof Map ? [...value] : value),
4
),
figmaRootNode: event.data.pluginMessage.data
}));
} else if (event.data.pluginMessage.type == 'IMAGE') {
const data = event.data.pluginMessage.data; const data = event.data.pluginMessage.data;
const image = document.createElement('img'); const image = document.createElement('img');
const thisObj = this;
image.addEventListener('load', function() { image.addEventListener('load', () => {
// Get byte array from response // Get byte array from response
thisObj.setState(state => this.setState(state => {
{
state.images[data.id] = { state.images[data.id] = {
value: data.value, value: data.value,
width: image.naturalWidth, width: image.naturalWidth,
height: image.naturalHeight height: image.naturalHeight
}; };
return state; return state;
} });
);
}); });
image.src = data.value; image.src = data.value;
}
} }
};
setDimensions = () => { setDimensions = () => {
const isMissingFonts = this.state.missingFonts.size > 0; const isMissingFonts = this.state.missingFonts.size > 0;
let width = 300; let width = 300;
let height = 280; let height = 280;
if (isMissingFonts) { if (isMissingFonts) {
height += (this.state.missingFonts.size * 20); height += this.state.missingFonts.size * 20;
width = 400; width = 400;
} }
@ -413,40 +441,40 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
width = 800; width = 800;
} }
parent.postMessage({ pluginMessage: { type: "resize", width: width, height: height } }, "*"); parent.postMessage({ pluginMessage: { type: 'resize', width: width, height: height } }, '*');
} };
toggleDebug = (event) => { toggleDebug = (event: React.ChangeEvent<HTMLInputElement>) => {
const isDebug = event.currentTarget.checked; const isDebug = event.currentTarget.checked;
this.setState (state => ({isDebug: isDebug})); this.setState(() => ({ isDebug: isDebug }));
} };
renderFontWarnings = () => { renderFontWarnings = () => {
return ( return (
<ul> <ul>
{Array.from(this.state.missingFonts).map((font) => ( {Array.from(this.state.missingFonts).map(font => (
<li key={font}>{font}</li> <li key={font}>{font}</li>
))} ))}
</ul> </ul>
); );
} };
render() { render() {
// Update the dimensions of the plugin window based on available data and selections // Update the dimensions of the plugin window based on available data and selections
return ( return (
<main> <main>
<header> <header>
<img src={require("./logo.svg")} /> <img src={require('./logo.svg')} />
<h2>Penpot Exporter</h2> <h2>Penpot Exporter</h2>
</header> </header>
<section> <section>
<div style={{display:this.state.missingFonts.size > 0 ? "inline" : "none"}}> <div style={{ display: this.state.missingFonts.size > 0 ? 'inline' : 'none' }}>
<div id="missing-fonts">{this.state.missingFonts.size} non-default font{this.state.missingFonts.size > 1 ? 's' : ''}: </div> <div id="missing-fonts">
<small>Ensure fonts are installed in Penpot before importing.</small> {this.state.missingFonts.size} non-default font
<div id="missing-fonts-list"> {this.state.missingFonts.size > 1 ? 's' : ''}:{' '}
{this.renderFontWarnings()}
</div> </div>
<small>Ensure fonts are installed in Penpot before importing.</small>
<div id="missing-fonts-list">{this.renderFontWarnings()}</div>
</div> </div>
<div> <div>
<input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug} /> <input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug} />
@ -455,11 +483,21 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
</section> </section>
<div style={{ display: this.state.isDebug ? '' : 'none' }}> <div style={{ display: this.state.isDebug ? '' : 'none' }}>
<section> <section>
<textarea style={{width:'790px', height:'270px'}} id="figma-file-data" value={this.state.figmaFileData} readOnly /> <textarea
style={{ width: '790px', height: '270px' }}
id="figma-file-data"
value={this.state.figmaFileData}
readOnly
/>
<label htmlFor="figma-file-data">Figma file data</label> <label htmlFor="figma-file-data">Figma file data</label>
</section> </section>
<section> <section>
<textarea style={{width:'790px', height:'270px'}} id="penpot-file-data" value={this.state.penpotFileData} readOnly /> <textarea
style={{ width: '790px', height: '270px' }}
id="penpot-file-data"
value={this.state.penpotFileData}
readOnly
/>
<label htmlFor="penpot-file-data">Penpot file data</label> <label htmlFor="penpot-file-data">Penpot file data</label>
</section> </section>
</div> </div>
@ -474,9 +512,8 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
} }
} }
ReactDOM.render( createRoot(document.getElementById('penpot-export-page') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<PenpotExporter /> <PenpotExporter />
</React.StrictMode>, </React.StrictMode>
document.getElementById('penpot-export-page')
); );

4
stylelint.config.mjs Normal file
View file

@ -0,0 +1,4 @@
/** @type {import('stylelint').Config} */
export default {
extends: ['stylelint-config-recommended', 'stylelint-config-standard']
};

View file

@ -1,13 +1,14 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es6", "esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"jsx": "react", "jsx": "react",
"typeRoots": [ "lib": ["DOM", "ES6"],
"./node_modules/@types", "target": "ES6",
"./node_modules/@figma" "module": "ESNext",
], "moduleResolution": "Node10",
"moduleResolution":"node" "strict": true,
}, "typeRoots": ["src/penpot.d.ts", "node_modules/@figma", "node_modules/@types"]
"include": ["src/**/*.ts", "src/**/*.tsx"] }
} }

View file

@ -1,8 +1,9 @@
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin'); /* eslint-disable @typescript-eslint/no-var-requires */
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack') const path = require('path');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const webpack = require('webpack');
module.exports = (env, argv) => ({ module.exports = (env, argv) => ({
mode: argv.mode === 'production' ? 'production' : 'development', mode: argv.mode === 'production' ? 'production' : 'development',
@ -12,7 +13,7 @@ module.exports = (env, argv) => ({
entry: { entry: {
ui: './src/ui.tsx', // The entry point for your UI code ui: './src/ui.tsx', // The entry point for your UI code
code: './src/code.ts', // The entry point for your plugin code code: './src/code.ts' // The entry point for your plugin code
}, },
module: { module: {
@ -27,7 +28,7 @@ module.exports = (env, argv) => ({
// Enables including CSS by doing "import './file.css'" in your TypeScript code // Enables including CSS by doing "import './file.css'" in your TypeScript code
{ {
test: /\.css$/, test: /\.css$/,
use: ["style-loader", "css-loader"], use: ['style-loader', 'css-loader']
}, },
// Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI // Allows you to use "<%= require('./file.svg') %>" in your HTML code to get a data URI
// { test: /\.(png|jpg|gif|webp|svg|zip)$/, loader: [{ loader: 'url-loader' }] } // { test: /\.(png|jpg|gif|webp|svg|zip)$/, loader: [{ loader: 'url-loader' }] }
@ -39,25 +40,24 @@ module.exports = (env, argv) => ({
}, },
// Webpack tries these extensions for you if you omit the extension like "import './file'" // Webpack tries these extensions for you if you omit the extension like "import './file'"
resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] , resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'], fallback: { crypto: false } },
fallback: { "crypto": false }},
output: { output: {
filename: '[name].js', filename: '[name].js',
path: path.resolve(__dirname, 'dist'), // Compile into a folder called "dist" path: path.resolve(__dirname, 'dist') // Compile into a folder called "dist"
}, },
// Tells Webpack to generate "ui.html" and to inline "ui.ts" into it // Tells Webpack to generate "ui.html" and to inline "ui.ts" into it
plugins: [ plugins: [
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'global': {} // Fix missing symbol error when running in developer VM global: {} // Fix missing symbol error when running in developer VM
}), }),
new HtmlWebpackPlugin({ new HtmlWebpackPlugin({
inject: "body", inject: 'body',
template: './src/ui.html', template: './src/ui.html',
filename: 'ui.html', filename: 'ui.html',
chunks: ['ui'] chunks: ['ui']
}), }),
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]), new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/])
], ]
}) });