diff --git a/package-lock.json b/package-lock.json index eb3e992c..bc89f1fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "react": "^18.2", "react-dom": "^18.2", - "slugify": "^1.6" + "slugify": "^1.6", + "svg-path-commander": "^2.0" }, "devDependencies": { "@figma/eslint-plugin-figma-plugins": "^0.15", @@ -2013,6 +2014,11 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@thednp/dommatrix": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@thednp/dommatrix/-/dommatrix-2.0.6.tgz", + "integrity": "sha512-DXQq4Rs/akYzeXYGkNy3KiJ4JoD8+SYr1QRWTXtAGoZ0+vJcyBt0aeqA1K4CxPaBaIfKdOTE+Te1HV9sAQ4I4A==" + }, "node_modules/@trivago/prettier-plugin-sort-imports": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@trivago/prettier-plugin-sort-imports/-/prettier-plugin-sort-imports-4.3.0.tgz", @@ -6482,6 +6488,18 @@ "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", "dev": true }, + "node_modules/svg-path-commander": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/svg-path-commander/-/svg-path-commander-2.0.9.tgz", + "integrity": "sha512-VfRLznHewlpQvuahtBK0MT/PlWAapbTx8RSytqgaVwD3US2keKcc3WYYlBBk4vIOR+jB3nQu/NAVlWHKlo0Fjw==", + "dependencies": { + "@thednp/dommatrix": "^2.0.6" + }, + "engines": { + "node": ">=16", + "pnpm": ">=8.6.0" + } + }, "node_modules/svg-tags": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", diff --git a/package.json b/package.json index eee6d859..49f6e47e 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "dependencies": { "react": "^18.2", "react-dom": "^18.2", - "slugify": "^1.6" + "slugify": "^1.6", + "svg-path-commander": "^2.0" }, "devDependencies": { "@figma/eslint-plugin-figma-plugins": "^0.15", diff --git a/plugin-src/transformers/index.ts b/plugin-src/transformers/index.ts index e15ef314..11ab8089 100644 --- a/plugin-src/transformers/index.ts +++ b/plugin-src/transformers/index.ts @@ -2,8 +2,10 @@ export * from './transformDocumentNode'; export * from './transformEllipseNode'; export * from './transformFrameNode'; export * from './transformGroupNode'; +export * from './transformImageNode'; export * from './transformPageNode'; +export * from './transformPolygonNode'; export * from './transformRectangleNode'; export * from './transformSceneNode'; -export * from './transformImageNode'; export * from './transformTextNode'; +export * from './transformVectorNode'; diff --git a/plugin-src/transformers/transformEllipseNode.ts b/plugin-src/transformers/transformEllipseNode.ts index 8d78b93c..6763236a 100644 --- a/plugin-src/transformers/transformEllipseNode.ts +++ b/plugin-src/transformers/transformEllipseNode.ts @@ -3,7 +3,7 @@ import { transformDimensionAndPosition, transformSceneNode } from '@plugin/transformers/partials'; -import { translateFills } from '@plugin/translators'; +import { translateFills, translateStrokes } from '@plugin/translators'; import { CircleShape } from '@ui/lib/types/circle/circleShape'; @@ -16,6 +16,7 @@ export const transformEllipseNode = ( type: 'circle', name: node.name, fills: translateFills(node.fills, node.width, node.height), + strokes: translateStrokes(node), ...transformDimensionAndPosition(node, baseX, baseY), ...transformSceneNode(node), ...transformBlend(node) diff --git a/plugin-src/transformers/transformFrameNode.ts b/plugin-src/transformers/transformFrameNode.ts index 031e134b..927f4c61 100644 --- a/plugin-src/transformers/transformFrameNode.ts +++ b/plugin-src/transformers/transformFrameNode.ts @@ -1,6 +1,6 @@ import { transformDimensionAndPosition, transformSceneNode } from '@plugin/transformers/partials'; import { transformChildren } from '@plugin/transformers/partials'; -import { translateFills } from '@plugin/translators'; +import { translateFills, translateStrokes } from '@plugin/translators'; import { FrameShape } from '@ui/lib/types/frame/frameShape'; @@ -13,6 +13,7 @@ export const transformFrameNode = async ( type: 'frame', name: node.name, fills: translateFills(node.fills, node.width, node.height), + strokes: translateStrokes(node), ...(await transformChildren(node, baseX, baseY)), ...transformDimensionAndPosition(node, baseX, baseY), ...transformSceneNode(node) diff --git a/plugin-src/transformers/transformPolygonNode.ts b/plugin-src/transformers/transformPolygonNode.ts new file mode 100644 index 00000000..773eff67 --- /dev/null +++ b/plugin-src/transformers/transformPolygonNode.ts @@ -0,0 +1,25 @@ +import { + transformBlend, + transformDimensionAndPosition, + transformSceneNode +} from '@plugin/transformers/partials'; +import { translateFills, translateStrokes, translateVectorPaths } from '@plugin/translators'; + +import { PathShape } from '@ui/lib/types/path/pathShape'; + +export const transformPolygonNode = ( + node: DefaultShapeMixin, + baseX: number, + baseY: number +): PathShape => { + return { + type: 'path', + name: node.name, + content: translateVectorPaths(node.fillGeometry, baseX + node.x, baseY + node.y), + strokes: translateStrokes(node), + fills: translateFills(node.fills, node.width, node.height), + ...transformDimensionAndPosition(node, baseX, baseY), + ...transformSceneNode(node), + ...transformBlend(node) + }; +}; diff --git a/plugin-src/transformers/transformRectangleNode.ts b/plugin-src/transformers/transformRectangleNode.ts index 61c33296..0ad5a792 100644 --- a/plugin-src/transformers/transformRectangleNode.ts +++ b/plugin-src/transformers/transformRectangleNode.ts @@ -3,7 +3,7 @@ import { transformDimensionAndPosition, transformSceneNode } from '@plugin/transformers/partials'; -import { translateFills } from '@plugin/translators'; +import { translateFills, translateStrokes } from '@plugin/translators'; import { RectShape } from '@ui/lib/types/rect/rectShape'; @@ -16,6 +16,7 @@ export const transformRectangleNode = ( type: 'rect', name: node.name, fills: translateFills(node.fills, node.width, node.height), + strokes: translateStrokes(node), ...transformDimensionAndPosition(node, baseX, baseY), ...transformSceneNode(node), ...transformBlend(node) diff --git a/plugin-src/transformers/transformSceneNode.ts b/plugin-src/transformers/transformSceneNode.ts index ecbee575..a7f03249 100644 --- a/plugin-src/transformers/transformSceneNode.ts +++ b/plugin-src/transformers/transformSceneNode.ts @@ -7,8 +7,10 @@ import { transformFrameNode, transformGroupNode, transformImageNode, + transformPolygonNode, transformRectangleNode, - transformTextNode + transformTextNode, + transformVectorNode } from '.'; export const transformSceneNode = async ( @@ -40,6 +42,11 @@ export const transformSceneNode = async ( return await transformGroupNode(node, baseX, baseY); case 'TEXT': return transformTextNode(node, baseX, baseY); + case 'STAR': + case 'POLYGON': + return transformPolygonNode(node, baseX, baseY); + case 'VECTOR': + return transformVectorNode(node, baseX, baseY); } throw new Error(`Unsupported node type: ${node.type}`); diff --git a/plugin-src/transformers/transformVectorNode.ts b/plugin-src/transformers/transformVectorNode.ts new file mode 100644 index 00000000..c82c160b --- /dev/null +++ b/plugin-src/transformers/transformVectorNode.ts @@ -0,0 +1,21 @@ +import { + transformBlend, + transformDimensionAndPosition, + transformSceneNode +} from '@plugin/transformers/partials'; +import { translateFills, translateStrokes, translateVectorPaths } from '@plugin/translators'; + +import { PathShape } from '@ui/lib/types/path/pathShape'; + +export const transformVectorNode = (node: VectorNode, baseX: number, baseY: number): PathShape => { + return { + type: 'path', + name: node.name, + fills: node.fillGeometry.length ? translateFills(node.fills, node.width, node.height) : [], + content: translateVectorPaths(node.vectorPaths, baseX + node.x, baseY + node.y), + strokes: translateStrokes(node), + ...transformDimensionAndPosition(node, baseX, baseY), + ...transformSceneNode(node), + ...transformBlend(node) + }; +}; diff --git a/plugin-src/translators/index.ts b/plugin-src/translators/index.ts index 7e149c00..10048294 100644 --- a/plugin-src/translators/index.ts +++ b/plugin-src/translators/index.ts @@ -1,6 +1,8 @@ +export * from './translateBlendMode'; export * from './translateFills'; export * from './translateGradientLinearFill'; export * from './translateSolidFill'; +export * from './translateStrokes'; export * from './translateTextDecoration'; export * from './translateTextTransform'; -export * from './translateBlendMode'; +export * from './translateVectorPaths'; diff --git a/plugin-src/translators/translateFills.ts b/plugin-src/translators/translateFills.ts index 2b6cb49e..1cd97272 100644 --- a/plugin-src/translators/translateFills.ts +++ b/plugin-src/translators/translateFills.ts @@ -3,7 +3,7 @@ import { Fill } from '@ui/lib/types/utils/fill'; import { translateGradientLinearFill } from './translateGradientLinearFill'; import { translateSolidFill } from './translateSolidFill'; -const translateFill = (fill: Paint, width: number, height: number): Fill | undefined => { +export const translateFill = (fill: Paint, width: number, height: number): Fill | undefined => { switch (fill.type) { case 'SOLID': return translateSolidFill(fill); diff --git a/plugin-src/translators/translateStrokes.ts b/plugin-src/translators/translateStrokes.ts new file mode 100644 index 00000000..85574464 --- /dev/null +++ b/plugin-src/translators/translateStrokes.ts @@ -0,0 +1,14 @@ +import { translateFill } from '@plugin/translators/translateFills'; + +import { Stroke } from '@ui/lib/types/utils/stroke'; + +export const translateStrokes = (node: MinimalStrokesMixin): Stroke[] => { + return node.strokes.map(stroke => { + const fill = translateFill(stroke, 0, 0); + return { + strokeColor: fill?.fillColor, + strokeOpacity: fill?.fillOpacity, + strokeWidth: node.strokeWeight === figma.mixed ? 1 : node.strokeWeight + }; + }); +}; diff --git a/plugin-src/translators/translateVectorPaths.ts b/plugin-src/translators/translateVectorPaths.ts new file mode 100644 index 00000000..5579f10d --- /dev/null +++ b/plugin-src/translators/translateVectorPaths.ts @@ -0,0 +1,60 @@ +import SVGPathCommander from 'svg-path-commander'; + +import { Segment } from '@ui/lib/types/path/PathContent'; + +export const translateVectorPaths = ( + paths: VectorPaths, + baseX: number, + baseY: number +): Segment[] => { + let segments: Segment[] = []; + + for (const path of paths) { + segments = [...segments, ...translateVectorPath(path, baseX, baseY)]; + } + + return segments; +}; + +const translateVectorPath = (path: VectorPath, baseX: number, baseY: number): Segment[] => { + const segments: Segment[] = []; + + const normalizedPath = SVGPathCommander.normalizePath(path.data); + + for (const [command, ...rest] of normalizedPath) { + switch (command) { + case 'M': + segments.push({ + command: 'move-to', + params: { x: (rest[0] ?? 0) + baseX, y: (rest[1] ?? 0) + baseY } + }); + break; + case 'L': + segments.push({ + command: 'line-to', + params: { x: (rest[0] ?? 0) + baseX, y: (rest[1] ?? 0) + baseY } + }); + break; + case 'C': + segments.push({ + command: 'curve-to', + params: { + c1x: (rest[0] ?? 0) + baseX, + c1y: (rest[1] ?? 0) + baseY, + c2x: (rest[2] ?? 0) + baseX, + c2y: (rest[3] ?? 0) + baseY, + x: (rest[4] ?? 0) + baseX, + y: (rest[5] ?? 0) + baseY + } + }); + break; + case 'Z': + segments.push({ + command: 'close-path' + }); + break; + } + } + + return segments; +}; diff --git a/ui-src/converters/createPenpotItem.ts b/ui-src/converters/createPenpotItem.ts index 7d0ed9f5..3453477a 100644 --- a/ui-src/converters/createPenpotItem.ts +++ b/ui-src/converters/createPenpotItem.ts @@ -6,6 +6,7 @@ import { createPenpotCircle, createPenpotGroup, createPenpotImage, + createPenpotPath, createPenpotRectangle, createPenpotText } from '.'; @@ -22,6 +23,8 @@ export const createPenpotItem = (file: PenpotFile, node: PenpotNode) => { return createPenpotGroup(file, node); case 'image': return createPenpotImage(file, node); + case 'path': + return createPenpotPath(file, node); case 'text': return createPenpotText(file, node); } diff --git a/ui-src/converters/createPenpotPath.ts b/ui-src/converters/createPenpotPath.ts new file mode 100644 index 00000000..bf7692c1 --- /dev/null +++ b/ui-src/converters/createPenpotPath.ts @@ -0,0 +1,21 @@ +import { PenpotFile } from '@ui/lib/penpot'; +import { PATH_TYPE } from '@ui/lib/types/path/pathAttributes'; +import { PathShape } from '@ui/lib/types/path/pathShape'; +import { + translateFillGradients, + translatePathContent, + translateUiBlendMode +} from '@ui/translators'; + +export const createPenpotPath = ( + file: PenpotFile, + { type, fills, blendMode, content, ...rest }: PathShape +) => { + file.createPath({ + type: PATH_TYPE, + fills: translateFillGradients(fills), + blendMode: translateUiBlendMode(blendMode), + content: translatePathContent(content), + ...rest + }); +}; diff --git a/ui-src/converters/index.ts b/ui-src/converters/index.ts index 93de8977..aec47104 100644 --- a/ui-src/converters/index.ts +++ b/ui-src/converters/index.ts @@ -5,5 +5,6 @@ export * from './createPenpotGroup'; export * from './createPenpotImage'; export * from './createPenpotItem'; export * from './createPenpotPage'; +export * from './createPenpotPath'; export * from './createPenpotRectangle'; export * from './createPenpotText'; diff --git a/ui-src/lib/penpot.d.ts b/ui-src/lib/penpot.d.ts index bf0fbc77..41af4400 100644 --- a/ui-src/lib/penpot.d.ts +++ b/ui-src/lib/penpot.d.ts @@ -3,6 +3,7 @@ import { CircleShape } from '@ui/lib/types/circle/circleShape'; import { FrameShape } from '@ui/lib/types/frame/frameShape'; import { GroupShape } from '@ui/lib/types/group/groupShape'; import { ImageShape } from '@ui/lib/types/image/imageShape'; +import { PathShape } from '@ui/lib/types/path/pathShape'; import { RectShape } from '@ui/lib/types/rect/rectShape'; import { TextShape } from '@ui/lib/types/text/textShape'; @@ -18,7 +19,7 @@ export interface PenpotFile { closeBool(): void; createRect(rect: RectShape): void; createCircle(circle: CircleShape): void; - // createPath(path: any): void; + createPath(path: PathShape): void; createText(options: TextShape): void; createImage(image: ImageShape): void; // createSVG(svg: any): void; diff --git a/ui-src/lib/types/path/PathContent.ts b/ui-src/lib/types/path/PathContent.ts new file mode 100644 index 00000000..3aa06e84 --- /dev/null +++ b/ui-src/lib/types/path/PathContent.ts @@ -0,0 +1,48 @@ +export const VECTOR_LINE_TO: unique symbol = Symbol.for('line-to'); +export const VECTOR_CLOSE_PATH: unique symbol = Symbol.for('close-path'); +export const VECTOR_MOVE_TO: unique symbol = Symbol.for('move-to'); +export const VECTOR_CURVE_TO: unique symbol = Symbol.for('curve-to'); + +export type PathContent = Segment[]; +export type Segment = LineTo | ClosePath | MoveTo | CurveTo; +export type Command = + | 'line-to' + | 'close-path' + | 'move-to' + | 'curve-to' + | typeof VECTOR_LINE_TO + | typeof VECTOR_CLOSE_PATH + | typeof VECTOR_MOVE_TO + | typeof VECTOR_CURVE_TO; + +type LineTo = { + command: 'line-to' | typeof VECTOR_LINE_TO; + params: { + x: number; + y: number; + }; +}; + +type ClosePath = { + command: 'close-path' | typeof VECTOR_CLOSE_PATH; +}; + +type MoveTo = { + command: 'move-to' | typeof VECTOR_MOVE_TO; + params: { + x: number; + y: number; + }; +}; + +type CurveTo = { + command: 'curve-to' | typeof VECTOR_CURVE_TO; + params: { + x: number; + y: number; + c1x: number; + c1y: number; + c2x: number; + c2y: number; + }; +}; diff --git a/ui-src/lib/types/path/pathAttributes.ts b/ui-src/lib/types/path/pathAttributes.ts new file mode 100644 index 00000000..bf80c331 --- /dev/null +++ b/ui-src/lib/types/path/pathAttributes.ts @@ -0,0 +1,8 @@ +import { PathContent } from '@ui/lib/types/path/PathContent'; + +export const PATH_TYPE: unique symbol = Symbol.for('path'); + +export type PathAttributes = { + type: 'path' | typeof PATH_TYPE; + content: PathContent; +}; diff --git a/ui-src/lib/types/path/pathShape.d.ts b/ui-src/lib/types/path/pathShape.d.ts new file mode 100644 index 00000000..1ccb6744 --- /dev/null +++ b/ui-src/lib/types/path/pathShape.d.ts @@ -0,0 +1,9 @@ +import { LayoutChildAttributes } from '@ui/lib/types/layout/layoutChildAttributes'; +import { PathAttributes } from '@ui/lib/types/path/pathAttributes'; +import { ShapeAttributes } from '@ui/lib/types/shape/shapeAttributes'; +import { ShapeBaseAttributes } from '@ui/lib/types/shape/shapeBaseAttributes'; + +export type PathShape = ShapeBaseAttributes & + ShapeAttributes & + PathAttributes & + LayoutChildAttributes; diff --git a/ui-src/lib/types/penpotNode.d.ts b/ui-src/lib/types/penpotNode.d.ts index 4b7731c2..1264bbb1 100644 --- a/ui-src/lib/types/penpotNode.d.ts +++ b/ui-src/lib/types/penpotNode.d.ts @@ -2,7 +2,15 @@ import { CircleShape } from '@ui/lib/types/circle/circleShape'; import { FrameShape } from '@ui/lib/types/frame/frameShape'; import { GroupShape } from '@ui/lib/types/group/groupShape'; import { ImageShape } from '@ui/lib/types/image/imageShape'; +import { PathShape } from '@ui/lib/types/path/pathShape'; import { RectShape } from '@ui/lib/types/rect/rectShape'; import { TextShape } from '@ui/lib/types/text/textShape'; -export type PenpotNode = FrameShape | GroupShape | RectShape | CircleShape | TextShape | ImageShape; +export type PenpotNode = + | FrameShape + | GroupShape + | PathShape + | RectShape + | CircleShape + | TextShape + | ImageShape; diff --git a/ui-src/lib/types/shape/shapeBaseAttributes.ts b/ui-src/lib/types/shape/shapeBaseAttributes.ts index f2ec6113..e8a72c1a 100644 --- a/ui-src/lib/types/shape/shapeBaseAttributes.ts +++ b/ui-src/lib/types/shape/shapeBaseAttributes.ts @@ -1,3 +1,4 @@ +import { PATH_TYPE } from '@ui/lib/types/path/pathAttributes'; import { Matrix } from '@ui/lib/types/utils/matrix'; import { Point } from '@ui/lib/types/utils/point'; import { Selrect } from '@ui/lib/types/utils/selrect'; @@ -12,7 +13,6 @@ import { RECT_TYPE } from '../rect/rectAttributes'; import { TEXT_TYPE } from '../text/textAttributes'; // @TODO: Move to its own file once we support all the shapes -export const PATH_TYPE: unique symbol = Symbol.for('path'); export const SVG_RAW_TYPE: unique symbol = Symbol.for('svg-raw'); export type ShapeBaseAttributes = { diff --git a/ui-src/lib/types/utils/stroke.d.ts b/ui-src/lib/types/utils/stroke.d.ts index f70b670c..6c839bb6 100644 --- a/ui-src/lib/types/utils/stroke.d.ts +++ b/ui-src/lib/types/utils/stroke.d.ts @@ -8,12 +8,14 @@ export type Stroke = { strokeOpacity?: number; strokeStyle?: 'solid' | 'dotted' | 'dashed' | 'mixed' | 'none' | 'svg'; strokeWidth?: number; - strokeAlignment?: 'center' | 'inner' | 'outer'; + strokeAlignment?: StrokeAlignment; strokeCapStart?: StrokeCaps; strokeCapEnd?: StrokeCaps; - strokeColorGradient: Gradient; + strokeColorGradient?: Gradient; }; +export type StrokeAlignment = 'center' | 'inner' | 'outer'; + type StrokeCapLine = 'round' | 'square'; type StrokeCapMarker = | 'line-arrow' diff --git a/ui-src/translators/index.ts b/ui-src/translators/index.ts index 05c5fa25..22fb2213 100644 --- a/ui-src/translators/index.ts +++ b/ui-src/translators/index.ts @@ -1,5 +1,6 @@ +export * from './translateFillGradients'; export * from './translateFontStyle'; export * from './translateHorizontalAlign'; -export * from './translateVerticalAlign'; -export * from './translateFillGradients'; +export * from './translatePathContent'; export * from './translateUiBlendMode'; +export * from './translateVerticalAlign'; diff --git a/ui-src/translators/translatePathContent.ts b/ui-src/translators/translatePathContent.ts new file mode 100644 index 00000000..70a417b7 --- /dev/null +++ b/ui-src/translators/translatePathContent.ts @@ -0,0 +1,32 @@ +import { + Command, + PathContent, + Segment, + VECTOR_CLOSE_PATH, + VECTOR_CURVE_TO, + VECTOR_LINE_TO, + VECTOR_MOVE_TO +} from '@ui/lib/types/path/PathContent'; + +export const translatePathContent = (content: PathContent): PathContent => + content.map(({ command, ...rest }) => { + return { + command: translatePathCommand(command), + ...rest + } as Segment; + }); + +const translatePathCommand = (command: Command): Command => { + switch (command) { + case 'line-to': + return VECTOR_LINE_TO; + case 'close-path': + return VECTOR_CLOSE_PATH; + case 'move-to': + return VECTOR_MOVE_TO; + case 'curve-to': + return VECTOR_CURVE_TO; + } + + throw new Error('Unknown path command'); +};