diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ee6395b..6c5376c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/figma-api/accessor-utils.ts b/packages/core/src/figma-api/accessor-utils.ts index 980c34cab..f20944d3a 100644 --- a/packages/core/src/figma-api/accessor-utils.ts +++ b/packages/core/src/figma-api/accessor-utils.ts @@ -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 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))) { diff --git a/packages/core/src/figma-api/accessors/basic.ts b/packages/core/src/figma-api/accessors/basic.ts index 157c6e54b..1429488b5 100644 --- a/packages/core/src/figma-api/accessors/basic.ts +++ b/packages/core/src/figma-api/accessors/basic.ts @@ -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 { diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index f584306e5..7d007d282 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -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 { diff --git a/packages/core/tests/figma-api/text-autosize.test.ts b/packages/core/tests/figma-api/text-autosize.test.ts new file mode 100644 index 000000000..4fcb60329 --- /dev/null +++ b/packages/core/tests/figma-api/text-autosize.test.ts @@ -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 & 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]) + }) +}) diff --git a/tests/engine/figma/api/layout/plugin-compat.test.ts b/tests/engine/figma/api/layout/plugin-compat.test.ts index 1d077bb84..b809c331a 100644 --- a/tests/engine/figma/api/layout/plugin-compat.test.ts +++ b/tests/engine/figma/api/layout/plugin-compat.test.ts @@ -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', () => {