openpencil/packages/fig/src/instance-overrides/materialize-instance.ts
Danila Poyarkov 12b6662ba3
fix(fig): keep Figma's order keys on instances and untouched siblings (#819)
* fix(fig): keep Figma's order keys on instances and untouched siblings

Materialized instance occurrences were built without the record's
parentIndex position, so every imported instance lost its key and was
renumbered on save. They now carry it like other imported nodes.

Sibling keys anchored greedily on the first imported key, so a layer moved
up past its siblings kept its key and pushed every sibling after it onto a
new one. Keep the longest increasing run of imported keys instead, so only
the moved and inserted layers are re-keyed.

* fix(fig): don't anchor sibling keys on one nothing can precede

A moved layer in front of the lowest key ('!') left no room below it, so
the anchor was dropped and every sibling up to the next anchor re-keyed.
Such a key no longer starts the kept run when a sibling precedes it.
2026-10-04 10:16:01 +00:00

234 lines
9.1 KiB
TypeScript

import type { NodeChange, Paint } from '@open-pencil/kiwi/fig/codec'
import { setInstanceOverride, type SceneGraph, type SceneNode } from '@open-pencil/scene-graph'
import { createDefaultSourceMetadata } from '@open-pencil/scene-graph/node-defaults'
import { nodeChangeToProps } from '../node-change'
import { numericVariableBindingScales } from '../node-change/variable/bindings'
import {
recordVariableBindingClaims,
occurrenceAssignmentScales,
occurrenceScale
} from './bindings/variables'
import { OVERRIDE_FIELDS, type OverrideField, type RawOverrideField } from './fields'
import { resolveOccurrencePath } from './interpret'
import type { InstanceOccurrence } from './occurrence/types'
import { symbolDataOf } from './types'
function occurrenceMetadata(
current: InstanceOccurrence,
converted: ReturnType<typeof nodeChangeToProps>
) {
const metadata = createDefaultSourceMetadata()
// Figma's own sibling key, so re-export keeps the order Figma saved.
metadata.orderKey = current.properties.parentIndex?.position ?? null
metadata.fig.layout = converted.source?.fig.layout ?? null
metadata.fig.uniformScaleFactor = symbolDataOf(current.properties)?.uniformScaleFactor ?? null
return metadata
}
export interface MaterializedInstance {
root: SceneNode
/** Occurrence object identity distinguishes repeated uses of a source node. */
nodes: ReadonlyMap<InstanceOccurrence, SceneNode>
}
/**
* Materialize an interpreted tree. Population, sync and layout are the caller's to run.
* Component IDs must refer to existing COMPONENT nodes in the destination graph.
* Occurrence provenance stays in the returned map, not in fabricated FIG metadata.
*/
/** What an occurrence tree needs beyond the graph it is written into. */
export interface MaterializeInstanceOptions {
blobs?: Uint8Array[]
/** Graph ids for occurrences a component definition already materialized. */
sourceChildren?: ReadonlyMap<InstanceOccurrence, string>
/** Nodes to update in place rather than create, keyed by occurrence. */
existingNodes?: ReadonlyMap<InstanceOccurrence, SceneNode>
}
/** A document or a variable is a resource record, never a node in an expansion. */
function assertMaterializableType(
nodeType: 'DOCUMENT' | 'VARIABLE' | SceneNode['type']
): asserts nodeType is SceneNode['type'] {
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') {
throw new Error(`Cannot materialize ${nodeType} as an instance descendant`)
}
}
export function materializeInstance(
graph: SceneGraph,
parentId: string,
occurrence: InstanceOccurrence,
components: ReadonlyMap<string, string>,
options: MaterializeInstanceOptions = {}
): MaterializedInstance {
const { blobs = [], sourceChildren = new Map(), existingNodes = new Map() } = options
if (!graph.getNode(parentId)) throw new Error('Missing materialization parent')
const prepared = new Map<InstanceOccurrence, ReturnType<typeof nodeChangeToProps>>()
const validate = (current: InstanceOccurrence): void => {
if (prepared.has(current)) throw new Error('Repeated or cyclic instance occurrence')
const converted = nodeChangeToProps(current.properties, blobs, 'occurrence')
if (converted.nodeType === 'TEXT' && current.derivedSize) {
converted.derivedLayout = { width: current.derivedSize.x, height: current.derivedSize.y }
}
assertMaterializableType(converted.nodeType)
const existing = existingNodes.get(current)
if (
existing &&
(graph.getNode(existing.id) !== existing || existing.type !== converted.nodeType)
) {
throw new Error(`Invalid preallocated occurrence ${current.sourceId}`)
}
prepared.set(current, converted)
const sourceChildId = sourceChildren.get(current)
if (sourceChildId && graph.getNode(sourceChildId)?.type !== converted.nodeType) {
throw new Error(`Invalid source child for ${current.sourceId}`)
}
if (current.mainComponentId !== null) {
const id = components.get(current.mainComponentId)
if (!id || graph.getNode(id)?.type !== 'COMPONENT') {
throw new Error(`Missing materialized component ${current.mainComponentId}`)
}
}
for (const child of current.children) validate(child)
}
validate(occurrence)
const nodes = new Map<InstanceOccurrence, SceneNode>(existingNodes)
const applyBindingClaims = (
current: InstanceOccurrence,
node: SceneNode,
owner?: SceneNode
): void => {
if (!owner) return
for (const claim of current.bindingClaims) {
if (claim.origin === 'assignment' && claim.field === 'visible') {
setInstanceOverride(owner.instanceOverrides, owner.id, node.id, 'visible', node.visible)
}
}
}
const create = (current: InstanceOccurrence, parent: string, owner?: SceneNode): SceneNode => {
const converted = prepared.get(current)
if (!converted) throw new Error('Missing prepared occurrence')
const { nodeType, ...props } = converted
assertMaterializableType(nodeType)
const metadata = occurrenceMetadata(current, converted)
const propsWithIdentity = {
...props,
variableAssignmentScales: occurrenceAssignmentScales(current),
componentScale: occurrenceScale(current),
variableBindingScales: numericVariableBindingScales(
props.boundVariables ?? {},
current.layoutScale ?? 1,
current.variableBindingScales
),
componentId:
current.mainComponentId === null
? (sourceChildren.get(current) ?? null)
: components.get(current.mainComponentId),
source: metadata
}
const existing = existingNodes.get(current)
if (existing && existing.parentId !== parent) {
throw new Error(`Preallocated occurrence has wrong parent ${current.sourceId}`)
}
const node = existing ?? graph.createNode(nodeType, parent, propsWithIdentity)
if (existing) graph.updateNode(existing.id, propsWithIdentity)
nodes.set(current, node)
if (existing && current !== occurrence && node.type === 'COMPONENT') return node
applyBindingClaims(current, node, owner)
const sourceChildId = sourceChildren.get(current)
if (owner && current.mainComponentId !== null && sourceChildId) {
setInstanceOverride(
owner.instanceOverrides,
owner.id,
node.id,
'sourceComponentId',
sourceChildId
)
const sourceChild = graph.getNode(sourceChildId)
if (sourceChild?.componentId !== node.componentId) {
setInstanceOverride(
owner.instanceOverrides,
owner.id,
node.id,
'componentId',
node.componentId
)
}
}
const children = current.children.map(
(child) => create(child, node.id, node.type === 'INSTANCE' ? node : owner).id
)
node.childIds = children
return node
}
const root = create(occurrence, parentId)
recordPropertyClaims(nodes)
return { root, nodes }
}
/** Whether a raw claim actually carries the scene value its kind maps to. */
function claimApplies(field: OverrideField, value: unknown, target: SceneNode): boolean {
if (field.kind === 'text')
return typeof value === 'object' && value !== null && 'characters' in value
if (field.kind === 'text-style') return Boolean(target.textStyleId)
return true
}
/**
* A claimed paint carries its colour alias inside the paint, so the binding it declares is
* part of the claim too; otherwise a later component sync restores the component's binding.
*/
function recordPaintBindingClaims(
owner: SceneNode,
target: SceneNode,
scene: keyof SceneNode,
paints: unknown
): void {
if (!Array.isArray(paints)) return
let declared = false
for (const [index, paint] of paints.entries()) {
const alias = (paint as Paint).colorVar?.value?.alias
if (!alias) continue
const field = `boundVariables/${scene}/${index}/color`
setInstanceOverride(
owner.instanceOverrides,
owner.id,
target.id,
field,
target.boundVariables[`${scene}/${index}/color`] ?? null
)
declared = true
}
if (declared) setInstanceOverride(owner.instanceOverrides, owner.id, target.id, 'boundVariables')
}
function recordPropertyClaims(nodes: ReadonlyMap<InstanceOccurrence, SceneNode>): void {
for (const [ownerOccurrence, owner] of nodes) {
if (owner.type !== 'INSTANCE') continue
for (const claim of ownerOccurrence.propertyClaims) {
const targetOccurrence = resolveOccurrencePath(ownerOccurrence, claim.path)
const target = nodes.get(targetOccurrence)
if (!target) throw new Error('Unmaterialized property claim target')
for (const raw of Object.keys(OVERRIDE_FIELDS) as RawOverrideField[]) {
if (!(raw in claim.properties)) continue
const field = OVERRIDE_FIELDS[raw]
if (!claimApplies(field, claim.properties[raw], target)) continue
for (const scene of field.scene) {
const value = target[scene]
setInstanceOverride(
owner.instanceOverrides,
owner.id,
target.id,
scene,
structuredClone(value)
)
}
if (field.kind === 'paint')
recordPaintBindingClaims(owner, target, field.scene[0], claim.properties[raw])
}
recordVariableBindingClaims(owner, target, claim.properties as NodeChange)
}
}
}