fix(fig): give every exported layer its own order key, and keep the imported ones

* fix(fig): give every layer on an exported canvas its own order key

Figma orders siblings by `parentIndex.position`. Shared styles, the
variable records and a canvas's own layers are written in three passes,
all landing on the internal canvas, and each numbered from the first
key — so up to five siblings claimed `!`. Exporting gold-preview left
402 records under one canvas with keys repeating up to five times.

Each pass now continues the keys already written for that canvas.
Counting them per canvas also covers the ordinary pages, whose layers
are written after the internal resources.

* fix(fig): keep the order keys a layer was imported with

Saving a document renumbered every sibling from the first key, so a
round trip rewrote positions Figma had assigned even where nothing
moved. A layer now keeps its imported key where that still orders it
after the previous sibling, and takes a key between its neighbours
otherwise.

Canvas children are excluded: shared styles and variable records are
written to the internal canvas by other passes that continue the keys
already there, so an imported key could collide with one of theirs.

Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>

* refactor(fig): look a sibling's order key up by id

`exportOrderKey` runs once per child and scanned the sibling list for
the node, so a parent with many layers cost O(n²) to export. The cached
entry now maps id to key directly.

Covers all three writers in the canvas test as well: an internal canvas
with its own layers, a shared style, and two variable collections, so
the pass boundaries that restarted a sequence are exercised rather than
only the page and variable passes.

* refactor(core): name the GUID type in the canvas order key test

---------

Co-authored-by: mrhard9090 <74858909+mrhard9090@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-10-01 14:57:46 +04:00 committed by GitHub
parent 7140328b1d
commit b7d9c02ebc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 345 additions and 9 deletions

View file

@ -30,6 +30,7 @@
### Fixed
- Keep the ordering keys a `.fig` gave its layers when saving one again, instead of renumbering every sibling, and give every layer on a canvas its own key. Shared styles, variables and the canvas's own layers were numbered in separate passes that each restarted, so Figma saw siblings claiming the same position and ordered them arbitrarily.
- Keep an AI reply running in the chat panel, with its Stop button, when you switch pages, instead of detaching the panel from the reply in progress.
- Undo an AI edit while another page is on screen; undo previously did nothing until you returned to the page the AI changed.
- Type `parameterConsumptionMap`, `propRefValue`, and `expressionValue` in the Kiwi `NodeChange` codec, which `fig.kiwi` declares but the TypeScript definitions omitted, so reading them no longer needs a cast.

View file

@ -34,6 +34,7 @@ import { originalFigArchive } from '#core/kiwi/fig/session/original-archive'
import {
appendVariableNodeChanges,
sequentialPositions,
assignSharedStyleGuids,
assignVariableGuid,
assignVariableGuids
@ -239,9 +240,24 @@ interface InternalResourceContext {
propertyIdToGuid: Map<string, GUID>
}
/**
* Children already written under a canvas. Shared styles, variables and the canvas's own
* layers all append to the internal canvas from separate passes, so each continues this
* count rather than numbering from zero and handing siblings the same order key.
*/
function countCanvasChildren(changes: readonly NodeChange[], canvas: GUID): number {
let count = 0
for (const change of changes) {
const parent = change.parentIndex?.guid
if (parent?.sessionID === canvas.sessionID && parent.localID === canvas.localID) count++
}
return count
}
function appendInternalResources(context: InternalResourceContext): void {
const { graph, internalCanvasGuid, nodeChanges } = context
if (!internalCanvasGuid) return
const written = countCanvasChildren(nodeChanges, internalCanvasGuid)
const sharedStyleNodes = [...graph.nodes.values()].filter((node) => node.sharedStyleType !== null)
assignSharedStyleGuids(
sharedStyleNodes,
@ -254,7 +270,7 @@ function appendInternalResources(context: InternalResourceContext): void {
...sceneNodeToKiwi(
sharedStyleNodes[index],
internalCanvasGuid,
index,
written + index,
context.localIdCounter,
graph,
context.blobs,
@ -278,7 +294,8 @@ function appendInternalResources(context: InternalResourceContext): void {
nodeChanges,
internalCanvasGuid,
context.varIdToGuid,
context.modeIdToGuid
context.modeIdToGuid,
sequentialPositions(written + sharedStyleNodes.length)
)
}
}
@ -417,9 +434,10 @@ export async function exportFigFile(
const children = graph
.getChildren(page.id)
.filter((child) => !child.internalOnly && child.sharedStyleType === null)
const base = countCanvasChildren(nodeChanges, canvasGuid)
for (let i = 0; i < children.length; i++) {
nodeChanges.push(
...sceneNodeToKiwi(children[i], canvasGuid, i, localIdCounter, graph, blobs, {
...sceneNodeToKiwi(children[i], canvasGuid, base + i, localIdCounter, graph, blobs, {
nodeIdToGuid,
fontDigestMap,
varIdToGuid,

View file

@ -104,19 +104,29 @@ function variableValueToKiwi(
return { value: { floatValue: Number(value) }, dataType: 'FLOAT', resolvedDataType: 'FLOAT' }
}
/**
* Order keys for one parent, in call order. Collections, variables, shared styles and the
* canvas's own layers all append to the internal canvas, so they share one sequence rather
* than each numbering from zero and handing siblings the same key.
*/
export function sequentialPositions(start = 0): () => string {
let index = start
return () => fractionalPosition(index++)
}
export function appendVariableNodeChanges(
graph: SceneGraph,
changes: NodeChange[],
parent: GUID,
ids: Map<string, GUID>,
modes: Map<string, GUID>
modes: Map<string, GUID>,
nextPosition: () => string = sequentialPositions()
): void {
let index = 0
for (const collection of graph.variableCollections.values()) {
const guid = ids.get(collection.id) ?? stringToGuid(collection.id)
changes.push({
guid,
parentIndex: { guid: parent, position: fractionalPosition(index++) },
parentIndex: { guid: parent, position: nextPosition() },
type: 'VARIABLE_SET',
name: collection.name,
phase: 'CREATED',
@ -128,13 +138,12 @@ export function appendVariableNodeChanges(
sortPosition: fractionalPosition(i)
}))
})
let variableIndex = 0
for (const id of collection.variableIds) {
const variable = graph.variables.get(id)
if (!variable) continue
changes.push({
guid: ids.get(id) ?? stringToGuid(id),
parentIndex: { guid: parent, position: fractionalPosition(variableIndex++) },
parentIndex: { guid: parent, position: nextPosition() },
type: 'VARIABLE',
name: variable.name,
phase: 'CREATED',

View file

@ -0,0 +1,65 @@
import { expect, test } from 'bun:test'
import { exportFigFile } from '@open-pencil/core/io'
import { initCodec } from '@open-pencil/core/kiwi'
import { parseFigBuffer } from '@open-pencil/fig'
import { SceneGraph } from '@open-pencil/scene-graph'
import type { GUID } from '@open-pencil/scene-graph/primitives'
function positionsByParent(nodeChanges: readonly { parentIndex?: unknown }[]) {
const byParent = new Map<string, string[]>()
for (const change of nodeChanges) {
const parentIndex = change.parentIndex as { guid?: GUID; position?: string } | undefined
const parent = parentIndex?.guid
if (!parent || parentIndex?.position === undefined) continue
const key = `${parent.sessionID}:${parent.localID}`
byParent.set(key, [...(byParent.get(key) ?? []), parentIndex.position])
}
return byParent
}
/**
* Figma orders siblings by `parentIndex.position`. A canvas's own layers, its shared styles
* and the variable records are written in three separate passes, and an internal canvas
* receives all three, so each has to continue the keys the others already wrote.
*/
test('every child of a canvas is exported with its own order key', async () => {
await initCodec()
const graph = new SceneGraph()
const page = graph.getPages()[0]
for (const name of ['One', 'Two', 'Three']) graph.createNode('FRAME', page.id, { name })
// An internal canvas carrying ordinary layers, so its own pass meets the appended ones.
const internal = graph.addPage('Internal resources')
graph.updateNode(internal.id, { internalOnly: true })
for (const name of ['Cached A', 'Cached B']) graph.createNode('FRAME', internal.id, { name })
graph.createNode('TEXT', internal.id, {
name: 'Shared typography',
sharedStyleType: 'TEXT',
text: 'Ag'
})
// Two collections, so the variable pass spans more than one group of records.
for (const [collectionName, variableName] of [
['Tokens', 'Accent'],
['Spacing', 'Gutter']
]) {
const collection = graph.createCollection(collectionName)
graph.createVariable(variableName, 'FLOAT', collection.id, 8)
graph.createVariable(`${variableName} alt`, 'FLOAT', collection.id, 16)
}
const bytes = await exportFigFile(graph)
const { nodeChanges } = parseFigBuffer(bytes.buffer as ArrayBuffer)
const byParent = positionsByParent(nodeChanges)
// The internal canvas is the parent all three passes write to.
const internalCanvas = nodeChanges.find((change) => change.name === 'Internal resources')
const internalKey = internalCanvas?.guid
? `${internalCanvas.guid.sessionID}:${internalCanvas.guid.localID}`
: ''
expect(byParent.get(internalKey)?.length ?? 0).toBeGreaterThan(5)
for (const [parent, positions] of byParent)
expect([parent, new Set(positions).size]).toEqual([parent, positions.length])
})

View file

@ -52,6 +52,78 @@ export function fractionalPosition(index: number): string {
return String.fromCharCode(TILDE).repeat(numTildes) + lastChar
}
const ORDER_KEY_MIN = 33 // '!'
const ORDER_KEY_MAX = 126 // '~'
const ORDER_KEY_MID = 'O'
/**
* A printable key strictly between `lo` and `hi` (either may be open), or null when none
* exists. Splits at the first character where they differ when a character fits between;
* otherwise keeps that character and recurses into the remainder, relying on string order.
*/
export function orderKeyBetween(lo: string | null, hi: string | null): string | null {
if (lo !== null && hi !== null && lo >= hi) return null
const low = lo ?? ''
// An open upper bound shares no prefix, so the empty string ends the scan immediately.
const high = hi ?? ''
let i = 0
while (i < low.length && i < high.length && low[i] === high[i]) i++
const a = i < low.length ? low.charCodeAt(i) : ORDER_KEY_MIN - 1
const b = hi !== null && i < hi.length ? hi.charCodeAt(i) : ORDER_KEY_MAX + 1
if (b - a > 1) return low.slice(0, i) + String.fromCharCode(Math.floor((a + b) / 2))
// Keep lo's character: anything above the rest of lo then sorts below hi.
if (i < low.length) {
return low.slice(0, i + 1) + (orderKeyBetween(low.slice(i + 1), null) ?? ORDER_KEY_MID)
}
// lo is a prefix of hi whose next character can't be lowered: keep it and go below the rest.
const rest = hi?.slice(i + 1) ?? ''
if (!hi || rest === '') return null
return hi.slice(0, i + 1) + (orderKeyBetween(null, rest) ?? '')
}
/**
* Order keys for siblings in their current order. Imported keys are kept while they still
* increase; siblings without a usable key get the index key when it fits between its
* neighbours, and otherwise a key between them, so no two siblings share a key.
*/
export function siblingOrderKeys(sourceKeys: ReadonlyArray<string | null | undefined>): string[] {
const keys: string[] = []
let prev: string | null = null
let pending: number[] = []
const fill = (upper: string | null): string[] | null => {
const filled: string[] = []
let lo = prev
for (const index of pending) {
const candidate = fractionalPosition(index)
const key =
(lo === null || candidate > lo) && (upper === null || candidate < upper)
? candidate
: orderKeyBetween(lo, upper)
if (key === null) return null
filled.push(key)
lo = key
}
return filled
}
for (let index = 0; index < sourceKeys.length; index++) {
const key = sourceKeys[index]
const filled = key && (prev === null || key > prev) ? fill(key) : null
if (key && filled) {
pending.forEach((pendingIndex, i) => (keys[pendingIndex] = filled[i]))
keys[index] = key
prev = key
pending = []
} else {
pending.push(index)
}
}
const rest = fill(null) ?? []
pending.forEach((pendingIndex, i) => (keys[pendingIndex] = rest[i]))
return keys
}
export function computeExportTransform(node: SceneNode): Matrix {
const sx = node.flipX ? -1 : 1
const cos = Math.cos((node.rotation * Math.PI) / 180)

View file

@ -13,6 +13,7 @@ import { DEFAULT_STROKE_MITER_LIMIT } from '@open-pencil/scene-graph'
import type { GUID, Matrix, Vector } from '@open-pencil/scene-graph/primitives'
/* eslint-disable max-lines */
import { siblingOrderKeys } from '../basics'
import { bytesToHex } from '../bytes'
import { exportCanvasGuides } from '../canvas-guides'
import { snapshotInstanceGeometry } from '../instance-geometry'
@ -42,6 +43,42 @@ import { mergeOverrides, serializeRuntimePropertyOverrides } from './override-cl
export type { KiwiNodeChange, SceneNodeToKiwiContext } from './context'
const siblingOrderKeyCache = new WeakMap<object, Map<string, { keyById: Map<string, string> }>>()
/**
* A layer's `parentIndex.position`: its imported key where that still orders it after the
* previous sibling, otherwise a key between its neighbours, so a round trip keeps the keys
* Figma wrote instead of renumbering every sibling.
*
* Canvas children are excluded. Shared styles and variable records are written to the
* internal canvas by other passes that continue the keys already there, and an imported
* key would collide with them, so those keep the caller's running index.
*/
function exportOrderKey(
context: SceneNodeToKiwiContext,
node: SceneNode,
childIndex: number
): string {
const parentId = node.parentId
if (!parentId) return context.fractionalPosition(childIndex)
const parent = context.graph.getNode(parentId)
if (!parent || parent.type === 'CANVAS') return context.fractionalPosition(childIndex)
let cache = siblingOrderKeyCache.get(context)
if (!cache) {
cache = new Map()
siblingOrderKeyCache.set(context, cache)
}
let entry = cache.get(parentId)
if (!entry) {
const siblings = context.graph.getChildren(parentId).filter((child) => !child.internalOnly)
const keys = siblingOrderKeys(siblings.map((sibling) => sibling.source.orderKey))
// Keyed by id: this runs once per child, so a scan per child would cost the parent O(n²).
entry = { keyById: new Map(siblings.map((sibling, index) => [sibling.id, keys[index]])) }
cache.set(parentId, entry)
}
return entry.keyById.get(node.id) ?? context.fractionalPosition(childIndex)
}
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation {
@ -830,7 +867,7 @@ export function sceneNodeToKiwiWithContext(
guid,
parentIndex: {
guid: parentGuid,
position: context.fractionalPosition(childIndex)
position: exportOrderKey(context, node, childIndex)
},
type: exportType,
name: node.name,

View file

@ -0,0 +1,134 @@
import { describe, expect, test } from 'bun:test'
import {
fractionalPosition,
orderKeyBetween,
sceneNodeToKiwi,
siblingOrderKeys
} from '@open-pencil/fig/node-change'
import { SceneGraph } from '@open-pencil/scene-graph'
function requireKey(key: string | null, label: string): string {
if (key === null) throw new Error(`expected a key ${label}`)
return key
}
function expectStrictlyIncreasing(keys: string[]) {
for (let i = 1; i < keys.length; i++) expect(keys[i] > keys[i - 1]).toBe(true)
}
describe('orderKeyBetween', () => {
test('returns a key strictly between its bounds', () => {
for (const [lo, hi] of [
[null, '#'],
['!', '"'],
['~', null],
['a', 'b'],
[null, null]
] as const) {
const key = requireKey(orderKeyBetween(lo, hi), `between ${lo} and ${hi}`)
if (lo !== null) expect(key > lo).toBe(true)
if (hi !== null) expect(key < hi).toBe(true)
}
})
test('stops at a longer prefix of hi when no character can be lowered', () => {
expect(orderKeyBetween('a', 'a!!')).toBe('a!')
expect(orderKeyBetween(null, '!!')).toBe('!')
expect(orderKeyBetween('a', 'a!')).toBeNull()
})
test('keeps keys short when many keys are appended one after another', () => {
let lo = '$'
for (let n = 0; n < 500; n++) {
const key = requireKey(orderKeyBetween(lo, null), 'after ' + lo)
expect(key > lo).toBe(true)
lo = key
}
expect(lo.length).toBeLessThan(100)
})
test('returns null when nothing sorts before the smallest key', () => {
expect(orderKeyBetween(null, '!')).toBeNull()
})
})
describe('siblingOrderKeys', () => {
test('keeps index keys when no sibling has an imported key', () => {
expect(siblingOrderKeys([undefined, undefined, undefined])).toEqual([
fractionalPosition(0),
fractionalPosition(1),
fractionalPosition(2)
])
})
test('keeps imported keys that are still in order', () => {
const keys = siblingOrderKeys(['!', '#', '%', undefined])
expect(keys.slice(0, 3)).toEqual(['!', '#', '%'])
expectStrictlyIncreasing(keys)
})
test('gives a layer inserted before imported siblings a key of its own', () => {
const keys = siblingOrderKeys([undefined, '!', '"', '#'])
expect(new Set(keys).size).toBe(4)
expectStrictlyIncreasing(keys)
})
test('gives a layer inserted between imported siblings a key between them', () => {
const keys = siblingOrderKeys(['!', undefined, '"'])
expect(keys[0]).toBe('!')
expect(keys[2]).toBe('"')
expectStrictlyIncreasing(keys)
})
test('re-keys imported siblings that were moved out of order', () => {
const keys = siblingOrderKeys(['#', '!', '"'])
expect(keys[0]).toBe('#')
expectStrictlyIncreasing(keys)
})
})
describe('Figma keys containing spaces', () => {
// Real Figma files use keys such as '&RQTOt7CO O'; the space sorts below the printable range.
test('finds a key between a key and its space-extended successor', () => {
const key = requireKey(orderKeyBetween('&RQTOt7CO', '&RQTOt7CO O'), 'before the space key')
expect(key > '&RQTOt7CO' && key < '&RQTOt7CO O').toBe(true)
})
test('keeps space-bearing imported keys and fits new layers between them', () => {
const keys = siblingOrderKeys(['&RQTOt7CO', null, '&RQTOt7CO O', '&RQTOt7CO f', null])
expect(keys[0]).toBe('&RQTOt7CO')
expect(keys[2]).toBe('&RQTOt7CO O')
expect(keys[3]).toBe('&RQTOt7CO f')
expect(new Set(keys).size).toBe(keys.length)
expectStrictlyIncreasing(keys)
})
})
describe('Figma export order keys', () => {
test('a layer added before imported siblings does not share their keys', () => {
const graph = new SceneGraph()
const page = graph.getPages()[0]
const frame = graph.createNode('FRAME', page.id, { name: 'Frame' })
for (const [name, orderKey] of [
['A', '!'],
['B', '"'],
['C', '#']
]) {
const child = graph.createNode('RECTANGLE', frame.id, { name })
child.source.orderKey = orderKey
}
const inserted = graph.createNode('RECTANGLE', frame.id, { name: 'Inserted' })
frame.childIds = [inserted.id, ...frame.childIds.filter((id) => id !== inserted.id)]
const changes = sceneNodeToKiwi(frame, { sessionID: 1, localID: 1 }, 0, { value: 2 }, graph, [])
const children = changes.slice(1)
const positions = children.map((change) => change.parentIndex?.position ?? '')
expect(children.map((change) => change.name)).toEqual(['Inserted', 'A', 'B', 'C'])
// Nothing sorts before '!', so A is re-keyed; B and C keep their imported keys.
expect(positions.slice(2)).toEqual(['"', '#'])
expect(new Set(positions).size).toBe(4)
expectStrictlyIncreasing(positions)
})
})