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
This commit is contained in:
Danila Poyarkov 2026-07-22 15:07:20 +03:00
parent 7124d0045f
commit 8c31d5ed87
6 changed files with 126 additions and 3 deletions

View file

@ -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.

View file

@ -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<Record<string, string>> {
return Object.freeze({ ...raw(this, internals).variableModes })
}
},
resolvedVariableModes: {
get(this: ProxyThis): Readonly<Record<string, string>> {
const sceneGraph = graph(this, internals)
const node = raw(this, internals)
const modes: Record<string, string> = {}
for (const collectionId of sceneGraph.variableCollections.keys()) {
const modeId = sceneGraph.getNodeVariableModeId(node.id, collectionId)
if (modeId) modes[collectionId] = modeId
}
return Object.freeze(modes)
}
}
})
}

View file

@ -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)))
}
})
}

View file

@ -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<Record<string, string>>
declare readonly resolvedVariableModes: Readonly<Record<string, string>>
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)

View file

@ -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({

View file

@ -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)
})
})