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:
parent
7124d0045f
commit
8c31d5ed87
|
|
@ -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.
|
||||
|
|
|
|||
32
packages/core/src/figma-api/accessors/variables.ts
Normal file
32
packages/core/src/figma-api/accessors/variables.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
29
packages/core/src/figma-api/accessors/vector.ts
Normal file
29
packages/core/src/figma-api/accessors/vector.ts
Normal 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)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
28
tests/engine/figma/api/vector/paths.test.ts
Normal file
28
tests/engine/figma/api/vector/paths.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue