feat(figma-api): add detachInstance() to instances (#778)

instance.detachInstance() turns an instance into a frame that keeps its content, as in Figma, from scripts run through eval. It reuses the graph's shared detach implementation, asserts editability like the proxy's other mutations, and joins the instance surface check against @figma/plugin-typings.
This commit is contained in:
mrhard9090 2026-09-30 04:19:11 +03:00 committed by GitHub
parent bb86d2ad7f
commit 2c9bb8fcd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 64 additions and 7 deletions

View file

@ -10,6 +10,7 @@
### Added
- Detach an instance from its component with `detachInstance()` in the plugin API, as in Figma, from scripts run through `eval`.
- Run scripts written for Figma's dynamic-page mode that call `figma.getNodeByIdAsync()` or `getMainComponentAsync()`; both resolve to the same nodes as their synchronous forms.
- Export components to Storybook with `openpencil export -f storybook`: one CSF3 story file per component set or component for React, Vue, or HTML, with a story and `select` controls per variant, a design image per variant, and an `openpencil://` link that opens the variant in OpenPencil. `--watch` re-exports on every save and removes stories of deleted components (#727).
- Export HTML and Tailwind JSX from the app's export options and through the IO registry, and export Tailwind JSX from the CLI with `-f tailwind-jsx` (`-f jsx --style tailwind` still works). HTML export of a single layer now includes the layer itself, as other formats do.

View file

@ -71,6 +71,7 @@ type InstancePropertySurfaceMatch = Expect<
| 'isExposedInstance'
| 'exposedInstances'
| 'getMainComponentAsync'
| 'detachInstance'
>,
Pick<
InstanceNode,
@ -80,6 +81,7 @@ type InstancePropertySurfaceMatch = Expect<
| 'isExposedInstance'
| 'exposedInstances'
| 'getMainComponentAsync'
| 'detachInstance'
>
>
>

View file

@ -244,6 +244,15 @@ export class FigmaNodeProxy {
return this[INTERNAL_API].wrapNode(inst.id)
}
/** Turns this instance into a frame that keeps its current content, like Figma's. */
detachInstance(): FigmaNodeProxy {
const n = this._raw()
if (n.type !== 'INSTANCE') throw new Error('detachInstance() can only be called on instances')
assertNodeEditable(this[INTERNAL_GRAPH], this[INTERNAL_ID])
this[INTERNAL_GRAPH].detachInstance(n.id)
return this[INTERNAL_API].wrapNode(n.id)
}
// --- Tree ---
get parent(): FigmaNodeProxy | null {

View file

@ -37,6 +37,6 @@ Exakte Namen wie `figma.createFrame()`, `node.appendChild()`, `fontSize` und `la
## Noch nicht kompatibel
Noch nicht angeboten werden unter anderem `node.exportAsync()`, `node.setBoundVariable()`, `node.detachInstance()`, `figma.combineAsVariants()` und die Stil-APIs von Figma.
Noch nicht angeboten werden unter anderem `node.exportAsync()`, `node.setBoundVariable()`, `figma.combineAsVariants()` und die Stil-APIs von Figma.
Dafür stehen je nach Aufgabe CLI-Export, Werkzeuge des Kernpakets oder direkte SceneGraph-Hilfsfunktionen zur Verfügung.

View file

@ -110,6 +110,6 @@ Las propiedades habituales se leen y modifican mediante el objeto correspondient
## Limitaciones
Aún no hay equivalentes completos para `node.exportAsync()`, `node.setBoundVariable()`, `node.detachInstance()`, `figma.combineAsVariants()`, estilos de pintura/texto y todas las operaciones booleanas vectoriales.
Aún no hay equivalentes completos para `node.exportAsync()`, `node.setBoundVariable()`, `figma.combineAsVariants()`, estilos de pintura/texto y todas las operaciones booleanas vectoriales.
Según la tarea, pueden usarse el comando de exportación, las herramientas del núcleo o las operaciones directas de SceneGraph.

View file

@ -55,6 +55,6 @@ Les identifiants exacts comme `figma.currentPage`, `createFrame`, `appendChild`,
## Limites
Il n’existe pas encore d’équivalent complet pour `node.exportAsync()`, `node.setBoundVariable()`, `node.detachInstance()`, `figma.combineAsVariants()`, les styles de peinture/texte et toutes les opérations booléennes vectorielles.
Il n’existe pas encore d’équivalent complet pour `node.exportAsync()`, `node.setBoundVariable()`, `figma.combineAsVariants()`, les styles de peinture/texte et toutes les opérations booléennes vectorielles.
Selon le besoin, utilisez aussi la commande d’exportation, les outils du noyau ou les opérations directes de SceneGraph.

View file

@ -47,4 +47,4 @@ Gli identificatori esatti come `figma.currentPage`, `createFrame`, `appendChild`
## Limiti
Non esistono ancora equivalenti completi per `node.exportAsync()`, `node.setBoundVariable()`, `node.detachInstance()`, `figma.combineAsVariants()`, gli stili e tutte le operazioni booleane vettoriali.
Non esistono ancora equivalenti completi per `node.exportAsync()`, `node.setBoundVariable()`, `figma.combineAsVariants()`, gli stili e tutte le operazioni booleane vettoriali.

View file

@ -106,6 +106,6 @@ Najczęściej używane właściwości można odczytywać i zapisywać przez poś
## Brak pełnej zgodności z Figmą
Nie są jeszcze dostępne między innymi `node.exportAsync()`, `node.setBoundVariable()`, `node.detachInstance()`, `figma.combineAsVariants()` i API stylów Figmy.
Nie są jeszcze dostępne między innymi `node.exportAsync()`, `node.setBoundVariable()`, `figma.combineAsVariants()` i API stylów Figmy.
Zamiast nich używaj poleceń eksportu CLI, narzędzi głównego pakietu albo bezpośrednich funkcji pomocniczych SceneGraph.

View file

@ -172,7 +172,6 @@ These Figma APIs are not exposed as compatible helpers yet:
- `node.exportAsync()`
- `node.setBoundVariable(field, variable)`
- `node.detachInstance()`
- `figma.combineAsVariants(components, parent)`
- Figma style APIs such as `figma.createPaintStyle()` / `figma.createTextStyle()`
- Full vector boolean operation parity

View file

@ -149,7 +149,6 @@ API намеренно близок к Figma Plugin API, но работает
- `node.exportAsync()`
- `node.setBoundVariable(field, variable)`
- `node.detachInstance()`
- `figma.combineAsVariants(components, parent)`
- API стилей Figma, например `figma.createPaintStyle()` и `figma.createTextStyle()`
- полная совместимость логических операций над векторами

View file

@ -53,4 +53,26 @@ describe('library definition capabilities', () => {
ReadOnlyLibraryDefinitionError
)
})
test('does not detach an instance inside a read-only definition', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const icon = graph.createNode('COMPONENT', page.id, { name: 'Icon' })
const component = graph.createNode('COMPONENT', page.id, {
name: 'Remote button',
librarySource: {
identity: { libraryId: 'design-system', assetKey: 'button', revisionId: 'r1' },
sourceNodeId: 'source',
readOnly: true
}
})
const nested = graph.createInstance(icon.id, component.id)
if (!nested) throw new Error('Expected instance')
const figma = new FigmaAPI(graph)
const nestedProxy = figma.getNodeById(nested.id)
if (!nestedProxy) throw new Error('Expected proxy')
expect(() => nestedProxy.detachInstance()).toThrow(ReadOnlyLibraryDefinitionError)
expect(graph.getNode(nested.id)?.type).toBe('INSTANCE')
})
})

View file

@ -62,6 +62,31 @@ describe('components', () => {
expect(expectDefined(instance.mainComponent, 'instance main component').id).toBe(comp.id)
})
test('detachInstance turns an instance into a frame with its content', () => {
const api = createAPI()
const component = api.createComponent()
component.resize(80, 40)
component.appendChild(Object.assign(api.createText(), { name: 'Label', characters: 'Default' }))
const instance = component.createInstance()
instance.x = 200
const detached = instance.detachInstance()
expect(detached.id).toBe(instance.id)
expect(detached.type).toBe('FRAME')
expect(detached.mainComponent).toBeNull()
expect([detached.x, detached.width, detached.height]).toEqual([200, 80, 40])
expect(detached.children.map((child) => child.name)).toEqual(['Label'])
expect(api.getNodeById(component.id)?.type).toBe('COMPONENT')
})
test('detachInstance rejects nodes that are not instances', () => {
const api = createAPI()
expect(() => api.createFrame().detachInstance()).toThrow(
'detachInstance() can only be called on instances'
)
})
test('async lookups resolve like their synchronous forms', async () => {
const api = createAPI()
const component = api.createComponent()