mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2024-12-21 21:23:06 -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:
parent
7dc730994f
commit
308fa93625
22 changed files with 9757 additions and 1850 deletions
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
dist
|
||||
src/penpot.js
|
22
.eslintrc.cjs
Normal file
22
.eslintrc.cjs
Normal 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
34
.github/workflows/ci.yaml
vendored
Normal 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
1
.gitignore
vendored
|
@ -1,3 +1,2 @@
|
|||
node_modules
|
||||
package-lock.json
|
||||
dist
|
||||
|
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
20
|
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
src/penpot.js
|
||||
LICENSE
|
69
DEV_GUIDE.md
69
DEV_GUIDE.md
|
@ -1,39 +1,40 @@
|
|||
# Developer guide
|
||||
|
||||
The plugin relies in [penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js)
|
||||
library. It contains a subset of Penpot frontend app, transpiled into
|
||||
javascript to be used from js code (this was easy since the frontend is written
|
||||
in ClojureScript, that has direct translation to javascript).
|
||||
The plugin relies in
|
||||
[penpot.js](https://github.com/penpot/penpot-exporter-figma-plugin/blob/main/src/penpot.js) library.
|
||||
It contains a subset of Penpot frontend app, transpiled into javascript to be used from js code
|
||||
(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
|
||||
represents a Penpot file as it resides in memory inside the frontend app. It
|
||||
has function to create pages and their content, and also an `export` function
|
||||
that generates and downloads a .zip archive with the Penpot file as svg
|
||||
documents in Penpot annotated format, that you can import directly into Penpot.
|
||||
Basically, it exports the `createFile` function and the `File` data type, that represents a Penpot
|
||||
file as it resides in memory inside the frontend app. It has function to create pages and their
|
||||
content, and also an `export` function that generates and downloads a .zip archive with the Penpot
|
||||
file as svg 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
|
||||
see [the data model](https://help.penpot.app/technical-guide/data-model/).
|
||||
Their full specifications are in the [common types module](https://github.com/penpot/penpot/tree/develop/common/src/app/common/types).
|
||||
To see a general description of the data types used in the functions you can see
|
||||
[the data model](https://help.penpot.app/technical-guide/data-model/). Their full specifications are
|
||||
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.
|
||||
For those unfamiliar with the syntax, here is a small basic guide:
|
||||
Those types are defined in [Clojure spec](https://clojure.org/guides/spec) format. For those
|
||||
unfamiliar with the syntax, here is a small basic guide:
|
||||
|
||||
```clojure
|
||||
(s/def ::name string?)
|
||||
(s/def ::id uuid?)
|
||||
```
|
||||
|
||||
A parameter or attribute called `name` is of type string, and `id` is an *uuid*
|
||||
string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843").
|
||||
A parameter or attribute called `name` is of type string, and `id` is an _uuid_ string (e.g.
|
||||
"000498f3-27fc-8000-8988-ca7d52f46843").
|
||||
|
||||
```clojure
|
||||
(s/def ::stroke-alignment #{:center :inner :outer})
|
||||
```
|
||||
|
||||
`stroke-alignment` is of an enumerated type, and the valid values are "center",
|
||||
"inner" and "outer".
|
||||
`stroke-alignment` is of an enumerated type, and the valid values are "center", "inner" and "outer".
|
||||
|
||||
```clojure
|
||||
(ns app.common.types.shape
|
||||
|
@ -44,17 +45,17 @@ string (e.g. "000498f3-27fc-8000-8988-ca7d52f46843").
|
|||
(s/def ::line-height ::us/safe-number)
|
||||
```
|
||||
|
||||
`line-height` is of the type `safe-number`, defined in `app.common.spec`
|
||||
namespace, that here is imported with the name `us` (a *safe number* is a
|
||||
integer or floating point number, with a value not too big or small).
|
||||
`line-height` is of the type `safe-number`, defined in `app.common.spec` namespace, that here is
|
||||
imported with the name `us` (a _safe number_ is a integer or floating point number, with a value not
|
||||
too big or small).
|
||||
|
||||
```clojure
|
||||
(s/def ::page
|
||||
(s/keys :req-un [::id ::name ::objects ::options]))
|
||||
```
|
||||
|
||||
`page` is an object with four required arguments: `id`, `name`, `objects` and
|
||||
`options`, whose types have to be defined above.
|
||||
`page` is an object with four required arguments: `id`, `name`, `objects` and `options`, whose types
|
||||
have to be defined above.
|
||||
|
||||
```clojure
|
||||
(s/def ::column
|
||||
|
@ -66,8 +67,8 @@ integer or floating point number, with a value not too big or small).
|
|||
::gutter]))
|
||||
```
|
||||
|
||||
`column` has one required attribute `color` and five optional ones `size`,
|
||||
`type`, `item-length`, `margin` and `gutter`.
|
||||
`column` has one required attribute `color` and five optional ones `size`, `type`, `item-length`,
|
||||
`margin` and `gutter`.
|
||||
|
||||
```clojure
|
||||
(s/def ::children
|
||||
|
@ -76,8 +77,8 @@ integer or floating point number, with a value not too big or small).
|
|||
:min-count 1))
|
||||
```
|
||||
|
||||
`children` is a collection (implemented as a vector) of objects of type
|
||||
`content`, and must have a minimun lenght of 1.
|
||||
`children` is a collection (implemented as a vector) of objects of type `content`, and must have a
|
||||
minimun lenght of 1.
|
||||
|
||||
```clojure
|
||||
(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))
|
||||
```
|
||||
|
||||
This is probably the most complex construct. `animation` is a multi schema
|
||||
object. It has an attribute called `animation-type` and the rest of the fields
|
||||
depend on its value. For example, if `animation-type` is "dissolve", the object
|
||||
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.
|
||||
This is probably the most complex construct. `animation` is a multi schema object. It has an
|
||||
attribute called `animation-type` and the rest of the fields depend on its value. For example, if
|
||||
`animation-type` is "dissolve", the object 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.
|
||||
|
|
121
README.md
121
README.md
|
@ -1,4 +1,3 @@
|
|||
|
||||
[uri_license]: https://www.mozilla.org/en-US/MPL/2.0
|
||||
[uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg
|
||||
|
||||
|
@ -25,9 +24,10 @@
|
|||
|
||||
![](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)
|
||||
- [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)
|
||||
- [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
|
||||
|
||||
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
|
||||
|
||||
#### For Windows users:
|
||||
|
||||
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`.
|
||||
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 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".
|
||||
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.
|
||||
1. Open the terminal by searching for "Terminal" in the Launchpad or by using the `Command + Space`
|
||||
keyboard shortcut and searching for "Terminal".
|
||||
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:
|
||||
|
||||
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`.
|
||||
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.
|
||||
|
||||
### 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.'>
|
||||
|
||||
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
|
||||
|
||||
1. Select what you want to export
|
||||
2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter`
|
||||
go to the `Plugins` menu in Figma and select `Development` followed by `Penpot Exporter`.
|
||||
2. `Figma menu` > `Plugins` > `Development` > `Penpot Exporter` go to the `Plugins` menu in Figma
|
||||
and select `Development` followed by `Penpot Exporter`.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
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).
|
||||
- **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).
|
||||
- **Images** (basic support)
|
||||
|
||||
## 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:
|
||||
## Limitations
|
||||
|
||||
- **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.
|
||||
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.
|
||||
- **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 experience.
|
||||
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.
|
||||
|
||||
For instance, it will be interesting to add:
|
||||
|
||||
- Strokes
|
||||
- Fills with radial gradients
|
||||
- Paths
|
||||
|
@ -137,10 +171,11 @@ For instance, it will be interesting to add:
|
|||
- 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
|
||||
|
@ -149,4 +184,6 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|||
|
||||
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
7654
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
63
package.json
63
package.json
|
@ -1,37 +1,52 @@
|
|||
{
|
||||
"name": "penpot-exporter",
|
||||
"id": "1161652283781700708",
|
||||
"version": "0.0.1",
|
||||
"description": "Penpot exporter",
|
||||
"type": "module",
|
||||
"main": "code.js",
|
||||
"scripts": {
|
||||
"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",
|
||||
"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": {
|
||||
"@figma-plugin/helpers": "^0.15.2",
|
||||
"@types/react": "^17.0.19",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"crypto": "^1.0.1",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dev-utils": "^12.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"slugify": "^1.6.5"
|
||||
"react": "^18.2",
|
||||
"react-dom": "^18.2",
|
||||
"slugify": "^1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@figma/eslint-plugin-figma-plugins": "^0.15",
|
||||
"@figma/plugin-typings": "^1.88",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3",
|
||||
"@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
19
prettier.config.mjs
Normal 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;
|
109
src/code.ts
109
src/code.ts
|
@ -1,80 +1,83 @@
|
|||
import { NodeData, TextData } from './interfaces';
|
||||
|
||||
|
||||
let fileData = [];
|
||||
|
||||
type NodeData = {
|
||||
id: string
|
||||
name: string,
|
||||
type: string,
|
||||
children: Node[],
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fills: any
|
||||
interface Signatures {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const signatures = {
|
||||
R0lGODdh: "image/gif",
|
||||
R0lGODlh: "image/gif",
|
||||
iVBORw0KGgo: "image/png",
|
||||
"/9j/": "image/jpg"
|
||||
const signatures: Signatures = {
|
||||
'R0lGODdh': 'image/gif',
|
||||
'R0lGODlh': 'image/gif',
|
||||
'iVBORw0KGgo': 'image/png',
|
||||
'/9j/': 'image/jpg'
|
||||
};
|
||||
|
||||
function detectMimeType(b64) {
|
||||
for (var s in signatures) {
|
||||
function detectMimeType(b64: string) {
|
||||
for (const s in signatures) {
|
||||
if (b64.indexOf(s) === 0) {
|
||||
return signatures[s];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function traverse(node): NodeData {
|
||||
let children:any[] = [];
|
||||
function traverse(node: BaseNode): NodeData | TextData {
|
||||
const children: (NodeData | TextData)[] = [];
|
||||
|
||||
if ("children" in node) {
|
||||
if (node.type !== "INSTANCE") {
|
||||
if ('children' in node) {
|
||||
if (node.type !== 'INSTANCE') {
|
||||
for (const child of node.children) {
|
||||
children.push (traverse(child))
|
||||
children.push(traverse(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = {
|
||||
const result = {
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
children: children,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
fills: node.fills === figma.mixed ? [] : node.fills //TODO: Support mixed fills
|
||||
}
|
||||
|
||||
if (node.fills && Array.isArray(node.fills)){
|
||||
x: 'x' in node ? node.x : 0,
|
||||
y: 'y' in node ? node.y : 0,
|
||||
width: 'width' in node ? node.width : 0,
|
||||
height: 'height' in node ? node.height : 0,
|
||||
fills: 'fills' in node ? (node.fills === figma.mixed ? [] : node.fills) : [] // TODO: Support mixed fills
|
||||
};
|
||||
|
||||
if (result.fills) {
|
||||
// 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) {
|
||||
// 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
|
||||
// objects, we're going to simplify this by exporting this shape as a PNG image.
|
||||
node.exportAsync({format: "PNG"}).then((value) => {
|
||||
const b64 = figma.base64Encode(value);
|
||||
figma.ui.postMessage({type: "IMAGE", data: {
|
||||
id: node.id,
|
||||
value: "data:" + detectMimeType(b64) + ";base64," + b64
|
||||
}});
|
||||
});
|
||||
'exportAsync' in node &&
|
||||
node.exportAsync({ format: 'PNG' }).then(value => {
|
||||
const b64 = figma.base64Encode(value);
|
||||
figma.ui.postMessage({
|
||||
type: 'IMAGE',
|
||||
data: {
|
||||
id: node.id,
|
||||
value: 'data:' + detectMimeType(b64) + ';base64,' + b64
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type == "TEXT") {
|
||||
const styledTextSegments = node.getStyledTextSegments(["fontName", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "textCase", "textDecoration", "fills"]);
|
||||
if (node.type == 'TEXT') {
|
||||
const styledTextSegments = node.getStyledTextSegments([
|
||||
'fontName',
|
||||
'fontSize',
|
||||
'fontWeight',
|
||||
'lineHeight',
|
||||
'letterSpacing',
|
||||
'textCase',
|
||||
'textDecoration',
|
||||
'fills'
|
||||
]);
|
||||
|
||||
if (styledTextSegments[0]) {
|
||||
let font = {
|
||||
const font = {
|
||||
...result,
|
||||
fontName: styledTextSegments[0].fontName,
|
||||
fontSize: styledTextSegments[0].fontSize.toString(),
|
||||
fontWeight: styledTextSegments[0].fontWeight.toString(),
|
||||
|
@ -88,25 +91,25 @@ function traverse(node): NodeData {
|
|||
textAlignVertical: node.textAlignVertical,
|
||||
children: styledTextSegments
|
||||
};
|
||||
result = {...result, ...font};
|
||||
|
||||
return font as TextData;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result as NodeData;
|
||||
}
|
||||
|
||||
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
|
||||
|
||||
let root: NodeData = traverse(figma.root) // start the traversal at the root
|
||||
figma.ui.postMessage({type: "FIGMAFILE", data: root});
|
||||
const root: NodeData | TextData = traverse(figma.root); // start the traversal at the root
|
||||
figma.ui.postMessage({ type: 'FIGMAFILE', data: root });
|
||||
|
||||
figma.ui.onmessage = (msg) => {
|
||||
if (msg.type === "cancel") {
|
||||
figma.ui.onmessage = msg => {
|
||||
if (msg.type === 'cancel') {
|
||||
figma.closePlugin();
|
||||
}
|
||||
|
||||
if (msg.type === "resize") {
|
||||
if (msg.type === 'resize') {
|
||||
figma.ui.resize(msg.width, msg.height);
|
||||
}
|
||||
|
||||
};
|
||||
|
|
2864
src/gfonts.json
2864
src/gfonts.json
File diff suppressed because it is too large
Load diff
43
src/interfaces.ts
Normal file
43
src/interfaces.ts
Normal 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
18
src/penpot.d.ts
vendored
Normal 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;
|
11
src/ui.css
11
src/ui.css
|
@ -23,8 +23,8 @@ main {
|
|||
body,
|
||||
input,
|
||||
button {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
font-size: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -43,16 +43,20 @@ button {
|
|||
border: 1px solid var(--color-border);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: var(--color-bg-active);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
border: none;
|
||||
outline-color: var(--color-border-focus);
|
||||
}
|
||||
|
||||
button.brand {
|
||||
--color-bg: var(--color-bg-brand);
|
||||
--color-text: var(--color-text-brand);
|
||||
|
@ -75,7 +79,7 @@ input:focus-visible {
|
|||
}
|
||||
|
||||
svg {
|
||||
stroke: var(--color-icon, rgba(0, 0, 0, 0.9));
|
||||
stroke: var(--color-icon, rgb(0 0 0 / 90%));
|
||||
}
|
||||
|
||||
main {
|
||||
|
@ -96,6 +100,7 @@ section {
|
|||
section > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
footer > * + * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
|
505
src/ui.tsx
505
src/ui.tsx
|
@ -1,250 +1,257 @@
|
|||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import "./ui.css";
|
||||
import * as penpot from "./penpot.js";
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import slugify from 'slugify';
|
||||
|
||||
import { extractLinearGradientParamsFromTransform } from "@figma-plugin/helpers";
|
||||
import slugify from "slugify";
|
||||
import { NodeData, TextData, TextDataChildren } from './interfaces';
|
||||
import { PenpotFile, createFile } from './penpot';
|
||||
import './ui.css';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
declare function require(path: string): any;
|
||||
|
||||
// Open resources/gfonts.json and create a set of matched font names
|
||||
const gfonts = new Set();
|
||||
require("./gfonts.json").forEach((font) => gfonts.add(font));
|
||||
|
||||
type PenpotExporterProps = {
|
||||
}
|
||||
require('./gfonts.json').forEach((font: string) => gfonts.add(font));
|
||||
|
||||
type FigmaImageData = {
|
||||
value: string,
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
value: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type PenpotExporterState = {
|
||||
isDebug: boolean,
|
||||
penpotFileData: string
|
||||
missingFonts: Set<string>
|
||||
figmaFileData: string
|
||||
figmaRootNode: NodeData
|
||||
images: { [id: string] : FigmaImageData; };
|
||||
}
|
||||
isDebug: boolean;
|
||||
penpotFileData: string;
|
||||
missingFonts: Set<string>;
|
||||
figmaFileData: string;
|
||||
figmaRootNode: NodeData | null;
|
||||
images: { [id: string]: FigmaImageData };
|
||||
};
|
||||
|
||||
export default class PenpotExporter extends React.Component<PenpotExporterProps, PenpotExporterState> {
|
||||
export default class PenpotExporter extends React.Component<unknown, PenpotExporterState> {
|
||||
state: PenpotExporterState = {
|
||||
isDebug: false,
|
||||
penpotFileData: "",
|
||||
figmaFileData: "",
|
||||
penpotFileData: '',
|
||||
figmaFileData: '',
|
||||
missingFonts: new Set(),
|
||||
figmaRootNode: null,
|
||||
images: {}
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
window.addEventListener("message", this.onMessage);
|
||||
}
|
||||
window.addEventListener('message', this.onMessage);
|
||||
};
|
||||
|
||||
componentDidUpdate = () => {
|
||||
this.setDimensions();
|
||||
}
|
||||
};
|
||||
|
||||
componentWillUnmount = () =>{
|
||||
componentWillUnmount = () => {
|
||||
window.removeEventListener('message', this.onMessage);
|
||||
}
|
||||
};
|
||||
|
||||
rgbToHex = (color) => {
|
||||
rgbToHex = (color: RGB) => {
|
||||
const r = Math.round(255 * color.r);
|
||||
const g = Math.round(255 * color.g);
|
||||
const b = Math.round(255 * color.b);
|
||||
const rgb = (r << 16) | (g << 8) | (b << 0);
|
||||
return '#' + (0x1000000 + rgb).toString(16).slice(1);
|
||||
}
|
||||
};
|
||||
|
||||
translateSolidFill(fill){
|
||||
translateSolidFill(fill: SolidPaint) {
|
||||
return {
|
||||
fillColor: this.rgbToHex(fill.color),
|
||||
fillOpacity: fill.opacity
|
||||
}
|
||||
fillOpacity: fill.visible === false ? 0 : fill.opacity
|
||||
};
|
||||
}
|
||||
|
||||
translateGradientLinearFill(fill, width, height){
|
||||
const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform);
|
||||
translateGradientLinearFill(fill: GradientPaint /*, width: number, height: number*/) {
|
||||
// const points = extractLinearGradientParamsFromTransform(width, height, fill.gradientTransform);
|
||||
return {
|
||||
fillColorGradient: {
|
||||
type: Symbol.for("linear"),
|
||||
width: 1,
|
||||
startX: points.start[0] / width,
|
||||
startY: points.start[1] / height,
|
||||
endX: points.end[0] / width,
|
||||
endY: points.end[1] / height,
|
||||
stops: [{
|
||||
color: this.rgbToHex(fill.gradientStops[0].color),
|
||||
offset: fill.gradientStops[0].position,
|
||||
opacity: fill.gradientStops[0].color.a * fill.opacity
|
||||
},
|
||||
{
|
||||
color: this.rgbToHex(fill.gradientStops[1].color),
|
||||
offset: fill.gradientStops[1].position,
|
||||
opacity: fill.gradientStops[1].color.a * fill.opacity
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
type: Symbol.for('linear'),
|
||||
width: 1,
|
||||
// startX: points.start[0] / width,
|
||||
// startY: points.start[1] / height,
|
||||
// endX: points.end[0] / width,
|
||||
// endY: points.end[1] / height,
|
||||
stops: [
|
||||
{
|
||||
color: this.rgbToHex(fill.gradientStops[0].color),
|
||||
offset: fill.gradientStops[0].position,
|
||||
opacity: fill.gradientStops[0].color.a * (fill.opacity ?? 1)
|
||||
},
|
||||
{
|
||||
color: this.rgbToHex(fill.gradientStops[1].color),
|
||||
offset: fill.gradientStops[1].position,
|
||||
opacity: fill.gradientStops[1].color.a * (fill.opacity ?? 1)
|
||||
}
|
||||
]
|
||||
},
|
||||
fillOpacity: fill.visible === false ? 0 : undefined
|
||||
};
|
||||
}
|
||||
|
||||
translateFill(fill, width, height){
|
||||
|
||||
if (fill.type === "SOLID"){
|
||||
translateFill(fill: Paint /*, width: number, height: number*/) {
|
||||
if (fill.type === 'SOLID') {
|
||||
return this.translateSolidFill(fill);
|
||||
} else if (fill.type === "GRADIENT_LINEAR"){
|
||||
return this.translateGradientLinearFill(fill, width, height);
|
||||
} else if (fill.type === 'GRADIENT_LINEAR') {
|
||||
return this.translateGradientLinearFill(fill /*, width, height*/);
|
||||
} else {
|
||||
console.error('Color type '+fill.type+' not supported yet');
|
||||
console.error('Color type ' + fill.type + ' not supported yet');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
translateFills(fills, width, height){
|
||||
let penpotFills = [];
|
||||
translateFills(fills: readonly Paint[] /*, width: number, height: number*/) {
|
||||
const penpotFills = [];
|
||||
let penpotFill = null;
|
||||
for (var fill of fills){
|
||||
penpotFill = this.translateFill(fill, width, height);
|
||||
for (const fill of fills) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return penpotFills;
|
||||
}
|
||||
|
||||
addFontWarning(font){
|
||||
addFontWarning(font: string) {
|
||||
const newMissingFonts = this.state.missingFonts;
|
||||
newMissingFonts.add(font);
|
||||
|
||||
this.setState(_ => ({missingFonts: newMissingFonts }));
|
||||
this.setState(() => ({ missingFonts: newMissingFonts }));
|
||||
}
|
||||
|
||||
createPenpotPage(file, node){
|
||||
createPenpotPage(file: PenpotFile, node: NodeData) {
|
||||
file.addPage(node.name);
|
||||
for (var child of node.children){
|
||||
for (const child of node.children) {
|
||||
this.createPenpotItem(file, child, 0, 0);
|
||||
}
|
||||
file.closePage();
|
||||
}
|
||||
|
||||
createPenpotBoard(file, node, baseX, baseY){
|
||||
file.addArtboard({ 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)
|
||||
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,
|
||||
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);
|
||||
}
|
||||
file.closeArtboard();
|
||||
}
|
||||
|
||||
createPenpotGroup(file, node, baseX, baseY){
|
||||
file.addGroup({name: node.name});
|
||||
for (var child of node.children){
|
||||
createPenpotGroup(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
|
||||
file.addGroup({ name: node.name });
|
||||
for (const child of node.children) {
|
||||
this.createPenpotItem(file, child, baseX, baseY);
|
||||
}
|
||||
file.closeGroup();
|
||||
}
|
||||
|
||||
createPenpotRectangle(file, node, baseX, baseY){
|
||||
file.createRect({ 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),
|
||||
});
|
||||
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,
|
||||
fills: this.translateFills(node.fills /*, node.width, node.height*/)
|
||||
});
|
||||
}
|
||||
|
||||
createPenpotCircle(file, node, baseX, baseY){
|
||||
file.createCircle({ 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: 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,
|
||||
fills: this.translateFills(node.fills /*, node.width, node.height*/)
|
||||
});
|
||||
}
|
||||
|
||||
translateHorizontalAlign(align: string){
|
||||
if (align === "RIGHT"){
|
||||
return Symbol.for("right");
|
||||
translateHorizontalAlign(align: string) {
|
||||
if (align === 'RIGHT') {
|
||||
return Symbol.for('right');
|
||||
}
|
||||
if (align === "CENTER"){
|
||||
return Symbol.for("center");
|
||||
if (align === 'CENTER') {
|
||||
return Symbol.for('center');
|
||||
}
|
||||
return Symbol.for("left")
|
||||
return Symbol.for('left');
|
||||
}
|
||||
|
||||
translateVerticalAlign(align: string){
|
||||
if (align === "BOTTOM"){
|
||||
return Symbol.for("bottom");
|
||||
translateVerticalAlign(align: string) {
|
||||
if (align === 'BOTTOM') {
|
||||
return Symbol.for('bottom');
|
||||
}
|
||||
if (align === "CENTER"){
|
||||
return Symbol.for("center");
|
||||
if (align === 'CENTER') {
|
||||
return Symbol.for('center');
|
||||
}
|
||||
return Symbol.for("top")
|
||||
return Symbol.for('top');
|
||||
}
|
||||
|
||||
translateFontStyle(style:string){
|
||||
return style.toLowerCase().replace(/\s/g, "")
|
||||
translateFontStyle(style: string) {
|
||||
return style.toLowerCase().replace(/\s/g, '');
|
||||
}
|
||||
|
||||
getTextDecoration(node){
|
||||
getTextDecoration(node: TextData | TextDataChildren) {
|
||||
const textDecoration = node.textDecoration;
|
||||
if (textDecoration === "STRIKETHROUGH"){
|
||||
return "line-through";
|
||||
if (textDecoration === 'STRIKETHROUGH') {
|
||||
return 'line-through';
|
||||
}
|
||||
if (textDecoration === "UNDERLINE"){
|
||||
return "underline";
|
||||
if (textDecoration === 'UNDERLINE') {
|
||||
return 'underline';
|
||||
}
|
||||
return "none";
|
||||
return 'none';
|
||||
}
|
||||
|
||||
getTextTransform(node){
|
||||
getTextTransform(node: TextData | TextDataChildren) {
|
||||
const textCase = node.textCase;
|
||||
if (textCase === "UPPER"){
|
||||
return "uppercase";
|
||||
if (textCase === 'UPPER') {
|
||||
return 'uppercase';
|
||||
}
|
||||
if (textCase === "LOWER"){
|
||||
return "lowercase";
|
||||
if (textCase === 'LOWER') {
|
||||
return 'lowercase';
|
||||
}
|
||||
if (textCase === "TITLE"){
|
||||
return "capitalize";
|
||||
if (textCase === 'TITLE') {
|
||||
return 'capitalize';
|
||||
}
|
||||
return "none";
|
||||
return 'none';
|
||||
}
|
||||
|
||||
validateFont(fontName) {
|
||||
validateFont(fontName: FontName) {
|
||||
const name = slugify(fontName.family.toLowerCase());
|
||||
if (!gfonts.has(name)) {
|
||||
this.addFontWarning(name);
|
||||
}
|
||||
}
|
||||
|
||||
createPenpotText(file, node, baseX, baseY){
|
||||
|
||||
const children = node.children.map((val) => {
|
||||
|
||||
createPenpotText(file: PenpotFile, node: TextData, baseX: number, baseY: number) {
|
||||
const children = node.children.map(val => {
|
||||
this.validateFont(val.fontName);
|
||||
|
||||
return {
|
||||
lineHeight: val.lineHeight,
|
||||
fontStyle: "normal",
|
||||
fontStyle: 'normal',
|
||||
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
|
||||
fontId: "gfont-" + slugify(val.fontName.family.toLowerCase()),
|
||||
fontId: 'gfont-' + slugify(val.fontName.family.toLowerCase()),
|
||||
fontSize: val.fontSize.toString(),
|
||||
fontWeight: val.fontWeight.toString(),
|
||||
fontVariantId: this.translateFontStyle(val.fontName.style),
|
||||
textDecoration: this.getTextDecoration(val),
|
||||
textTransform: this.getTextTransform(val),
|
||||
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,
|
||||
text: val.characters }
|
||||
});
|
||||
text: val.characters
|
||||
};
|
||||
});
|
||||
|
||||
this.validateFont(node.fontName);
|
||||
|
||||
|
@ -255,34 +262,49 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
|||
width: node.width,
|
||||
height: node.height,
|
||||
rotation: 0,
|
||||
type: Symbol.for("text"),
|
||||
type: Symbol.for('text'),
|
||||
content: {
|
||||
type: "root",
|
||||
type: 'root',
|
||||
verticalAlign: this.translateVerticalAlign(node.textAlignVertical),
|
||||
children: [{
|
||||
type: "paragraph-set",
|
||||
children: [{
|
||||
lineHeight: node.lineHeight,
|
||||
fontStyle: "normal",
|
||||
children: children,
|
||||
textTransform: this.getTextTransform(node),
|
||||
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
|
||||
fontId: "gfont-" + slugify(node.fontName.family.toLowerCase()),
|
||||
fontSize: node.fontSize.toString(),
|
||||
fontWeight: node.fontWeight.toString(),
|
||||
type: "paragraph",
|
||||
textDecoration: this.getTextDecoration(node),
|
||||
letterSpacing: node.letterSpacing,
|
||||
fills: this.translateFills(node.fills, node.width, node.height),
|
||||
fontFamily: node.fontName.family
|
||||
}]
|
||||
}]
|
||||
children: [
|
||||
{
|
||||
type: 'paragraph-set',
|
||||
children: [
|
||||
{
|
||||
lineHeight: node.lineHeight,
|
||||
fontStyle: 'normal',
|
||||
children: children,
|
||||
textTransform: this.getTextTransform(node),
|
||||
textAlign: this.translateHorizontalAlign(node.textAlignHorizontal),
|
||||
fontId: 'gfont-' + slugify(node.fontName.family.toLowerCase()),
|
||||
fontSize: node.fontSize.toString(),
|
||||
fontWeight: node.fontWeight.toString(),
|
||||
type: 'paragraph',
|
||||
textDecoration: this.getTextDecoration(node),
|
||||
letterSpacing: node.letterSpacing,
|
||||
fills: this.translateFills(node.fills /*, node.width, node.height*/),
|
||||
fontFamily: node.fontName.family
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createPenpotImage(file, node, baseX, baseY, image){
|
||||
file.createImage({ name: node.name, x: node.x + baseX, y: node.y + baseY, width: image.width, height: image.height,
|
||||
createPenpotImage(
|
||||
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: {
|
||||
width: image.width,
|
||||
height: image.height
|
||||
|
@ -291,65 +313,66 @@ 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
|
||||
// current adjustment.
|
||||
let adjustedX = 0;
|
||||
let adjustedY = 0;
|
||||
for (var child of node.children){
|
||||
if (child.x < adjustedX){
|
||||
for (const child of node.children) {
|
||||
if (child.x < adjustedX) {
|
||||
adjustedX = child.x;
|
||||
}
|
||||
if (child.y < adjustedY){
|
||||
if (child.y < adjustedY) {
|
||||
adjustedY = child.y;
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
const hasImageFill = node.fills?.some(fill => fill.type === "IMAGE");
|
||||
if (hasImageFill){
|
||||
|
||||
const hasImageFill = node.fills?.some((fill: Paint) => fill.type === 'IMAGE');
|
||||
if (hasImageFill) {
|
||||
// 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
|
||||
// height of the image.
|
||||
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]);
|
||||
}
|
||||
else if (node.type == "PAGE"){
|
||||
this.createPenpotImage(
|
||||
file,
|
||||
node,
|
||||
baseX + adjustedX,
|
||||
baseY + adjustedY,
|
||||
this.state.images[node.id]
|
||||
);
|
||||
} else if (node.type == 'PAGE') {
|
||||
this.createPenpotPage(file, node);
|
||||
}
|
||||
else if (node.type == "FRAME"){
|
||||
} else if (node.type == 'FRAME') {
|
||||
this.createPenpotBoard(file, node, baseX, baseY);
|
||||
}
|
||||
else if (node.type == "GROUP"){
|
||||
this.createPenpotGroup(file, node,baseX, baseY);
|
||||
}
|
||||
else if (node.type == "RECTANGLE"){
|
||||
} else if (node.type == 'GROUP') {
|
||||
this.createPenpotGroup(file, node, baseX, baseY);
|
||||
} else if (node.type == 'RECTANGLE') {
|
||||
this.createPenpotRectangle(file, node, baseX, baseY);
|
||||
}
|
||||
else if (node.type == "ELLIPSE"){
|
||||
} else if (node.type == 'ELLIPSE') {
|
||||
this.createPenpotCircle(file, node, baseX, baseY);
|
||||
}
|
||||
else if (node.type == "TEXT"){
|
||||
this.createPenpotText(file, node, baseX, baseY);
|
||||
} else if (node.type == 'TEXT') {
|
||||
this.createPenpotText(file, node as unknown as TextData, baseX, baseY);
|
||||
}
|
||||
}
|
||||
|
||||
createPenpotFile(){
|
||||
let node = this.state.figmaRootNode;
|
||||
const file = penpot.createFile(node.name);
|
||||
for (var page of node.children){
|
||||
createPenpotFile() {
|
||||
const node = this.state.figmaRootNode;
|
||||
|
||||
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);
|
||||
}
|
||||
return file;
|
||||
|
@ -358,110 +381,125 @@ export default class PenpotExporter extends React.Component<PenpotExporterProps,
|
|||
onCreatePenpot = () => {
|
||||
const file = this.createPenpotFile();
|
||||
const penpotFileMap = file.asMap();
|
||||
this.setState(state => ({penpotFileData: JSON.stringify(penpotFileMap, (key, value) => (value instanceof Map ? [...value] : value), 4)}));
|
||||
file.export()
|
||||
this.setState(() => ({
|
||||
penpotFileData: JSON.stringify(
|
||||
penpotFileMap,
|
||||
(key, value) => (value instanceof Map ? [...value] : value),
|
||||
4
|
||||
)
|
||||
}));
|
||||
file.export();
|
||||
};
|
||||
|
||||
onCancel = () => {
|
||||
parent.postMessage({ pluginMessage: { type: "cancel" } }, "*");
|
||||
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
|
||||
};
|
||||
|
||||
onMessage = (event) => {
|
||||
if (event.data.pluginMessage.type == "FIGMAFILE") {
|
||||
this.setState(state => ({
|
||||
figmaFileData: JSON.stringify(event.data.pluginMessage.data, (key, value) => (value instanceof Map ? [...value] : value), 4),
|
||||
figmaRootNode: event.data.pluginMessage.data}));
|
||||
}
|
||||
else if (event.data.pluginMessage.type == "IMAGE") {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onMessage = (event: any) => {
|
||||
if (event.data.pluginMessage.type == 'FIGMAFILE') {
|
||||
this.setState(() => ({
|
||||
figmaFileData: JSON.stringify(
|
||||
event.data.pluginMessage.data,
|
||||
(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 image = document.createElement('img');
|
||||
const thisObj = this;
|
||||
|
||||
image.addEventListener('load', function() {
|
||||
image.addEventListener('load', () => {
|
||||
// Get byte array from response
|
||||
thisObj.setState(state =>
|
||||
{
|
||||
state.images[data.id] = {
|
||||
value: data.value,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight
|
||||
};
|
||||
return state;
|
||||
}
|
||||
);
|
||||
this.setState(state => {
|
||||
state.images[data.id] = {
|
||||
value: data.value,
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight
|
||||
};
|
||||
return state;
|
||||
});
|
||||
});
|
||||
image.src = data.value;
|
||||
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setDimensions = () => {
|
||||
|
||||
const isMissingFonts = this.state.missingFonts.size > 0;
|
||||
|
||||
let width = 300;
|
||||
let height = 280;
|
||||
|
||||
if (isMissingFonts) {
|
||||
height += (this.state.missingFonts.size * 20);
|
||||
height += this.state.missingFonts.size * 20;
|
||||
width = 400;
|
||||
}
|
||||
|
||||
if (this.state.isDebug){
|
||||
if (this.state.isDebug) {
|
||||
height += 600;
|
||||
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;
|
||||
this.setState (state => ({isDebug: isDebug}));
|
||||
}
|
||||
this.setState(() => ({ isDebug: isDebug }));
|
||||
};
|
||||
|
||||
renderFontWarnings = () => {
|
||||
return (
|
||||
<ul >
|
||||
{Array.from(this.state.missingFonts).map((font) => (
|
||||
<ul>
|
||||
{Array.from(this.state.missingFonts).map(font => (
|
||||
<li key={font}>{font}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
// Update the dimensions of the plugin window based on available data and selections
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<img src={require("./logo.svg")} />
|
||||
<img src={require('./logo.svg')} />
|
||||
<h2>Penpot Exporter</h2>
|
||||
</header>
|
||||
<section>
|
||||
<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>
|
||||
<small>Ensure fonts are installed in Penpot before importing.</small>
|
||||
<div id="missing-fonts-list">
|
||||
{this.renderFontWarnings()}
|
||||
<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>
|
||||
<small>Ensure fonts are installed in Penpot before importing.</small>
|
||||
<div id="missing-fonts-list">{this.renderFontWarnings()}</div>
|
||||
</div>
|
||||
<div >
|
||||
<input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug}/>
|
||||
<div>
|
||||
<input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug} />
|
||||
<label htmlFor="chkDebug">Show debug data</label>
|
||||
</div>
|
||||
</section>
|
||||
<div style={{display: this.state.isDebug? '': 'none'}}>
|
||||
<div style={{ display: this.state.isDebug ? '' : 'none' }}>
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
<footer>
|
||||
<button className="brand" onClick={this.onCreatePenpot}>
|
||||
|
@ -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>
|
||||
<PenpotExporter />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('penpot-export-page')
|
||||
<PenpotExporter />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
4
stylelint.config.mjs
Normal file
4
stylelint.config.mjs
Normal file
|
@ -0,0 +1,4 @@
|
|||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: ['stylelint-config-recommended', 'stylelint-config-standard']
|
||||
};
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react",
|
||||
"typeRoots": [
|
||||
"./node_modules/@types",
|
||||
"./node_modules/@figma"
|
||||
],
|
||||
"moduleResolution":"node"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
||||
"lib": ["DOM", "ES6"],
|
||||
"target": "ES6",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node10",
|
||||
"strict": true,
|
||||
"typeRoots": ["src/penpot.d.ts", "node_modules/@figma", "node_modules/@types"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
|
||||
const path = require('path')
|
||||
const webpack = require('webpack')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const path = require('path');
|
||||
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
|
||||
const webpack = require('webpack');
|
||||
|
||||
module.exports = (env, argv) => ({
|
||||
mode: argv.mode === 'production' ? 'production' : 'development',
|
||||
|
@ -12,7 +13,7 @@ module.exports = (env, argv) => ({
|
|||
|
||||
entry: {
|
||||
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: {
|
||||
|
@ -27,7 +28,7 @@ module.exports = (env, argv) => ({
|
|||
// Enables including CSS by doing "import './file.css'" in your TypeScript code
|
||||
{
|
||||
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
|
||||
// { 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'"
|
||||
resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'] ,
|
||||
fallback: { "crypto": false }},
|
||||
resolve: { extensions: ['.tsx', '.ts', '.jsx', '.js'], fallback: { crypto: false } },
|
||||
|
||||
output: {
|
||||
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
|
||||
plugins: [
|
||||
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({
|
||||
inject: "body",
|
||||
inject: 'body',
|
||||
template: './src/ui.html',
|
||||
filename: 'ui.html',
|
||||
chunks: ['ui']
|
||||
}),
|
||||
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/]),
|
||||
],
|
||||
})
|
||||
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/ui/])
|
||||
]
|
||||
});
|
Loading…
Reference in a new issue