From 4195f13fadf04a16a217ced869d376503339412d Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 18 May 2026 04:15:11 +0300 Subject: [PATCH] fix(fig): preserve imported hug container bounds --- packages/core/src/canvas/scene.ts | 3 +- packages/core/src/kiwi/node-change/convert.ts | 46 ++++++-- .../imported-derived-layout.test.ts | 106 ++++++++++++++++++ tests/engine/render/canvas/line.test.ts | 73 ++++++++++++ 4 files changed, 219 insertions(+), 9 deletions(-) create mode 100644 tests/engine/layout/auto-layout/imported-derived-layout.test.ts create mode 100644 tests/engine/render/canvas/line.test.ts diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 5d1cf7e60..c7c46a384 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -60,7 +60,8 @@ function applyNodeTransforms( const rotation = overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation if (rotation !== 0) { - canvas.rotate(rotation, node.width / 2, node.height / 2) + if (node.type === 'LINE') canvas.rotate(rotation, 0, 0) + else canvas.rotate(rotation, node.width / 2, node.height / 2) } if (node.flipX || node.flipY) { diff --git a/packages/core/src/kiwi/node-change/convert.ts b/packages/core/src/kiwi/node-change/convert.ts index 8694e997b..6bd47b35b 100644 --- a/packages/core/src/kiwi/node-change/convert.ts +++ b/packages/core/src/kiwi/node-change/convert.ts @@ -328,9 +328,27 @@ function convertLayoutPadding( } } -function convertLayoutProps( - nc: NodeChange -): Pick< +function visibleContainerDerivedLayout( + nc: NodeChange, + layoutMode: SceneNode['layoutMode'], + primaryAxisSizing: SceneNode['primaryAxisSizing'], + counterAxisSizing: SceneNode['counterAxisSizing'] +): SceneNode['figmaDerivedLayout'] | undefined { + const hasHugAxis = primaryAxisSizing === 'HUG' || counterAxisSizing === 'HUG' + const hasVisiblePaint = + (nc.fillPaints?.some((paint) => paint.visible !== false) ?? false) || + (nc.strokePaints?.some((paint) => paint.visible !== false) ?? false) + if (layoutMode === 'NONE' || !hasHugAxis || !hasVisiblePaint) return undefined + + return { + x: nc.transform?.m02 ?? 0, + y: nc.transform?.m12 ?? 0, + width: nc.size?.x ?? 100, + height: nc.size?.y ?? 100 + } +} + +function convertLayoutProps(nc: NodeChange): Pick< SceneNode, | 'layoutMode' | 'itemSpacing' @@ -351,13 +369,24 @@ function convertLayoutProps( | 'itemReverseZIndex' | 'strokesIncludedInLayout' | 'layoutDirection' -> { +> & + Partial> { + const layoutMode = mapStackMode(nc.stackMode) + const primaryAxisSizing = mapStackSizing(nc.stackPrimarySizing) + const counterAxisSizing = mapStackSizing(nc.stackCounterSizing) + const figmaDerivedLayout = visibleContainerDerivedLayout( + nc, + layoutMode, + primaryAxisSizing, + counterAxisSizing + ) + return { - layoutMode: mapStackMode(nc.stackMode), + layoutMode, itemSpacing: nc.stackSpacing ?? 0, ...convertLayoutPadding(nc), - primaryAxisSizing: mapStackSizing(nc.stackPrimarySizing), - counterAxisSizing: mapStackSizing(nc.stackCounterSizing), + primaryAxisSizing, + counterAxisSizing, primaryAxisAlign: mapStackJustify(nc.stackPrimaryAlignItems ?? nc.stackJustify), counterAxisAlign: mapStackCounterAlign(nc.stackCounterAlignItems ?? nc.stackCounterAlign), layoutWrap: nc.stackWrap === 'WRAP' ? 'WRAP' : 'NO_WRAP', @@ -372,7 +401,8 @@ function convertLayoutProps( layoutDirection: (getOpenPencilPluginValue(nc, LAYOUT_DIRECTION_PLUGIN_KEY) as | SceneNode['layoutDirection'] - | null) || 'AUTO' + | null) || 'AUTO', + ...(figmaDerivedLayout ? { figmaDerivedLayout } : {}) } } diff --git a/tests/engine/layout/auto-layout/imported-derived-layout.test.ts b/tests/engine/layout/auto-layout/imported-derived-layout.test.ts new file mode 100644 index 000000000..598faaaf5 --- /dev/null +++ b/tests/engine/layout/auto-layout/imported-derived-layout.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from 'bun:test' + +import { computeAllLayouts, SceneGraph } from '@open-pencil/core' + +describe('imported auto-layout bounds', () => { + test('preserves visible hug container bounds when hidden children would collapse layout', () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + width: 280, + height: 44, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + paddingTop: 2, + paddingRight: 2, + paddingBottom: 2, + paddingLeft: 2, + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 1, b: 1, a: 1 }, + opacity: 1, + visible: true + } + ], + strokes: [ + { + color: { r: 0.58, g: 0.64, b: 0.72, a: 1 }, + weight: 2, + opacity: 1, + visible: true, + align: 'OUTSIDE' + } + ], + figmaDerivedLayout: { x: 0, y: 0, width: 280, height: 44 } + }) + const wrapper = graph.createNode('FRAME', frame.id, { + x: 2, + y: 2, + width: 276, + height: 40, + layoutMode: 'VERTICAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + const field = graph.createNode('FRAME', wrapper.id, { + width: 276, + height: 40, + layoutMode: 'HORIZONTAL', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG', + layoutAlignSelf: 'STRETCH', + paddingTop: 8, + paddingRight: 56, + paddingBottom: 8, + paddingLeft: 12, + fills: [ + { + type: 'SOLID', + color: { r: 1, g: 1, b: 1, a: 1 }, + opacity: 1, + visible: true + } + ], + strokes: [ + { + color: { r: 0.8, g: 0.84, b: 0.88, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'OUTSIDE' + } + ], + figmaDerivedLayout: { x: 0, y: 0, width: 276, height: 40 } + }) + graph.createNode('TEXT', field.id, { + x: 12, + y: 8, + width: 132, + height: 24, + text: 'typing something', + visible: false + }) + graph.createNode('LINE', field.id, { + x: 12, + y: 8, + width: 24, + height: 0, + rotation: 90, + strokes: [ + { + color: { r: 0, g: 0, b: 0, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'CENTER' + } + ] + }) + + computeAllLayouts(graph) + + expect(graph.getNode(field.id)).toMatchObject({ x: 0, y: 0, width: 276, height: 40 }) + }) +}) diff --git a/tests/engine/render/canvas/line.test.ts b/tests/engine/render/canvas/line.test.ts new file mode 100644 index 000000000..4dd02a1dc --- /dev/null +++ b/tests/engine/render/canvas/line.test.ts @@ -0,0 +1,73 @@ +import { beforeAll, describe, expect, test } from 'bun:test' + +import { renderNodesToImage, SceneGraph, SkiaRenderer } from '@open-pencil/core' + +import { initCanvasKit } from '#cli/headless' + +import { expectDefined } from '#tests/helpers/assert' + +let ck: Awaited> + +beforeAll(async () => { + ck = await initCanvasKit() +}) + +describe('line rendering', () => { + test('rotates lines around their transform origin', async () => { + const graph = new SceneGraph() + const page = graph.getPages()[0] + const frame = graph.createNode('FRAME', page.id, { + width: 40, + height: 40 + }) + graph.createNode('LINE', frame.id, { + x: 10, + y: 8, + width: 24, + height: 0, + rotation: 90, + strokes: [ + { + color: { r: 0, g: 0, b: 0, a: 1 }, + weight: 1, + opacity: 1, + visible: true, + align: 'CENTER' + } + ] + }) + + const surface = expectDefined(ck.MakeSurface(1, 1), 'surface') + const renderer = new SkiaRenderer(ck, surface) + + try { + const png = expectDefined( + renderNodesToImage(ck, renderer, graph, page.id, [frame.id], { + scale: 1, + format: 'PNG' + }), + 'line png' + ) + const image = expectDefined(ck.MakeImageFromEncoded(png), 'line image') + const pixels = expectDefined( + image.readPixels(0, 0, { + width: image.width(), + height: image.height(), + colorType: ck.ColorType.RGBA_8888, + alphaType: ck.AlphaType.Unpremul, + colorSpace: ck.ColorSpace.SRGB + }), + 'line pixels' + ) + const width = image.width() + const alphaAt = (x: number, y: number) => pixels[(y * width + x) * 4 + 3] + + expect(alphaAt(10, 36)).toBeGreaterThan(0) + expect(alphaAt(22, 36)).toBe(0) + + image.delete() + } finally { + surface.delete() + } + }) +})