Frames with resizeToFit=true are Figma's internal representation of Groups. They auto-resize to fit children and never clip content, regardless of the frameMaskDisabled flag. Previously all frames with frameMaskDisabled=false were imported with clipsContent=true, causing group-like frames to incorrectly clip their children. This was visible as hard edges on gradient fills that should extend beyond the group bounds.
423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
|
|
import { importNodeChanges, type NodeChange } from '@open-pencil/core'
|
|
|
|
function doc(): NodeChange {
|
|
return {
|
|
guid: { sessionID: 0, localID: 0 },
|
|
type: 'DOCUMENT',
|
|
name: 'Document',
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
} as NodeChange
|
|
}
|
|
|
|
function canvas(localID = 1): NodeChange {
|
|
return {
|
|
guid: { sessionID: 0, localID },
|
|
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
|
|
type: 'CANVAS',
|
|
name: 'Page 1',
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
} as NodeChange
|
|
}
|
|
|
|
function node(type: string, localID: number, parentLocalID: number, overrides: Partial<NodeChange> = {}): NodeChange {
|
|
return {
|
|
guid: { sessionID: 1, localID },
|
|
parentIndex: { guid: { sessionID: 0, localID: parentLocalID }, position: String.fromCharCode(33 + localID) },
|
|
type,
|
|
name: `${type}_${localID}`,
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
size: { x: 100, y: 100 },
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
...overrides,
|
|
} as NodeChange
|
|
}
|
|
|
|
describe('fig-import: node types', () => {
|
|
test('ROUNDED_RECTANGLE imported as its own type', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ROUNDED_RECTANGLE', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('ROUNDED_RECTANGLE')
|
|
})
|
|
|
|
test('COMPONENT maps to COMPONENT', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('COMPONENT', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('INSTANCE maps to INSTANCE', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('INSTANCE', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('INSTANCE')
|
|
})
|
|
|
|
test('SYMBOL maps to COMPONENT', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('SYMBOL', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('REGULAR_POLYGON maps to POLYGON', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('REGULAR_POLYGON', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('POLYGON')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: gradient fills', () => {
|
|
test('linear gradient', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'GRADIENT_LINEAR',
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 },
|
|
],
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills).toHaveLength(1)
|
|
expect(n.fills[0].type).toBe('GRADIENT_LINEAR')
|
|
expect(n.fills[0].gradientStops).toHaveLength(2)
|
|
expect(n.fills[0].gradientStops![0].color.r).toBe(1)
|
|
expect(n.fills[0].gradientStops![1].color.b).toBe(1)
|
|
expect(n.fills[0].gradientTransform).toBeDefined()
|
|
})
|
|
|
|
test('radial gradient', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'GRADIENT_RADIAL',
|
|
opacity: 0.8,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 0, a: 1 }, position: 1 },
|
|
],
|
|
transform: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills[0].type).toBe('GRADIENT_RADIAL')
|
|
expect(n.fills[0].opacity).toBe(0.8)
|
|
expect(n.fills[0].gradientStops).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: image fills', () => {
|
|
test('image fill with hash', () => {
|
|
const hash: Record<string, number> = {}
|
|
for (let i = 0; i < 20; i++) hash[String(i)] = i + 10
|
|
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'IMAGE',
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
image: { hash, name: 'test-image' },
|
|
imageScaleMode: 'FILL',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills[0].type).toBe('IMAGE')
|
|
expect(n.fills[0].imageHash).toBeDefined()
|
|
expect(n.fills[0].imageHash!.length).toBe(40)
|
|
expect(n.fills[0].imageScaleMode).toBe('FILL')
|
|
})
|
|
|
|
test('images stored on graph', () => {
|
|
const images = new Map<string, Uint8Array>()
|
|
images.set('abc123', new Uint8Array([1, 2, 3]))
|
|
const graph = importNodeChanges([doc(), canvas()], [], images)
|
|
expect(graph.images.get('abc123')).toEqual(new Uint8Array([1, 2, 3]))
|
|
})
|
|
})
|
|
|
|
describe('fig-import: effects', () => {
|
|
test('drop shadow', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
effects: [{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
|
offset: { x: 4, y: 4 },
|
|
radius: 8,
|
|
spread: 0,
|
|
visible: true,
|
|
}] as unknown as NodeChange['effects'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.effects).toHaveLength(1)
|
|
expect(n.effects[0].type).toBe('DROP_SHADOW')
|
|
expect(n.effects[0].radius).toBe(8)
|
|
expect(n.effects[0].offset.x).toBe(4)
|
|
})
|
|
|
|
test('inner shadow', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
effects: [{
|
|
type: 'INNER_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.5 },
|
|
offset: { x: 0, y: 2 },
|
|
radius: 4,
|
|
spread: 0,
|
|
visible: true,
|
|
}] as unknown as NodeChange['effects'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.effects[0].type).toBe('INNER_SHADOW')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: stroke options', () => {
|
|
test('stroke cap and join', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('VECTOR', 10, 1, {
|
|
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
strokeWeight: 3,
|
|
strokeAlign: 'CENTER',
|
|
strokeCap: 'ROUND',
|
|
strokeJoin: 'BEVEL',
|
|
} as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.strokes[0].cap).toBe('ROUND')
|
|
expect(n.strokes[0].join).toBe('BEVEL')
|
|
})
|
|
|
|
test('dash pattern', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
strokeWeight: 2,
|
|
strokeAlign: 'CENTER',
|
|
dashPattern: [10, 5],
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.strokes[0].dashPattern).toEqual([10, 5])
|
|
})
|
|
})
|
|
|
|
describe('fig-import: text properties', () => {
|
|
test('text auto resize', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('TEXT', 10, 1, {
|
|
textData: { characters: 'Hello' },
|
|
fontSize: 16,
|
|
textAlignHorizontal: 'CENTER',
|
|
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.textAutoResize).toBe('WIDTH_AND_HEIGHT')
|
|
expect(n.textAlignHorizontal).toBe('CENTER')
|
|
})
|
|
|
|
test('font weight mapping', () => {
|
|
const cases = [
|
|
['Bold', 700],
|
|
['Semi Bold', 600],
|
|
['Light', 300],
|
|
['ExtraBold', 800],
|
|
['Black', 900],
|
|
['Thin', 100],
|
|
['Medium', 500],
|
|
['Regular', 400],
|
|
] as const
|
|
|
|
for (const [style, expected] of cases) {
|
|
const graph = importNodeChanges([doc(), canvas(), node('TEXT', 10, 1, {
|
|
textData: { characters: 'X' },
|
|
fontName: { family: 'Inter', style },
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fontWeight).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('fig-import: arc data', () => {
|
|
test('partial ellipse', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
arcData: {
|
|
startingAngle: 0,
|
|
endingAngle: Math.PI,
|
|
innerRadius: 0,
|
|
},
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.arcData).toBeDefined()
|
|
expect(n.arcData!.startingAngle).toBe(0)
|
|
expect(n.arcData!.endingAngle).toBeCloseTo(Math.PI)
|
|
expect(n.arcData!.innerRadius).toBe(0)
|
|
})
|
|
|
|
test('donut (inner radius)', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
arcData: {
|
|
startingAngle: 0,
|
|
endingAngle: Math.PI * 2,
|
|
innerRadius: 0.5,
|
|
},
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.arcData!.innerRadius).toBe(0.5)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: constraints', () => {
|
|
test('horizontal and vertical constraints', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
horizontalConstraint: 'STRETCH',
|
|
verticalConstraint: 'CENTER',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.horizontalConstraint).toBe('STRETCH')
|
|
expect(n.verticalConstraint).toBe('CENTER')
|
|
})
|
|
|
|
test('defaults to MIN', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.horizontalConstraint).toBe('MIN')
|
|
expect(n.verticalConstraint).toBe('MIN')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: blend mode', () => {
|
|
test('node blend mode', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
blendMode: 'MULTIPLY',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.blendMode).toBe('MULTIPLY')
|
|
})
|
|
|
|
test('defaults to PASS_THROUGH', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.blendMode).toBe('PASS_THROUGH')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: independent stroke weights', () => {
|
|
test('border weights imported', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('FRAME', 10, 1, {
|
|
borderTopWeight: 2,
|
|
borderRightWeight: 4,
|
|
borderBottomWeight: 2,
|
|
borderLeftWeight: 4,
|
|
borderStrokeWeightsIndependent: true,
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.borderTopWeight).toBe(2)
|
|
expect(n.borderRightWeight).toBe(4)
|
|
expect(n.independentStrokeWeights).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: multiple fills', () => {
|
|
test('solid + gradient stacked', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [
|
|
{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' },
|
|
{
|
|
type: 'GRADIENT_LINEAR',
|
|
opacity: 0.5,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
|
|
],
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
},
|
|
] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills).toHaveLength(2)
|
|
expect(n.fills[0].type).toBe('SOLID')
|
|
expect(n.fills[1].type).toBe('GRADIENT_LINEAR')
|
|
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')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: clipsContent with resizeToFit', () => {
|
|
test('regular FRAME with frameMaskDisabled=false clips content', () => {
|
|
const graph = importNodeChanges([
|
|
doc(), canvas(),
|
|
node('FRAME', 10, 1, { frameMaskDisabled: false }),
|
|
])
|
|
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(frame.clipsContent).toBe(true)
|
|
})
|
|
|
|
test('resizeToFit FRAME does not clip even with frameMaskDisabled=false', () => {
|
|
const graph = importNodeChanges([
|
|
doc(), canvas(),
|
|
node('FRAME', 10, 1, { frameMaskDisabled: false, resizeToFit: true }),
|
|
])
|
|
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(frame.clipsContent).toBe(false)
|
|
})
|
|
|
|
test('FRAME with frameMaskDisabled=true does not clip', () => {
|
|
const graph = importNodeChanges([
|
|
doc(), canvas(),
|
|
node('FRAME', 10, 1, { frameMaskDisabled: true }),
|
|
])
|
|
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(frame.clipsContent).toBe(false)
|
|
})
|
|
})
|