fix(fig): treat the unset override key as no key (#967)
Figma saves layers without an override key with Kiwi's all-ones GUID, so many siblings share it. The importer stored it as a real key, and export addressed each instance layer by that key, so two such siblings got the same derived geometry address and export threw "Ambiguous instance geometry address". Instance linkage and override path matching also treated the shared value as an identity. The importer now reads the unset GUID as no key, through an isUnsetGuid helper that the cleared-style and default-slot checks share. The Preline UI kit has 1,165 such text and rounded rectangle layers; after an edit it now exports and reopens with all 104 pages.
This commit is contained in:
parent
6e68366bc6
commit
7b011bceaf
|
|
@ -1,5 +1,5 @@
|
|||
import type { GUID, NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { guidToString } from '@open-pencil/kiwi/fig/guid'
|
||||
import { guidToString, isUnsetGuid } from '@open-pencil/kiwi/fig/guid'
|
||||
|
||||
import { findWithinBoundary, type TreeShape } from './tree'
|
||||
|
||||
|
|
@ -62,7 +62,9 @@ export function readOverrideKey(value: unknown): GUID | undefined {
|
|||
if (!value || typeof value !== 'object' || !('sessionID' in value) || !('localID' in value))
|
||||
return undefined
|
||||
if (typeof value.sessionID !== 'number' || typeof value.localID !== 'number') return undefined
|
||||
return { sessionID: value.sessionID, localID: value.localID }
|
||||
const key = { sessionID: value.sessionID, localID: value.localID }
|
||||
// Layers without an override key share the unset GUID, so it identifies none of them.
|
||||
return isUnsetGuid(key) ? undefined : key
|
||||
}
|
||||
|
||||
/** A path segment addresses a record by GUID or by its stable override key. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { GUID, NodeChange, VariableConsumptionEntry } from '@open-pencil/kiwi/fig/codec'
|
||||
import { isUnsetGuid, UNSET_GUID } from '@open-pencil/kiwi/fig/guid'
|
||||
import type { Matrix, Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
export interface VariableConsumptionMapFields {
|
||||
|
|
@ -59,16 +60,13 @@ export type ComponentPropValue = {
|
|||
}
|
||||
|
||||
/** Figma's slot value for "the component's own content", the default of every slot property. */
|
||||
export const DEFAULT_SLOT_CONTENT: Readonly<GUID> = { sessionID: 0xffffffff, localID: 0xffffffff }
|
||||
export const DEFAULT_SLOT_CONTENT: Readonly<GUID> = UNSET_GUID
|
||||
|
||||
/** The content frame a slot value names, unless it names the component's own content. */
|
||||
export function assignedSlotContent(value: ComponentPropValue | undefined): GUID | undefined {
|
||||
const guid = value?.slotContentIdValue?.guid
|
||||
if (!guid) return undefined
|
||||
return guid.sessionID === DEFAULT_SLOT_CONTENT.sessionID &&
|
||||
guid.localID === DEFAULT_SLOT_CONTENT.localID
|
||||
? undefined
|
||||
: guid
|
||||
return isUnsetGuid(guid) ? undefined : guid
|
||||
}
|
||||
|
||||
export interface ComponentPropAssignment {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { guidToString } from '@open-pencil/kiwi/fig/guid'
|
||||
import { guidToString, isUnsetGuid } from '@open-pencil/kiwi/fig/guid'
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_STROKE_MITER_LIMIT,
|
||||
|
|
@ -510,14 +510,8 @@ function styleRefId(value: unknown): string | null {
|
|||
if (!value || typeof value !== 'object' || !('guid' in value)) return null
|
||||
const guid = value.guid
|
||||
if (!guid || typeof guid !== 'object') return null
|
||||
// Kiwi's all-ones GUID denotes an explicitly cleared style reference.
|
||||
if (
|
||||
'sessionID' in guid &&
|
||||
'localID' in guid &&
|
||||
guid.sessionID === 0xffffffff &&
|
||||
guid.localID === 0xffffffff
|
||||
)
|
||||
return null
|
||||
// The unset GUID denotes an explicitly cleared style reference.
|
||||
if (isUnsetGuid(guid as GUID)) return null
|
||||
return guidToString(guid as GUID)
|
||||
}
|
||||
|
||||
|
|
@ -902,11 +896,13 @@ type ComponentMetadataProps = Pick<
|
|||
| 'variantPropSpecs'
|
||||
>
|
||||
|
||||
/** Null for a missing GUID and for the unset GUID, which many layers share and names none. */
|
||||
function guidToStringOrNull(value: unknown): string | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const guid = value as Partial<GUID>
|
||||
if (typeof guid.sessionID !== 'number' || typeof guid.localID !== 'number') return null
|
||||
return guidToString({ sessionID: guid.sessionID, localID: guid.localID })
|
||||
const parsed = { sessionID: guid.sessionID, localID: guid.localID }
|
||||
return isUnsetGuid(parsed) ? null : guidToString(parsed)
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { expect, test } from 'bun:test'
|
||||
|
||||
import type { DerivedSymbolOverride } from '#fig/instance-overrides/types'
|
||||
import { sceneNodeToKiwi } from '#fig/node-change/index'
|
||||
import { snapshotInstanceGeometry } from '#fig/node-change/instance/geometry'
|
||||
|
||||
import { stringToGuid } from '@open-pencil/kiwi/fig/guid'
|
||||
import { materializeDocument } from '@open-pencil/fig'
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { stringToGuid, UNSET_GUID } from '@open-pencil/kiwi/fig/guid'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
import { expectDefined } from '../helpers/assert'
|
||||
|
|
@ -42,3 +45,66 @@ test('geometry snapshots reject ambiguous occurrence addresses', () => {
|
|||
}))
|
||||
).toThrow('Ambiguous instance geometry address')
|
||||
})
|
||||
|
||||
// Figma saves layers that have no override key with the unset GUID, so many siblings share it.
|
||||
test('layers saved with the unset override key keep distinct geometry addresses', () => {
|
||||
const at = (localID: number) => ({ sessionID: 1, localID })
|
||||
const transform = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
const { graph } = materializeDocument([
|
||||
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document', phase: 'CREATED' },
|
||||
{
|
||||
guid: { sessionID: 0, localID: 1 },
|
||||
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
|
||||
type: 'CANVAS',
|
||||
name: 'Page',
|
||||
phase: 'CREATED'
|
||||
},
|
||||
{
|
||||
guid: at(1),
|
||||
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
|
||||
type: 'SYMBOL',
|
||||
name: 'Card',
|
||||
phase: 'CREATED',
|
||||
size: { x: 100, y: 40 },
|
||||
transform
|
||||
},
|
||||
...['Title', 'Body'].map((name, index) => ({
|
||||
guid: at(2 + index),
|
||||
overrideKey: { ...UNSET_GUID },
|
||||
parentIndex: { guid: at(1), position: index === 0 ? '!' : '"' },
|
||||
type: 'ROUNDED_RECTANGLE',
|
||||
name,
|
||||
phase: 'CREATED',
|
||||
size: { x: 100, y: 20 },
|
||||
transform: { ...transform, m12: index * 20 }
|
||||
})),
|
||||
{
|
||||
guid: at(4),
|
||||
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
|
||||
type: 'INSTANCE',
|
||||
name: 'Card use',
|
||||
phase: 'CREATED',
|
||||
size: { x: 100, y: 40 },
|
||||
transform,
|
||||
symbolData: { symbolID: at(1) }
|
||||
}
|
||||
] as NodeChange[])
|
||||
const page = graph.getPages()[0]
|
||||
const instance = expectDefined(
|
||||
graph.getChildren(page.id).find((node) => node.type === 'INSTANCE')
|
||||
)
|
||||
expect(graph.getChildren(instance.id).map((node) => node.overrideKey)).toEqual([null, null])
|
||||
|
||||
const [change] = sceneNodeToKiwi(
|
||||
instance,
|
||||
{ sessionID: 0, localID: 1 },
|
||||
1,
|
||||
{ value: 10 },
|
||||
graph,
|
||||
[]
|
||||
)
|
||||
const derived = (change.derivedSymbolData ?? []) as DerivedSymbolOverride[]
|
||||
const paths = derived.map((entry) => JSON.stringify(entry.guidPath?.guids))
|
||||
expect(paths).toHaveLength(2)
|
||||
expect(new Set(paths).size).toBe(2)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import type { GUID } from './types'
|
||||
|
||||
/**
|
||||
* Kiwi's all-ones GUID names no node: a cleared style reference, a slot's own content, or a
|
||||
* layer without an override key.
|
||||
*/
|
||||
export const UNSET_GUID: Readonly<GUID> = { sessionID: 0xffffffff, localID: 0xffffffff }
|
||||
|
||||
export function isUnsetGuid(guid: GUID): boolean {
|
||||
return guid.sessionID === UNSET_GUID.sessionID && guid.localID === UNSET_GUID.localID
|
||||
}
|
||||
|
||||
export function guidToString(guid: GUID): string {
|
||||
return `${guid.sessionID}:${guid.localID}`
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue