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:
parent
4ded73e0e9
commit
c013e80962
27 changed files with 414 additions and 1040 deletions
5
.changeset/brown-pigs-end.md
Normal file
5
.changeset/brown-pigs-end.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"penpot-exporter": minor
|
||||
---
|
||||
|
||||
Basic support for custom fonts
|
|
@ -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
1003
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
});
|
||||
};
|
||||
|
|
40
plugin-src/findAllTextnodes.ts
Normal file
40
plugin-src/findAllTextnodes.ts
Normal 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);
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
export function handleCancelMessage() {
|
||||
figma.closePlugin();
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export function handleResizeMessage(width: number, height: number) {
|
||||
figma.ui.resize(width, height);
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
export * from './handleCancelMessage';
|
||||
export * from './handleExportMessage';
|
||||
export * from './handleResizeMessage';
|
9
plugin-src/translators/text/custom/customFontIds.ts
Normal file
9
plugin-src/translators/text/custom/customFontIds.ts
Normal 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);
|
||||
};
|
|
@ -1 +1,3 @@
|
|||
export * from './customFontIds';
|
||||
export * from './translateCustomFont';
|
||||
export * from './translateFontVariantId';
|
||||
|
|
|
@ -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)
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
export const translateFontVariantId = (fontName: FontName, fontWeight: number) => {
|
||||
const style = fontName.style.toLowerCase().includes('italic') ? 'italic' : 'normal';
|
||||
|
||||
return `${style}-${fontWeight.toString()}`;
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,6 @@ export const translateFontId = (fontName: FontName, fontWeight: number): FontId
|
|||
return (
|
||||
translateGoogleFont(fontName, fontWeight) ??
|
||||
translateLocalFont(fontName, fontWeight) ??
|
||||
translateCustomFont(fontName)
|
||||
translateCustomFont(fontName, fontWeight)
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
12
ui-src/App.tsx
Normal 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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
9
ui-src/components/Loader.tsx
Normal file
9
ui-src/components/Loader.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
type LoaderProps = {
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export const Loader = ({ loading }: LoaderProps) => {
|
||||
if (!loading) return;
|
||||
|
||||
return <section>Checking for missing fonts...</section>;
|
||||
};
|
34
ui-src/components/MissingFontsSection.tsx
Normal file
34
ui-src/components/MissingFontsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
78
ui-src/components/PenpotExporter.tsx
Normal file
78
ui-src/components/PenpotExporter.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"moduleResolution": "Node",
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue