From 8c31d5ed8740d3b5fd6d129eca38e6d1c8610141 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 22 Jul 2026 15:07:20 +0300 Subject: [PATCH] feat(figma-api): expose vector paths and modes - Return SVG path data for imported vector geometry - Report explicit and inherited variable modes on node proxies --- CHANGELOG.md | 2 +- .../core/src/figma-api/accessors/variables.ts | 32 +++++++++++++++++++ .../core/src/figma-api/accessors/vector.ts | 29 +++++++++++++++++ packages/core/src/figma-api/proxy.ts | 13 ++++++-- tests/engine/figma/api/variables.test.ts | 25 +++++++++++++++ tests/engine/figma/api/vector/paths.test.ts | 28 ++++++++++++++++ 6 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/figma-api/accessors/variables.ts create mode 100644 packages/core/src/figma-api/accessors/vector.ts create mode 100644 tests/engine/figma/api/vector/paths.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 26f41e22c..c76d48c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,7 @@ - Improve `.fig` import and rendering fidelity for groups, booleans, instances, rotated vectors, complex text fills, auto-sized text, layout grids, page guides, patterns, noise effects, masks, and canvas backgrounds. - Preserve pages, components, prototype and library metadata, export settings, unsupported effects, and other unrelated Figma data when editing and resaving `.fig` files. - Prevent duplicate generated IDs from corrupting `.fig` round trips. -- Populate lazy `.fig` pages for file-mode CLI inspection and preserve the whole document when exporting FIG unless a page is explicitly requested. +- Populate lazy `.fig` pages for file-mode CLI inspection, preserve the whole document when exporting FIG unless a page is explicitly requested, and expose vector paths and variable modes to Plugin API scripts. - Match Figma auto-layout reflow after deleting children, hiding optional instance slots, or syncing component changes. - Make group and boolean-operation children scale with their parent during resize. - Restore desktop copy, cut, and paste when browser clipboard events are unavailable. diff --git a/packages/core/src/figma-api/accessors/variables.ts b/packages/core/src/figma-api/accessors/variables.ts new file mode 100644 index 000000000..d05c5225b --- /dev/null +++ b/packages/core/src/figma-api/accessors/variables.ts @@ -0,0 +1,32 @@ +import type { SceneGraph } from '@open-pencil/scene-graph' + +import { raw, type NodeProxyInternals, type ProxyThis } from '#core/figma-api/accessor-utils' + +function graph(target: ProxyThis, internals: NodeProxyInternals): SceneGraph { + return target[internals.graph] as SceneGraph +} + +export function installVariableModeNodeProxyAccessors( + prototype: object, + internals: NodeProxyInternals +): void { + Object.defineProperties(prototype, { + explicitVariableModes: { + get(this: ProxyThis): Readonly> { + return Object.freeze({ ...raw(this, internals).variableModes }) + } + }, + resolvedVariableModes: { + get(this: ProxyThis): Readonly> { + const sceneGraph = graph(this, internals) + const node = raw(this, internals) + const modes: Record = {} + for (const collectionId of sceneGraph.variableCollections.keys()) { + const modeId = sceneGraph.getNodeVariableModeId(node.id, collectionId) + if (modeId) modes[collectionId] = modeId + } + return Object.freeze(modes) + } + } + }) +} diff --git a/packages/core/src/figma-api/accessors/vector.ts b/packages/core/src/figma-api/accessors/vector.ts new file mode 100644 index 000000000..000c14acc --- /dev/null +++ b/packages/core/src/figma-api/accessors/vector.ts @@ -0,0 +1,29 @@ +import { raw, type NodeProxyInternals, type ProxyThis } from '#core/figma-api/accessor-utils' +import { geometryBlobToSVGPath, vectorNetworkToSVGPaths } from '#core/io/formats/svg/paths' + +export interface FigmaVectorPath { + readonly windingRule: 'NONZERO' | 'EVENODD' + readonly data: string +} + +export function installVectorNodeProxyAccessors( + prototype: object, + internals: NodeProxyInternals +): void { + Object.defineProperty(prototype, 'vectorPaths', { + get(this: ProxyThis): readonly FigmaVectorPath[] { + const node = raw(this, internals) + const paths = + node.fillGeometry.length > 0 + ? node.fillGeometry.map((geometry) => ({ + windingRule: geometry.windingRule, + data: geometryBlobToSVGPath(geometry.commandsBlob) + })) + : (node.vectorNetwork ? vectorNetworkToSVGPaths(node.vectorNetwork) : []).map((data) => ({ + windingRule: 'NONZERO' as const, + data + })) + return Object.freeze(paths.map((path) => Object.freeze(path))) + } + }) +} diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index b1b6bc817..e3fe44900 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -19,6 +19,8 @@ import type { OkHCLColor, OkHCLPayload } from '#core/color/okhcl' import { installBasicNodeProxyAccessors } from './accessors/basic' import { installLayoutNodeProxyAccessors } from './accessors/layout' +import { installVariableModeNodeProxyAccessors } from './accessors/variables' +import { installVectorNodeProxyAccessors, type FigmaVectorPath } from './accessors/vector' import { installVisualNodeProxyAccessors } from './accessors/visual' import type { FigmaFontName } from './fonts' import * as PluginData from './plugin-data' @@ -103,6 +105,9 @@ export class FigmaNodeProxy { declare maxWidth: number | null declare minHeight: number | null declare maxHeight: number | null + declare readonly vectorPaths: readonly FigmaVectorPath[] + declare readonly explicitVariableModes: Readonly> + declare readonly resolvedVariableModes: Readonly> constructor(id: string, graph: SceneGraph, api: NodeProxyHost) { this[INTERNAL_ID] = id @@ -546,8 +551,12 @@ installVisualNodeProxyAccessors( MIXED ) -installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, { +const proxyInternals = { id: INTERNAL_ID, graph: INTERNAL_GRAPH, api: INTERNAL_API -}) +} + +installLayoutNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals) +installVariableModeNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals) +installVectorNodeProxyAccessors(FigmaNodeProxy.prototype, proxyInternals) diff --git a/tests/engine/figma/api/variables.test.ts b/tests/engine/figma/api/variables.test.ts index 075454bca..412e96792 100644 --- a/tests/engine/figma/api/variables.test.ts +++ b/tests/engine/figma/api/variables.test.ts @@ -70,6 +70,31 @@ describe('variables', () => { expect(cols[0].name).toBe('Colors') }) + test('exposes explicit and inherited variable modes on nodes', () => { + const api = createAPI() + api.graph.addCollection({ + id: 'col1', + name: 'Theme', + modes: [ + { modeId: 'light', name: 'Light' }, + { modeId: 'dark', name: 'Dark' } + ], + defaultModeId: 'light', + variableIds: [] + }) + api.graph.setActiveMode('col1', 'dark') + + const parent = api.createFrame() + const child = api.createRectangle() + parent.appendChild(child) + api.graph.updateNode(parent.id, { variableModes: { col1: 'light' } }) + + expect(parent.explicitVariableModes).toEqual({ col1: 'light' }) + expect(child.explicitVariableModes).toEqual({}) + expect(child.resolvedVariableModes).toEqual({ col1: 'light' }) + expect(Object.isFrozen(child.resolvedVariableModes)).toBe(true) + }) + test('getVariableCollectionById', () => { const api = createAPI() api.graph.addCollection({ diff --git a/tests/engine/figma/api/vector/paths.test.ts b/tests/engine/figma/api/vector/paths.test.ts new file mode 100644 index 000000000..317597674 --- /dev/null +++ b/tests/engine/figma/api/vector/paths.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' + +import { encodePathCommandsBlob } from '@open-pencil/fig/node-change' + +import { createAPI } from '../helpers' + +describe('vector paths', () => { + test('exposes imported geometry as Figma SVG paths', () => { + const api = createAPI() + const vector = api.createVector() + api.graph.updateNode(vector.id, { + fillGeometry: [ + { + windingRule: 'EVENODD', + commandsBlob: encodePathCommandsBlob([ + { type: 'M', x: 0, y: 0 }, + { type: 'L', x: 10, y: 10 }, + { type: 'Z' } + ]) + } + ] + }) + + expect(vector.vectorPaths).toEqual([{ windingRule: 'EVENODD', data: 'M0 0L10 -10Z' }]) + expect(Object.isFrozen(vector.vectorPaths)).toBe(true) + expect(Object.isFrozen(vector.vectorPaths[0])).toBe(true) + }) +})