Detect component sets and skip internal canvas on paste
FRAME nodes with VARIANT componentPropDefs are now promoted to COMPONENT_SET on import (both .fig files and clipboard paste). Clipboard paste now respects the Internal Only Canvas: components on it are used to populate instance children but are not pasted as visible nodes, matching Figma's behavior.
This commit is contained in:
parent
3d4f083d5e
commit
3eb3a921a0
|
|
@ -148,7 +148,24 @@ export function importClipboardNodes(
|
|||
}
|
||||
}
|
||||
|
||||
const internalCanvasIds = new Set<string>()
|
||||
for (const [id, nc] of guidMap) {
|
||||
if (nc.type === 'CANVAS' && (nc as unknown as Record<string, unknown>).internalOnly) {
|
||||
internalCanvasIds.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const internalFigmaIds = new Set<string>()
|
||||
function markInternal(id: string) {
|
||||
internalFigmaIds.add(id)
|
||||
for (const [childId, pid] of parentMap) {
|
||||
if (pid === id && !internalFigmaIds.has(childId)) markInternal(childId)
|
||||
}
|
||||
}
|
||||
for (const canvasId of internalCanvasIds) markInternal(canvasId)
|
||||
|
||||
const topLevel: string[] = []
|
||||
const internalTopLevel: string[] = []
|
||||
for (const [id, nc] of guidMap) {
|
||||
if (NON_VISUAL_TYPES.has(nc.type ?? '')) continue
|
||||
const parentId = parentMap.get(id)
|
||||
|
|
@ -157,7 +174,11 @@ export function importClipboardNodes(
|
|||
!guidMap.has(parentId) ||
|
||||
NON_VISUAL_TYPES.has(guidMap.get(parentId)?.type ?? '')
|
||||
) {
|
||||
topLevel.push(id)
|
||||
if (parentId && internalCanvasIds.has(parentId)) {
|
||||
internalTopLevel.push(id)
|
||||
} else {
|
||||
topLevel.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +201,7 @@ export function importClipboardNodes(
|
|||
const node = graph.createNode(nodeType, ourParentId, props)
|
||||
|
||||
created.set(figmaId, node.id)
|
||||
if (ourParentId === targetParentId) createdIds.push(node.id)
|
||||
if (ourParentId === targetParentId && !internalFigmaIds.has(figmaId)) createdIds.push(node.id)
|
||||
|
||||
const children: string[] = []
|
||||
for (const [childId, pid] of parentMap) {
|
||||
|
|
@ -198,6 +219,9 @@ export function importClipboardNodes(
|
|||
}
|
||||
}
|
||||
|
||||
for (const id of internalTopLevel) {
|
||||
createNode(id, targetParentId)
|
||||
}
|
||||
for (const id of topLevel) {
|
||||
createNode(id, targetParentId)
|
||||
}
|
||||
|
|
@ -216,6 +240,11 @@ export function importClipboardNodes(
|
|||
graph.populateInstanceChildren(ourId, ourComponentId)
|
||||
}
|
||||
|
||||
for (const figmaId of internalTopLevel) {
|
||||
const ourId = created.get(figmaId)
|
||||
if (ourId) graph.deleteNode(ourId)
|
||||
}
|
||||
|
||||
return createdIds
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -396,7 +396,8 @@ export function nodeChangeToProps(
|
|||
nc: NodeChange,
|
||||
blobs: Uint8Array[]
|
||||
): Partial<SceneNode> & { nodeType: NodeType | 'DOCUMENT' | 'VARIABLE' } {
|
||||
const nodeType = mapNodeType(nc.type)
|
||||
let nodeType = mapNodeType(nc.type)
|
||||
if (nodeType === 'FRAME' && isComponentSet(nc)) nodeType = 'COMPONENT_SET'
|
||||
|
||||
const x = nc.transform?.m02 ?? 0
|
||||
const y = nc.transform?.m12 ?? 0
|
||||
|
|
@ -508,6 +509,12 @@ export function nodeChangeToProps(
|
|||
}
|
||||
}
|
||||
|
||||
function isComponentSet(nc: NodeChange): boolean {
|
||||
const defs = ext(nc).componentPropDefs as Array<{ type?: string }> | undefined
|
||||
if (!defs?.length) return false
|
||||
return defs.some((d) => d.type === 'VARIANT')
|
||||
}
|
||||
|
||||
function extractSymbolId(nc: NodeChange): string {
|
||||
const sd = (nc as unknown as Record<string, unknown>).symbolData as
|
||||
| { symbolID?: GUID }
|
||||
|
|
|
|||
|
|
@ -287,6 +287,40 @@ describe('importClipboardNodes', () => {
|
|||
expect(instanceChildren[0].type).toBe('VECTOR')
|
||||
})
|
||||
|
||||
it('internal canvas components populate instances but are not pasted', () => {
|
||||
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 1' },
|
||||
// Internal Only Canvas with component
|
||||
{ guid: { sessionID: 99, localID: 2 }, parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '"' }, type: 'CANVAS', name: 'Internal Only Canvas', internalOnly: true },
|
||||
{ guid: { sessionID: 1, localID: 10 }, parentIndex: { guid: { sessionID: 99, localID: 2 }, position: '!' }, type: 'SYMBOL', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
|
||||
{ guid: { sessionID: 1, localID: 11 }, parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, type: 'VECTOR', name: 'Path', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } },
|
||||
// Visible page with instance
|
||||
{ guid: { sessionID: 2, localID: 20 }, parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' }, type: 'INSTANCE', name: 'Icon', size: { x: 24, y: 24 }, transform: { m00: 1, m01: 0, m02: 50, m10: 0, m11: 1, m12: 50 }, symbolData: { symbolID: { sessionID: 1, localID: 10 } } },
|
||||
] as any[]
|
||||
|
||||
const created = importClipboardNodes(nodeChanges, graph, pageId)
|
||||
expect(created).toHaveLength(1)
|
||||
|
||||
const instance = graph.getNode(created[0])!
|
||||
expect(instance.type).toBe('INSTANCE')
|
||||
expect(instance.name).toBe('Icon')
|
||||
|
||||
const children = graph.getChildren(instance.id)
|
||||
expect(children).toHaveLength(1)
|
||||
expect(children[0].name).toBe('Path')
|
||||
expect(children[0].type).toBe('VECTOR')
|
||||
|
||||
// Component should NOT exist as a visible node
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type === 'COMPONENT' && node.name === 'Icon') {
|
||||
throw new Error('Internal component should not be pasted as visible node')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('imports textAutoResize from clipboard data', () => {
|
||||
const { graph, pageId } = createGraphWithPage()
|
||||
|
||||
|
|
|
|||
|
|
@ -354,3 +354,42 @@ describe('fig-import: multiple fills', () => {
|
|||
expect(n.fills[1].opacity).toBe(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fig-import: component set detection', () => {
|
||||
test('FRAME with VARIANT componentPropDefs becomes COMPONENT_SET', () => {
|
||||
const changes: NodeChange[] = [
|
||||
doc(),
|
||||
canvas(),
|
||||
{
|
||||
...node('FRAME', 10, 1),
|
||||
name: 'Button',
|
||||
componentPropDefs: [
|
||||
{ id: { sessionID: 0, localID: 1 }, name: 'State', type: 'VARIANT' },
|
||||
],
|
||||
} as unknown as NodeChange,
|
||||
{ ...node('SYMBOL', 11, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, name: 'State=Default' } as NodeChange,
|
||||
{ ...node('SYMBOL', 12, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '"' }, name: 'State=Hover' } as NodeChange,
|
||||
]
|
||||
const graph = importNodeChanges(changes, [])
|
||||
const page = graph.getPages()[0]
|
||||
const set = graph.getChildren(page.id)[0]
|
||||
expect(set.type).toBe('COMPONENT_SET')
|
||||
expect(set.name).toBe('Button')
|
||||
const children = graph.getChildren(set.id)
|
||||
expect(children).toHaveLength(2)
|
||||
expect(children[0].type).toBe('COMPONENT')
|
||||
expect(children[1].type).toBe('COMPONENT')
|
||||
})
|
||||
|
||||
test('FRAME without componentPropDefs stays FRAME', () => {
|
||||
const changes: NodeChange[] = [
|
||||
doc(),
|
||||
canvas(),
|
||||
node('FRAME', 10, 1, { name: 'Regular Frame' }),
|
||||
]
|
||||
const graph = importNodeChanges(changes, [])
|
||||
const page = graph.getPages()[0]
|
||||
const frame = graph.getChildren(page.id)[0]
|
||||
expect(frame.type).toBe('FRAME')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue