fix(canvas): render imported layout grids

This commit is contained in:
Danila Poyarkov 2026-05-27 17:57:41 +03:00
parent 711106518e
commit 42fcb37f4d
3 changed files with 204 additions and 0 deletions

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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()
})
})