Fix Figma paste redo + add clipboard import tests

Redo was creating duplicate childIds because snapshots captured after
tree construction had populated childIds, and createNode appends to
parent.childIds again. Fix: clear childIds in redo snapshots.

New tests (7):
- layoutAlignSelf from stackChildAlignSelf
- clipsContent from frameMaskDisabled
- fontWeight from fontName.style via styleToWeight
- letterSpacing object → pixels conversion
- undo removes full subtree
- redo recreates correct parent-child tree
- redo without childIds:[] demonstrates the duplicate bug
This commit is contained in:
Danila Poyarkov 2026-03-02 18:05:22 +03:00
parent 8850625a6f
commit db7a019385
2 changed files with 176 additions and 2 deletions

View file

@ -1619,7 +1619,10 @@ export function createEditorStore() {
label: 'Paste',
forward: () => {
for (const snapshot of allNodes) {
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, snapshot)
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, {
...snapshot,
childIds: []
})
}
computeAllLayouts(graph)
state.selectedIds = new Set(created)

View file

@ -5,7 +5,7 @@ import {
importClipboardNodes,
figmaNodesBounds,
} from '../../packages/core/src/clipboard'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph'
function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) {
// Minimal fig-kiwi clipboard: just meta + empty figma buffer
@ -127,6 +127,177 @@ describe('importClipboardNodes', () => {
expect(node.strokes[0].color.b).toBe(1)
expect(node.strokes[0].weight).toBe(2)
})
it('imports layoutAlignSelf from stackChildAlignSelf', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Row', size: { x: 400, y: 100 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, stackMode: 'HORIZONTAL' },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'FRAME', name: 'Stretched', size: { x: 200, y: 50 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, stackChildAlignSelf: 'STRETCH' },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '"' }, type: 'FRAME', name: 'Auto', size: { x: 200, y: 50 }, transform: { m00: 1, m01: 0, m02: 200, m10: 0, m11: 1, m12: 0 } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const row = graph.getNode(created[0])!
const children = graph.getChildren(row.id)
expect(children[0].layoutAlignSelf).toBe('STRETCH')
expect(children[1].layoutAlignSelf).toBe('AUTO')
})
it('imports clipsContent from frameMaskDisabled', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Clipped', size: { x: 200, y: 100 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, frameMaskDisabled: false },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'FRAME', name: 'Unclipped', size: { x: 200, y: 100 }, transform: { m00: 1, m01: 0, m02: 200, m10: 0, m11: 1, m12: 0 }, frameMaskDisabled: true },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' }, type: 'FRAME', name: 'Default', size: { x: 200, y: 100 }, transform: { m00: 1, m01: 0, m02: 400, m10: 0, m11: 1, m12: 0 } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.clipsContent).toBe(true)
expect(graph.getNode(created[1])!.clipsContent).toBe(false)
expect(graph.getNode(created[2])!.clipsContent).toBe(false)
})
it('imports fontWeight from fontName.style via styleToWeight', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'TEXT', name: 'Medium', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, textData: { characters: 'Hello' }, fontSize: 14, fontName: { family: 'PT Root UI', style: 'Medium', postscript: 'PTRootUI-Medium' } },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'TEXT', name: 'Bold', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 20 }, textData: { characters: 'World' }, fontSize: 14, fontName: { family: 'Inter', style: 'Bold', postscript: 'Inter-Bold' } },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' }, type: 'TEXT', name: 'Italic', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 40 }, textData: { characters: 'Italic' }, fontSize: 14, fontName: { family: 'Inter', style: 'Bold Italic', postscript: 'Inter-BoldItalic' } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.fontWeight).toBe(500)
expect(graph.getNode(created[1])!.fontWeight).toBe(700)
expect(graph.getNode(created[2])!.fontWeight).toBe(700)
expect(graph.getNode(created[2])!.italic).toBe(true)
})
it('converts letterSpacing object to pixels', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'TEXT', name: 'PixelSpacing', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }, textData: { characters: 'A' }, fontSize: 20, letterSpacing: { value: 2, units: 'PIXELS' } },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' }, type: 'TEXT', name: 'PercentSpacing', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 20 }, textData: { characters: 'B' }, fontSize: 20, letterSpacing: { value: 10, units: 'PERCENT' } },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '#' }, type: 'TEXT', name: 'NoSpacing', size: { x: 100, y: 20 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 40 }, textData: { characters: 'C' }, fontSize: 20 },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.letterSpacing).toBe(2)
expect(graph.getNode(created[1])!.letterSpacing).toBe(2) // 10% of 20px
expect(graph.getNode(created[2])!.letterSpacing).toBe(0)
})
it('undo removes all imported nodes including children', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Parent', size: { x: 400, y: 300 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'RECTANGLE', name: 'Child1', size: { x: 100, y: 100 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '"' }, type: 'TEXT', name: 'Child2', size: { x: 200, y: 30 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 100 }, textData: { characters: 'Hello' }, fontSize: 14 },
] as any[]
const nodesBefore = [...graph.getAllNodes()].length
const childrenBefore = graph.getChildren(pageId).length
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect([...graph.getAllNodes()].length).toBe(nodesBefore + 3)
for (const id of [...created].reverse()) graph.deleteNode(id)
expect([...graph.getAllNodes()].length).toBe(nodesBefore)
expect(graph.getChildren(pageId)).toHaveLength(childrenBefore)
})
it('redo recreates full subtree with correct parent-child relationships', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Parent', size: { x: 400, y: 300 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'RECTANGLE', name: 'Child1', size: { x: 100, y: 100 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 0, localID: 12 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '"' }, type: 'TEXT', name: 'Child2', size: { x: 200, y: 30 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 100 }, textData: { characters: 'Hello' }, fontSize: 14 },
] as any[]
const childrenBefore = graph.getChildren(pageId).length
const created = importClipboardNodes(nodeChanges, graph, pageId)
const parent = graph.getNode(created[0])!
const allSnapshots: SceneNode[] = []
function walk(id: string) {
const n = graph.getNode(id)!
allSnapshots.push({ ...n })
for (const cid of n.childIds) walk(cid)
}
for (const id of created) walk(id)
// Undo
for (const id of [...created].reverse()) graph.deleteNode(id)
expect(graph.getChildren(pageId)).toHaveLength(childrenBefore)
// Redo — recreate with childIds: [] to avoid duplicates from createNode's parent-append
for (const snapshot of allSnapshots) {
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, {
...snapshot,
childIds: []
})
}
const restored = graph.getNode(parent.id)!
expect(restored).toBeTruthy()
expect(restored.name).toBe('Parent')
expect(restored.childIds).toHaveLength(2)
const children = graph.getChildren(restored.id)
expect(children[0].name).toBe('Child1')
expect(children[1].name).toBe('Child2')
expect(children[1].text).toBe('Hello')
})
it('redo without childIds:[] causes duplicate children', () => {
const { graph, pageId } = createGraphWithPage()
const nodeChanges = [
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Doc' },
{ guid: { sessionID: 0, localID: 1 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' }, type: 'CANVAS', name: 'Page' },
{ guid: { sessionID: 0, localID: 10 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'FRAME', name: 'Parent', size: { x: 400, y: 300 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
{ guid: { sessionID: 0, localID: 11 }, parentIndex: { guid: { sessionID: 0, localID: 10 }, position: '!' }, type: 'RECTANGLE', name: 'Child', size: { x: 100, y: 100 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
] as any[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const parent = graph.getNode(created[0])!
const allSnapshots: SceneNode[] = []
function walk(id: string) {
const n = graph.getNode(id)!
allSnapshots.push({ ...n })
for (const cid of n.childIds) walk(cid)
}
for (const id of created) walk(id)
for (const id of [...created].reverse()) graph.deleteNode(id)
// Recreate WITHOUT clearing childIds — demonstrates the bug
for (const snapshot of allSnapshots) {
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, snapshot)
}
const restored = graph.getNode(parent.id)!
// Bug: parent has duplicated childIds because snapshot already had [childId]
// and createNode appends childId again
expect(restored.childIds.length).toBeGreaterThan(1)
})
})
describe('figmaNodesBounds', () => {