diff --git a/CHANGELOG.md b/CHANGELOG.md index 725f321cc..4df0bdb85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas. - Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance. - Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale. +- Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102) - Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452) ## 0.14.0 - 2026-08-10 diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 97c9f9e37..e16acc0bb 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -8,7 +8,7 @@ import { sortChildren } from '@open-pencil/fig/node-change' import { initCodec, getCompiledSchema, getSchemaBytes } from '@open-pencil/kiwi/fig/codec' -import type { NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' +import type { GUID, NodeChange as KiwiNodeChange } from '@open-pencil/kiwi/fig/codec' import { decodeBinarySchema, compileSchema, ByteBuffer } from '@open-pencil/kiwi/schema-runtime' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' @@ -283,7 +283,9 @@ export function importClipboardNodes( remapComponentIds(created, graph) - populateAndApplyOverrides(graph, guidMap as Map, created, blobs) + graph.preserveSourceMetadataDuring(() => { + populateAndApplyOverrides(graph, guidMap as Map, created, blobs) + }) for (const figmaId of internalTopLevel) { const ourId = created.get(figmaId) @@ -321,6 +323,8 @@ export async function buildFigmaClipboardHTML( } } + const nodeIdToGuid = new Map() + const assignedGuidValues = new Set() const blobs: Uint8Array[] = [] for (let i = 0; i < nodes.length; i++) { collectTextNodes(nodes[i]) @@ -332,8 +336,12 @@ export async function buildFigmaClipboardHTML( localIdCounter, graph, blobs, + nodeIdToGuid, + fontDigestMap, undefined, - fontDigestMap + undefined, + undefined, + assignedGuidValues ) ) } diff --git a/packages/fig/src/instance-overrides/sync/propagate.ts b/packages/fig/src/instance-overrides/sync/propagate.ts index 027c35972..fe321cf5e 100644 --- a/packages/fig/src/instance-overrides/sync/propagate.ts +++ b/packages/fig/src/instance-overrides/sync/propagate.ts @@ -1,6 +1,6 @@ import type { SceneGraph } from '@open-pencil/scene-graph' -import type { ProtectionMap } from '../patches' +import { isFieldProtected, type ProtectionMap } from '../patches' import { buildClonesMap, syncChildrenDeep } from './clones' import { syncNodeProps } from './fields' import { indexCloneSubtree, remapRepopulatedChildSources, snapshotChildSources } from './sources' @@ -129,6 +129,15 @@ export function propagateOverridesTransitively( if (!node) continue if (skip.has(cloneId)) { + // A directly overridden clone may still inherit effective text from an + // overridden source. Respect its own text override when present. + if ( + source.type === 'TEXT' && + node.type === 'TEXT' && + !isFieldProtected(protections, node.id, 'text') + ) { + graph.updateNode(node.id, { text: source.text }) + } syncQueue.push(cloneId) continue } diff --git a/packages/fig/tests/instance-overrides.test.ts b/packages/fig/tests/instance-overrides.test.ts index 779edb900..015ca2480 100644 --- a/packages/fig/tests/instance-overrides.test.ts +++ b/packages/fig/tests/instance-overrides.test.ts @@ -8,6 +8,7 @@ import { syncNodeProps, type ProtectionMap } from '../src/instance-overrides' +import { propagateOverridesTransitively } from '../src/instance-overrides/sync/propagate' describe('@open-pencil/fig instance interpretation', () => { test('populates an empty instance from its component tree', () => { @@ -407,6 +408,33 @@ describe('@open-pencil/fig instance interpretation', () => { expect(graph.getNode(target.id)?.boundVariables).toEqual({ width: 'width-var' }) }) + test('inherits effective text on a structurally protected clone', () => { + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + const component = graph.createNode('COMPONENT', pageId) + const source = graph.createNode('TEXT', component.id, { + text: 'Effective label' + }) + const instance = graph.createNode('INSTANCE', pageId, { componentId: component.id }) + graph.populateInstanceChildren(instance.id, component.id, 'fig-import') + const clone = graph.getChildren(instance.id)[0] + graph.updateNode(clone.id, { text: 'Default label' }) + const protections: ProtectionMap = new Map() + protectField(protections, clone.id, 'width') + + propagateOverridesTransitively( + graph, + new Set([source.id, clone.id]), + new Set(), + new Map(), + undefined, + undefined, + protections + ) + + expect(graph.getNode(clone.id)?.text).toBe('Effective label') + }) + test('preserves protected text while synchronizing other fields', () => { const graph = new SceneGraph() const pageId = graph.getPages()[0].id diff --git a/tests/engine/clipboard/figma/html.test.ts b/tests/engine/clipboard/figma/html.test.ts index f8625026d..5800b52ff 100644 --- a/tests/engine/clipboard/figma/html.test.ts +++ b/tests/engine/clipboard/figma/html.test.ts @@ -141,6 +141,37 @@ describe('buildFigmaClipboardHTML', () => { expect(html).toContain('figmeta') }) + it('preserves source metadata while importing instance overrides', async () => { + const source = new SceneGraph() + const sourcePage = source.getPages()[0] + const component = source.createNode('COMPONENT', sourcePage.id, { name: 'Button' }) + source.createNode('TEXT', component.id, { name: 'Label', text: 'Effective label' }) + const instance = source.createNode('INSTANCE', sourcePage.id, { + name: 'Button instance', + componentId: component.id + }) + source.populateInstanceChildren(instance.id, component.id) + + const html = await buildFigmaClipboardHTML([component, instance], source) + const parsed = await parseFigmaClipboard(expectDefined(html, 'Figma clipboard html')) + const clipboard = expectDefined(parsed, 'Figma clipboard') + const target = new SceneGraph() + const targetPage = target.getPages()[0] + importClipboardNodes(clipboard.nodes, target, targetPage.id) + const importedInstance = [...target.getAllNodes()].find( + (node) => node.type === 'INSTANCE' && node.name === 'Button instance' + ) + const importedLabel = importedInstance + ? [...target.getAllNodes()].find( + (node) => node.type === 'TEXT' && node.parentId === importedInstance.id + ) + : undefined + + expect(importedInstance).toBeDefined() + expect(importedLabel?.text).toBe('Effective label') + expect(importedLabel?.source.editedFields).toEqual([]) + }) + it('roundtrips: encode then decode back', async () => { const graph = new SceneGraph() const page = graph.getPages()[0]