fix(canvas): render Figma mask stacks

This commit is contained in:
Danila Poyarkov 2026-05-22 21:05:35 +03:00
parent 8e1d5d40e7
commit 0ce57ea0fa
5 changed files with 191 additions and 8 deletions

View file

@ -14,6 +14,7 @@
- Fix MCP startup in the browser.
- Fix CanvasKit loading outside the browser when project paths contain spaces.
- Render imported Figma layer and fill blend modes such as multiply, screen, overlay, difference, hue, saturation, color, and luminosity.
- Render common imported Figma mask stacks so visible layers above a mask are clipped by the mask shape.
### Performance

View file

@ -0,0 +1,47 @@
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
function resetMaskPaint(r: SkiaRenderer): void {
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
export function renderMaskedChildIds(
r: SkiaRenderer,
canvas: Canvas,
childIds: string[],
isVisibleMask: (childId: string) => boolean,
renderChild: (childId: string) => void,
renderMask: (childId: string) => void
): void {
for (let index = 0; index < childIds.length; index++) {
const childId = childIds[index]
if (!isVisibleMask(childId)) {
renderChild(childId)
continue
}
const start = index + 1
let end = start
while (end < childIds.length && !isVisibleMask(childIds[end])) end++
if (start === end) continue
resetMaskPaint(r)
canvas.save()
canvas.saveLayer(r.effectLayerPaint)
for (let maskedIndex = start; maskedIndex < end; maskedIndex++) renderChild(childIds[maskedIndex])
resetMaskPaint(r)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.DstIn)
canvas.saveLayer(r.effectLayerPaint)
renderMask(childId)
canvas.restore()
canvas.restore()
canvas.restore()
resetMaskPaint(r)
index = end - 1
}
}

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines -- scene dispatch stays together while shape domains live in sibling modules */
import type { Canvas, Path } from 'canvaskit-wasm'
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants'
@ -7,6 +8,7 @@ import { vectorNetworkToCenterlinePath } from '#core/vector'
import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend'
import { renderBooleanOperation } from './boolean'
import { renderMaskedChildIds } from './masks'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import { nodeHasRadius } from './shapes'
import {
@ -106,6 +108,46 @@ function renderNodeContent(
}
}
function renderMaskNodeContent(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
nodeId: string,
overlays: RenderOverlays
): void {
canvas.save()
canvas.translate(node.x, node.y)
applyNodeTransforms(r, canvas, node, nodeId, overlays)
renderNodeContent(r, canvas, graph, node, nodeId, {})
canvas.restore()
}
function renderChildIds(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
childIds: string[],
overlays: RenderOverlays,
absX: number,
absY: number
): void {
renderMaskedChildIds(
r,
canvas,
childIds,
(childId) => {
const child = graph.getNode(childId)
return !!child?.visible && child.isMask
},
(childId) => r.renderNode(canvas, graph, childId, overlays, absX, absY),
(childId) => {
const child = graph.getNode(childId)
if (child) renderMaskNodeContent(r, canvas, graph, child, childId, overlays)
}
)
}
function renderChildren(
r: SkiaRenderer,
canvas: Canvas,
@ -125,14 +167,10 @@ function renderChildren(
} else {
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true)
}
for (const childId of node.childIds) {
r.renderNode(canvas, graph, childId, overlays, absX, absY)
}
renderChildIds(r, canvas, graph, node.childIds, overlays, absX, absY)
canvas.restore()
} else {
for (const childId of node.childIds) {
r.renderNode(canvas, graph, childId, overlays, absX, absY)
}
renderChildIds(r, canvas, graph, node.childIds, overlays, absX, absY)
}
}
export function renderNode(
@ -145,7 +183,7 @@ export function renderNode(
parentAbsY = 0
): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
if (!node || !node.visible || node.isMask) return
// Hide the node being edited in node-edit mode (overlay draws it live)
if (overlays.nodeEditState?.nodeId === nodeId) return

View file

@ -139,7 +139,7 @@ Figma's design documentation groups features into these areas:
| Effect styles | ↩ | — | — | ↩ | — | Style IDs round-trip; no style manager. |
| Corner radius | ✅ | ✅ | ✅ | ✅ | ✅ | Uniform and independent radii supported. |
| Corner smoothing | ✅ | — | — | ✅ | ✅ | Stored/exported but rendered as ordinary rounded rectangles. |
| Masks | ✅ | ◐ | — | ✅ | ✅ | `isMask`/`maskType` exist; true Figma mask stack/type semantics are incomplete. |
| Masks | ✅ | ◐ | — | ✅ | ✅ | Common sibling mask stacks render; luminance masks, UI controls, and edge-case Figma semantics remain incomplete. |
| Auto layout: vertical/horizontal | ✅ | ✅ | ✅ | ✅ | ✅ | Yoga-backed layout. |
| Auto layout: wrap | ✅ | ✅ | ✅ | ✅ | ✅ | UI toggle exists. |
| Auto layout: grid | ✅ | ◐ | ◐ | ✅ | ✅ | CSS-grid-like support is partial. |

View file

@ -0,0 +1,97 @@
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderNode } from '#core/canvas/scene'
import { SceneGraph } from '#core/scene-graph'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function createCanvas() {
return {
save: mock(() => undefined),
restore: mock(() => undefined),
translate: mock(() => undefined),
rotate: mock(() => undefined),
scale: mock(() => undefined),
saveLayer: mock(() => undefined),
clipRect: mock(() => undefined),
clipRRect: mock(() => undefined)
}
}
function createRenderer() {
const rendered: string[] = []
const renderer = {
_nodeCount: 0,
_culledCount: 0,
worldViewport: { x: -100, y: -100, w: 1000, h: 1000 },
ck: {
BlendMode: { SrcOver: 'SrcOver', DstIn: 'DstIn' },
LTRBRect: mock(() => new Float32Array(4)),
ClipOp: { Intersect: 'Intersect' }
},
opacityPaint: {
setAlphaf: mock(() => undefined),
setBlendMode: mock(() => undefined)
},
effectLayerPaint: {
setImageFilter: mock(() => undefined),
setColorFilter: mock(() => undefined),
setBlendMode: mock(() => undefined)
},
getCachedBlur: mock(() => null),
renderShape: mock((_canvas: Canvas, node) => {
rendered.push(node.id)
}),
renderSection: mock((_canvas: Canvas, node) => {
rendered.push(node.id)
}),
renderComponentSet: mock((_canvas: Canvas, node) => {
rendered.push(node.id)
}),
renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) {
renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
}
}
return { renderer: renderer as SkiaRenderer, rendered }
}
describe('canvas masks', () => {
test('uses a visible mask node to clip following siblings', () => {
const graph = new SceneGraph()
const frame = graph.createNode('FRAME', pageId(graph), { width: 200, height: 200 })
const below = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 })
const mask = graph.createNode('ELLIPSE', frame.id, {
width: 100,
height: 100,
isMask: true
})
const clipped = graph.createNode('RECTANGLE', frame.id, { width: 200, height: 200 })
const { renderer, rendered } = createRenderer()
const canvas = createCanvas()
renderNode(renderer, canvas as Canvas, graph, frame.id, {})
expect(rendered).toEqual([frame.id, below.id, clipped.id, mask.id])
expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenCalledWith('DstIn')
expect(renderer.effectLayerPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver')
expect(canvas.saveLayer).toHaveBeenCalledTimes(2)
})
test('does not draw mask nodes as ordinary layers', () => {
const graph = new SceneGraph()
const mask = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
isMask: true
})
const { renderer, rendered } = createRenderer()
renderNode(renderer, createCanvas() as Canvas, graph, mask.id, {})
expect(rendered).toEqual([])
})
})