From bc79d16ac4f313aa16ded057f2cc20a77253f6dc Mon Sep 17 00:00:00 2001 From: Victor Wads Date: Mon, 10 Aug 2026 04:30:28 -0300 Subject: [PATCH] fix(fig): preserve instance text edits on save --- packages/core/src/editor/text.ts | 56 ++++++++++++++++++ packages/core/src/io/formats/fig/export.ts | 6 +- packages/fig/src/instance-overrides/index.ts | 9 ++- packages/fig/src/node-change/export-node.ts | 53 +++++++++++++++-- .../io/fig/export/lazy-population.test.ts | 57 +++++++++++++++++++ tests/engine/text/edit-undo.test.ts | 34 +++++++++++ 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/packages/core/src/editor/text.ts b/packages/core/src/editor/text.ts index 93811bed6..0f08dc79e 100644 --- a/packages/core/src/editor/text.ts +++ b/packages/core/src/editor/text.ts @@ -7,6 +7,56 @@ import { } from './text/session' import type { EditorContext } from './types' +type InstanceOverridesSnapshot = { + instanceId: string + overrides: Record +} + +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) } }) } diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index a1f692cfd..51e291129 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -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 { + // 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() diff --git a/packages/fig/src/instance-overrides/index.ts b/packages/fig/src/instance-overrides/index.ts index a719bcca3..0b7952403 100644 --- a/packages/fig/src/instance-overrides/index.ts +++ b/packages/fig/src/instance-overrides/index.ts @@ -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 } diff --git a/packages/fig/src/node-change/export-node.ts b/packages/fig/src/node-change/export-node.ts index 7399a6edf..d8d6aeafe 100644 --- a/packages/fig/src/node-change/export-node.ts +++ b/packages/fig/src/node-change/export-node.ts @@ -24,6 +24,12 @@ export type KiwiNodeChange = NodeChange & Record type KiwiBooleanOperation = NonNullable +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 = { 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 } diff --git a/tests/engine/io/fig/export/lazy-population.test.ts b/tests/engine/io/fig/export/lazy-population.test.ts index 785ab334c..035293b32 100644 --- a/tests/engine/io/fig/export/lazy-population.test.ts +++ b/tests/engine/io/fig/export/lazy-population.test.ts @@ -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('') + }) }) diff --git a/tests/engine/text/edit-undo.test.ts b/tests/engine/text/edit-undo.test.ts index 43cb8128e..164f16145 100644 --- a/tests/engine/text/edit-undo.test.ts +++ b/tests/engine/text/edit-undo.test.ts @@ -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 })