Merge pull request #498 from open-pencil/issue-102-multifixture-audit

fix(fig): preserve nested text overrides
This commit is contained in:
Danila Poyarkov 2026-08-12 20:58:22 +03:00 committed by GitHub
commit fece63b5b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 81 additions and 4 deletions

View file

@ -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

View file

@ -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<string, InstanceNodeChange>, created, blobs)
graph.preserveSourceMetadataDuring(() => {
populateAndApplyOverrides(graph, guidMap as Map<string, InstanceNodeChange>, created, blobs)
})
for (const figmaId of internalTopLevel) {
const ourId = created.get(figmaId)
@ -321,6 +323,8 @@ export async function buildFigmaClipboardHTML(
}
}
const nodeIdToGuid = new Map<string, GUID>()
const assignedGuidValues = new Set<string>()
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
)
)
}

View file

@ -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
}

View file

@ -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

View file

@ -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]