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

Implement unordered and ordered lists (#88)

* wip

* implement bullet points

* needs more ifs

* refactor

* revert

* refactor

* fix and refactor

* refactor

* refactor

* refactor

* add ordered class

* wip

* wip

* fixes

* package.json

* little refactors

* base list

* fixes

* abstract baselist

* refactors

* refactor

* changeset

* fix eslint issue

* fix

* fixes

* fixes

---------

Co-authored-by: Alex Sánchez <sion333@gmail.com>
This commit is contained in:
Jordi Sala Morales 2024-05-07 12:18:15 +02:00 committed by GitHub
parent afa47af0a6
commit 2920ac297b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 362 additions and 123 deletions

View file

@ -0,0 +1,5 @@
---
"penpot-exporter": minor
---
Ordered and unordered list support

View file

@ -21,6 +21,9 @@ module.exports = {
}
},
rules: {
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }]
'@typescript-eslint/no-unused-vars': [
'error',
{ ignoreRestSiblings: true, argsIgnorePattern: '^_' }
]
}
};

6
package-lock.json generated
View file

@ -12,6 +12,7 @@
"react": "^18.3",
"react-dom": "^18.3",
"react-hook-form": "^7.51",
"romans": "^2.0",
"slugify": "^1.6",
"svg-path-parser": "^1.1"
},
@ -6550,6 +6551,11 @@
"fsevents": "~2.3.2"
}
},
"node_modules/romans": {
"version": "2.0.15",
"resolved": "https://registry.npmjs.org/romans/-/romans-2.0.15.tgz",
"integrity": "sha512-/0/Wdz+Q948fkUlBt+JUgkxdYAmlBStLoSIqpBxaEDg9NGORrGaMCu9iYk8eRsiwRe2cLWflJswZP6g9vGIE1w=="
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",

View file

@ -26,6 +26,7 @@
"react": "^18.3",
"react-dom": "^18.3",
"react-hook-form": "^7.51",
"romans": "^2.0",
"slugify": "^1.6",
"svg-path-parser": "^1.1"
},

View file

@ -1,7 +1,7 @@
import { transformDocumentNode } from '@plugin/transformers';
import { findAllTextNodes } from './findAllTextnodes';
import { setCustomFontId } from './translators/text/custom';
import { setCustomFontId } from './translators/text/font/custom';
figma.showUI(__html__, { themeColors: true, height: 300, width: 400 });

View file

@ -1,5 +1,5 @@
import { isGoogleFont } from './translators/text/gfonts';
import { isLocalFont } from './translators/text/local';
import { isGoogleFont } from './translators/text/font/gfonts';
import { isLocalFont } from './translators/text/font/local';
export const findAllTextNodes = async () => {
await figma.loadAllPagesAsync();

View file

@ -1,10 +1,6 @@
import { transformFills } from '@plugin/transformers/partials';
import {
transformTextStyle,
translateGrowType,
translateStyleTextSegments,
translateVerticalAlign
} from '@plugin/translators/text';
import { transformTextStyle, translateStyleTextSegments } from '@plugin/translators/text';
import { translateGrowType, translateVerticalAlign } from '@plugin/translators/text/properties';
import { TextShape } from '@ui/lib/types/shapes/textShape';
@ -17,6 +13,8 @@ export const transformText = (node: TextNode): Partial<TextShape> => {
'letterSpacing',
'textCase',
'textDecoration',
'indentation',
'listOptions',
'fills'
]);

View file

@ -1,5 +1,5 @@
export * from './translateBlendMode';
export * from './translateShadowEffects';
export * from './translateFills';
export * from './translateShadowEffects';
export * from './translateStrokes';
export * from './translateVectorPaths';

View file

@ -1,4 +1,4 @@
import { getCustomFontId, translateFontVariantId } from '@plugin/translators/text/custom';
import { getCustomFontId, translateFontVariantId } from '@plugin/translators/text/font/custom';
import { FontId } from '@ui/lib/types/shapes/textShape';

View file

@ -1,3 +1,3 @@
export * from './googleFont';
export * from './translateGoogleFont';
export * from './translateFontVariantId';
export * from './translateGoogleFont';

View file

@ -1,6 +1,6 @@
import slugify from 'slugify';
import { translateFontVariantId } from '@plugin/translators/text/gfonts';
import { translateFontVariantId } from '@plugin/translators/text/font/gfonts';
import { FontId } from '@ui/lib/types/shapes/textShape';

View file

@ -0,0 +1 @@
export * from './translateFontId';

View file

@ -1,3 +1,3 @@
export * from './localFont';
export * from './translateLocalFont';
export * from './translateFontVariantId';
export * from './translateLocalFont';

View file

@ -1,4 +1,4 @@
import { LocalFont, translateFontVariantId } from '@plugin/translators/text/local';
import { LocalFont, translateFontVariantId } from '@plugin/translators/text/font/local';
import { FontId } from '@ui/lib/types/shapes/textShape';

View file

@ -1,11 +1 @@
export * from './translateFontId';
export * from './translateFontStyle';
export * from './translateGrowType';
export * from './translateHorizontalAlign';
export * from './translateLetterSpacing';
export * from './translateLineHeight';
export * from './translateParagraphProperties';
export * from './translateStyleTextSegments';
export * from './translateTextDecoration';
export * from './translateTextTransform';
export * from './translateVerticalAlign';

View file

@ -0,0 +1,79 @@
import { StyleTextSegment } from '@plugin/translators/text/paragraph/translateParagraphProperties';
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
import { ListTypeFactory } from './ListTypeFactory';
type Level = {
style: PenpotTextNode;
counter: number;
type: ListType;
};
type ListType = 'ORDERED' | 'UNORDERED';
export class List {
private levels: Map<number, Level> = new Map();
private indentation = 0;
protected counter: number[] = [];
private listTypeFactory = new ListTypeFactory();
public update(textNode: PenpotTextNode, segment: StyleTextSegment): void {
if (segment.indentation < this.indentation) {
for (let i = segment.indentation + 1; i <= this.indentation; i++) {
this.levels.delete(i);
}
}
let level = this.levels.get(segment.indentation);
if (!level || level.type !== this.getListType(segment)) {
level = {
style: this.createStyle(textNode, segment.indentation),
counter: 0,
type: this.getListType(segment)
};
this.levels.set(segment.indentation, level);
}
level.counter++;
this.indentation = segment.indentation;
}
public getCurrentList(textNode: PenpotTextNode, segment: StyleTextSegment): PenpotTextNode {
const level = this.levels.get(segment.indentation);
if (level === undefined) {
throw new Error('Levels not updated');
}
const listType = this.listTypeFactory.getListType(segment.listOptions);
return this.updateCurrentSymbol(
listType.getCurrentSymbol(level.counter, segment.indentation),
level.style
);
}
private getListType(segment: StyleTextSegment): ListType {
if (segment.listOptions.type === 'NONE') {
throw new Error('List type not valid');
}
return segment.listOptions.type;
}
private createStyle(node: PenpotTextNode, indentation: number): PenpotTextNode {
return {
...node,
text: `${'\t'.repeat(Math.max(0, indentation - 1))}{currentSymbol}`
};
}
private updateCurrentSymbol(character: string, currentStyle: PenpotTextNode): PenpotTextNode {
return {
...currentStyle,
text: currentStyle.text.replace('{currentSymbol}', character)
};
}
}

View file

@ -0,0 +1,3 @@
export interface ListType {
getCurrentSymbol(number: number, indentation: number): string;
}

View file

@ -0,0 +1,19 @@
import { ListType } from './ListType';
import { OrderedListType } from './OrderedListType';
import { UnorderedListType } from './UnorderedListType';
export class ListTypeFactory {
private unorderedList = new UnorderedListType();
private orderedList = new OrderedListType();
public getListType(textListOptions: TextListOptions): ListType {
switch (textListOptions.type) {
case 'ORDERED':
return this.orderedList;
case 'UNORDERED':
return this.unorderedList;
}
throw new Error('List type not valid');
}
}

View file

@ -0,0 +1,42 @@
import * as romans from 'romans';
import { ListType } from './ListType';
export class OrderedListType implements ListType {
public getCurrentSymbol(number: number, indentation: number): string {
let symbol = '. ';
switch (indentation % 3) {
case 0:
symbol = romans.romanize(number).toLowerCase() + symbol;
break;
case 2:
symbol = this.letterOrderedList(number) + symbol;
break;
case 1:
default:
symbol = number.toString() + symbol;
break;
}
return symbol;
}
private letterOrderedList(number: number): string {
let result = '';
while (number > 0) {
let letterCode = number % 26;
if (letterCode === 0) {
letterCode = 26;
number = Math.floor(number / 26) - 1;
} else {
number = Math.floor(number / 26);
}
result = String.fromCharCode(letterCode + 96) + result;
}
return result;
}
}

View file

@ -0,0 +1,93 @@
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
import { List } from './List';
import { StyleTextSegment } from './translateParagraphProperties';
export class Paragraph {
private isParagraphStarting = false;
private isPreviousNodeAList = false;
private firstTextNode: PenpotTextNode | null = null;
private list = new List();
public format(
node: TextNode,
textNode: PenpotTextNode,
segment: StyleTextSegment
): PenpotTextNode[] {
const textNodes: PenpotTextNode[] = [];
const spacing = this.applySpacing(segment, node);
if (spacing) textNodes.push(spacing);
const indentation = this.applyIndentation(textNode, segment, node);
if (indentation) textNodes.push(indentation);
textNodes.push(textNode);
this.isPreviousNodeAList = segment.listOptions.type !== 'NONE';
this.isParagraphStarting = textNode.text === '\n';
return textNodes;
}
private applyIndentation(
textNode: PenpotTextNode,
segment: StyleTextSegment,
node: TextNode
): PenpotTextNode | undefined {
if (this.isParagraphStarting || this.isFirstTextNode(textNode)) {
this.list.update(textNode, segment);
return segment.listOptions.type !== 'NONE'
? this.list.getCurrentList(textNode, segment)
: this.segmentIndent(node.paragraphIndent);
}
}
private applySpacing(segment: StyleTextSegment, node: TextNode): PenpotTextNode | undefined {
if (this.isParagraphStarting) {
const isList = segment.listOptions.type !== 'NONE';
return this.segmentParagraphSpacing(
this.isPreviousNodeAList && isList ? node.listSpacing : node.paragraphSpacing
);
}
}
private isFirstTextNode(textNode: PenpotTextNode) {
if (this.firstTextNode === null) {
this.firstTextNode = textNode;
return true;
}
return false;
}
private segmentIndent(indent: number): PenpotTextNode {
return {
text: ' '.repeat(indent),
fontId: 'sourcesanspro',
fontVariantId: 'regular',
fontSize: '5',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 1,
letterSpacing: 0
};
}
private segmentParagraphSpacing(paragraphSpacing: number): PenpotTextNode | undefined {
if (paragraphSpacing === 0) return;
return {
text: '\n',
fontId: 'sourcesanspro',
fontVariantId: 'regular',
fontSize: paragraphSpacing.toString(),
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 1,
letterSpacing: 0
};
}
}

View file

@ -0,0 +1,7 @@
import { ListType } from './ListType';
export class UnorderedListType implements ListType {
public getCurrentSymbol(_number: number, _indentation: number): string {
return ' • ';
}
}

View file

@ -0,0 +1,7 @@
export * from './List';
export * from './ListType';
export * from './ListTypeFactory';
export * from './OrderedListType';
export * from './Paragraph';
export * from './translateParagraphProperties';
export * from './UnorderedListType';

View file

@ -0,0 +1,66 @@
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
import { Paragraph } from './Paragraph';
export type StyleTextSegment = Pick<
StyledTextSegment,
| 'characters'
| 'start'
| 'end'
| 'fontName'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'textCase'
| 'textDecoration'
| 'indentation'
| 'listOptions'
| 'fills'
>;
type PartialTranslation = {
textNodes: PenpotTextNode[];
segment: StyleTextSegment;
};
export const translateParagraphProperties = (
node: TextNode,
partials: { textNode: PenpotTextNode; segment: StyleTextSegment }[]
): PenpotTextNode[] => {
const splitSegments: PartialTranslation[] = [];
partials.forEach(({ textNode, segment }) => {
splitSegments.push({
textNodes: splitTextNodeByEOL(textNode),
segment
});
});
return addParagraphProperties(node, splitSegments);
};
const splitTextNodeByEOL = (node: PenpotTextNode): PenpotTextNode[] => {
const split = node.text.split(/(\n)/).filter(text => text !== '');
return split.map(text => ({
...node,
text: text
}));
};
const addParagraphProperties = (
node: TextNode,
partials: PartialTranslation[]
): PenpotTextNode[] => {
const formattedParagraphs: PenpotTextNode[] = [];
const paragraph = new Paragraph();
partials.forEach(({ textNodes, segment }) =>
textNodes.forEach(textNode => {
formattedParagraphs.push(...paragraph.format(node, textNode, segment));
})
);
return formattedParagraphs;
};

View file

@ -0,0 +1,8 @@
export * from './translateFontStyle';
export * from './translateGrowType';
export * from './translateHorizontalAlign';
export * from './translateLetterSpacing';
export * from './translateLineHeight';
export * from './translateTextDecoration';
export * from './translateTextTransform';
export * from './translateVerticalAlign';

View file

@ -1,75 +0,0 @@
import { TextNode as PenpotTextNode } from '@ui/lib/types/shapes/textShape';
export const translateParagraphProperties = (
node: TextNode,
segments: PenpotTextNode[]
): PenpotTextNode[] => {
if (node.paragraphSpacing === 0 && node.paragraphIndent === 0) return segments;
const splitSegments: PenpotTextNode[] = [segmentIndent(node.paragraphIndent)];
segments.forEach(segment => {
splitSegments.push(...splitTextNodeByEOL(segment));
});
return addParagraphProperties(splitSegments, node.paragraphIndent, node.paragraphSpacing);
};
const splitTextNodeByEOL = (node: PenpotTextNode): PenpotTextNode[] => {
const split = node.text.split(/(\n)/).filter(text => text !== '');
return split.map(text => ({
...node,
text: text
}));
};
const addParagraphProperties = (
nodes: PenpotTextNode[],
indent: number,
paragraphSpacing: number
): PenpotTextNode[] => {
const indentedTextNodes: PenpotTextNode[] = [];
nodes.forEach(node => {
indentedTextNodes.push(node);
if (node.text !== '\n') return;
if (paragraphSpacing !== 0) {
indentedTextNodes.push(segmentParagraphSpacing(paragraphSpacing));
}
if (indent !== 0) {
indentedTextNodes.push(segmentIndent(indent));
}
});
return indentedTextNodes;
};
const segmentIndent = (indent: number): PenpotTextNode => {
return {
text: ' '.repeat(indent),
fontId: 'sourcesanspro',
fontVariantId: 'regular',
fontSize: '5',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 1,
letterSpacing: 0
};
};
const segmentParagraphSpacing = (paragraphSpacing: number): PenpotTextNode => {
return {
text: '\n',
fontId: 'sourcesanspro',
fontVariantId: 'regular',
fontSize: paragraphSpacing.toString(),
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 1,
letterSpacing: 0
};
};

View file

@ -1,41 +1,27 @@
import { translateFills } from '@plugin/translators';
import { translateFontId } from '@plugin/translators/text/font';
import { StyleTextSegment, translateParagraphProperties } from '@plugin/translators/text/paragraph';
import {
translateFontId,
translateFontStyle,
translateHorizontalAlign,
translateLetterSpacing,
translateLineHeight,
translateParagraphProperties,
translateTextDecoration,
translateTextTransform
} from '@plugin/translators/text';
} from '@plugin/translators/text/properties';
import { TextNode as PenpotTextNode, TextStyle } from '@ui/lib/types/shapes/textShape';
type StyleTextSegment = Pick<
StyledTextSegment,
| 'characters'
| 'start'
| 'end'
| 'fontName'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'textCase'
| 'textDecoration'
| 'fills'
>;
export const translateStyleTextSegments = (
node: TextNode,
segments: StyleTextSegment[]
): PenpotTextNode[] => {
const textNodes = segments.map(segment => {
return translateStyleTextSegment(node, segment);
});
const partials = segments.map(segment => ({
textNode: translateStyleTextSegment(node, segment),
segment
}));
return translateParagraphProperties(node, textNodes);
return translateParagraphProperties(node, partials);
};
export const transformTextStyle = (