fix(fig): preserve instance text edits on save
This commit is contained in:
parent
e159893ec4
commit
bc79d16ac4
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue