mirror of
https://github.com/penpot/penpot.git
synced 2026-03-20 01:13:41 +00:00
538 lines
20 KiB
TypeScript
538 lines
20 KiB
TypeScript
import { Board, Bounds, Fill, FlexLayout, GridLayout, Page, Rectangle, Shape, Text } from "@penpot/plugin-types";
|
|
|
|
export class PenpotUtils {
|
|
/**
|
|
* Generates an overview structure of the given shape,
|
|
* providing its id, name and type, and recursively its children's attributes.
|
|
* The `type` field indicates the type in the Penpot API.
|
|
* If the shape has a layout system (flex or grid), includes layout information.
|
|
*
|
|
* @param shape - The root shape to generate the structure from
|
|
* @param maxDepth - Optional maximum depth to traverse (leave undefined for unlimited)
|
|
* @returns An object representing the shape structure
|
|
*/
|
|
public static shapeStructure(shape: Shape, maxDepth: number | undefined = undefined): object {
|
|
let children = undefined;
|
|
if (maxDepth === undefined || maxDepth > 0) {
|
|
if ("children" in shape && shape.children) {
|
|
children = shape.children.map((child) =>
|
|
this.shapeStructure(child, maxDepth === undefined ? undefined : maxDepth - 1)
|
|
);
|
|
}
|
|
}
|
|
|
|
const result: any = {
|
|
id: shape.id,
|
|
name: shape.name,
|
|
type: shape.type,
|
|
};
|
|
|
|
// add layout information if present
|
|
if ("flex" in shape && shape.flex) {
|
|
const flex: FlexLayout = shape.flex;
|
|
result.layout = {
|
|
type: "flex",
|
|
dir: flex.dir,
|
|
rowGap: flex.rowGap,
|
|
columnGap: flex.columnGap,
|
|
};
|
|
} else if ("grid" in shape && shape.grid) {
|
|
const grid: GridLayout = shape.grid;
|
|
result.layout = {
|
|
type: "grid",
|
|
rows: grid.rows,
|
|
columns: grid.columns,
|
|
rowGap: grid.rowGap,
|
|
columnGap: grid.columnGap,
|
|
};
|
|
}
|
|
|
|
// add component instance information if present
|
|
if (shape.isComponentInstance()) {
|
|
result.componentInstance = {};
|
|
const component = shape.component();
|
|
if (component) {
|
|
result.componentInstance.componentId = component.id;
|
|
result.componentInstance.componentName = component.name;
|
|
const mainInstance = component.mainInstance();
|
|
if (mainInstance) {
|
|
result.componentInstance.mainInstanceId = mainInstance.id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// finally, add children (last for more readable nesting order)
|
|
result.children = children;
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Finds all shapes that matches the given predicate in the given shape tree.
|
|
*
|
|
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
|
* @param root - The root shape to start the search from (if null, searches all pages)
|
|
*/
|
|
public static findShapes(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape[] {
|
|
let result = new Array<Shape>();
|
|
|
|
let find = function (shape: Shape | null) {
|
|
if (!shape) {
|
|
return;
|
|
}
|
|
if (predicate(shape)) {
|
|
result.push(shape);
|
|
}
|
|
if ("children" in shape && shape.children) {
|
|
for (let child of shape.children) {
|
|
find(child);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (root === null) {
|
|
const pages = penpot.currentFile?.pages;
|
|
if (pages) {
|
|
for (let page of pages) {
|
|
find(page.root);
|
|
}
|
|
}
|
|
} else {
|
|
find(root);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Finds the first shape that matches the given predicate in the given shape tree.
|
|
*
|
|
* @param predicate - A function that takes a shape and returns true if it matches the criteria
|
|
* @param root - The root shape to start the search from (if null, searches all pages)
|
|
*/
|
|
public static findShape(predicate: (shape: Shape) => boolean, root: Shape | null = null): Shape | null {
|
|
let find = function (shape: Shape | null): Shape | null {
|
|
if (!shape) {
|
|
return null;
|
|
}
|
|
if (predicate(shape)) {
|
|
return shape;
|
|
}
|
|
if ("children" in shape && shape.children) {
|
|
for (let child of shape.children) {
|
|
let result = find(child);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (root === null) {
|
|
const pages = penpot.currentFile?.pages;
|
|
if (pages) {
|
|
for (let page of pages) {
|
|
let result = find(page.root);
|
|
if (result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
} else {
|
|
return find(root);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds a shape by its unique ID.
|
|
*
|
|
* @param id - The unique ID of the shape to find
|
|
* @returns The shape with the matching ID, or null if not found
|
|
*/
|
|
public static findShapeById(id: string): Shape | null {
|
|
return this.findShape((shape) => shape.id === id);
|
|
}
|
|
|
|
public static findPage(predicate: (page: Page) => boolean): Page | null {
|
|
let page = penpot.currentFile!.pages.find(predicate);
|
|
return page || null;
|
|
}
|
|
|
|
public static getPages(): { id: string; name: string }[] {
|
|
return penpot.currentFile!.pages.map((page) => ({ id: page.id, name: page.name }));
|
|
}
|
|
|
|
public static getPageById(id: string): Page | null {
|
|
return this.findPage((page) => page.id === id);
|
|
}
|
|
|
|
public static getPageByName(name: string): Page | null {
|
|
return this.findPage((page) => page.name.toLowerCase() === name.toLowerCase());
|
|
}
|
|
|
|
public static getPageForShape(shape: Shape): Page | null {
|
|
for (const page of penpot.currentFile!.pages) {
|
|
if (page.getShapeById(shape.id)) {
|
|
return page;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public static generateCss(shape: Shape): string {
|
|
const page = this.getPageForShape(shape);
|
|
if (!page) {
|
|
throw new Error("Shape is not part of any page");
|
|
}
|
|
penpot.openPage(page);
|
|
return penpot.generateStyle([shape], { type: "css", includeChildren: true });
|
|
}
|
|
|
|
/**
|
|
* Gets the actual rendering bounds of a shape. For most shapes, this is simply the `bounds` property.
|
|
* However, for Text shapes, the `bounds` may not reflect the true size of the rendered text content,
|
|
* so we use the `textBounds` property instead.
|
|
*
|
|
* @param shape - The shape to get the bounds for
|
|
*/
|
|
public static getBounds(shape: Shape): Bounds {
|
|
if (shape.type === "text") {
|
|
const text = shape as Text;
|
|
// TODO: Remove ts-ignore once type definitions are updated
|
|
// @ts-ignore
|
|
return text.textBounds;
|
|
} else {
|
|
return shape.bounds;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a child shape is fully contained within its parent's bounds.
|
|
* Visual containment means all edges of the child are within the parent's bounding box.
|
|
*
|
|
* @param child - The child shape to check
|
|
* @param parent - The parent shape to check against
|
|
* @returns true if child is fully contained within parent bounds, false otherwise
|
|
*/
|
|
public static isContainedIn(child: Shape, parent: Shape): boolean {
|
|
const childBounds = this.getBounds(child);
|
|
const parentBounds = this.getBounds(parent);
|
|
return (
|
|
childBounds.x >= parentBounds.x &&
|
|
childBounds.y >= parentBounds.y &&
|
|
childBounds.x + childBounds.width <= parentBounds.x + parentBounds.width &&
|
|
childBounds.y + childBounds.height <= parentBounds.y + parentBounds.height
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sets the position of a shape relative to its parent's position.
|
|
* This is a convenience method since parentX and parentY are read-only properties.
|
|
*
|
|
* @param shape - The shape to position
|
|
* @param parentX - The desired X position relative to the parent
|
|
* @param parentY - The desired Y position relative to the parent
|
|
* @throws Error if the shape has no parent
|
|
*/
|
|
public static setParentXY(shape: Shape, parentX: number, parentY: number): void {
|
|
if (!shape.parent) {
|
|
throw new Error("Shape has no parent - cannot set parent-relative position");
|
|
}
|
|
shape.x = shape.parent.x + parentX;
|
|
shape.y = shape.parent.y + parentY;
|
|
}
|
|
|
|
/**
|
|
* Adds a flex layout to a container while preserving the visual order of existing children.
|
|
* Without this, adding a flex layout can arbitrarily reorder children.
|
|
*
|
|
* The method sorts children by their current position (x for "row", y for "column") before
|
|
* adding the layout, then reorders them to maintain that visual sequence.
|
|
*
|
|
* @param container - The container (board) to add the flex layout to
|
|
* @param dir - The layout direction: "row" for horizontal, "column" for vertical
|
|
* @returns The created FlexLayout instance
|
|
*/
|
|
public static addFlexLayout(container: Board, dir: "column" | "row"): FlexLayout {
|
|
// obtain children sorted by position (ascending)
|
|
const children = "children" in container && container.children ? [...container.children] : [];
|
|
const sortedChildren = children.sort((a, b) => (dir === "row" ? a.x - b.x : a.y - b.y));
|
|
|
|
// add the flex layout
|
|
const flexLayout = container.addFlexLayout();
|
|
flexLayout.dir = dir;
|
|
|
|
// reorder children to preserve visual order; since the children array is reversed
|
|
// relative to visual order for dir="column" or dir="row", we insert each child at
|
|
// index 0 in sorted order, which places the first (smallest position) at the highest
|
|
// index, making it appear first visually
|
|
for (const child of sortedChildren) {
|
|
child.setParentIndex(0);
|
|
}
|
|
|
|
return flexLayout;
|
|
}
|
|
|
|
/**
|
|
* Analyzes all descendants of a shape by applying an evaluator function to each.
|
|
* Only descendants for which the evaluator returns a non-null/non-undefined value are included in the result.
|
|
* This is a general-purpose utility for validation, analysis, or collecting corrector functions.
|
|
*
|
|
* @param root - The root shape whose descendants to analyze
|
|
* @param evaluator - Function called for each descendant with (root, descendant); return null/undefined to skip
|
|
* @param maxDepth - Optional maximum depth to traverse (undefined for unlimited)
|
|
* @returns Array of objects containing the shape and the evaluator's result
|
|
*/
|
|
public static analyzeDescendants<T>(
|
|
root: Shape,
|
|
evaluator: (root: Shape, descendant: Shape) => T | null | undefined,
|
|
maxDepth: number | undefined = undefined
|
|
): Array<{ shape: Shape; result: NonNullable<T> }> {
|
|
const results: Array<{ shape: Shape; result: NonNullable<T> }> = [];
|
|
|
|
const traverse = (shape: Shape, currentDepth: number): void => {
|
|
const result = evaluator(root, shape);
|
|
if (result !== null && result !== undefined) {
|
|
results.push({ shape, result: result as NonNullable<T> });
|
|
}
|
|
|
|
if (maxDepth === undefined || currentDepth < maxDepth) {
|
|
if ("children" in shape && shape.children) {
|
|
for (const child of shape.children) {
|
|
traverse(child, currentDepth + 1);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Start traversal with root's children (not root itself)
|
|
if ("children" in root && root.children) {
|
|
for (const child of root.children) {
|
|
traverse(child, 1);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Decodes a base64 string to a Uint8Array.
|
|
*
|
|
* @param base64 - The base64-encoded string to decode
|
|
* @returns The decoded data as a Uint8Array
|
|
*/
|
|
public static base64ToByteArray(base64: string): Uint8Array {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
/**
|
|
* Imports an image from base64 data into the Penpot design as a Rectangle shape filled with the image.
|
|
* The rectangle has the image's original proportions by default.
|
|
* Optionally accepts position (x, y) and dimensions (width, height) parameters.
|
|
* If only one dimension is provided, the other is calculated to maintain the image's aspect ratio.
|
|
*
|
|
* This function is used internally by the ImportImageTool in the MCP server.
|
|
*
|
|
* @param base64 - The base64-encoded image data
|
|
* @param mimeType - The MIME type of the image (e.g., "image/png")
|
|
* @param name - The name to assign to the newly created rectangle shape
|
|
* @param x - The x-coordinate for positioning the rectangle (optional)
|
|
* @param y - The y-coordinate for positioning the rectangle (optional)
|
|
* @param width - The desired width of the rectangle (optional)
|
|
* @param height - The desired height of the rectangle (optional)
|
|
*/
|
|
public static async importImage(
|
|
base64: string,
|
|
mimeType: string,
|
|
name: string,
|
|
x: number | undefined,
|
|
y: number | undefined,
|
|
width: number | undefined,
|
|
height: number | undefined
|
|
): Promise<Rectangle> {
|
|
// convert base64 to Uint8Array
|
|
const bytes = PenpotUtils.base64ToByteArray(base64);
|
|
|
|
// upload the image data to Penpot
|
|
const imageData = await penpot.uploadMediaData(name, bytes, mimeType);
|
|
|
|
// create a rectangle shape
|
|
const rect = penpot.createRectangle();
|
|
rect.name = name;
|
|
|
|
// calculate dimensions
|
|
let rectWidth, rectHeight;
|
|
const hasWidth = width !== undefined;
|
|
const hasHeight = height !== undefined;
|
|
|
|
if (hasWidth && hasHeight) {
|
|
// both width and height provided - use them directly
|
|
rectWidth = width;
|
|
rectHeight = height;
|
|
} else if (hasWidth) {
|
|
// only width provided - maintain aspect ratio
|
|
rectWidth = width;
|
|
rectHeight = rectWidth * (imageData.height / imageData.width);
|
|
} else if (hasHeight) {
|
|
// only height provided - maintain aspect ratio
|
|
rectHeight = height;
|
|
rectWidth = rectHeight * (imageData.width / imageData.height);
|
|
} else {
|
|
// neither provided - use original dimensions
|
|
rectWidth = imageData.width;
|
|
rectHeight = imageData.height;
|
|
}
|
|
|
|
// set rectangle dimensions
|
|
rect.resize(rectWidth, rectHeight);
|
|
|
|
// set position if provided
|
|
if (x !== undefined) {
|
|
rect.x = x;
|
|
}
|
|
if (y !== undefined) {
|
|
rect.y = y;
|
|
}
|
|
|
|
// apply the image as a fill
|
|
rect.fills = [{ fillOpacity: 1, fillImage: imageData }];
|
|
|
|
return rect;
|
|
}
|
|
|
|
/**
|
|
* Exports the given shape (or its fill) to BASE64 image data.
|
|
*
|
|
* This function is used internally by the ExportImageTool in the MCP server.
|
|
*
|
|
* @param shape - The shape whose image data to export
|
|
* @param mode - Either "shape" (to export the entire shape, including descendants) or "fill"
|
|
* to export the shape's raw fill image data
|
|
* @param asSVG - Whether to export as SVG rather than as a pixel image (only supported for mode "shape")
|
|
* @returns A byte array containing the exported image data.
|
|
* - For mode="shape", it will be PNG or SVG data depending on the value of `asSVG`.
|
|
* - For mode="fill", it will be whatever format the fill image is stored in.
|
|
*/
|
|
public static async exportImage(shape: Shape, mode: "shape" | "fill", asSVG: boolean): Promise<Uint8Array> {
|
|
switch (mode) {
|
|
case "shape":
|
|
return shape.export({ type: asSVG ? "svg" : "png" });
|
|
case "fill":
|
|
if (asSVG) {
|
|
throw new Error("Image fills cannot be exported as SVG");
|
|
}
|
|
// check whether the shape has the `fills` member
|
|
if (!("fills" in shape)) {
|
|
throw new Error("Shape with `fills` member is required for fill export mode");
|
|
}
|
|
// find first fill that has fillImage
|
|
const fills: Fill[] = (shape as any).fills;
|
|
for (const fill of fills) {
|
|
if (fill.fillImage) {
|
|
const imageData = fill.fillImage;
|
|
return imageData.data();
|
|
}
|
|
}
|
|
throw new Error("No fill with image data found in the shape");
|
|
default:
|
|
throw new Error(`Unsupported export mode: ${mode}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds all tokens that match the given name across all token sets.
|
|
*
|
|
* @param name - The name of the token to search for (case-sensitive exact match)
|
|
* @returns An array of all matching tokens (may be empty)
|
|
*/
|
|
public static findTokensByName(name: string): any[] {
|
|
const tokens: any[] = [];
|
|
// @ts-ignore
|
|
const tokenCatalog = penpot.library.local.tokens;
|
|
|
|
for (const set of tokenCatalog.sets) {
|
|
for (const token of set.tokens) {
|
|
if (token.name === name) {
|
|
tokens.push(token);
|
|
}
|
|
}
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Finds the first token that matches the given name across all token sets.
|
|
*
|
|
* @param name - The name of the token to search for (case-sensitive exact match)
|
|
* @returns The first matching token, or null if not found
|
|
*/
|
|
public static findTokenByName(name: string): any | null {
|
|
// @ts-ignore
|
|
const tokenCatalog = penpot.library.local.tokens;
|
|
|
|
for (const set of tokenCatalog.sets) {
|
|
for (const token of set.tokens) {
|
|
if (token.name === name) {
|
|
return token;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Gets the token set that contains the given token.
|
|
*
|
|
* @param token - The token whose set to find
|
|
* @returns The TokenSet containing this token, or null if not found
|
|
*/
|
|
public static getTokenSet(token: any): any | null {
|
|
// @ts-ignore
|
|
const tokenCatalog = penpot.library.local.tokens;
|
|
|
|
for (const set of tokenCatalog.sets) {
|
|
if (set.tokens.includes(token)) {
|
|
return set;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Generates an overview of all tokens organized by token set name, token type, and token name.
|
|
* The result is a nested object structure: {tokenSetName: {tokenType: [tokenName, ...]}}.
|
|
*
|
|
* @returns An object mapping token set names to objects that map token types to arrays of token names
|
|
*/
|
|
public static tokenOverview(): Record<string, Record<string, string[]>> {
|
|
const overview: Record<string, Record<string, string[]>> = {};
|
|
// @ts-ignore
|
|
const tokenCatalog = penpot.library.local.tokens;
|
|
|
|
for (const set of tokenCatalog.sets) {
|
|
const setOverview: Record<string, string[]> = {};
|
|
|
|
for (const token of set.tokens) {
|
|
const tokenType = token.type;
|
|
if (!setOverview[tokenType]) {
|
|
setOverview[tokenType] = [];
|
|
}
|
|
setOverview[tokenType].push(token.name);
|
|
}
|
|
|
|
overview[set.name] = setOverview;
|
|
}
|
|
|
|
return overview;
|
|
}
|
|
}
|