From 2c9bb8fcd7a04be540d809ef53f4abb8fd4b42d5 Mon Sep 17 00:00:00 2001 From: mrhard9090 <74858909+mrhard9090@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:19:11 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + packages/core/src/figma-api/compatibility.ts | 2 ++ packages/core/src/figma-api/proxy.ts | 9 +++++++ .../docs/de/programmable/cli/scripting.md | 2 +- .../docs/es/programmable/cli/scripting.md | 2 +- .../docs/fr/programmable/cli/scripting.md | 2 +- .../docs/it/programmable/cli/scripting.md | 2 +- .../docs/pl/programmable/cli/scripting.md | 2 +- packages/docs/programmable/cli/scripting.md | 1 - .../docs/ru/programmable/cli/scripting.md | 1 - .../components/library-capabilities.test.ts | 22 ++++++++++++++++ tests/engine/figma/api/components.test.ts | 25 +++++++++++++++++++ 12 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 986f8ef90..bda115f75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/figma-api/compatibility.ts b/packages/core/src/figma-api/compatibility.ts index dd6a8e356..e4f639b78 100644 --- a/packages/core/src/figma-api/compatibility.ts +++ b/packages/core/src/figma-api/compatibility.ts @@ -71,6 +71,7 @@ type InstancePropertySurfaceMatch = Expect< | 'isExposedInstance' | 'exposedInstances' | 'getMainComponentAsync' + | 'detachInstance' >, Pick< InstanceNode, @@ -80,6 +81,7 @@ type InstancePropertySurfaceMatch = Expect< | 'isExposedInstance' | 'exposedInstances' | 'getMainComponentAsync' + | 'detachInstance' > > > diff --git a/packages/core/src/figma-api/proxy.ts b/packages/core/src/figma-api/proxy.ts index 641760c65..e09f6d752 100644 --- a/packages/core/src/figma-api/proxy.ts +++ b/packages/core/src/figma-api/proxy.ts @@ -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 { diff --git a/packages/docs/de/programmable/cli/scripting.md b/packages/docs/de/programmable/cli/scripting.md index 058aae5f9..e3770af57 100644 --- a/packages/docs/de/programmable/cli/scripting.md +++ b/packages/docs/de/programmable/cli/scripting.md @@ -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. diff --git a/packages/docs/es/programmable/cli/scripting.md b/packages/docs/es/programmable/cli/scripting.md index ff2757656..9024f2f20 100644 --- a/packages/docs/es/programmable/cli/scripting.md +++ b/packages/docs/es/programmable/cli/scripting.md @@ -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. diff --git a/packages/docs/fr/programmable/cli/scripting.md b/packages/docs/fr/programmable/cli/scripting.md index 71f80233e..cb446fe05 100644 --- a/packages/docs/fr/programmable/cli/scripting.md +++ b/packages/docs/fr/programmable/cli/scripting.md @@ -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. diff --git a/packages/docs/it/programmable/cli/scripting.md b/packages/docs/it/programmable/cli/scripting.md index f9db829cd..d0e5e5f6e 100644 --- a/packages/docs/it/programmable/cli/scripting.md +++ b/packages/docs/it/programmable/cli/scripting.md @@ -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. diff --git a/packages/docs/pl/programmable/cli/scripting.md b/packages/docs/pl/programmable/cli/scripting.md index 6778ae835..eeb4705d5 100644 --- a/packages/docs/pl/programmable/cli/scripting.md +++ b/packages/docs/pl/programmable/cli/scripting.md @@ -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. diff --git a/packages/docs/programmable/cli/scripting.md b/packages/docs/programmable/cli/scripting.md index e3c0f0478..564f4e037 100644 --- a/packages/docs/programmable/cli/scripting.md +++ b/packages/docs/programmable/cli/scripting.md @@ -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 diff --git a/packages/docs/ru/programmable/cli/scripting.md b/packages/docs/ru/programmable/cli/scripting.md index 2c429608a..30ad54d16 100644 --- a/packages/docs/ru/programmable/cli/scripting.md +++ b/packages/docs/ru/programmable/cli/scripting.md @@ -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()` - полная совместимость логических операций над векторами diff --git a/tests/engine/editor/components/library-capabilities.test.ts b/tests/engine/editor/components/library-capabilities.test.ts index 395871b66..e51ab5ad7 100644 --- a/tests/engine/editor/components/library-capabilities.test.ts +++ b/tests/engine/editor/components/library-capabilities.test.ts @@ -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') + }) }) diff --git a/tests/engine/figma/api/components.test.ts b/tests/engine/figma/api/components.test.ts index 4b22235ac..4a9f8970f 100644 --- a/tests/engine/figma/api/components.test.ts +++ b/tests/engine/figma/api/components.test.ts @@ -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()