fix(canvas): render smoothed corners and effect blend modes
This commit is contained in:
parent
ed8c62cefc
commit
76ee122dee
|
|
@ -15,6 +15,7 @@
|
|||
- 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.
|
||||
- Render smoothed rectangle corners and effect blend modes from imported Figma files.
|
||||
|
||||
### Performance
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Canvas, Paint } from 'canvaskit-wasm'
|
|||
import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
|
||||
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
|
||||
|
||||
export function drawNodeFill(
|
||||
r: SkiaRenderer,
|
||||
|
|
@ -50,7 +51,11 @@ export function drawNodeFill(
|
|||
break
|
||||
}
|
||||
default:
|
||||
if (hasRadius) {
|
||||
if (nodeHasSmoothCorners(node)) {
|
||||
const path = makeSmoothRRectPath(r, node)
|
||||
canvas.drawPath(path, r.fillPaint)
|
||||
path.delete()
|
||||
} else if (hasRadius) {
|
||||
canvas.drawRRect(r.makeRRect(node), r.fillPaint)
|
||||
} else {
|
||||
canvas.drawRect(rect, r.fillPaint)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { figmaBlendModeToSkia, needsIsolatedBlendLayer } from './blend'
|
|||
import { renderBooleanOperation } from './boolean'
|
||||
import { renderMaskedChildIds } from './masks'
|
||||
import type { SkiaRenderer, RenderOverlays } from './renderer'
|
||||
import { nodeHasRadius } from './shapes'
|
||||
import { makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes'
|
||||
import {
|
||||
drawDashedRRectWithSolidCorners,
|
||||
drawStyledRRectStroke,
|
||||
|
|
@ -162,7 +162,11 @@ function renderChildren(
|
|||
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
|
||||
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
|
||||
canvas.save()
|
||||
if (nodeHasRadius(node)) {
|
||||
if (nodeHasSmoothCorners(node)) {
|
||||
const clipPath = makeSmoothRRectPath(r, node)
|
||||
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
|
||||
clipPath.delete()
|
||||
} else if (nodeHasRadius(node)) {
|
||||
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
||||
} else {
|
||||
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, true)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import type { Canvas, Path } from 'canvaskit-wasm'
|
|||
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
import { figmaBlendModeToSkia } from './blend'
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
import { makeNodeShapePath, nodeHasRadius } from './shapes'
|
||||
import { makeNodeShapePath, makeSmoothRRectPath, nodeHasRadius, nodeHasSmoothCorners } from './shapes'
|
||||
|
||||
function resetEffectLayerPaint(r: SkiaRenderer): void {
|
||||
r.effectLayerPaint.setImageFilter(null)
|
||||
|
|
@ -108,6 +109,10 @@ function drawShadowCutout(
|
|||
canvas.drawOval(r.ltrb(0, 0, shapeNode.width, shapeNode.height), r.auxFill)
|
||||
} else if (isPathShape(shapeNode)) {
|
||||
drawPathShape(r, canvas, shapeNode, shapeHasRadius)
|
||||
} else if (nodeHasSmoothCorners(shapeNode)) {
|
||||
const path = makeSmoothRRectPath(r, shapeNode)
|
||||
canvas.drawPath(path, r.auxFill)
|
||||
path.delete()
|
||||
} else if (shapeHasRadius) {
|
||||
canvas.drawRRect(r.makeRRect(shapeNode), r.auxFill)
|
||||
} else {
|
||||
|
|
@ -143,6 +148,7 @@ function drawShapeDropShadow(
|
|||
r.auxFill.setColor(r.color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a))
|
||||
r.auxFill.setMaskFilter(r.getCachedMaskBlur(effect.radius / 2))
|
||||
r.auxFill.setImageFilter(null)
|
||||
r.auxFill.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
|
||||
canvas.save()
|
||||
let savedLayer = false
|
||||
try {
|
||||
|
|
@ -163,6 +169,10 @@ function drawShapeDropShadow(
|
|||
canvas.drawOval(r.ltrb(-sp, -sp, shapeNode.width + sp, shapeNode.height + sp), r.auxFill)
|
||||
} else if (isPathShape(shapeNode)) {
|
||||
drawPathShape(r, canvas, shapeNode, shapeHasRadius, sp)
|
||||
} else if (nodeHasSmoothCorners(shapeNode)) {
|
||||
const path = makeSmoothRRectPath(r, shapeNode, sp)
|
||||
canvas.drawPath(path, r.auxFill)
|
||||
path.delete()
|
||||
} else if (shapeHasRadius) {
|
||||
canvas.drawRRect(r.makeRRectWithSpread(shapeNode, sp), r.auxFill)
|
||||
} else {
|
||||
|
|
@ -206,6 +216,7 @@ function renderDropShadow(
|
|||
if (shadowShapeChild) drawChildTransform(canvas, shadowShapeChild, effect.offset)
|
||||
else canvas.translate(effect.offset.x, effect.offset.y)
|
||||
|
||||
r.effectLayerPaint.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
|
||||
r.effectLayerPaint.setImageFilter(dropFilter)
|
||||
canvas.saveLayer(r.effectLayerPaint)
|
||||
savedLayer = true
|
||||
|
|
@ -303,6 +314,7 @@ function drawShapeInnerShadow(
|
|||
const shapeNode = shadowShapeChild ?? node
|
||||
r.auxFill.setColor(r.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a))
|
||||
r.auxFill.setImageFilter(r.getCachedDecalBlur(effect.radius / 2))
|
||||
r.auxFill.setBlendMode(figmaBlendModeToSkia(r.ck, effect.blendMode))
|
||||
|
||||
const shapeRect = shadowShapeChild ? r.ck.LTRBRect(0, 0, shapeNode.width, shapeNode.height) : rect
|
||||
const shapeHasRadius = shadowShapeChild ? nodeHasRadius(shadowShapeChild) : hasRadius
|
||||
|
|
@ -326,6 +338,10 @@ function drawShapeInnerShadow(
|
|||
} finally {
|
||||
path.delete()
|
||||
}
|
||||
} else if (nodeHasSmoothCorners(shapeNode)) {
|
||||
const path = makeSmoothRRectPath(r, shapeNode)
|
||||
canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
|
||||
path.delete()
|
||||
} else if (shapeHasRadius) {
|
||||
canvas.clipRRect(r.makeRRect(shapeNode), r.ck.ClipOp.Intersect, true)
|
||||
} else {
|
||||
|
|
@ -368,6 +384,13 @@ function drawShapeInnerShadow(
|
|||
} finally {
|
||||
innerPath.delete()
|
||||
}
|
||||
} else if (nodeHasSmoothCorners(shapeNode)) {
|
||||
const innerPath = makeSmoothRRectPath(r, shapeNode, -sp, localOffsetX, localOffsetY)
|
||||
try {
|
||||
bigPath.op(innerPath, r.ck.PathOp.Difference)
|
||||
} finally {
|
||||
innerPath.delete()
|
||||
}
|
||||
} else if (shapeHasRadius) {
|
||||
const innerPath = new r.ck.Path()
|
||||
try {
|
||||
|
|
@ -399,6 +422,7 @@ function drawShapeInnerShadow(
|
|||
} finally {
|
||||
canvas.restore()
|
||||
r.auxFill.setImageFilter(null)
|
||||
r.auxFill.setBlendMode(r.ck.BlendMode.SrcOver)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,61 @@ export function nodeHasRadius(node: SceneNode): boolean {
|
|||
)
|
||||
}
|
||||
|
||||
export function nodeHasSmoothCorners(node: SceneNode): boolean {
|
||||
return !node.independentCorners && node.cornerRadius > 0 && node.cornerSmoothing > 0
|
||||
}
|
||||
|
||||
function clampCornerRadius(width: number, height: number, radius: number): number {
|
||||
return Math.max(0, Math.min(radius, width / 2, height / 2))
|
||||
}
|
||||
|
||||
export function makeSmoothRRectPath(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
spread = 0,
|
||||
offsetX = 0,
|
||||
offsetY = 0
|
||||
): Path {
|
||||
const path = new r.ck.Path()
|
||||
const left = offsetX - spread
|
||||
const top = offsetY - spread
|
||||
const right = offsetX + node.width + spread
|
||||
const bottom = offsetY + node.height + spread
|
||||
const radius = clampCornerRadius(right - left, bottom - top, node.cornerRadius + spread)
|
||||
|
||||
if (radius === 0) {
|
||||
path.addRect(r.ck.LTRBRect(left, top, right, bottom))
|
||||
return path
|
||||
}
|
||||
|
||||
const smoothing = Math.max(0, Math.min(node.cornerSmoothing, 1))
|
||||
const exponent = 2 + smoothing * 3
|
||||
const samples = 12
|
||||
const addCorner = (cx: number, cy: number, startAngle: number, endAngle: number) => {
|
||||
for (let i = 1; i <= samples; i++) {
|
||||
const t = i / samples
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
const x = cx + Math.sign(cos) * radius * Math.abs(cos) ** (2 / exponent)
|
||||
const y = cy + Math.sign(sin) * radius * Math.abs(sin) ** (2 / exponent)
|
||||
path.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
path.moveTo(left + radius, top)
|
||||
path.lineTo(right - radius, top)
|
||||
addCorner(right - radius, top + radius, -Math.PI / 2, 0)
|
||||
path.lineTo(right, bottom - radius)
|
||||
addCorner(right - radius, bottom - radius, 0, Math.PI / 2)
|
||||
path.lineTo(left + radius, bottom)
|
||||
addCorner(left + radius, bottom - radius, Math.PI / 2, Math.PI)
|
||||
path.lineTo(left, top + radius)
|
||||
addCorner(left + radius, top + radius, Math.PI, (Math.PI * 3) / 2)
|
||||
path.close()
|
||||
return path
|
||||
}
|
||||
|
||||
export function makeNodeShapePath(
|
||||
r: SkiaRenderer,
|
||||
node: SceneNode,
|
||||
|
|
@ -43,7 +98,11 @@ export function makeNodeShapePath(
|
|||
break
|
||||
}
|
||||
default:
|
||||
if (hasRadius) {
|
||||
if (nodeHasSmoothCorners(node)) {
|
||||
const smoothPath = makeSmoothRRectPath(r, node)
|
||||
path.addPath(smoothPath)
|
||||
smoothPath.delete()
|
||||
} else if (hasRadius) {
|
||||
path.addRRect(r.makeRRect(node))
|
||||
} else {
|
||||
path.addRect(rect)
|
||||
|
|
@ -157,6 +216,10 @@ export function clipNodeShape(
|
|||
clipPath.addOval(rect)
|
||||
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
|
||||
clipPath.delete()
|
||||
} else if (nodeHasSmoothCorners(node)) {
|
||||
const clipPath = makeSmoothRRectPath(r, node)
|
||||
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
|
||||
clipPath.delete()
|
||||
} else if (hasRadius) {
|
||||
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { SceneNode, Stroke } from '#core/scene-graph'
|
|||
import type { Color } from '#core/types'
|
||||
|
||||
import type { SkiaRenderer } from './renderer'
|
||||
import { makeSmoothRRectPath, nodeHasSmoothCorners } from './shapes'
|
||||
|
||||
export function getStrokeCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
|
||||
switch (cap) {
|
||||
|
|
@ -148,7 +149,11 @@ export function drawNodeStroke(
|
|||
break
|
||||
}
|
||||
default:
|
||||
if (hasRadius) {
|
||||
if (nodeHasSmoothCorners(node)) {
|
||||
const path = makeSmoothRRectPath(r, node)
|
||||
canvas.drawPath(path, r.strokePaint)
|
||||
path.delete()
|
||||
} else if (hasRadius) {
|
||||
canvas.drawRRect(r.makeRRect(node), r.strokePaint)
|
||||
} else {
|
||||
canvas.drawRect(rect, r.strokePaint)
|
||||
|
|
@ -199,6 +204,11 @@ export function drawRRectStrokeWithAlign(
|
|||
node: SceneNode,
|
||||
stroke: Stroke
|
||||
): void {
|
||||
if (nodeHasSmoothCorners(node)) {
|
||||
drawStrokeWithAlign(r, canvas, node, r.ck.LTRBRect(0, 0, node.width, node.height), true, stroke.align)
|
||||
return
|
||||
}
|
||||
|
||||
if (stroke.align === 'INSIDE') {
|
||||
canvas.save()
|
||||
canvas.clipRRect(rrect, r.ck.ClipOp.Intersect, true)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ Figma's design documentation groups features into these areas:
|
|||
| Frames | ✅ | ✅ | ✅ | ✅ | ✅ | Includes clipping and auto-layout container behavior. |
|
||||
| Groups | ✅ | ✅ | ✅ | ✅ | ✅ | Grouping preserves visual positions. |
|
||||
| Sections | ✅ | ✅ | ✅ | ✅ | ✅ | Section rendering and title pills are OpenPencil-specific approximations. |
|
||||
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii supported; corner smoothing is not rendered. |
|
||||
| Rectangles / rounded rectangles | ✅ | ✅ | ✅ | ✅ | ✅ | Per-corner radii and smoothed uniform corners render; independent-corner smoothing remains approximate. |
|
||||
| Ellipses / arcs | ✅ | ✅ | ◐ | ✅ | ✅ | `arcData` renders/exports; no full inspector controls. |
|
||||
| Lines | ✅ | ✅ | ✅ | ✅ | ✅ | Stroke caps/joins render but are not fully exposed in UI. |
|
||||
| Polygons / stars | ✅ | ✅ | ◐ | ✅ | ✅ | `pointCount` and `starInnerRadius` modeled. |
|
||||
|
|
@ -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 | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node and fill blend modes; effect blend modes and Figma isolation edge cases are still partial. |
|
||||
| Layer/fill/effect blend modes | ✅ | ◐ | — | ✅ | ✅ | Canvas applies node, fill, and common shadow effect blend modes; Figma isolation edge cases remain 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. |
|
||||
|
|
@ -203,9 +203,9 @@ OpenPencil deliberately preserves many Figma/Kiwi fields even when they are not
|
|||
|
||||
These are parsed or visible in Figma docs and most likely to cause visible differences in real design files:
|
||||
|
||||
1. **Blend modes** — node, fill, and effect blend modes should be applied in CanvasKit rendering, not just stored/exported.
|
||||
2. **Masks** — implement Figma mask stacks and `maskType` behavior beyond frame clipping.
|
||||
3. **Corner smoothing** — render Figma's smooth/squircle corners instead of ordinary rounded rectangles.
|
||||
1. **Masks** — extend common sibling mask stacks with luminance masks and more exact `maskType` behavior.
|
||||
2. **Corner smoothing** — improve the current smoothed uniform-corner approximation for independent corners and stroke edge cases.
|
||||
3. **Pattern fills** — render imported Figma pattern/image tiling semantics beyond current image scale modes.
|
||||
4. **Pattern fills/strokes** — support Figma pattern fills and their transforms.
|
||||
5. **Font variations** — apply variable-font axes from imported Figma metadata.
|
||||
6. **Boolean operation import** — keep Figma `BOOLEAN_OPERATION` nodes as boolean operations where possible instead of importing them as vectors.
|
||||
|
|
|
|||
90
tests/engine/render/canvas/corner-smoothing.test.ts
Normal file
90
tests/engine/render/canvas/corner-smoothing.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import type { Canvas } from 'canvaskit-wasm'
|
||||
|
||||
import { drawNodeFill } from '#core/canvas/fills'
|
||||
import type { SkiaRenderer } from '#core/canvas/renderer'
|
||||
import { makeSmoothRRectPath } from '#core/canvas/shapes'
|
||||
import { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
function pageId(graph: SceneGraph) {
|
||||
return graph.getPages()[0].id
|
||||
}
|
||||
|
||||
function createRenderer() {
|
||||
const paths: Array<{
|
||||
addRect: ReturnType<typeof mock>
|
||||
moveTo: ReturnType<typeof mock>
|
||||
lineTo: ReturnType<typeof mock>
|
||||
close: ReturnType<typeof mock>
|
||||
delete: ReturnType<typeof mock>
|
||||
}> = []
|
||||
|
||||
class MockPath {
|
||||
addRect = mock(() => undefined)
|
||||
moveTo = mock(() => undefined)
|
||||
lineTo = mock(() => undefined)
|
||||
close = mock(() => undefined)
|
||||
delete = mock(() => undefined)
|
||||
|
||||
constructor() {
|
||||
paths.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
const renderer = {
|
||||
ck: {
|
||||
Path: MockPath,
|
||||
LTRBRect: mock((l, t, r, b) => new Float32Array([l, t, r, b]))
|
||||
},
|
||||
fillPaint: {}
|
||||
} as SkiaRenderer
|
||||
|
||||
return { renderer, paths }
|
||||
}
|
||||
|
||||
function createCanvas() {
|
||||
return {
|
||||
drawPath: mock(() => undefined),
|
||||
drawRRect: mock(() => undefined),
|
||||
drawRect: mock(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
describe('canvas corner smoothing', () => {
|
||||
test('builds a superellipse-style path for smoothed rectangular corners', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 120,
|
||||
height: 80,
|
||||
cornerRadius: 24,
|
||||
cornerSmoothing: 0.75
|
||||
})
|
||||
const { renderer, paths } = createRenderer()
|
||||
|
||||
makeSmoothRRectPath(renderer, node).delete()
|
||||
|
||||
expect(paths).toHaveLength(1)
|
||||
expect(paths[0].moveTo).toHaveBeenCalledWith(24, 0)
|
||||
expect(paths[0].lineTo).toHaveBeenCalledWith(96, 0)
|
||||
expect(paths[0].lineTo).toHaveBeenCalledTimes(52)
|
||||
expect(paths[0].close).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('draws smoothed rectangle fills as paths instead of regular rrects', () => {
|
||||
const graph = new SceneGraph()
|
||||
const node = graph.createNode('RECTANGLE', pageId(graph), {
|
||||
width: 120,
|
||||
height: 80,
|
||||
cornerRadius: 24,
|
||||
cornerSmoothing: 0.75
|
||||
})
|
||||
const { renderer } = createRenderer()
|
||||
const canvas = createCanvas()
|
||||
|
||||
drawNodeFill(renderer, canvas as Canvas, node, new Float32Array([0, 0, 120, 80]), true)
|
||||
|
||||
expect(canvas.drawPath).toHaveBeenCalled()
|
||||
expect(canvas.drawRRect).not.toHaveBeenCalled()
|
||||
expect(canvas.drawRect).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -32,7 +32,7 @@ export function createMockRenderer(overrides: Partial<SkiaRenderer> = {}): SkiaR
|
|||
PathOp: { Difference: 0, Union: 1 },
|
||||
StrokeJoin: { Round: 0 },
|
||||
Matrix: { translated: mock(() => new Float32Array(9)) },
|
||||
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2 },
|
||||
BlendMode: { SrcOver: 0, SrcIn: 1, DstOut: 2, Screen: 3, Multiply: 4 },
|
||||
ColorType: { RGBA_8888: 0 },
|
||||
AlphaType: { Premul: 0, Unpremul: 1 },
|
||||
ColorSpace: { SRGB: 0 },
|
||||
|
|
|
|||
|
|
@ -72,6 +72,35 @@ describe('Renderer handles all effect types (Behavioral)', () => {
|
|||
expect(r.auxFill.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.DstOut)
|
||||
})
|
||||
|
||||
test('applies effect blend modes to rendered shadow paint', () => {
|
||||
const r = createMockRenderer()
|
||||
const canvas = createMockCanvas()
|
||||
const node: Partial<SceneNode> = {
|
||||
type: 'RECTANGLE',
|
||||
width: 100,
|
||||
height: 100,
|
||||
fills: [],
|
||||
childIds: [],
|
||||
strokeGeometry: [],
|
||||
effects: [
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
visible: true,
|
||||
color: { r: 0, g: 0, b: 0, a: 0.5 },
|
||||
offset: { x: 5, y: 5 },
|
||||
radius: 10,
|
||||
spread: 0,
|
||||
blendMode: 'SCREEN'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
renderEffects(r, canvas as Canvas, node as SceneNode, new Float32Array(4), false, 'behind')
|
||||
|
||||
expect(r.auxFill.setBlendMode).toHaveBeenCalledWith(r.ck.BlendMode.Screen)
|
||||
expect(r.auxFill.setBlendMode).toHaveBeenLastCalledWith(r.ck.BlendMode.SrcOver)
|
||||
})
|
||||
|
||||
test('handles INNER_SHADOW', () => {
|
||||
const r = createMockRenderer()
|
||||
const canvas = createMockCanvas()
|
||||
|
|
|
|||
Loading…
Reference in a new issue