mirror of
https://github.com/penpot/penpot-exporter-figma-plugin.git
synced 2024-12-22 13:43:03 -05:00
commit
cedb9fc85d
5 changed files with 41 additions and 138 deletions
27
src/code.ts
27
src/code.ts
|
@ -43,27 +43,21 @@ async function traverse(node: BaseNode): Promise<NodeData | TextData> {
|
||||||
y: 'y' in node ? node.y : 0,
|
y: 'y' in node ? node.y : 0,
|
||||||
width: 'width' in node ? node.width : 0,
|
width: 'width' in node ? node.width : 0,
|
||||||
height: 'height' in node ? node.height : 0,
|
height: 'height' in node ? node.height : 0,
|
||||||
fills: 'fills' in node ? (node.fills === figma.mixed ? [] : node.fills) : [] // TODO: Support mixed fills
|
fills: 'fills' in node ? (node.fills === figma.mixed ? [] : node.fills) : [], // TODO: Support mixed fills
|
||||||
|
imageFill: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.fills) {
|
if (result.fills) {
|
||||||
// Find any fill of type image
|
// Find any fill of type image
|
||||||
const imageFill = result.fills.find(fill => fill.type === 'IMAGE');
|
const imageFill = result.fills.find(fill => fill.type === 'IMAGE');
|
||||||
if (imageFill) {
|
if (imageFill && 'exportAsync' in node) {
|
||||||
// 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.
|
||||||
'exportAsync' in node &&
|
const value = await node.exportAsync({ format: 'PNG' });
|
||||||
node.exportAsync({ format: 'PNG' }).then(value => {
|
|
||||||
const b64 = figma.base64Encode(value);
|
const b64 = figma.base64Encode(value);
|
||||||
figma.ui.postMessage({
|
|
||||||
type: 'IMAGE',
|
result.imageFill = 'data:' + detectMimeType(b64) + ';base64,' + b64;
|
||||||
data: {
|
|
||||||
id: node.id,
|
|
||||||
value: 'data:' + detectMimeType(b64) + ';base64,' + b64
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,10 +99,11 @@ async function traverse(node: BaseNode): Promise<NodeData | TextData> {
|
||||||
|
|
||||||
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
|
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
|
||||||
|
|
||||||
const root: NodeData | TextData = await traverse(figma.root); // start the traversal at the root
|
figma.ui.onmessage = async msg => {
|
||||||
figma.ui.postMessage({ type: 'FIGMAFILE', data: root });
|
if (msg.type === 'export') {
|
||||||
|
const root: NodeData | TextData = await traverse(figma.root); // start the traversal at the root
|
||||||
figma.ui.onmessage = msg => {
|
figma.ui.postMessage({ type: 'FIGMAFILE', data: root });
|
||||||
|
}
|
||||||
if (msg.type === 'cancel') {
|
if (msg.type === 'cancel') {
|
||||||
figma.closePlugin();
|
figma.closePlugin();
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ export type NodeData = {
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
fills: readonly Paint[];
|
fills: readonly Paint[];
|
||||||
|
imageFill?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TextDataChildren = Pick<
|
export type TextDataChildren = Pick<
|
||||||
|
|
|
@ -57,6 +57,12 @@ button:focus-visible {
|
||||||
outline-color: var(--color-border-focus);
|
outline-color: var(--color-border-focus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: black;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
140
src/ui.tsx
140
src/ui.tsx
|
@ -2,40 +2,22 @@ import * as React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
|
|
||||||
|
import fonts from './gfonts.json';
|
||||||
import { NodeData, TextData, TextDataChildren } from './interfaces';
|
import { NodeData, TextData, TextDataChildren } from './interfaces';
|
||||||
import { PenpotFile, createFile } from './penpot';
|
import { PenpotFile, createFile } from './penpot';
|
||||||
import './ui.css';
|
import './ui.css';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const gfonts = new Set(fonts);
|
||||||
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: string) => gfonts.add(font));
|
|
||||||
|
|
||||||
type FigmaImageData = {
|
|
||||||
value: string;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PenpotExporterState = {
|
type PenpotExporterState = {
|
||||||
isDebug: boolean;
|
|
||||||
penpotFileData: string;
|
|
||||||
missingFonts: Set<string>;
|
missingFonts: Set<string>;
|
||||||
figmaFileData: string;
|
exporting: boolean;
|
||||||
figmaRootNode: NodeData | null;
|
|
||||||
images: { [id: string]: FigmaImageData };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class PenpotExporter extends React.Component<unknown, PenpotExporterState> {
|
export default class PenpotExporter extends React.Component<unknown, PenpotExporterState> {
|
||||||
state: PenpotExporterState = {
|
state: PenpotExporterState = {
|
||||||
isDebug: false,
|
|
||||||
penpotFileData: '',
|
|
||||||
figmaFileData: '',
|
|
||||||
missingFonts: new Set(),
|
missingFonts: new Set(),
|
||||||
figmaRootNode: null,
|
exporting: false
|
||||||
images: {}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount = () => {
|
componentDidMount = () => {
|
||||||
|
@ -292,24 +274,18 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createPenpotImage(
|
createPenpotImage(file: PenpotFile, node: NodeData, baseX: number, baseY: number) {
|
||||||
file: PenpotFile,
|
|
||||||
node: NodeData,
|
|
||||||
baseX: number,
|
|
||||||
baseY: number,
|
|
||||||
image: FigmaImageData
|
|
||||||
) {
|
|
||||||
file.createImage({
|
file.createImage({
|
||||||
name: node.name,
|
name: node.name,
|
||||||
x: node.x + baseX,
|
x: node.x + baseX,
|
||||||
y: node.y + baseY,
|
y: node.y + baseY,
|
||||||
width: image.width,
|
width: node.width,
|
||||||
height: image.height,
|
height: node.height,
|
||||||
metadata: {
|
metadata: {
|
||||||
width: image.width,
|
width: node.width,
|
||||||
height: image.height
|
height: node.height
|
||||||
},
|
},
|
||||||
dataUri: image.value
|
dataUri: node.imageFill
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,13 +318,7 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
// height of the image.
|
// height of the image.
|
||||||
const [adjustedX, adjustedY] = this.calculateAdjustment(node);
|
const [adjustedX, adjustedY] = this.calculateAdjustment(node);
|
||||||
|
|
||||||
this.createPenpotImage(
|
this.createPenpotImage(file, node, baseX + adjustedX, baseY + adjustedY);
|
||||||
file,
|
|
||||||
node,
|
|
||||||
baseX + adjustedX,
|
|
||||||
baseY + adjustedY,
|
|
||||||
this.state.images[node.id]
|
|
||||||
);
|
|
||||||
} else if (node.type == 'PAGE') {
|
} else if (node.type == 'PAGE') {
|
||||||
this.createPenpotPage(file, node);
|
this.createPenpotPage(file, node);
|
||||||
} else if (node.type == 'FRAME') {
|
} else if (node.type == 'FRAME') {
|
||||||
|
@ -364,13 +334,7 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createPenpotFile() {
|
createPenpotFile(node: NodeData) {
|
||||||
const node = this.state.figmaRootNode;
|
|
||||||
|
|
||||||
if (node === null) {
|
|
||||||
throw new Error('No Figma file data found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = createFile(node.name);
|
const file = createFile(node.name);
|
||||||
for (const page of node.children) {
|
for (const page of node.children) {
|
||||||
this.createPenpotItem(file, page, 0, 0);
|
this.createPenpotItem(file, page, 0, 0);
|
||||||
|
@ -379,16 +343,8 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
}
|
}
|
||||||
|
|
||||||
onCreatePenpot = () => {
|
onCreatePenpot = () => {
|
||||||
const file = this.createPenpotFile();
|
this.setState(() => ({ exporting: true }));
|
||||||
const penpotFileMap = file.asMap();
|
parent.postMessage({ pluginMessage: { type: 'export' } }, '*');
|
||||||
this.setState(() => ({
|
|
||||||
penpotFileData: JSON.stringify(
|
|
||||||
penpotFileMap,
|
|
||||||
(key, value) => (value instanceof Map ? [...value] : value),
|
|
||||||
4
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
file.export();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onCancel = () => {
|
onCancel = () => {
|
||||||
|
@ -398,30 +354,9 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onMessage = (event: any) => {
|
onMessage = (event: any) => {
|
||||||
if (event.data.pluginMessage.type == 'FIGMAFILE') {
|
if (event.data.pluginMessage.type == 'FIGMAFILE') {
|
||||||
this.setState(() => ({
|
const file = this.createPenpotFile(event.data.pluginMessage.data);
|
||||||
figmaFileData: JSON.stringify(
|
file.export();
|
||||||
event.data.pluginMessage.data,
|
this.setState(() => ({ exporting: false }));
|
||||||
(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');
|
|
||||||
|
|
||||||
image.addEventListener('load', () => {
|
|
||||||
// Get byte array from response
|
|
||||||
this.setState(state => {
|
|
||||||
state.images[data.id] = {
|
|
||||||
value: data.value,
|
|
||||||
width: image.naturalWidth,
|
|
||||||
height: image.naturalHeight
|
|
||||||
};
|
|
||||||
return state;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
image.src = data.value;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -434,19 +369,8 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
if (isMissingFonts) {
|
if (isMissingFonts) {
|
||||||
height += this.state.missingFonts.size * 20;
|
height += this.state.missingFonts.size * 20;
|
||||||
width = 400;
|
width = 400;
|
||||||
}
|
|
||||||
|
|
||||||
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: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const isDebug = event.currentTarget.checked;
|
|
||||||
this.setState(() => ({ isDebug: isDebug }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderFontWarnings = () => {
|
renderFontWarnings = () => {
|
||||||
|
@ -476,34 +400,10 @@ export default class PenpotExporter extends React.Component<unknown, PenpotExpor
|
||||||
<small>Ensure fonts are installed in Penpot before importing.</small>
|
<small>Ensure fonts are installed in Penpot before importing.</small>
|
||||||
<div id="missing-fonts-list">{this.renderFontWarnings()}</div>
|
<div id="missing-fonts-list">{this.renderFontWarnings()}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<input type="checkbox" id="chkDebug" name="chkDebug" onChange={this.toggleDebug} />
|
|
||||||
<label htmlFor="chkDebug">Show debug data</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<div style={{ display: this.state.isDebug ? '' : 'none' }}>
|
|
||||||
<section>
|
|
||||||
<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
|
|
||||||
/>
|
|
||||||
<label htmlFor="penpot-file-data">Penpot file data</label>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<footer>
|
<footer>
|
||||||
<button className="brand" onClick={this.onCreatePenpot}>
|
<button className="brand" disabled={this.state.exporting} onClick={this.onCreatePenpot}>
|
||||||
Export
|
{this.state.exporting ? 'Exporting...' : 'Export to Penpot'}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={this.onCancel}>Cancel</button>
|
<button onClick={this.onCancel}>Cancel</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Node10",
|
"moduleResolution": "Node10",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"typeRoots": ["src/penpot.d.ts", "node_modules/@figma", "node_modules/@types"]
|
"typeRoots": ["src/penpot.d.ts", "node_modules/@figma", "node_modules/@types"],
|
||||||
|
"resolveJsonModule": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue