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

Custom Fonts (#82)

* wip

* moved logic to validate custom fonts

* missing fonts section

* lint

* refactor and improve

* simplify resize

* refactor

* Implemente custom fonts

* styling plugin

* styling plugin

* minor styling fix

* changeset

* fix

* fix eslint

---------

Co-authored-by: Jordi Sala Morales <jordism91@gmail.com>
This commit is contained in:
Alex Sánchez 2024-05-03 13:43:07 +02:00 committed by GitHub
parent 4ded73e0e9
commit c013e80962
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 414 additions and 1040 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Basic support for custom fonts

View file

@ -1,6 +1,7 @@
module.exports = {
root: true,
parserOptions: {
project: './tsconfig.base.json',
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module'

1003
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -23,8 +23,9 @@
"author": "Kaleidos",
"license": "MPL2.0",
"dependencies": {
"react": "^18.2",
"react-dom": "^18.2",
"react": "^18.3",
"react-dom": "^18.3",
"react-hook-form": "^7.51",
"slugify": "^1.6",
"svg-path-parser": "^1.1"
},
@ -32,13 +33,13 @@
"@changesets/changelog-github": "^0.5",
"@changesets/cli": "^2.27",
"@figma/eslint-plugin-figma-plugins": "^0.15",
"@figma/plugin-typings": "^1.90",
"@figma/plugin-typings": "^1.92",
"@trivago/prettier-plugin-sort-imports": "^4.3",
"@types/react": "^18.2",
"@types/react-dom": "^18.2",
"@typescript-eslint/eslint-plugin": "^7.5",
"@typescript-eslint/parser": "^7.5",
"@types/react": "^18.3",
"@types/react-dom": "^18.3",
"@types/svg-path-parser": "^1.1",
"@typescript-eslint/eslint-plugin": "^7.8",
"@typescript-eslint/parser": "^7.8",
"@vitejs/plugin-react-swc": "^3.6",
"concurrently": "^8.2",
"esbuild": "^0.20",
@ -47,9 +48,8 @@
"eslint-plugin-prettier": "^5.1",
"eslint-plugin-react": "^7.34",
"prettier": "^3.2",
"stylelint": "^16.3",
"stylelint": "^16.4",
"stylelint-config-standard": "^36.0",
"tsconfig-paths-webpack-plugin": "^4.1",
"typescript": "^5.4",
"vite": "^5.2",
"vite-plugin-singlefile": "^2.0",

View file

@ -1,19 +1,33 @@
import {
handleCancelMessage,
handleExportMessage,
handleResizeMessage
} from '@plugin/messageHandlers';
import { transformDocumentNode } from '@plugin/transformers';
figma.showUI(__html__, { themeColors: true, height: 200, width: 300 });
import { findAllTextNodes } from './findAllTextnodes';
import { setCustomFontId } from './translators/text/custom';
figma.ui.onmessage = async msg => {
if (msg.type === 'export') {
await handleExportMessage();
figma.showUI(__html__, { themeColors: true, height: 300, width: 400 });
figma.ui.onmessage = message => {
if (message.type === 'ready') {
findAllTextNodes();
}
if (msg.type === 'cancel') {
handleCancelMessage();
if (message.type === 'export') {
handleExportMessage(message.data as Record<string, string>);
}
if (msg.type === 'resize') {
handleResizeMessage(msg.width, msg.height);
if (message.type === 'cancel') {
figma.closePlugin();
}
};
const handleExportMessage = async (missingFontIds: Record<string, string>) => {
await figma.loadAllPagesAsync();
Object.entries(missingFontIds).forEach(([fontFamily, fontId]) => {
setCustomFontId(fontFamily, fontId);
});
figma.ui.postMessage({
type: 'PENPOT_DOCUMENT',
data: await transformDocumentNode(figma.root)
});
};

View file

@ -0,0 +1,40 @@
import { isGoogleFont } from './translators/text/gfonts';
import { isLocalFont } from './translators/text/local';
export const findAllTextNodes = async () => {
await figma.loadAllPagesAsync();
const nodes = figma.root.findAllWithCriteria({
types: ['TEXT']
});
const fonts = new Set<string>();
nodes.forEach(node => {
const styledTextSegments = node.getStyledTextSegments(['fontName']);
styledTextSegments.forEach(segment => {
if (isGoogleFont(segment.fontName) || isLocalFont(segment.fontName)) {
return;
}
fonts.add(segment.fontName.family);
});
});
figma.ui.postMessage({
type: 'CUSTOM_FONTS',
data: Array.from(fonts)
});
const maxHeight = 300;
if (fonts.size === 0) return;
if (fonts.size * 40 > maxHeight) {
figma.ui.resize(400, 300 + maxHeight);
return;
}
figma.ui.resize(400, 300 + fonts.size * 40);
};

View file

@ -1,3 +0,0 @@
export function handleCancelMessage() {
figma.closePlugin();
}

View file

@ -1,8 +0,0 @@
import { transformDocumentNode } from '@plugin/transformers';
export async function handleExportMessage() {
await figma.loadAllPagesAsync();
const penpotNode = await transformDocumentNode(figma.root);
figma.ui.postMessage({ type: 'FIGMAFILE', data: penpotNode });
}

View file

@ -1,3 +0,0 @@
export function handleResizeMessage(width: number, height: number) {
figma.ui.resize(width, height);
}

View file

@ -1,3 +0,0 @@
export * from './handleCancelMessage';
export * from './handleExportMessage';
export * from './handleResizeMessage';

View file

@ -0,0 +1,9 @@
const customFontIds = new Map<string, string>();
export const getCustomFontId = (fontName: FontName) => {
return customFontIds.get(fontName.family);
};
export const setCustomFontId = (fontFamily: string, fontId: string) => {
customFontIds.set(fontFamily, fontId);
};

View file

@ -1 +1,3 @@
export * from './customFontIds';
export * from './translateCustomFont';
export * from './translateFontVariantId';

View file

@ -1,17 +1,10 @@
import slugify from 'slugify';
import { getCustomFontId, translateFontVariantId } from '@plugin/translators/text/custom';
import { FontId } from '@ui/lib/types/text/textContent';
/**
* @TODO: implement custom font loading for Penpot
*/
export const translateCustomFont = (fontName: FontName): FontId | undefined => {
// For now display a message in the UI, so the user knows
// that the file is using a custom font not present in Penpot
figma.ui.postMessage({ type: 'FONT_NAME', data: fontName.family });
export const translateCustomFont = (fontName: FontName, fontWeight: number): FontId | undefined => {
return {
fontId: slugify(fontName.family.toLowerCase()),
fontVariantId: fontName.style.toLowerCase().replace(/\s/g, '')
fontId: `custom-${getCustomFontId(fontName)}`,
fontVariantId: translateFontVariantId(fontName, fontWeight)
};
};

View file

@ -0,0 +1,5 @@
export const translateFontVariantId = (fontName: FontName, fontWeight: number) => {
const style = fontName.style.toLowerCase().includes('italic') ? 'italic' : 'normal';
return `${style}-${fontWeight.toString()}`;
};

View file

@ -18,6 +18,10 @@ export const translateGoogleFont = (fontName: FontName, fontWeight: number): Fon
};
};
export const isGoogleFont = (fontName: FontName): boolean => {
return getGoogleFont(fontName) !== undefined;
};
const getGoogleFont = (fontName: FontName): GoogleFont | undefined => {
return gfonts.find(font => font.family === fontName.family);
};

View file

@ -15,6 +15,10 @@ export const translateLocalFont = (fontName: FontName, fontWeight: number): Font
};
};
export const isLocalFont = (fontName: FontName): boolean => {
return getLocalFont(fontName) !== undefined;
};
const getLocalFont = (fontName: FontName): LocalFont | undefined => {
return localFonts.find(localFont => localFont.name === fontName.family);
};

View file

@ -8,6 +8,6 @@ export const translateFontId = (fontName: FontName, fontWeight: number): FontId
return (
translateGoogleFont(fontName, fontWeight) ??
translateLocalFont(fontName, fontWeight) ??
translateCustomFont(fontName)
translateCustomFont(fontName, fontWeight)
);
};

View file

@ -1,8 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "es6",
"lib": ["es6"],
"target": "ES2017",
"lib": ["ES2017"],
"strict": true,
"typeRoots": ["../node_modules/@figma"],
"moduleResolution": "Node",

12
ui-src/App.tsx Normal file
View file

@ -0,0 +1,12 @@
import Logo from '@ui/assets/logo.svg?react';
import { PenpotExporter } from '@ui/components/PenpotExporter';
export const App = () => (
<main>
<header>
<Logo />
<h2>Penpot Exporter</h2>
</header>
<PenpotExporter />
</main>
);

View file

@ -1,94 +0,0 @@
import { useEffect, useState } from 'react';
import { createPenpotFile } from '@ui/converters';
import { PenpotDocument } from '@ui/lib/types/penpotDocument';
import Logo from './logo.svg?react';
export const PenpotExporter = () => {
const [missingFonts, setMissingFonts] = useState(new Set<string>());
const [exporting, setExporting] = useState(false);
const addFontWarning = (font: string) => {
setMissingFonts(missingFonts => missingFonts.add(font));
};
const onMessage = (event: MessageEvent<{ pluginMessage: { type: string; data: unknown } }>) => {
if (event.data.pluginMessage?.type == 'FIGMAFILE') {
const document = event.data.pluginMessage.data as PenpotDocument;
const file = createPenpotFile(document);
file.export();
setExporting(false);
} else if (event.data.pluginMessage?.type == 'FONT_NAME') {
addFontWarning(event.data.pluginMessage.data as string);
}
};
const onCreatePenpot = () => {
setExporting(true);
parent.postMessage({ pluginMessage: { type: 'export' } }, '*');
};
const onCancel = () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
};
const setDimensions = () => {
const isMissingFonts = missingFonts.size > 0;
let width = 300;
let height = 280;
if (isMissingFonts) {
height += missingFonts.size * 20;
width = 400;
parent.postMessage({ pluginMessage: { type: 'resize', width: width, height: height } }, '*');
}
};
useEffect(() => {
window.addEventListener('message', onMessage);
return () => {
window.removeEventListener('message', onMessage);
};
}, []);
useEffect(() => {
setDimensions();
}, [missingFonts]);
return (
<main>
<header>
<Logo />
<h2>Penpot Exporter</h2>
</header>
<section>
<div style={{ display: missingFonts.size > 0 ? 'inline' : 'none' }}>
<div id="missing-fonts">
{missingFonts.size} non-default font
{missingFonts.size > 1 ? 's' : ''}:{' '}
</div>
<small>Ensure fonts are installed in Penpot before importing.</small>
<div id="missing-fonts-list">
<ul>
{Array.from(missingFonts).map(font => (
<li key={font}>{font}</li>
))}
</ul>
</div>
</div>
</section>
<footer>
<button className="brand" disabled={exporting} onClick={onCreatePenpot}>
{exporting ? 'Exporting...' : 'Export to Penpot'}
</button>
<button onClick={onCancel}>Cancel</button>
</footer>
</main>
);
};

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,9 @@
type LoaderProps = {
loading: boolean;
};
export const Loader = ({ loading }: LoaderProps) => {
if (!loading) return;
return <section>Checking for missing fonts...</section>;
};

View file

@ -0,0 +1,34 @@
import { useFormContext } from 'react-hook-form';
type MissingFontsSectionProps = {
fonts?: string[];
};
export const MissingFontsSection = ({ fonts }: MissingFontsSectionProps) => {
const { register } = useFormContext();
if (fonts === undefined || !fonts.length) return;
return (
<section className="missing-fonts-section">
<div className="missing-fonts-header">
{fonts.length} missing font{fonts.length > 1 ? 's' : ''}:{' '}
</div>
<small className="font-install-message">
Ensure fonts are installed in Penpot before exporting.
</small>
<div className="missing-fonts-list">
{fonts.map(font => (
<div key={font} className="font-input-row">
<span className="font-name">{font}</span>
<input
className="font-id-input"
placeholder="Enter Penpot font id"
{...register(font)}
/>
</div>
))}
</div>
</section>
);
};

View file

@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { createPenpotFile } from '@ui/converters';
import { PenpotDocument } from '@ui/lib/types/penpotDocument';
import { Loader } from './Loader';
import { MissingFontsSection } from './MissingFontsSection';
type FormValues = Record<string, string>;
export const PenpotExporter = () => {
const [missingFonts, setMissingFonts] = useState<string[]>();
const [exporting, setExporting] = useState(false);
const methods = useForm<FormValues>();
methods.getValues();
const onMessage = (event: MessageEvent<{ pluginMessage: { type: string; data: unknown } }>) => {
if (event.data.pluginMessage?.type == 'PENPOT_DOCUMENT') {
const document = event.data.pluginMessage.data as PenpotDocument;
const file = createPenpotFile(document);
file.export();
setExporting(false);
} else if (event.data.pluginMessage?.type == 'CUSTOM_FONTS') {
setMissingFonts(event.data.pluginMessage.data as string[]);
}
};
const exportPenpot = (data: FormValues) => {
setExporting(true);
parent.postMessage(
{
pluginMessage: {
type: 'export',
data
}
},
'*'
);
};
const cancel = () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*');
};
useEffect(() => {
window.addEventListener('message', onMessage);
parent.postMessage({ pluginMessage: { type: 'ready' } }, '*');
return () => {
window.removeEventListener('message', onMessage);
};
}, []);
const pluginReady = missingFonts !== undefined;
return (
<FormProvider {...methods}>
<form className="centered-form" onSubmit={methods.handleSubmit(exportPenpot)}>
<Loader loading={!pluginReady} />
<div className="missing-fonts-form-container">
<MissingFontsSection fonts={missingFonts} />
</div>
<footer>
<button type="submit" className="brand" disabled={exporting || !pluginReady}>
{exporting ? 'Exporting...' : 'Export to Penpot'}
</button>
<button onClick={cancel}>Cancel</button>
</footer>
</form>
</FormProvider>
);
};

View file

@ -123,3 +123,54 @@ ul {
padding: 0;
list-style-type: none;
}
.centered-form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.missing-fonts-form-container {
width: 100%;
margin: 0 auto 10px;
max-height: 300px;
overflow: auto;
}
.missing-fonts-section {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
.missing-fonts-list {
display: flex;
flex-direction: column;
align-items: stretch;
width: 90%;
}
.font-input-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.font-name {
flex: 0 0 auto;
width: 100%;
max-width: 30%;
text-align: center;
white-space: normal;
}
.font-id-input {
flex: 1;
width: 70%;
max-width: 70%;
margin-left: 10px;
padding: 5px;
box-sizing: border-box;
}

View file

@ -1,11 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { PenpotExporter } from './PenpotExporter';
import { App } from './App';
import './main.css';
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<PenpotExporter />
<App />
</StrictMode>
);

View file

@ -13,6 +13,7 @@
"moduleResolution": "Node",
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
}
}