* fix(figma-api): size script text to its content, as Figma does figma.createText() made fixed 100px text at 14px, and the API never re-measured text, so scripts saw sizes Figma does not. New text is now empty 12px WIDTH_AND_HEIGHT text, one line tall; API updates re-measure auto-sizing text through the editor's textAutoResizeChanges; and resize() sets textAutoResize to NONE. Values were recorded in live Figma. * test(figma-api): hold recorded text sizes to the stated 1 px
192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { FigmaAPI, type FigmaNodeProxy } from '@open-pencil/core/figma-api'
|
|
import { SceneGraph } from '@open-pencil/scene-graph'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
import { solidStroke } from '../helpers'
|
|
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
return { graph, api: new FigmaAPI(graph) }
|
|
}
|
|
|
|
describe('Figma Plugin API layout compatibility', () => {
|
|
test('rescale scales geometry, visual properties, and descendants from the root top-left', () => {
|
|
const { api, graph } = setup()
|
|
const frame: FigmaNodeProxy = api.createFrame()
|
|
frame.x = 30
|
|
frame.y = 40
|
|
frame.resize(100, 80)
|
|
frame.paddingLeft = 10
|
|
frame.paddingRight = 12
|
|
frame.paddingTop = 6
|
|
frame.paddingBottom = 8
|
|
frame.itemSpacing = 5
|
|
frame.cornerRadius = 7
|
|
frame.strokes = [solidStroke({ r: 0, g: 0, b: 0, a: 1 }, { weight: 3, align: 'CENTER' })]
|
|
frame.effects = [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
offset: { x: 5, y: -3 },
|
|
radius: 2,
|
|
spread: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
|
|
const text: FigmaNodeProxy = api.createText()
|
|
text.characters = 'Hi'
|
|
text.fontSize = 10
|
|
text.letterSpacing = 2
|
|
text.lineHeight = 14
|
|
frame.appendChild(text)
|
|
text.x = 11
|
|
text.y = 13
|
|
const textWidth = graph.getNode(text.id)?.width ?? 0
|
|
expect(textWidth).toBeGreaterThan(0)
|
|
|
|
frame.rescale(2)
|
|
|
|
expect(frame.x).toBe(30)
|
|
expect(frame.y).toBe(40)
|
|
expect(frame.width).toBe(200)
|
|
expect(frame.height).toBe(160)
|
|
expect(frame.paddingLeft).toBe(20)
|
|
expect(frame.paddingRight).toBe(24)
|
|
expect(frame.paddingTop).toBe(12)
|
|
expect(frame.paddingBottom).toBe(16)
|
|
expect(frame.itemSpacing).toBe(10)
|
|
expect(frame.cornerRadius).toBe(14)
|
|
expect(frame.strokeWeight).toBe(6)
|
|
const shadow = frame.effects[0]
|
|
if (shadow?.type !== 'DROP_SHADOW') throw new Error('expected a scaled drop shadow')
|
|
expect(shadow.offset).toEqual({ x: 10, y: -6 })
|
|
expect(shadow.radius).toBe(4)
|
|
expect(shadow.spread).toBe(2)
|
|
expect(text.x).toBe(22)
|
|
expect(text.y).toBe(26)
|
|
expect(text.fontSize).toBe(20)
|
|
expect(text.letterSpacing).toBe(4)
|
|
expect(text.lineHeight).toBe(28)
|
|
expect(graph.getNode(text.id)?.width).toBe(textWidth * 2)
|
|
})
|
|
|
|
test('rescale rejects occurrence descendants without mutation but permits definition children', () => {
|
|
const { api, graph } = setup()
|
|
const page = graph.getPages()[0]
|
|
const inner = graph.createNode('COMPONENT', page.id)
|
|
graph.createNode('RECTANGLE', inner.id, { width: 20, height: 10 })
|
|
const outer = graph.createNode('COMPONENT', page.id)
|
|
const definition = graph.createInstance(inner.id, outer.id)
|
|
if (!definition) throw new Error('Missing definition instance')
|
|
expectDefined(api.getNodeById(definition.id)).rescale(2)
|
|
expect(graph.getChildren(definition.id)[0].width).toBe(40)
|
|
const owner = graph.createInstance(outer.id, page.id)
|
|
if (!owner) throw new Error('Missing placed owner')
|
|
const nested = graph.getChildren(owner.id)[0]
|
|
const rectangle = graph.getChildren(nested.id)[0]
|
|
for (const target of [nested, rectangle]) {
|
|
const before = structuredClone(target)
|
|
expect(() => expectDefined(api.getNodeById(target.id)).rescale(1)).toThrow(
|
|
'cannot be overridden in an instance: size'
|
|
)
|
|
expect(target).toEqual(before)
|
|
}
|
|
})
|
|
|
|
test('rescale rejects non-Figma scale factors and pages', () => {
|
|
const { api } = setup()
|
|
const rect = api.createRectangle()
|
|
expect(() => rect.rescale(0.009)).toThrow('Scale must be at least 0.01')
|
|
expect(() => rect.rescale(Number.POSITIVE_INFINITY)).toThrow('Scale must be a finite number')
|
|
expect(() => api.currentPage.rescale(2)).toThrow('rescale() is not supported on this node')
|
|
const section = api.createSection()
|
|
expect(() => section.rescale(2)).toThrow('rescale() is not supported on this node')
|
|
})
|
|
|
|
test('page backgrounds expose imported canvas paints and support assignment', () => {
|
|
const { api, graph } = setup()
|
|
const page = graph.getNode(api.currentPage.id)
|
|
expect(page).toBeDefined()
|
|
if (!page) return
|
|
page.source.fig.rawNodeFields.backgroundPaints = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0.5, g: 0.75, b: 1, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
|
|
expect(api.currentPage.backgrounds).toEqual([
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 0.5, g: 0.75, b: 1, a: 1 },
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
])
|
|
|
|
api.currentPage.backgrounds = [
|
|
{
|
|
type: 'SOLID',
|
|
color: { r: 1, g: 0, b: 0, a: 1 },
|
|
opacity: 0.5,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
expect(api.currentPage.backgrounds[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 })
|
|
expect(page.source.fig.rawNodeFields.backgroundColor).toEqual({ r: 1, g: 0, b: 0, a: 1 })
|
|
})
|
|
|
|
test('absoluteRenderBounds includes strokes and effects and returns null for invisible pixels', () => {
|
|
const { api } = setup()
|
|
const rect = api.createRectangle()
|
|
rect.x = 3
|
|
rect.y = 4
|
|
rect.resize(10, 20)
|
|
rect.strokes = [solidStroke({ r: 1, g: 0, b: 0, a: 1 }, { weight: 4, align: 'OUTSIDE' })]
|
|
rect.effects = [
|
|
{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 1 },
|
|
offset: { x: 5, y: -3 },
|
|
radius: 2,
|
|
spread: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL'
|
|
}
|
|
]
|
|
|
|
expect(rect.absoluteBoundingBox).toEqual({ x: 3, y: 4, width: 10, height: 20 })
|
|
expect(rect.absoluteRenderBounds).toEqual({ x: -1, y: -6, width: 26, height: 34 })
|
|
|
|
rect.visible = false
|
|
expect(rect.absoluteBoundingBox).toEqual({ x: 3, y: 4, width: 10, height: 20 })
|
|
expect(rect.absoluteRenderBounds).toBeNull()
|
|
|
|
const emptyVector = api.createVector()
|
|
expect(emptyVector.absoluteRenderBounds).toBeNull()
|
|
})
|
|
|
|
test('absoluteRenderBounds is clipped by ancestor frames', () => {
|
|
const { api } = setup()
|
|
const frame = api.createFrame()
|
|
frame.resize(100, 100)
|
|
frame.clipsContent = true
|
|
const rect = api.createRectangle()
|
|
rect.x = 150
|
|
rect.resize(20, 20)
|
|
frame.appendChild(rect)
|
|
|
|
expect(rect.absoluteRenderBounds).toBeNull()
|
|
})
|
|
})
|