diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dced748..4acb64e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 3961c500c..537c65160 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -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 diff --git a/packages/core/src/editor/nodes.ts b/packages/core/src/editor/nodes.ts index 6d31cca33..b7a80ded5 100644 --- a/packages/core/src/editor/nodes.ts +++ b/packages/core/src/editor/nodes.ts @@ -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) { - 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, label = 'Update') { const node = ctx.graph.getNode(id) if (!node) return - const previous = pick(node, Object.keys(changes) as (keyof SceneNode)[]) as Partial - ctx.graph.updateNode(id, changes) + const nextChanges = { ...changes, ...textAutoResizeChanges(node, changes) } + const previous = pick( + node, + Object.keys(nextChanges) as (keyof SceneNode)[] + ) as Partial + 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: () => { diff --git a/packages/core/src/editor/text/auto-resize.ts b/packages/core/src/editor/text/auto-resize.ts new file mode 100644 index 000000000..4fa38d0ff --- /dev/null +++ b/packages/core/src/editor/text/auto-resize.ts @@ -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([ + 'text', + 'fontSize', + 'fontFamily', + 'fontWeight', + 'italic', + 'lineHeight', + 'letterSpacing', + 'styleRuns', + 'fontVariations', + 'fontFeatures', + 'textAutoResize', + 'width', + 'maxLines' +]) + +const TEXT_AUTO_WIDTH_KEYS = new Set([ + 'text', + 'fontSize', + 'fontFamily', + 'fontWeight', + 'italic', + 'letterSpacing', + 'styleRuns', + 'fontVariations', + 'fontFeatures', + 'textAutoResize' +]) + +export function hasTextAutoResizeChange(changes: Partial): boolean { + return Object.keys(changes).some((key) => TEXT_AUTO_RESIZE_KEYS.has(key as keyof SceneNode)) +} + +function hasTextAutoWidthChange(changes: Partial): boolean { + return Object.keys(changes).some((key) => TEXT_AUTO_WIDTH_KEYS.has(key as keyof SceneNode)) +} + +export function textAutoResizeChanges( + node: SceneNode | undefined, + changes: Partial +): Partial> { + 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 + > = { + 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 +} diff --git a/packages/core/src/editor/undo.ts b/packages/core/src/editor/undo.ts index 4002f935f..1f080214f 100644 --- a/packages/core/src/editor/undo.ts +++ b/packages/core/src/editor/undo.ts @@ -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 @@ -156,7 +157,11 @@ export function createUndoActions(ctx: EditorContext) { function commitNodeUpdate(nodeId: string, previous: Partial, label = 'Update') { const node = ctx.graph.getNode(nodeId) if (!node) return - const current = pick(node, Object.keys(previous) as (keyof SceneNode)[]) as Partial + const restoredPrevious = { ...previous, ...textAutoResizeChanges(node, previous) } + const current = pick( + node, + Object.keys(restoredPrevious) as (keyof SceneNode)[] + ) as Partial 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) } }) diff --git a/packages/core/src/kiwi/fig/import.ts b/packages/core/src/kiwi/fig/import.ts index 8650eba65..13d922371 100644 --- a/packages/core/src/kiwi/fig/import.ts +++ b/packages/core/src/kiwi/fig/import.ts @@ -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) diff --git a/packages/core/src/kiwi/fig/node-change/convert.ts b/packages/core/src/kiwi/fig/node-change/convert.ts index 69fd425ed..2d0aa8b7f 100644 --- a/packages/core/src/kiwi/fig/node-change/convert.ts +++ b/packages/core/src/kiwi/fig/node-change/convert.ts @@ -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[] diff --git a/tests/engine/editor/text-auto-resize.test.ts b/tests/engine/editor/text-auto-resize.test.ts new file mode 100644 index 000000000..39dad4194 --- /dev/null +++ b/tests/engine/editor/text-auto-resize.test.ts @@ -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) + }) +}) diff --git a/tests/engine/io/fig/import/text-sizing.test.ts b/tests/engine/io/fig/import/text-sizing.test.ts new file mode 100644 index 000000000..5b6e95ae6 --- /dev/null +++ b/tests/engine/io/fig/import/text-sizing.test.ts @@ -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 { + 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') + }) +})