openpencil/packages/core/src/figma-api/index.ts
xemc 7dc46bb993 feat(figma-api): add exposeInstanceSwap for real instance-swap component properties
Adds figma.exposeInstanceSwap(slots, candidates, propertyName), which
exposes one or more nested instances as an instance-swap slot — the
enclosing component (or component set, walking up past intermediate
variant members) gets an INSTANCE_SWAP property offering the given
candidates, and each slot instance is tagged to respond to it. Mirrors
Figma's "Create component property > Instance swap", letting a designer
pick which component fills a slot (e.g. an icon on a button) instead of
the slot being baked-in static content.

Exposed as the expose_instance_swap MCP/CLI/chat tool.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-29 16:30:42 +03:00

613 lines
19 KiB
TypeScript

import type {
ComponentPropertyDefinition,
SceneGraph,
SceneNode as CoreSceneNode,
NodeType,
Variable,
VariableCollection,
VariableType,
VariableValue
} from '@open-pencil/scene-graph'
import { copyFills, copyStrokes, copyEffects } from '@open-pencil/scene-graph/copy'
import { computeBounds } from '@open-pencil/scene-graph/geometry'
import { computeImageHash } from '@open-pencil/scene-graph/images'
import type { Rect, Vector } from '@open-pencil/scene-graph/primitives'
import { decodeBase64, encodeBase64 } from '#core/bytes'
import type { SkiaRenderer } from '#core/canvas'
import { canMakeBooleanSourceNode } from '#core/canvas/boolean'
import { flattenNodesToVectorProps } from '#core/canvas/flatten'
import { IS_BROWSER } from '#core/constants'
import type { RasterExportFormat } from '#core/io/formats/raster'
import { randomHex } from '#core/random'
import { documentFontStatus, type DocumentFontStatus } from '#core/text/font/status'
import type {
FigmaBooleanOperationNode,
FigmaComponentNode,
FigmaEllipseNode,
FigmaFrameNode,
FigmaGroupNode,
FigmaLineNode,
FigmaPolygonNode,
FigmaRectangleNode,
FigmaSectionNode,
FigmaStarNode,
FigmaTextNode,
FigmaVectorNode
} from './node-types'
import {
FigmaNodeProxy,
INTERNAL_ID,
MIXED,
type FigmaFont,
type FigmaFontName,
type NodeProxyHost
} from './proxy'
const noop = () => undefined
export { FigmaNodeProxy } from './proxy'
export type {
FigmaBooleanOperationNode,
FigmaComponentNode,
FigmaEllipseNode,
FigmaFrameNode,
FigmaGroupNode,
FigmaLineNode,
FigmaPolygonNode,
FigmaRectangleNode,
FigmaSectionNode,
FigmaStarNode,
FigmaTextNode,
FigmaVectorNode
} from './node-types'
export type { FigmaFont, FigmaFontName } from './proxy'
export { computeImageHash }
// TODO(figma-api): Implement the full official PluginAPI interface once our compatibility
// layer covers all required node-specific return types and unsupported APIs are modeled explicitly.
export class FigmaAPI implements NodeProxyHost {
readonly graph: SceneGraph
private _currentPageId: string
private _selection: FigmaNodeProxy[] = []
private _nodeCache = new Map<string, FigmaNodeProxy>()
private _pageProxies = new WeakSet<FigmaNodeProxy>()
private _renderer: SkiaRenderer | null = null
readonly mixed = MIXED
constructor(graph: SceneGraph) {
this.graph = graph
const pages = graph.getPages()
this._currentPageId = pages[0]?.id ?? graph.rootId
}
setRenderer(renderer: SkiaRenderer | null): void {
this._renderer = renderer
}
get currentPageId(): string {
return this._currentPageId
}
wrapNode(id: string): FigmaNodeProxy {
let proxy = this._nodeCache.get(id)
if (!proxy) {
proxy = new FigmaNodeProxy(id, this.graph, this)
this._nodeCache.set(id, proxy)
}
return proxy
}
private _ensurePageProxy(
proxy: FigmaNodeProxy
): FigmaNodeProxy & { selection: FigmaNodeProxy[] } {
if (!this._pageProxies.has(proxy)) {
Object.defineProperty(proxy, 'selection', {
get: () => this._selection,
set: (nodes: FigmaNodeProxy[]) => {
this._selection = nodes
},
enumerable: true,
configurable: true
})
this._pageProxies.add(proxy)
}
return proxy as FigmaNodeProxy & { selection: FigmaNodeProxy[] }
}
get root(): FigmaNodeProxy {
return this.wrapNode(this.graph.rootId)
}
get currentPage(): FigmaNodeProxy & { selection: FigmaNodeProxy[] } {
return this._ensurePageProxy(this.wrapNode(this._currentPageId))
}
set currentPage(page: FigmaNodeProxy) {
this._currentPageId = page[INTERNAL_ID]
}
getNodeById(id: string): FigmaNodeProxy | null {
const node = this.graph.getNode(id)
return node ? this.wrapNode(id) : null
}
// --- Node Creation ---
private _createNode(type: NodeType): FigmaNodeProxy {
const node = this.graph.createNode(type, this._currentPageId)
return this.wrapNode(node.id)
}
createFrame(): FigmaFrameNode {
return this._createNode('FRAME') as FigmaFrameNode
}
createRectangle(): FigmaRectangleNode {
return this._createNode('RECTANGLE') as FigmaRectangleNode
}
createEllipse(): FigmaEllipseNode {
return this._createNode('ELLIPSE') as FigmaEllipseNode
}
createText(): FigmaTextNode {
return this._createNode('TEXT') as FigmaTextNode
}
createLine(): FigmaLineNode {
return this._createNode('LINE') as FigmaLineNode
}
createPolygon(): FigmaPolygonNode {
return this._createNode('POLYGON') as FigmaPolygonNode
}
createStar(): FigmaStarNode {
return this._createNode('STAR') as FigmaStarNode
}
createVector(): FigmaVectorNode {
return this._createNode('VECTOR') as FigmaVectorNode
}
createComponent(): FigmaComponentNode {
return this._createNode('COMPONENT') as FigmaComponentNode
}
createSection(): FigmaSectionNode {
return this._createNode('SECTION') as FigmaSectionNode
}
createPage(): FigmaNodeProxy {
const page = this.graph.addPage('Page')
return this.wrapNode(page.id)
}
// --- Grouping ---
private _nodeId(node: BaseNode | FigmaNodeProxy): string {
return (node as BaseNode & { [INTERNAL_ID]: string })[INTERNAL_ID]
}
group(
nodes: ReadonlyArray<FigmaNodeProxy>,
parent: FigmaNodeProxy,
index?: number
): FigmaGroupNode
group(nodes: ReadonlyArray<BaseNode>, parent: BaseNode & ChildrenMixin, index?: number): GroupNode
group(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy,
index?: number
): FigmaGroupNode {
const parentId = this._nodeId(parent)
const groupNode = this.graph.createNode('GROUP', parentId)
for (const n of nodes) {
this.graph.reparentNode(this._nodeId(n), groupNode.id)
}
if (index != null) this.graph.reorderChild(groupNode.id, parentId, index)
return this.wrapNode(groupNode.id) as FigmaGroupNode
}
ungroup(node: FigmaNodeProxy): FigmaNodeProxy[]
ungroup(node: SceneNode & ChildrenMixin): Array<SceneNode>
ungroup(node: (SceneNode & ChildrenMixin) | FigmaNodeProxy): Array<SceneNode> | FigmaNodeProxy[] {
const nodeId = this._nodeId(node)
const raw = this.graph.getNode(nodeId)
if (!raw || raw.childIds.length === 0) return []
const parentId = raw.parentId ?? this._currentPageId
const children = Array.from(raw.childIds)
for (const childId of children) {
this.graph.reparentNode(childId, parentId)
}
this.graph.deleteNode(nodeId)
return children.map((id) => this.wrapNode(id))
}
createComponentFromNode(node: FigmaNodeProxy): FigmaNodeProxy {
const raw = this.graph.getNode(node[INTERNAL_ID])
if (!raw) throw new Error('Node not found')
const parentId = raw.parentId ?? this._currentPageId
const comp = this.graph.createNode('COMPONENT', parentId)
this.graph.updateNode(comp.id, {
name: raw.name,
width: raw.width,
height: raw.height,
x: raw.x,
y: raw.y,
fills: copyFills(raw.fills),
strokes: copyStrokes(raw.strokes),
effects: copyEffects(raw.effects),
cornerRadius: raw.cornerRadius,
topLeftRadius: raw.topLeftRadius,
topRightRadius: raw.topRightRadius,
bottomRightRadius: raw.bottomRightRadius,
bottomLeftRadius: raw.bottomLeftRadius,
independentCorners: raw.independentCorners,
opacity: raw.opacity,
layoutMode: raw.layoutMode,
primaryAxisAlign: raw.primaryAxisAlign,
counterAxisAlign: raw.counterAxisAlign,
itemSpacing: raw.itemSpacing,
paddingTop: raw.paddingTop,
paddingRight: raw.paddingRight,
paddingBottom: raw.paddingBottom,
paddingLeft: raw.paddingLeft,
pluginData: structuredClone(raw.pluginData),
pluginRelaunchData: structuredClone(raw.pluginRelaunchData)
})
for (const childId of raw.childIds) {
this.graph.cloneTree(childId, comp.id)
}
this.graph.deleteNode(node[INTERNAL_ID])
return this.wrapNode(comp.id)
}
private _findPropertyHost(nodeId: string | null): CoreSceneNode | null {
let current = nodeId ? this.graph.getNode(nodeId) : null
let fallbackComponent: CoreSceneNode | null = null
while (current) {
if (current.type === 'COMPONENT_SET') return current
if (current.type === 'COMPONENT' && !fallbackComponent) fallbackComponent = current
current = current.parentId ? this.graph.getNode(current.parentId) : null
}
return fallbackComponent
}
/**
* Exposes one or more nested instances as an instance-swap slot: the
* enclosing component (or component set) gets an INSTANCE_SWAP property
* offering `candidates`, and each slot instance is tagged to respond to it
* — mirrors Figma's "Create component property > Instance swap".
*/
exposeInstanceSwap(
slots: ReadonlyArray<FigmaNodeProxy>,
candidates: ReadonlyArray<FigmaNodeProxy>,
propertyName = 'Instance'
): FigmaNodeProxy {
if (slots.length === 0) throw new Error('Provide at least one instance to expose')
if (candidates.length === 0) throw new Error('Provide at least one candidate component')
const slotNodes = slots.map((s) => this.graph.getNode(s[INTERNAL_ID]))
if (!slotNodes.every((n): n is CoreSceneNode => n?.type === 'INSTANCE')) {
throw new Error('exposeInstanceSwap requires INSTANCE nodes')
}
const candidateNodes = candidates.map((c) => this.graph.getNode(c[INTERNAL_ID]))
if (!candidateNodes.every((n): n is CoreSceneNode => n?.type === 'COMPONENT')) {
throw new Error('Candidates must be COMPONENT nodes')
}
const candidateIds = candidateNodes.map((n) => n.id)
const host = this._findPropertyHost(slotNodes[0].parentId)
if (!host) throw new Error('Instance must be nested inside a COMPONENT or COMPONENT_SET')
const sameHost = slotNodes.every((n) => this._findPropertyHost(n.parentId)?.id === host.id)
if (!sameHost) throw new Error('All instances must belong to the same component or component set')
const propId = `prop:${randomHex(8)}`
const propDef: ComponentPropertyDefinition = {
id: propId,
name: propertyName,
type: 'INSTANCE_SWAP',
defaultValue: slotNodes[0].componentId ?? candidateIds[0],
preferredValues: candidateIds
}
this.graph.updateNode(host.id, {
componentPropertyDefinitions: [...host.componentPropertyDefinitions, propDef]
})
for (const node of slotNodes) {
this.graph.updateNode(node.id, {
componentPropertyReferences: [
...node.componentPropertyReferences.filter((r) => r.field !== 'INSTANCE_SWAP'),
{ propertyId: propId, field: 'INSTANCE_SWAP' }
]
})
}
return this.wrapNode(host.id)
}
// --- Variables ---
getVariableById(id: string): Variable | null {
return this.graph.variables.get(id) ?? null
}
getLocalVariables(type?: string): Variable[] {
const vars = [...this.graph.variables.values()]
if (type) return vars.filter((v) => v.type === type)
return vars
}
getLocalVariableCollections(): VariableCollection[] {
return [...this.graph.variableCollections.values()]
}
getVariableCollectionById(id: string): VariableCollection | null {
return this.graph.variableCollections.get(id) ?? null
}
// --- Variable/Collection CRUD ---
createVariable(
name: string,
type: VariableType,
collectionId: string,
value?: VariableValue
): Variable {
return this.graph.createVariable(name, type, collectionId, value)
}
setVariableValue(variableId: string, modeId: string, value: VariableValue): void {
const variable = this.graph.variables.get(variableId)
if (!variable) throw new Error(`Variable "${variableId}" not found`)
variable.valuesByMode[modeId] = value
}
deleteVariable(id: string): void {
this.graph.removeVariable(id)
}
createVariableCollection(name: string): VariableCollection {
return this.graph.createCollection(name)
}
deleteVariableCollection(id: string): void {
this.graph.removeCollection(id)
}
bindVariable(nodeId: string, field: string, variableId: string): void {
this.graph.bindVariable(nodeId, field, variableId)
}
unbindVariable(nodeId: string, field: string): void {
this.graph.unbindVariable(nodeId, field)
}
// --- Boolean Operations ---
private _booleanOperation(
operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE',
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
parent: (BaseNode & ChildrenMixin) | FigmaNodeProxy,
index?: number
): FigmaBooleanOperationNode {
if (nodes.length < 2) throw new Error('Need at least 2 nodes for boolean operation')
const parentId = this._nodeId(parent)
const first = this.graph.getNode(this._nodeId(nodes[0]))
if (!first) throw new Error('Node not found')
const group = this.graph.createNode('BOOLEAN_OPERATION', parentId, {
name: `Boolean ${operation.toLowerCase()}`,
x: first.x,
y: first.y,
width: first.width,
height: first.height,
booleanOperation: operation
})
for (const node of nodes) {
this.graph.reparentNode(this._nodeId(node), group.id)
}
if (index != null) this.graph.reorderChild(group.id, parentId, index)
return this.wrapNode(group.id) as FigmaBooleanOperationNode
}
private _nodesById(nodeIds: string[]) {
return nodeIds.map((id) => {
const node = this.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
return node
})
}
booleanOperation(
operation: 'UNION' | 'SUBTRACT' | 'INTERSECT' | 'EXCLUDE',
nodeIds: string[]
): FigmaBooleanOperationNode {
const first = this.graph.getNode(nodeIds[0])
const parent = this.wrapNode(first?.parentId ?? this._currentPageId)
return this._booleanOperation(operation, this._nodesById(nodeIds), parent)
}
union(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('UNION', nodes, parent, index)
}
subtract(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('SUBTRACT', nodes, parent, index)
}
intersect(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('INTERSECT', nodes, parent, index)
}
exclude(
nodes: ReadonlyArray<BaseNode>,
parent: BaseNode & ChildrenMixin,
index?: number
): BooleanOperationNode {
return this._booleanOperation('EXCLUDE', nodes, parent, index)
}
// --- Flatten ---
flatten(
nodes: ReadonlyArray<FigmaNodeProxy>,
parent?: FigmaNodeProxy,
index?: number
): FigmaVectorNode
flatten(
nodes: ReadonlyArray<BaseNode>,
parent?: BaseNode & ChildrenMixin,
index?: number
): VectorNode
flatten(
nodes: ReadonlyArray<BaseNode | FigmaNodeProxy>,
parent?: (BaseNode & ChildrenMixin) | FigmaNodeProxy,
index?: number
): FigmaVectorNode {
if (nodes.length === 0) throw new Error('Need at least 1 node to flatten')
const parentId = this._nodeId(parent ?? this.currentPage)
const sourceNodes: CoreSceneNode[] = []
for (const node of nodes) {
const raw = this.graph.getNode(this._nodeId(node))
if (!raw) throw new Error('Node not found')
sourceNodes.push(raw)
}
const vector = this._renderer
? this._flattenWithRenderer(sourceNodes, parentId)
: this._flattenPlaceholder(sourceNodes, parentId)
if (index != null) this.graph.reorderChild(vector.id, parentId, index)
for (const node of nodes) {
this.graph.deleteNode(this._nodeId(node))
}
return this.wrapNode(vector.id) as FigmaVectorNode
}
private _flattenPlaceholder(nodes: CoreSceneNode[], parentId: string): CoreSceneNode {
const first = nodes[0]
return this.graph.createNode('VECTOR', parentId, {
name: 'Flatten',
x: first.x,
y: first.y,
width: first.width,
height: first.height,
fills: copyFills(first.fills)
})
}
private _flattenWithRenderer(nodes: CoreSceneNode[], parentId: string): CoreSceneNode {
const renderer = this._renderer
if (!renderer) return this._flattenPlaceholder(nodes, parentId)
if (nodes.some((node) => !canMakeBooleanSourceNode(node, this.graph))) {
throw new Error('Cannot flatten unsupported node type')
}
const vectorProps = flattenNodesToVectorProps(renderer, this.graph, nodes)
if (!vectorProps) throw new Error('Cannot flatten empty node path')
return this.graph.createNode('VECTOR', parentId, vectorProps)
}
flattenNode(nodeIds: string[]): FigmaVectorNode {
const first = this.graph.getNode(nodeIds[0])
const parent = this.wrapNode(first?.parentId ?? this._currentPageId)
return this.flatten(this._nodesById(nodeIds), parent)
}
// --- Viewport ---
private _viewport = { x: 0, y: 0, zoom: 1 }
get viewport(): {
center: Vector
zoom: number
scrollAndZoomIntoView: (nodes: readonly { absoluteBoundingBox: Rect }[]) => void
} {
return {
center: { x: this._viewport.x, y: this._viewport.y },
zoom: this._viewport.zoom,
scrollAndZoomIntoView: (nodes) => {
const b = computeBounds(nodes.map((n) => n.absoluteBoundingBox))
if (b.width === 0 && b.height === 0 && nodes.length === 0) return
const padding = 80
const contentW = b.width + padding * 2
const contentH = b.height + padding * 2
const viewW = IS_BROWSER ? window.innerWidth : 1280
const viewH = IS_BROWSER ? window.innerHeight : 720
const zoom = Math.min(viewW / contentW, viewH / contentH, 1)
this._viewport = { x: b.x + b.width / 2, y: b.y + b.height / 2, zoom }
}
}
}
set viewport(v: { center: Vector; zoom: number }) {
this._viewport = { x: v.center.x, y: v.center.y, zoom: v.zoom }
}
createImage(data: Uint8Array): { hash: string } {
const hash = computeImageHash(data)
this.graph.images.set(hash, data)
return { hash }
}
// --- Stubs ---
async loadFontAsync(_fontName: FigmaFontName): Promise<void> {
// No-op: we don't gate text editing on font loading
}
async listAvailableFontsAsync(): Promise<FigmaFont[]> {
// Default: pure browser / test contexts have no enumeration surface.
// Desktop hosts override this to return system + bundled fonts.
return []
}
getFontStatus(): DocumentFontStatus {
return documentFontStatus(this.graph, this.currentPageId)
}
base64Encode(data: Uint8Array): string {
return encodeBase64(data)
}
base64Decode(data: string): Uint8Array {
return decodeBase64(data)
}
notify(message: string): { cancel: () => void } {
if (typeof console !== 'undefined') console.warn(`[figma.notify] ${message}`)
return { cancel: noop }
}
commitUndo(): void {
return undefined
}
triggerUndo(): void {
return undefined
}
exportImage?: (
nodeIds: string[],
options: { scale?: number; format?: RasterExportFormat; quality?: number }
) => Promise<Uint8Array | null>
}