From 42fcb37f4d0e51f124f77da37b2d9e5b7b5a47fc Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 27 May 2026 17:57:41 +0300 Subject: [PATCH] fix(canvas): render imported layout grids --- packages/core/src/canvas/layout-grids.ts | 120 ++++++++++++++++++ packages/core/src/canvas/scene.ts | 2 + .../engine/render/canvas/layout-grids.test.ts | 82 ++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 packages/core/src/canvas/layout-grids.ts create mode 100644 tests/engine/render/canvas/layout-grids.test.ts diff --git a/packages/core/src/canvas/layout-grids.ts b/packages/core/src/canvas/layout-grids.ts new file mode 100644 index 000000000..2082ab516 --- /dev/null +++ b/packages/core/src/canvas/layout-grids.ts @@ -0,0 +1,120 @@ +import type { Canvas } from 'canvaskit-wasm' + +import { SELECTION_COLOR } from '#core/constants' +import type { SceneNode } from '#core/scene-graph' +import type { Color } from '#core/types' + +import type { SkiaRenderer } from './renderer' + +interface RawLayoutGrid { + visible?: boolean + color?: Color + pattern?: string + axis?: string + type?: string + alignment?: string + numSections?: number + count?: number + offset?: number + sectionSize?: number + gutterSize?: number +} + +interface GridGeometry { + pattern: 'COLUMNS' | 'ROWS' | 'GRID' + alignment: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH' + count: number + offset: number + sectionSize: number + gutterSize: number + color: Color +} + +function rawLayoutGrids(node: SceneNode): RawLayoutGrid[] { + const grids = node.source.fig.rawNodeFields.layoutGrids + if (!Array.isArray(grids)) return [] + return grids.filter((grid): grid is RawLayoutGrid => grid !== null && typeof grid === 'object') +} + +function rawGridPattern(grid: RawLayoutGrid): GridGeometry['pattern'] { + if (grid.pattern === 'GRID') return 'GRID' + if (grid.pattern === 'ROWS') return 'ROWS' + if (grid.pattern === 'COLUMNS') return 'COLUMNS' + if (grid.axis === 'Y') return 'ROWS' + return 'COLUMNS' +} + +function rawGridAlignment(grid: RawLayoutGrid): GridGeometry['alignment'] { + const value = grid.alignment ?? grid.type + if (value === 'CENTER' || value === 'MAX' || value === 'STRETCH') return value + return 'MIN' +} + +function gridGeometry(grid: RawLayoutGrid): GridGeometry | null { + if (grid.visible === false) return null + const count = grid.count ?? grid.numSections ?? 1 + const sectionSize = grid.sectionSize ?? 0 + if (count <= 0 || sectionSize <= 0) return null + return { + pattern: rawGridPattern(grid), + alignment: rawGridAlignment(grid), + count, + offset: grid.offset ?? 0, + sectionSize, + gutterSize: grid.gutterSize ?? 0, + color: grid.color ?? { ...SELECTION_COLOR, a: 0.1 } + } +} + +function gridStart(nodeSize: number, grid: GridGeometry): number { + const span = grid.count * grid.sectionSize + Math.max(0, grid.count - 1) * grid.gutterSize + if (grid.alignment === 'CENTER') return (nodeSize - span) / 2 + grid.offset + if (grid.alignment === 'MAX') return nodeSize - span - grid.offset + return grid.offset +} + +function drawColumnGrid( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + grid: GridGeometry +): void { + const x0 = gridStart(node.width, grid) + for (let index = 0; index < grid.count; index++) { + const x = x0 + index * (grid.sectionSize + grid.gutterSize) + canvas.drawRect(r.ck.LTRBRect(x, 0, x + grid.sectionSize, node.height), r.auxFill) + } +} + +function drawRowGrid(r: SkiaRenderer, canvas: Canvas, node: SceneNode, grid: GridGeometry): void { + const y0 = gridStart(node.height, grid) + for (let index = 0; index < grid.count; index++) { + const y = y0 + index * (grid.sectionSize + grid.gutterSize) + canvas.drawRect(r.ck.LTRBRect(0, y, node.width, y + grid.sectionSize), r.auxFill) + } +} + +function drawSquareGrid( + r: SkiaRenderer, + canvas: Canvas, + node: SceneNode, + grid: GridGeometry +): void { + for (let x = grid.offset; x < node.width; x += grid.sectionSize) { + canvas.drawRect(r.ck.LTRBRect(x, 0, x + 1, node.height), r.auxFill) + } + for (let y = grid.offset; y < node.height; y += grid.sectionSize) { + canvas.drawRect(r.ck.LTRBRect(0, y, node.width, y + 1), r.auxFill) + } +} + +export function drawLayoutGrids(r: SkiaRenderer, canvas: Canvas, node: SceneNode): void { + for (const rawGrid of rawLayoutGrids(node)) { + const grid = gridGeometry(rawGrid) + if (!grid) continue + r.auxFill.setColor(r.ck.Color4f(grid.color.r, grid.color.g, grid.color.b, grid.color.a)) + if (grid.pattern === 'GRID') drawSquareGrid(r, canvas, node, grid) + else if (grid.pattern === 'ROWS') drawRowGrid(r, canvas, node, grid) + else drawColumnGrid(r, canvas, node, grid) + } +} diff --git a/packages/core/src/canvas/scene.ts b/packages/core/src/canvas/scene.ts index 056e6b57f..fecb0b810 100644 --- a/packages/core/src/canvas/scene.ts +++ b/packages/core/src/canvas/scene.ts @@ -9,6 +9,7 @@ import { vectorNetworkToCenterlinePath } from '#core/vector' import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend' import { renderBooleanOperation } from './boolean' +import { drawLayoutGrids } from './layout-grids' import { renderMaskedChildIds } from './masks' import type { SkiaRenderer, RenderOverlays } from './renderer' import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes' @@ -250,6 +251,7 @@ export function renderNode( applyNodeTransforms(r, canvas, node, nodeId, overlays) renderNodeContent(r, canvas, graph, node, nodeId, overlays) + drawLayoutGrids(r, canvas, node) renderChildren(r, canvas, graph, node, overlays, absX, absY) if (layerBlur) { diff --git a/tests/engine/render/canvas/layout-grids.test.ts b/tests/engine/render/canvas/layout-grids.test.ts new file mode 100644 index 000000000..c657b0b3f --- /dev/null +++ b/tests/engine/render/canvas/layout-grids.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test' + +import type { Canvas } from 'canvaskit-wasm' + +import { drawLayoutGrids } from '#core/canvas/layout-grids' +import type { SceneNode } from '#core/scene-graph' + +import { createMockCanvas, createMockRenderer, mockCalls } from './effects/helpers' + +function nodeWithLayoutGrids(layoutGrids: unknown[]): SceneNode { + return { + type: 'FRAME', + width: 240, + height: 160, + source: { + fig: { + rawNodeFields: { layoutGrids } + } + } + } as SceneNode +} + +describe('layout grid rendering', () => { + test('renders Figma plugin column grids', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node = nodeWithLayoutGrids([ + { + pattern: 'COLUMNS', + visible: true, + color: { r: 1, g: 0, b: 0, a: 0.1 }, + sectionSize: 48, + gutterSize: 16, + alignment: 'MIN', + count: 4, + offset: 16 + } + ]) + + drawLayoutGrids(r, canvas as Canvas, node) + + expect(mockCalls(canvas.drawRect)).toHaveLength(4) + expect(mockCalls(r.ck.LTRBRect)[0]).toEqual([16, 0, 64, 160]) + expect(mockCalls(r.ck.LTRBRect)[3]).toEqual([208, 0, 256, 160]) + }) + + test('renders Kiwi row grids', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node = nodeWithLayoutGrids([ + { + pattern: 'STRIPES', + axis: 'Y', + visible: true, + color: { r: 0, g: 0, b: 1, a: 0.2 }, + sectionSize: 20, + gutterSize: 10, + type: 'MIN', + numSections: 2, + offset: 5 + } + ]) + + drawLayoutGrids(r, canvas as Canvas, node) + + expect(mockCalls(canvas.drawRect)).toHaveLength(2) + expect(mockCalls(r.ck.LTRBRect)).toEqual([ + [0, 5, 240, 25], + [0, 35, 240, 55] + ]) + }) + + test('skips hidden grids', () => { + const r = createMockRenderer() + const canvas = createMockCanvas() + const node = nodeWithLayoutGrids([{ visible: false, sectionSize: 20, count: 2 }]) + + drawLayoutGrids(r, canvas as Canvas, node) + + expect(canvas.drawRect).not.toHaveBeenCalled() + }) +})