fix(fig): preserve instance text edits on save

This commit is contained in:
Victor Wads 2026-08-10 04:30:28 -03:00
parent e159893ec4
commit bc79d16ac4
No known key found for this signature in database
6 changed files with 208 additions and 7 deletions

View file

@ -7,6 +7,56 @@ import {
} from './text/session'
import type { EditorContext } from './types'
type InstanceOverridesSnapshot = {
instanceId: string
overrides: Record<string, unknown>
}
function containingInstanceIds(ctx: EditorContext, nodeId: string): string[] {
const ids: string[] = []
let current = ctx.graph.getNode(nodeId)
while (current?.parentId) {
current = ctx.graph.getNode(current.parentId)
if (current?.type === 'INSTANCE') ids.push(current.id)
}
return ids
}
function snapshotInstanceOverrides(
ctx: EditorContext,
instanceIds: string[]
): InstanceOverridesSnapshot[] {
return instanceIds.flatMap((instanceId) => {
const instance = ctx.graph.getNode(instanceId)
return instance?.type === 'INSTANCE'
? [{ instanceId, overrides: structuredClone(instance.overrides) }]
: []
})
}
function restoreInstanceOverrides(ctx: EditorContext, snapshots: InstanceOverridesSnapshot[]) {
for (const snapshot of snapshots) {
ctx.graph.updateNode(snapshot.instanceId, {
overrides: structuredClone(snapshot.overrides)
})
}
}
function applyTextInstanceOverride(
ctx: EditorContext,
instanceIds: string[],
nodeId: string,
text: string
) {
for (const instanceId of instanceIds) {
const instance = ctx.graph.getNode(instanceId)
if (instance?.type !== 'INSTANCE') continue
ctx.graph.updateNode(instanceId, {
overrides: { ...instance.overrides, [`${nodeId}:text`]: text }
})
}
}
export function createTextActions(ctx: EditorContext) {
let activeSession: TextEditSession | null = null
@ -48,6 +98,8 @@ export function createTextActions(ctx: EditorContext) {
before.text !== after.text ? resizeTextNodeForEdit(node, textState.paragraph) : {}
if (Object.keys(sizeChanges).length > 0) after.size = sizeChanges
const changed = textSnapshotChanged(before, after)
const containingInstances = containingInstanceIds(ctx, result.nodeId)
const instanceOverridesBefore = snapshotInstanceOverrides(ctx, containingInstances)
te.stop()
@ -63,6 +115,8 @@ export function createTextActions(ctx: EditorContext) {
styleRuns: after.styleRuns,
...sizeChanges
})
applyTextInstanceOverride(ctx, containingInstances, result.nodeId, after.text)
const instanceOverridesAfter = snapshotInstanceOverrides(ctx, containingInstances)
ctx.state.editingTextId = null
activeSession = null
@ -74,6 +128,7 @@ export function createTextActions(ctx: EditorContext) {
styleRuns: after.styleRuns,
...after.size
})
restoreInstanceOverrides(ctx, instanceOverridesAfter)
},
inverse: () => {
ctx.graph.updateNode(result.nodeId, {
@ -81,6 +136,7 @@ export function createTextActions(ctx: EditorContext) {
styleRuns: before.styleRuns,
...before.size
})
restoreInstanceOverrides(ctx, instanceOverridesBefore)
}
})
}

View file

@ -23,6 +23,7 @@ import {
makeDocumentNodeChange,
makeCanvasNodeChange
} from '#core/kiwi/fig/node-change/serialize'
import { deserializeSceneGraph, serializeSceneGraph } from '#core/kiwi/fig/parse/transfer'
const THUMBNAIL_1X1 = decodeBase64(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=='
@ -379,12 +380,15 @@ function appendInternalResources(context: InternalResourceContext): void {
}
export async function exportFigFile(
graph: SceneGraph,
sourceGraph: SceneGraph,
ck?: CanvasKit,
renderer?: SkiaRenderer,
pageId?: string,
renderHeadlessThumbnail = false
): Promise<Uint8Array> {
// Lazy population synchronizes component trees and therefore mutates its graph. Saving must not
// rewrite the live editor document or restore component values over edits made by the user.
const graph = deserializeSceneGraph(structuredClone(serializeSceneGraph(sourceGraph)))
populateAllLazyFigImportRoots(graph)
await initCodec()

View file

@ -73,7 +73,14 @@ function buildKiwiPropertyNodes(
const hasDiffVisible = nc.visible === false && comp.visible
const hasDiffFills = nc.fillPaints !== undefined && !isEqual(node.fills, comp.fills)
const hasDiffStrokes = nc.strokePaints !== undefined && !isEqual(node.strokes, comp.strokes)
if (hasDiffRadius || hasDiffVisible || hasDiffFills || hasDiffStrokes) result.add(nodeId)
const hasDiffText =
nc.textData !== undefined &&
node.type === 'TEXT' &&
comp.type === 'TEXT' &&
node.text !== comp.text
if (hasDiffRadius || hasDiffVisible || hasDiffFills || hasDiffStrokes || hasDiffText) {
result.add(nodeId)
}
}
return result
}

View file

@ -24,6 +24,12 @@ export type KiwiNodeChange = NodeChange & Record<string, unknown>
type KiwiBooleanOperation = NonNullable<NodeChange['booleanOperation']>
interface KiwiSymbolOverridePayload {
guidPath?: { guids?: GUID[] }
textData?: { characters?: string }
[key: string]: unknown
}
function toKiwiBooleanOperation(operation: SceneNode['booleanOperation']): KiwiBooleanOperation {
return operation === 'EXCLUDE' ? 'XOR' : (operation ?? 'UNION')
}
@ -343,6 +349,42 @@ function getOrCreateNodeGuid(
return guid
}
function isDescendantOf(context: SceneNodeToKiwiContext, nodeId: string, ancestorId: string) {
let current = context.graph.getNode(nodeId)
while (current?.parentId) {
if (current.parentId === ancestorId) return true
current = context.graph.getNode(current.parentId)
}
return false
}
function serializeTextOverrides(
context: SceneNodeToKiwiContext,
instance: SceneNode,
localIdCounter: { value: number }
): KiwiSymbolOverridePayload[] {
const result: KiwiSymbolOverridePayload[] = []
for (const [key, value] of Object.entries(instance.overrides)) {
if (!key.endsWith(':text') || typeof value !== 'string') continue
const targetId = key.slice(0, -':text'.length)
const target = context.graph.getNode(targetId)
if (!target || !isDescendantOf(context, targetId, instance.id)) continue
const sourceId = target.componentId
if (!sourceId) continue
const source = context.graph.getNode(sourceId)
const overrideGuid = source?.overrideKey ? parseGuidOrNull(source.overrideKey) : null
const targetGuid = overrideGuid ?? getOrCreateNodeGuid(context, sourceId, localIdCounter)
if (!targetGuid) continue
result.push({
guidPath: { guids: [targetGuid] },
textData: { characters: value }
})
}
return result
}
/**
* Fields that are ALWAYS set by explicit serialization and must NOT be
* overwritten by rawNodeFields (which may contain stale Figma defaults).
@ -451,17 +493,18 @@ function applyInstancePayload(
)
if (symbolID) {
const symbolData: Record<string, unknown> = { symbolID }
const symbolOverrides: KiwiSymbolOverridePayload[] = []
if (node.source.fig.symbolOverrides.length > 0) {
symbolData.symbolOverrides = materializeFigmaPayload(
node.source.fig.symbolOverrides,
context.blobs,
{
symbolOverrides.push(
...(materializeFigmaPayload(node.source.fig.symbolOverrides, context.blobs, {
blobIndexByHex: context.blobIndexByHex,
includePaintVariables: true,
includeVariableMaps: true
}
}) as KiwiSymbolOverridePayload[])
)
}
symbolOverrides.push(...serializeTextOverrides(context, node, localIdCounter))
if (symbolOverrides.length > 0) symbolData.symbolOverrides = symbolOverrides
if (node.source.fig.uniformScaleFactor != null) {
symbolData.uniformScaleFactor = node.source.fig.uniformScaleFactor
}

View file

@ -23,6 +23,21 @@ function lazyExportGraph() {
return { graph, secondPage, instance }
}
function createEditedInstance(
graph: SceneGraph,
componentId: string,
parentId: string,
name: string,
text: string
) {
const instance = graph.createInstance(componentId, parentId, { name })
if (!instance) throw new Error(`Could not create instance: ${name}`)
const textId = instance.childIds[0]
graph.updateNode(textId, { text })
graph.updateNode(instance.id, { overrides: { [`${textId}:text`]: text } })
return { instance, textId }
}
describe('FIG population export lifecycle', () => {
test('exports all remaining lazy pages after a partial visit', async () => {
await initCodec()
@ -30,6 +45,7 @@ describe('FIG population export lifecycle', () => {
expect(graph.getChildren(instance.id)).toHaveLength(0)
const exported = await exportFigFile(graph)
expect(graph.getChildren(instance.id)).toHaveLength(0)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer, { populate: 'all' })
const reimportedInstance = [...reimported.getAllNodes()].find(
(node) => node.type === 'INSTANCE' && node.name === 'Button instance'
@ -37,4 +53,45 @@ describe('FIG population export lifecycle', () => {
expect(reimportedInstance).toBeDefined()
expect(reimported.getChildren(reimportedInstance?.id ?? '')).toHaveLength(1)
})
test('preserves edited instance text overrides without mutating the live graph', async () => {
await initCodec()
const { graph, secondPage } = lazyExportGraph()
const firstPage = graph.getPages()[0]
const component = [...graph.getAllNodes()].find(
(node) => node.type === 'COMPONENT' && node.name === 'Button'
)
expect(component).toBeDefined()
const partial = createEditedInstance(
graph,
component?.id ?? '',
firstPage.id,
'Partially edited instance',
'Lab'
)
const empty = createEditedInstance(
graph,
component?.id ?? '',
firstPage.id,
'Empty edited instance',
''
)
const exported = await exportFigFile(graph)
expect(graph.getNode(partial.textId)?.text).toBe('Lab')
expect(graph.getNode(empty.textId)?.text).toBe('')
expect(graph.getChildren(secondPage.id)[0]?.childIds).toHaveLength(0)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer, { populate: 'all' })
const reimportedPartial = [...reimported.getAllNodes()].find(
(node) => node.type === 'INSTANCE' && node.name === 'Partially edited instance'
)
const reimportedEmpty = [...reimported.getAllNodes()].find(
(node) => node.type === 'INSTANCE' && node.name === 'Empty edited instance'
)
expect(reimportedPartial).toBeDefined()
expect(reimportedEmpty).toBeDefined()
expect(reimported.getChildren(reimportedPartial?.id ?? '')[0]?.text).toBe('Lab')
expect(reimported.getChildren(reimportedEmpty?.id ?? '')[0]?.text).toBe('')
})
})

View file

@ -103,6 +103,40 @@ describe('text edit undo', () => {
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
})
test('keeps an empty text override inside an instance through sync and undo', () => {
const { graph, undo, textEditor, actions } = setup()
const page = graph.getPages()[0]
const component = graph.createNode('COMPONENT', page.id, {
width: 100,
height: 20
})
graph.createNode('TEXT', component.id, {
text: 'Confidential',
width: 100,
height: 20
})
const instance = expectDefined(graph.createInstance(component.id, page.id), 'instance')
const instanceText = getNodeOrThrow(graph, instance.childIds[0])
actions.startTextEditing(instanceText.id)
textEditor.selectAll()
textEditor.backspace(instanceText)
actions.commitTextEdit()
expect(getNodeOrThrow(graph, instanceText.id).text).toBe('')
expect(getNodeOrThrow(graph, instance.id).overrides[`${instanceText.id}:text`]).toBe('')
graph.syncInstances(component.id)
expect(getNodeOrThrow(graph, instanceText.id).text).toBe('')
undo.undo()
expect(getNodeOrThrow(graph, instanceText.id).text).toBe('Confidential')
expect(`${instanceText.id}:text` in getNodeOrThrow(graph, instance.id).overrides).toBe(false)
undo.redo()
graph.syncInstances(component.id)
expect(getNodeOrThrow(graph, instanceText.id).text).toBe('')
})
test('commitTextEdit preserves auto-height text bounds', () => {
const { graph, undo, textEditor, textNode, actions } = setup()
graph.updateNode(textNode.id, { textAutoResize: 'HEIGHT', height: 18 })