diff --git a/packages/fig/src/instance-overrides/source-index.ts b/packages/fig/src/instance-overrides/source-index.ts index 70cc6fec6..c7b74ae67 100644 --- a/packages/fig/src/instance-overrides/source-index.ts +++ b/packages/fig/src/instance-overrides/source-index.ts @@ -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. */ diff --git a/packages/fig/src/instance-overrides/types.ts b/packages/fig/src/instance-overrides/types.ts index 6e6b3e146..620bc6e41 100644 --- a/packages/fig/src/instance-overrides/types.ts +++ b/packages/fig/src/instance-overrides/types.ts @@ -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 = { sessionID: 0xffffffff, localID: 0xffffffff } +export const DEFAULT_SLOT_CONTENT: Readonly = 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 { diff --git a/packages/fig/src/node-change/convert.ts b/packages/fig/src/node-change/convert.ts index ec98114f5..e36d5cf98 100644 --- a/packages/fig/src/node-change/convert.ts +++ b/packages/fig/src/node-change/convert.ts @@ -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 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 { diff --git a/packages/fig/tests/node-change/instance-geometry.test.ts b/packages/fig/tests/node-change/instance-geometry.test.ts index add5f9fd8..d089e88e4 100644 --- a/packages/fig/tests/node-change/instance-geometry.test.ts +++ b/packages/fig/tests/node-change/instance-geometry.test.ts @@ -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) +}) diff --git a/packages/kiwi/src/fig/guid.ts b/packages/kiwi/src/fig/guid.ts index 73aa97342..f86d175e4 100644 --- a/packages/kiwi/src/fig/guid.ts +++ b/packages/kiwi/src/fig/guid.ts @@ -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 = { 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}` }