fix(canvas): render Figma blend modes

This commit is contained in:
Danila Poyarkov 2026-05-22 20:59:53 +03:00
parent da65cb6dc7
commit 8e1d5d40e7
5 changed files with 210 additions and 6 deletions

View file

@ -13,6 +13,7 @@
- Fix accidental duplicate creation when Alt-clicking without dragging.
- 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.
### Performance

View file

@ -0,0 +1,44 @@
import type { BlendMode as SkBlendMode, CanvasKit } from 'canvaskit-wasm'
import type { BlendMode } from '#core/scene-graph'
export function figmaBlendModeToSkia(ck: CanvasKit, mode?: BlendMode): SkBlendMode {
switch (mode) {
case 'DARKEN':
return ck.BlendMode.Darken
case 'MULTIPLY':
return ck.BlendMode.Multiply
case 'COLOR_BURN':
return ck.BlendMode.ColorBurn
case 'LIGHTEN':
return ck.BlendMode.Lighten
case 'SCREEN':
return ck.BlendMode.Screen
case 'COLOR_DODGE':
return ck.BlendMode.ColorDodge
case 'OVERLAY':
return ck.BlendMode.Overlay
case 'SOFT_LIGHT':
return ck.BlendMode.SoftLight
case 'HARD_LIGHT':
return ck.BlendMode.HardLight
case 'DIFFERENCE':
return ck.BlendMode.Difference
case 'EXCLUSION':
return ck.BlendMode.Exclusion
case 'HUE':
return ck.BlendMode.Hue
case 'SATURATION':
return ck.BlendMode.Saturation
case 'COLOR':
return ck.BlendMode.Color
case 'LUMINOSITY':
return ck.BlendMode.Luminosity
default:
return ck.BlendMode.SrcOver
}
}
export function needsIsolatedBlendLayer(mode?: BlendMode): boolean {
return mode !== undefined && mode !== 'NORMAL' && mode !== 'PASS_THROUGH'
}

View file

@ -5,6 +5,7 @@ import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
import type { Color } from '#core/types'
import { vectorNetworkToCenterlinePath } from '#core/vector'
import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend'
import { renderBooleanOperation } from './boolean'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import { nodeHasRadius } from './shapes'
@ -27,11 +28,12 @@ function drawVisibleFills(
if (!fill.visible) continue
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
r.fillPaint.setBlendMode(figmaBlendModeToSkia(r.ck, fill.blendMode))
draw(fill)
r.fillPaint.setShader(null)
r.fillPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
}
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
const canCull =
node.childIds.length === 0 ||
@ -75,7 +77,6 @@ function applyNodeTransforms(
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
}
}
function renderNodeContent(
r: SkiaRenderer,
canvas: Canvas,
@ -134,7 +135,6 @@ function renderChildren(
}
}
}
export function renderNode(
r: SkiaRenderer,
canvas: Canvas,
@ -163,8 +163,10 @@ export function renderNode(
canvas.save()
canvas.translate(node.x, node.y)
if (node.opacity < 1) {
const needsNodeLayer = node.opacity < 1 || needsIsolatedBlendLayer(node.blendMode)
if (needsNodeLayer) {
r.opacityPaint.setAlphaf(node.opacity)
r.opacityPaint.setBlendMode(figmaBlendModeToSkia(r.ck, node.blendMode))
canvas.saveLayer(r.opacityPaint)
}
@ -192,8 +194,10 @@ export function renderNode(
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
if (node.opacity < 1) {
if (needsNodeLayer) {
canvas.restore()
r.opacityPaint.setAlphaf(1)
r.opacityPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
canvas.restore()
}

View file

@ -131,7 +131,7 @@ Figma's design documentation groups features into these areas:
| Image fills | ✅ | ✅ | ◐ | ✅ | ✅ | Fill/fit/crop/tile support exists; exact Figma image transform parity is partial. |
| Pattern fills/strokes | — | — | — | — | — | Figma pattern fills are not currently modeled. |
| Video/GIF/media fills | — | — | — | — | — | No video playback or media layer support. |
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Parsed/exported, SVG export maps some modes, but Canvas rendering does not fully apply node/fill/effect blend modes. |
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node and fill blend modes; effect blend modes and Figma isolation edge cases are still partial. |
| Opacity | ✅ | ✅ | ✅ | ✅ | ✅ | Node opacity uses save layers in the renderer. |
| Strokes | ✅ | ✅ | ✅ | ✅ | ✅ | Weight, alignment, dashes, and side weights are supported. |
| Stroke caps / joins / miter limit | ✅ | ✅ | ◐ | ✅ | ✅ | Renderer/export support exists; inspector controls are limited. |

View file

@ -0,0 +1,155 @@
import { describe, expect, mock, test } from 'bun:test'
import type { Canvas } from 'canvaskit-wasm'
import { figmaBlendModeToSkia } from '#core/canvas/blend'
import type { SkiaRenderer } from '#core/canvas/renderer'
import { renderNode, renderSection } from '#core/canvas/scene'
import { SceneGraph } from '#core/scene-graph'
import type { BlendMode, Fill, SceneNode } from '#core/scene-graph'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
const blendModes = {
SrcOver: 'SrcOver',
Darken: 'Darken',
Multiply: 'Multiply',
ColorBurn: 'ColorBurn',
Lighten: 'Lighten',
Screen: 'Screen',
ColorDodge: 'ColorDodge',
Overlay: 'Overlay',
SoftLight: 'SoftLight',
HardLight: 'HardLight',
Difference: 'Difference',
Exclusion: 'Exclusion',
Hue: 'Hue',
Saturation: 'Saturation',
Color: 'Color',
Luminosity: 'Luminosity'
}
function createCanvas() {
return {
save: mock(() => undefined),
restore: mock(() => undefined),
translate: mock(() => undefined),
saveLayer: mock(() => undefined),
drawRRect: mock(() => undefined)
}
}
function createRenderer(overrides: Partial<SkiaRenderer> = {}) {
return {
_nodeCount: 0,
_culledCount: 0,
worldViewport: { x: -100, y: -100, w: 1000, h: 1000 },
ck: {
BlendMode: blendModes,
LTRBRect: mock(() => new Float32Array(4)),
RRectXY: mock(() => new Float32Array(12))
},
opacityPaint: {
setAlphaf: mock(() => undefined),
setBlendMode: mock(() => undefined)
},
effectLayerPaint: {
setImageFilter: mock(() => undefined),
setColorFilter: mock(() => undefined),
setBlendMode: mock(() => undefined)
},
fillPaint: {
setAlphaf: mock(() => undefined),
setBlendMode: mock(() => undefined),
setShader: mock(() => undefined)
},
getCachedBlur: mock(() => null),
applyFill: mock(() => true),
makeRRect: mock(() => new Float32Array(12)),
renderShape: mock(() => undefined),
renderSection: mock(() => undefined),
renderComponentSet: mock(() => undefined),
renderNode(canvas, graph, nodeId, overlays, parentAbsX, parentAbsY) {
renderNode(this as SkiaRenderer, canvas, graph, nodeId, overlays, parentAbsX, parentAbsY)
},
...overrides
} as SkiaRenderer
}
function calls(fn: ReturnType<typeof mock>): unknown[][] {
return (fn as { mock: { calls: unknown[][] } }).mock.calls
}
describe('canvas blend modes', () => {
test('maps Figma blend modes to CanvasKit blend modes', () => {
const ck = { BlendMode: blendModes } as SkiaRenderer['ck']
const cases: Array<[BlendMode, string]> = [
['NORMAL', 'SrcOver'],
['PASS_THROUGH', 'SrcOver'],
['DARKEN', 'Darken'],
['MULTIPLY', 'Multiply'],
['COLOR_BURN', 'ColorBurn'],
['LIGHTEN', 'Lighten'],
['SCREEN', 'Screen'],
['COLOR_DODGE', 'ColorDodge'],
['OVERLAY', 'Overlay'],
['SOFT_LIGHT', 'SoftLight'],
['HARD_LIGHT', 'HardLight'],
['DIFFERENCE', 'Difference'],
['EXCLUSION', 'Exclusion'],
['HUE', 'Hue'],
['SATURATION', 'Saturation'],
['COLOR', 'Color'],
['LUMINOSITY', 'Luminosity']
]
for (const [figmaMode, skiaMode] of cases) {
expect(figmaBlendModeToSkia(ck, figmaMode)).toBe(skiaMode)
}
})
test('isolates node content when node blend mode needs compositing', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), {
width: 100,
height: 100,
blendMode: 'MULTIPLY'
})
const renderer = createRenderer()
const canvas = createCanvas()
renderNode(renderer, canvas as Canvas, graph, node.id, {})
expect(renderer.opacityPaint.setAlphaf).toHaveBeenCalledWith(1)
expect(renderer.opacityPaint.setBlendMode).toHaveBeenCalledWith('Multiply')
expect(canvas.saveLayer).toHaveBeenCalledWith(renderer.opacityPaint)
expect(renderer.renderShape).toHaveBeenCalled()
expect(renderer.opacityPaint.setBlendMode).toHaveBeenLastCalledWith('SrcOver')
})
test('applies fill blend modes per fill and resets shared paint', () => {
const graph = new SceneGraph()
const fill: Fill = {
type: 'SOLID',
color: { r: 1, g: 0, b: 0, a: 1 },
opacity: 1,
visible: true,
blendMode: 'SCREEN'
}
const node = graph.createNode('SECTION', pageId(graph), {
width: 100,
height: 100,
fills: [fill]
}) as Extract<SceneNode, { type: 'SECTION' }>
const renderer = createRenderer()
const canvas = createCanvas()
renderSection(renderer, canvas as Canvas, node, graph)
expect(calls(renderer.fillPaint.setBlendMode as ReturnType<typeof mock>)).toEqual([
['Screen'],
['SrcOver']
])
})
})