* 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.
234 lines
9.1 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|