fix(figma-api): size script text to its content, as Figma does (#950)

* 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
This commit is contained in:
Danila Poyarkov 2026-10-07 20:49:37 +00:00 committed by GitHub
parent 974905289a
commit 0201feb35f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 90 additions and 4 deletions

View file

@ -132,6 +132,7 @@
- Resize auto-layout frames that fill across their parent with it: one with a fixed size on that axis kept its old size. A filling child still counts toward a hugging parent's size, as in Figma.
- Set `layoutSizingHorizontal` and `layoutSizingVertical` in scripts as Figma does: Fill is recorded on the child for that axis instead of on the frame's own sizing, which `.fig` export dropped, text switches its `textAutoResize`, and sizing Figma refuses, such as Hug on a frame without auto layout, throws Figma's error.
- Read geometry in scripts after an edit without waiting for the script to finish: `x`, `y`, `width`, `height`, `relativeTransform`, `absoluteTransform`, `absoluteBoundingBox`, and `absoluteRenderBounds` lay out what the script changed first, as in Figma, so a hugging parent reports its new size right after a child is added.
- Size text from scripts as Figma does: `figma.createText()` makes empty 12px text that sizes itself to its content instead of a fixed 100px box, auto-sizing text resizes when its characters, font, or size change, and `resize()` fixes its size.
- Draw segmented controls in the properties panel at the height of the fields beside them.
- Keep saving AI chat history in Safari Private Browsing after a message with an image or a reply that changed the document. Safari cannot store image data that way in a private window, so the conversation stopped saving from that point and showed "Chat history could not be saved".
- Keep a component set's dashed border one pixel wide at every zoom; zooming in after opening a page scaled it into thick dashes until the page was redrawn.

View file

@ -6,6 +6,7 @@ import {
import type { GroupFitOptions, SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { assertNodeEditable } from '#core/editor/capabilities'
import { textAutoResizeChanges } from '#core/editor/text/auto-resize'
import type { NodeProxyHost } from '#core/figma-api/proxy'
export interface NodeProxyInternals {
@ -77,6 +78,8 @@ export function updateNode(
.map((key) => [key, Reflect.get(changes, key)])
) as Partial<SceneNode>
if (Object.keys(applied).length === 0) return
// Auto-sizing text measures its new content, as the editor's updates do.
Object.assign(applied, textAutoResizeChanges(g.getNode(id), applied))
g.updateNode(id, applied)
recordInstanceOverride(g, id, Object.keys(applied))
if (Object.keys(applied).some((key) => GEOMETRY_FIELDS.has(key))) {

View file

@ -212,8 +212,20 @@ export function installBasicNodeProxyAccessors(
const scene = graph(this, internals)
const node = raw(this, internals)
const before = containerTransform(node, scene)
scene.updateNode(node.id, { width, height })
recordInstanceOverride(scene, node.id, ['width', 'height'])
// Text given a size stops sizing itself to its content, as in Figma.
const fixesText =
node.type === 'TEXT' &&
(node.textAutoResize === 'WIDTH_AND_HEIGHT' || node.textAutoResize === 'HEIGHT')
scene.updateNode(node.id, {
width,
height,
...(fixesText ? { textAutoResize: 'NONE' as const } : {})
})
recordInstanceOverride(scene, node.id, [
'width',
'height',
...(fixesText ? ['textAutoResize'] : [])
])
setTransform(this, internals, () => before)
},
resizeWithoutConstraints(this: ProxyThis, width: number, height: number): void {

View file

@ -23,6 +23,7 @@ import { newLayerDefaults } from '#core/editor/shapes/defaults'
import { booleanOperationPaints, createBooleanOperation } from '#core/editor/structure/boolean'
import { wrapNodes } from '#core/editor/structure/container-wrap'
import { ungroupNode } from '#core/editor/structure/group'
import { textAutoResizeChanges } from '#core/editor/text/auto-resize'
import { setDefaultPageBackground } from '#core/figma-api/page-backgrounds'
import type { RasterCodec } from '#core/io/formats/raster'
import { reconcileVariableLayouts } from '#core/layout/variables'
@ -187,7 +188,13 @@ export class FigmaAPI implements NodeProxyHost {
}
createText(): FigmaTextNode {
return this._createNode('TEXT') as FigmaTextNode
const text = this._createNode('TEXT') as FigmaTextNode
// Figma's plugin text starts empty at 12px and sizes itself to its content: no width, one
// line tall.
const node = this.graph.getNode(text.id)
const defaults = { fontSize: 12, textAutoResize: 'WIDTH_AND_HEIGHT' as const, width: 0 }
this.graph.updateNode(text.id, { ...defaults, ...textAutoResizeChanges(node, defaults) })
return text
}
createLine(): FigmaLineNode {

View file

@ -0,0 +1,61 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import { expectDefined } from '#core-tests/helpers/assert'
import { fontManager } from '#core/text/fonts'
import { FigmaAPI } from '@open-pencil/core/figma-api'
import { SceneGraph } from '@open-pencil/scene-graph'
// Recorded with the same script in live Figma on 2026-10-07, Inter Regular. Line metrics round
// differently by up to a pixel.
beforeAll(async () => {
fontManager.markLoaded(
'Inter',
'Regular',
expectDefined(await fontManager.fetchBundledFont('/Inter-Regular.ttf'), 'bundled Inter font')
)
})
function text() {
const figma = new FigmaAPI(new SceneGraph())
return figma.createText() as ReturnType<FigmaAPI['createText']> & TextNode
}
function size(node: { width: number; height: number }) {
return [node.width, node.height]
}
describe('plugin text sizing', () => {
test('new text is empty, 12px, and sizes itself to its content', () => {
const node = text()
expect(node.textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(node.fontSize).toBe(12)
expect(size(node)).toEqual([0, 15])
node.characters = 'Hello'
expect(size(node)).toEqual([29, 15])
node.characters = 'Hello world, a longer line of text'
expect(Math.abs(node.width - 180)).toBeLessThanOrEqual(1)
node.characters = 'Hello'
node.fontSize = 24
expect(node.width).toBe(58)
expect(Math.abs(node.height - 29)).toBeLessThanOrEqual(1)
})
test('resizing text fixes its size', () => {
const node = text()
// Read through a call, so the assignment below does not narrow what TypeScript expects.
const mode = (): string => node.textAutoResize
node.characters = 'Hello'
node.resize(200, 50)
expect(mode()).toBe('NONE')
expect(size(node)).toEqual([200, 50])
node.textAutoResize = 'HEIGHT'
node.resize(100, 10)
expect(mode()).toBe('NONE')
node.characters = 'Hello there, this wraps across lines'
expect(size(node)).toEqual([100, 10])
})
})

View file

@ -46,6 +46,8 @@ describe('Figma Plugin API layout compatibility', () => {
frame.appendChild(text)
text.x = 11
text.y = 13
const textWidth = graph.getNode(text.id)?.width ?? 0
expect(textWidth).toBeGreaterThan(0)
frame.rescale(2)
@ -70,7 +72,7 @@ describe('Figma Plugin API layout compatibility', () => {
expect(text.fontSize).toBe(20)
expect(text.letterSpacing).toBe(4)
expect(text.lineHeight).toBe(28)
expect(graph.getNode(text.id)?.width).toBe(200)
expect(graph.getNode(text.id)?.width).toBe(textWidth * 2)
})
test('rescale rejects occurrence descendants without mutation but permits definition children', () => {