fix(canvas): render Figma mask stacks
This commit is contained in:
parent
8e1d5d40e7
commit
0ce57ea0fa
|
|
@ -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
|
||||
|
||||
|
|
|
|||
47
packages/core/src/canvas/masks.ts
Normal file
47
packages/core/src/canvas/masks.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
97
tests/engine/render/canvas/masks.test.ts
Normal file
97
tests/engine/render/canvas/masks.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue