fix(text): preserve Figma auto-size text imports

- Normalize auto-layout text imported from Figma when fixed bounds match derived text layout
- Recompute auto-size text bounds on typography metric changes
- Clear stale Figma derived text layout caches when text sizing changes
- Cover FIG import text sizing and line-height resize regressions
This commit is contained in:
Danila Poyarkov 2026-07-02 17:30:15 +03:00
parent 13314d6b6d
commit 2c69e0949b
9 changed files with 297 additions and 7 deletions

View file

@ -34,6 +34,7 @@
### Fixes
- Resize auto-height text when typography metrics such as line height or font size change, keeping imported Figma text bounds editable and undoable.
- Match Figma auto-layout reflow when deleting children or hiding optional instance slots, including HUG-height component instances.
- Fix desktop clipboard copy/cut/paste by using Tauri's system clipboard bridge when browser clipboard events are unavailable.
- Add AI provider connection testing with clearer setup errors for OpenAI-compatible endpoints.

View file

@ -8,7 +8,11 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { shapeTextForClipboard } from './canvas/text'
import { populateAndApplyOverrides } from './kiwi/fig/instance-overrides'
import type { InstanceNodeChange } from './kiwi/fig/instance-overrides'
import { nodeChangeToProps, sortChildren } from './kiwi/fig/node-change/convert'
import {
nodeChangeToProps,
shouldImportTextAsAutoSize,
sortChildren
} from './kiwi/fig/node-change/convert'
import {
sceneNodeToKiwi,
buildFigKiwi,
@ -243,6 +247,9 @@ export function importClipboardNodes(
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE') return
if (shouldImportTextAsAutoSize(nc, guidMap.get(parentMap.get(figmaId) ?? ''))) {
props.textAutoResize = 'WIDTH_AND_HEIGHT'
}
if (ourParentId === targetParentId) {
props.x = (props.x ?? 0) + offsetX

View file

@ -4,6 +4,7 @@ import type { SceneNode } from '@open-pencil/scene-graph'
import { createLayoutModeActions } from './layout-mode'
import { createNudgeActions } from './nudge'
import { textAutoResizeChanges } from './text/auto-resize'
import type { EditorContext } from './types'
import { createVariableBindingActions } from './variable-bindings'
@ -13,20 +14,26 @@ export function createNodeActions(ctx: EditorContext) {
const variableBindingActions = createVariableBindingActions(ctx)
function updateNode(id: string, changes: Partial<SceneNode>) {
ctx.graph.updateNode(id, changes)
const node = ctx.graph.getNode(id)
const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) }
ctx.graph.updateNode(id, nextChanges)
ctx.runLayoutForNode(id)
}
function updateNodeWithUndo(id: string, changes: Partial<SceneNode>, label = 'Update') {
const node = ctx.graph.getNode(id)
if (!node) return
const previous = pick(node, Object.keys(changes) as (keyof SceneNode)[]) as Partial<SceneNode>
ctx.graph.updateNode(id, changes)
const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) }
const previous = pick(
node,
Object.keys(nextChanges) as (keyof SceneNode)[]
) as Partial<SceneNode>
ctx.graph.updateNode(id, nextChanges)
ctx.runLayoutForNode(id)
ctx.undo.push({
label,
forward: () => {
ctx.graph.updateNode(id, changes)
ctx.graph.updateNode(id, nextChanges)
ctx.runLayoutForNode(id)
},
inverse: () => {

View file

@ -0,0 +1,66 @@
import type { SceneNode } from '@open-pencil/scene-graph'
import { estimateTextSize, getTextMeasurer } from '#core/layout/text-measurement'
export const TEXT_AUTO_RESIZE_KEYS = new Set<keyof SceneNode>([
'text',
'fontSize',
'fontFamily',
'fontWeight',
'italic',
'lineHeight',
'letterSpacing',
'styleRuns',
'fontVariations',
'fontFeatures',
'textAutoResize',
'width',
'maxLines'
])
const TEXT_AUTO_WIDTH_KEYS = new Set<keyof SceneNode>([
'text',
'fontSize',
'fontFamily',
'fontWeight',
'italic',
'letterSpacing',
'styleRuns',
'fontVariations',
'fontFeatures',
'textAutoResize'
])
export function hasTextAutoResizeChange(changes: Partial<SceneNode>): boolean {
return Object.keys(changes).some((key) => TEXT_AUTO_RESIZE_KEYS.has(key as keyof SceneNode))
}
function hasTextAutoWidthChange(changes: Partial<SceneNode>): boolean {
return Object.keys(changes).some((key) => TEXT_AUTO_WIDTH_KEYS.has(key as keyof SceneNode))
}
export function textAutoResizeChanges(
node: SceneNode | undefined,
changes: Partial<SceneNode>
): Partial<Pick<SceneNode, 'width' | 'height' | 'figmaDerivedLayout' | 'figmaDerivedTextGlyphs'>> {
if (node?.type !== 'TEXT' || !hasTextAutoResizeChange(changes)) return {}
const next = { ...node, ...changes }
const mode = next.textAutoResize
if (mode !== 'HEIGHT' && mode !== 'WIDTH_AND_HEIGHT') return {}
const maxWidth = mode === 'HEIGHT' ? next.width : undefined
const measured = getTextMeasurer()?.(next, maxWidth) ?? estimateTextSize(next, maxWidth)
const resized: Partial<
Pick<SceneNode, 'width' | 'height' | 'figmaDerivedLayout' | 'figmaDerivedTextGlyphs'>
> = {
figmaDerivedLayout: null,
figmaDerivedTextGlyphs: null
}
if (mode === 'WIDTH_AND_HEIGHT' && hasTextAutoWidthChange(changes) && measured.width > 0)
resized.width = measured.width
if (measured.height > 0) resized.height = measured.height
return resized
}

View file

@ -11,6 +11,7 @@ import {
snapshotPage as createPageSnapshot,
type PageSnapshot
} from './history/snapshot'
import { textAutoResizeChanges } from './text/auto-resize'
import type { EditorContext } from './types'
type ResizeSnapshot = Pick<SceneNode, 'x' | 'y' | 'width' | 'height' | 'vectorNetwork'>
@ -156,7 +157,11 @@ export function createUndoActions(ctx: EditorContext) {
function commitNodeUpdate(nodeId: string, previous: Partial<SceneNode>, label = 'Update') {
const node = ctx.graph.getNode(nodeId)
if (!node) return
const current = pick(node, Object.keys(previous) as (keyof SceneNode)[]) as Partial<SceneNode>
const restoredPrevious = { ...previous, ...textAutoResizeChanges(node, previous) }
const current = pick(
node,
Object.keys(restoredPrevious) as (keyof SceneNode)[]
) as Partial<SceneNode>
ctx.undo.push({
label,
forward: () => {
@ -164,7 +169,7 @@ export function createUndoActions(ctx: EditorContext) {
ctx.runLayoutForNode(nodeId)
},
inverse: () => {
ctx.graph.updateNode(nodeId, previous)
ctx.graph.updateNode(nodeId, restoredPrevious)
ctx.runLayoutForNode(nodeId)
}
})

View file

@ -11,6 +11,7 @@ import { setLazyFigImportContext } from '#core/kiwi/fig/lazy-import'
import {
guidToString,
nodeChangeToProps,
shouldImportTextAsAutoSize,
sortChildren,
setVariableColorResolver,
VARIABLE_BINDING_FIELDS_INVERSE
@ -445,6 +446,9 @@ export function importNodeChanges(
const { nodeType, ...props } = nodeChangeToProps(nc, blobs)
if (nodeType === 'DOCUMENT' || nodeType === 'VARIABLE' || nc.type === 'VARIABLE_SET') return
if (shouldImportTextAsAutoSize(nc, changeMap.get(parentMap.get(ncId) ?? ''))) {
props.textAutoResize = 'WIDTH_AND_HEIGHT'
}
const parentId = canvasIdToPageId.get(graphParentId) ?? graphParentId
const node = graph.createNode(nodeType, parentId, props)

View file

@ -544,6 +544,22 @@ function resolveNodeType(nc: NodeChange): NodeType | 'DOCUMENT' | 'VARIABLE' {
return nodeType
}
function nearlyEqualSize(a: number | undefined, b: number | undefined): boolean {
return Math.abs((a ?? 0) - (b ?? 0)) <= 0.5
}
export function shouldImportTextAsAutoSize(
nc: NodeChange,
parentNc: NodeChange | undefined
): boolean {
if (nc.type !== 'TEXT' || nc.textAutoResize !== 'NONE') return false
if (parentNc?.stackMode !== 'HORIZONTAL' && parentNc?.stackMode !== 'VERTICAL') return false
if (!nc.textData?.characters) return false
const layoutSize = nc.derivedTextData?.layoutSize
if (!layoutSize || !nc.size) return false
return nearlyEqualSize(layoutSize.x, nc.size.x) && nearlyEqualSize(layoutSize.y, nc.size.y)
}
export function nodeChangeToProps(
nc: NodeChange,
blobs: Uint8Array[]

View file

@ -0,0 +1,71 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { setTextMeasurer } from '@open-pencil/core/layout'
import { getNodeOrThrow } from '#tests/helpers/assert'
afterEach(() => {
setTextMeasurer(null)
})
describe('editor text auto-resize updates', () => {
test('lineHeight changes resize auto-height text', () => {
setTextMeasurer((node) => ({ width: node.width, height: node.lineHeight ?? 20 }))
const editor = createEditor()
const text = editor.graph.createNode('TEXT', editor.state.currentPageId, {
text: 'Hello',
textAutoResize: 'HEIGHT',
width: 120,
height: 20,
lineHeight: 20
})
editor.updateNode(text.id, { lineHeight: 48 })
expect(getNodeOrThrow(editor.graph, text.id).lineHeight).toBe(48)
expect(getNodeOrThrow(editor.graph, text.id).height).toBe(48)
})
test('lineHeight changes on auto-height text are undoable with height', () => {
setTextMeasurer((node) => ({ width: node.width, height: node.lineHeight ?? 20 }))
const editor = createEditor()
const text = editor.graph.createNode('TEXT', editor.state.currentPageId, {
text: 'Hello',
textAutoResize: 'HEIGHT',
width: 120,
height: 20,
lineHeight: 20
})
editor.updateNodeWithUndo(text.id, { lineHeight: 48 }, 'Change lineHeight')
expect(getNodeOrThrow(editor.graph, text.id).height).toBe(48)
editor.undo.undo()
expect(getNodeOrThrow(editor.graph, text.id).lineHeight).toBe(20)
expect(getNodeOrThrow(editor.graph, text.id).height).toBe(20)
editor.undo.redo()
expect(getNodeOrThrow(editor.graph, text.id).lineHeight).toBe(48)
expect(getNodeOrThrow(editor.graph, text.id).height).toBe(48)
})
test('font size changes resize width-and-height text', () => {
setTextMeasurer((node) => ({ width: node.fontSize * 4, height: node.fontSize * 2 }))
const editor = createEditor()
const text = editor.graph.createNode('TEXT', editor.state.currentPageId, {
text: 'Text',
textAutoResize: 'WIDTH_AND_HEIGHT',
width: 40,
height: 20,
fontSize: 10
})
editor.updateNode(text.id, { fontSize: 16 })
expect(getNodeOrThrow(editor.graph, text.id).width).toBe(64)
expect(getNodeOrThrow(editor.graph, text.id).height).toBe(32)
})
})

View file

@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test'
import type { Vector } from '@open-pencil/core'
import { importNodeChanges } from '@open-pencil/core/kiwi'
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
import { getNodeOrThrow } from '#tests/helpers/assert'
function guid(localID: number): NonNullable<NodeChange['guid']> {
return { sessionID: 0, localID }
}
function documentNode(): NodeChange {
return { guid: guid(0), type: 'DOCUMENT', name: 'Document' }
}
function canvasNode(): NodeChange {
return {
guid: guid(1),
type: 'CANVAS',
name: 'Page 1',
parentIndex: { guid: guid(0), position: '!' }
}
}
function stackFrame(localID: number): NodeChange {
return {
guid: guid(localID),
type: 'FRAME',
name: 'Button',
parentIndex: { guid: guid(1), position: '!' },
size: { x: 88, y: 32 },
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
stackMode: 'HORIZONTAL',
stackCounterSizing: 'RESIZE_TO_FIT_WITH_IMPLICIT_SIZE',
stackHorizontalPadding: 16,
stackVerticalPadding: 5
}
}
function textNode(
localID: number,
parentID: number,
options: { size?: Vector; derivedSize?: Vector } = {}
): NodeChange {
const size = options.size ?? { x: 56, y: 22 }
return {
guid: guid(localID),
type: 'TEXT',
name: '↘︎ Text',
parentIndex: { guid: guid(parentID), position: '!' },
size,
transform: { m00: 1, m01: 0, m02: 16, m10: 0, m11: 1, m12: 5 },
textAutoResize: 'NONE',
textUserLayoutVersion: 4,
textExplicitLayoutVersion: 1,
textData: { characters: '主要按钮', lines: [{ lineType: 'PLAIN' }] },
fontSize: 14,
fontName: { family: 'PingFang SC', style: 'Medium', postscript: '' },
lineHeight: { value: 22, units: 'PIXELS' },
textAlignHorizontal: 'CENTER',
textAlignVertical: 'TOP',
derivedTextData: {
layoutSize: options.derivedSize ?? size,
baselines: [
{
position: { x: 0, y: 22 },
width: 56,
lineHeight: 22,
lineAscent: 19.2,
firstCharacter: 0,
endCharacter: 3
}
]
}
}
}
describe('FIG text sizing import', () => {
test('imports Figma auto-layout text matching derived layout as auto-size', () => {
const graph = importNodeChanges([documentNode(), canvasNode(), stackFrame(2), textNode(3, 2)])
const text = graph.getAllNodes().find((node) => node.type === 'TEXT')
if (!text) throw new Error('Expected text node')
expect(getNodeOrThrow(graph, text.id).textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(getNodeOrThrow(graph, text.id).width).toBe(56)
expect(getNodeOrThrow(graph, text.id).height).toBe(22)
})
test('keeps fixed text outside auto-layout fixed', () => {
const graph = importNodeChanges([documentNode(), canvasNode(), textNode(3, 1)])
const text = graph.getAllNodes().find((node) => node.type === 'TEXT')
if (!text) throw new Error('Expected text node')
expect(getNodeOrThrow(graph, text.id).textAutoResize).toBe('NONE')
})
test('keeps explicit larger text boxes inside auto-layout fixed', () => {
const graph = importNodeChanges([
documentNode(),
canvasNode(),
stackFrame(2),
textNode(3, 2, { size: { x: 120, y: 48 }, derivedSize: { x: 56, y: 22 } })
])
const text = graph.getAllNodes().find((node) => node.type === 'TEXT')
if (!text) throw new Error('Expected text node')
expect(getNodeOrThrow(graph, text.id).textAutoResize).toBe('NONE')
})
})