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:
parent
13314d6b6d
commit
2c69e0949b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () => {
|
||||
|
|
|
|||
66
packages/core/src/editor/text/auto-resize.ts
Normal file
66
packages/core/src/editor/text/auto-resize.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
71
tests/engine/editor/text-auto-resize.test.ts
Normal file
71
tests/engine/editor/text-auto-resize.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
113
tests/engine/io/fig/import/text-sizing.test.ts
Normal file
113
tests/engine/io/fig/import/text-sizing.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue