openpencil/packages/core/src/canvas/scene.ts
Danila Poyarkov 7f3eb532d8 fix(core): render gradient text fills
- Clip gradient paints through the shaped paragraph mask instead of reading only the paint color
- Cover the paragraph mask path and a headless red-to-blue text render

Fixes #246
2026-05-06 00:22:17 +03:00

572 lines
17 KiB
TypeScript

import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '#core/constants'
import { vectorNetworkToCenterlinePath } from '#core/vector'
import { nodeHasRadius } from './effects'
import type { SceneNode, SceneGraph, Fill } from '#core/scene-graph'
import type { Color } from '#core/types'
import type { SkiaRenderer, RenderOverlays } from './renderer'
import type { Canvas, EmbindEnumEntity, Path } from 'canvaskit-wasm'
function drawVisibleFills(
r: SkiaRenderer,
node: SceneNode,
graph: SceneGraph,
draw: (fill: Fill) => void
): void {
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]
if (!fill.visible) continue
if (!r.applyFill(fill, node, graph, fi)) continue
r.fillPaint.setAlphaf(fill.opacity)
draw(fill)
r.fillPaint.setShader(null)
}
}
function isCulled(r: SkiaRenderer, node: SceneNode, absX: number, absY: number): boolean {
const canCull =
node.childIds.length === 0 ||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
node.clipsContent)
if (!canCull) return false
const vp = r.worldViewport
const bw = node.width
const bh = node.height
if (node.rotation !== 0) {
const diag = Math.sqrt(bw * bw + bh * bh)
const cx = absX + bw / 2
const cy = absY + bh / 2
return (
cx - diag / 2 > vp.x + vp.w ||
cy - diag / 2 > vp.y + vp.h ||
cx + diag / 2 < vp.x ||
cy + diag / 2 < vp.y
)
}
return absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y
}
function applyNodeTransforms(
_r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
nodeId: string,
overlays: RenderOverlays
): void {
const rotation =
overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation
if (rotation !== 0) {
canvas.rotate(rotation, node.width / 2, node.height / 2)
}
if (node.flipX || node.flipY) {
canvas.translate(node.flipX ? node.width : 0, node.flipY ? node.height : 0)
canvas.scale(node.flipX ? -1 : 1, node.flipY ? -1 : 1)
}
}
function renderNodeContent(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
nodeId: string,
overlays: RenderOverlays
): void {
if (node.type === 'SECTION') {
r.renderSection(canvas, node, graph)
} else if (node.type === 'COMPONENT_SET') {
r.renderComponentSet(canvas, node, graph)
} else {
r.renderShape(canvas, node, graph)
}
if (overlays.editingTextId === nodeId && overlays.textEditor?.state?.paragraph) {
r.drawTextEditOverlay(canvas, node, overlays.textEditor)
}
if (overlays.dropTargetId === nodeId) {
r.auxStroke.setStrokeWidth(DROP_HIGHLIGHT_STROKE / r.zoom)
r.auxStroke.setColor(r.selColor(DROP_HIGHLIGHT_ALPHA))
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke)
}
}
function renderChildren(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
node: SceneNode,
overlays: RenderOverlays
): void {
const isClippableContainer =
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
canvas.save()
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)
}
for (const childId of node.childIds) {
r.renderNode(canvas, graph, childId, overlays)
}
canvas.restore()
} else {
for (const childId of node.childIds) {
r.renderNode(canvas, graph, childId, overlays)
}
}
}
export function renderNode(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
overlays: RenderOverlays,
parentAbsX = 0,
parentAbsY = 0
): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
// Hide the node being edited in node-edit mode (overlay draws it live)
if (overlays.nodeEditState?.nodeId === nodeId) return
r._nodeCount++
const absX = parentAbsX + node.x
const absY = parentAbsY + node.y
if (isCulled(r, node, absX, absY)) {
r._culledCount++
return
}
canvas.save()
canvas.translate(node.x, node.y)
if (node.opacity < 1) {
r.opacityPaint.setAlphaf(node.opacity)
canvas.saveLayer(r.opacityPaint)
}
const layerBlur = node.effects.find(
(e) => e.visible && (e.type === 'LAYER_BLUR' || e.type === 'FOREGROUND_BLUR')
)
if (layerBlur) {
// Entry guard: reset shared paint to known state
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
canvas.saveLayer(r.effectLayerPaint)
}
applyNodeTransforms(r, canvas, node, nodeId, overlays)
renderNodeContent(r, canvas, graph, node, nodeId, overlays)
renderChildren(r, canvas, graph, node, overlays)
if (layerBlur) {
canvas.restore()
// Exit guard: ensure shared paint is in clean state
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
}
if (node.opacity < 1) {
canvas.restore()
}
canvas.restore()
}
function makeNodeRRect(r: SkiaRenderer, node: SceneNode, radius: number): Float32Array {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
return r.ck.RRectXY(rect, radius, radius)
}
function forVisibleStrokes(
r: SkiaRenderer,
node: SceneNode,
graph: SceneGraph,
draw: (stroke: SceneNode['strokes'][number], color: Color) => void
): void {
for (let index = 0; index < node.strokes.length; index++) {
const stroke = node.strokes[index]
if (!stroke.visible) continue
draw(stroke, r.resolveStrokeColor(stroke, index, node, graph))
}
}
export function renderSection(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
graph: SceneGraph
): void {
const rrect = makeNodeRRect(r, node, SECTION_CORNER_RADIUS)
drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint))
forVisibleStrokes(r, node, graph, (stroke, color) => {
r.strokePaint.setColor(r.ck.Color4f(color.r, color.g, color.b, color.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
if (node.independentStrokeWeights) r.drawIndividualSideStrokes(canvas, node, stroke.align)
else r.drawRRectStrokeWithAlign(canvas, rrect, node, stroke)
})
}
export function renderComponentSet(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
graph: SceneGraph
): void {
const rrect = makeNodeRRect(r, node, 5)
drawVisibleFills(r, node, graph, () => canvas.drawRRect(rrect, r.fillPaint))
r.auxStroke.setStrokeWidth(r.COMPONENT_SET_BORDER_WIDTH / r.zoom)
r.auxStroke.setColor(r.compColor())
r.auxStroke.setPathEffect(
r.ck.PathEffect.MakeDash([r.COMPONENT_SET_DASH / r.zoom, r.COMPONENT_SET_DASH_GAP / r.zoom], 0)
)
canvas.drawRRect(rrect, r.auxStroke)
r.auxStroke.setPathEffect(null)
}
export function renderShape(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
graph: SceneGraph
): void {
const hasEffects = node.effects.length > 0 && node.effects.some((e) => e.visible)
if (hasEffects) {
const cached = r.nodePictureCache.get(node.id)
if (cached) {
canvas.drawPicture(cached)
return
}
const margin = r.effectOverflow(node)
const bounds = r.ck.LTRBRect(-margin, -margin, node.width + margin, node.height + margin)
const recorder = new r.ck.PictureRecorder()
const recCanvas = recorder.beginRecording(bounds)
r.renderShapeUncached(recCanvas, node, graph)
const picture = recorder.finishRecordingAsPicture()
recorder.delete()
r.nodePictureCache.set(node.id, picture)
canvas.drawPicture(picture)
} else {
r.renderShapeUncached(canvas, node, graph)
}
}
/**
* When a container has no visible fills, Figma renders drop shadows
* using the shape of its children rather than its own rectangle.
* Returns the child to use for shadow shape, or null to use the node itself.
*/
function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | null {
if (node.fills.some((f) => f.visible)) return null
if (node.childIds.length === 0) return null
const child = graph.getNode(node.childIds[0])
if (!child?.visible) return null
return child
}
function getCapEntity(r: SkiaRenderer, cap: string | undefined): EmbindEnumEntity {
switch (cap) {
case 'ROUND':
return r.ck.StrokeCap.Round
case 'SQUARE':
return r.ck.StrokeCap.Square
default:
return r.ck.StrokeCap.Butt
}
}
function getJoinEntity(r: SkiaRenderer, join: string | undefined): EmbindEnumEntity {
switch (join) {
case 'ROUND':
return r.ck.StrokeJoin.Round
case 'BEVEL':
return r.ck.StrokeJoin.Bevel
default:
return r.ck.StrokeJoin.Miter
}
}
function drawVectorStrokeGeometry(
r: SkiaRenderer,
canvas: Canvas,
sg: Path[],
sc: Color,
opacity: number
): void {
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(opacity)
r.fillPaint.setShader(null)
for (const p of sg) canvas.drawPath(p, r.fillPaint)
}
function vectorStrokePaths(r: SkiaRenderer, node: SceneNode): Path[] | null {
if (!node.vectorNetwork) return null
const paths: Path[] = []
for (const segment of node.vectorNetwork.segments) {
const start = node.vectorNetwork.vertices[segment.start]
const end = node.vectorNetwork.vertices[segment.end]
const path = new r.ck.Path()
path.moveTo(start.x, start.y)
const isStraight =
Math.abs(segment.tangentStart.x) < 0.001 &&
Math.abs(segment.tangentStart.y) < 0.001 &&
Math.abs(segment.tangentEnd.x) < 0.001 &&
Math.abs(segment.tangentEnd.y) < 0.001
if (isStraight) {
path.lineTo(end.x, end.y)
} else {
path.cubicTo(
start.x + segment.tangentStart.x,
start.y + segment.tangentStart.y,
end.x + segment.tangentEnd.x,
end.y + segment.tangentEnd.y,
end.x,
end.y
)
}
paths.push(path)
}
return paths.length > 0 ? paths : null
}
function drawVectorPathStrokes(
r: SkiaRenderer,
canvas: Canvas,
vectorPaths: Path[],
stroke: SceneNode['strokes'][0],
sc: Color
): void {
const dash = stroke.dashPattern
if (dash && dash.length > 0) {
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.strokePaint.setAlphaf(stroke.opacity)
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap ?? 'NONE'))
r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join ?? 'MITER'))
r.strokePaint.setShader(null)
const effect = r.ck.PathEffect.MakeDash(dash, 0)
r.strokePaint.setPathEffect(effect)
for (const vp of vectorPaths) canvas.drawPath(vp, r.strokePaint)
r.strokePaint.setPathEffect(null)
effect.delete()
return
}
const strokeOpts = {
width: stroke.weight,
miter_limit: 4,
cap: getCapEntity(r, stroke.cap ?? 'NONE'),
join: getJoinEntity(r, stroke.join ?? 'MITER')
}
r.fillPaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.fillPaint.setAlphaf(stroke.opacity)
r.fillPaint.setShader(null)
for (const vp of vectorPaths) {
const outline = vp.copy().stroke(strokeOpts)
if (outline) {
canvas.drawPath(outline, r.fillPaint)
outline.delete()
}
}
}
function drawRegularStroke(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
stroke: SceneNode['strokes'][0],
sc: Color
): void {
r.strokePaint.setColor(r.ck.Color4f(sc.r, sc.g, sc.b, sc.a))
r.strokePaint.setStrokeWidth(stroke.weight)
r.strokePaint.setAlphaf(stroke.opacity)
if (stroke.cap) {
r.strokePaint.setStrokeCap(getCapEntity(r, stroke.cap))
}
if (stroke.join) {
r.strokePaint.setStrokeJoin(getJoinEntity(r, stroke.join))
}
if (stroke.dashPattern && stroke.dashPattern.length > 0) {
r.strokePaint.setPathEffect(r.ck.PathEffect.MakeDash(stroke.dashPattern, 0))
} else {
r.strokePaint.setPathEffect(null)
}
if (node.independentStrokeWeights && r.isRectangularType(node.type)) {
r.drawIndividualSideStrokes(canvas, node, stroke.align)
} else {
r.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)
}
}
function drawNodeStroke(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
stroke: SceneNode['strokes'][0],
sc: Color,
sg: Path[] | null,
vectorPaths: Path[] | null,
vectorStroke: Path[] | null
): void {
if (vectorStroke && stroke.align === 'CENTER' && node.cornerRadius === 0) {
drawVectorPathStrokes(r, canvas, vectorStroke, stroke, sc)
return
}
if (!sg) {
if (vectorPaths) drawVectorPathStrokes(r, canvas, vectorPaths, stroke, sc)
else drawRegularStroke(r, canvas, node, rect, hasRadius, stroke, sc)
return
}
if (stroke.align !== 'INSIDE') {
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
return
}
const clipPaths = node.type === 'VECTOR' ? r.getFillGeometry(node) : null
if (node.type === 'VECTOR' && !clipPaths) {
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
return
}
canvas.save()
if (clipPaths) {
for (const path of clipPaths) canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
} else {
r.clipNodeShape(canvas, node, rect, hasRadius)
}
drawVectorStrokeGeometry(r, canvas, sg, sc, stroke.opacity)
canvas.restore()
}
export function renderShapeUncached(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
graph: SceneGraph
): void {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
const hasRadius = nodeHasRadius(node)
const shadowChild = getShadowShapeChild(node, graph)
r.renderEffects(canvas, node, rect, hasRadius, 'behind', shadowChild)
drawVisibleFills(r, node, graph, (fill) => r.drawNodeFill(canvas, node, rect, hasRadius, fill))
const sg = node.strokeGeometry.length > 0 ? r.getStrokeGeometry(node) : null
const vectorPaths = node.type === 'VECTOR' ? r.getVectorPaths(node) : null
const vectorStroke = node.type === 'VECTOR' ? vectorStrokePaths(r, node) : null
forVisibleStrokes(r, node, graph, (stroke, color) => {
if (
stroke.dashPattern &&
stroke.dashPattern.length > 0 &&
node.type === 'VECTOR' &&
node.vectorNetwork
) {
const centerline = vectorNetworkToCenterlinePath(r.ck, node.vectorNetwork)
drawVectorPathStrokes(r, canvas, [centerline], stroke, color)
centerline.delete()
return
}
drawNodeStroke(r, canvas, node, rect, hasRadius, stroke, color, sg, vectorPaths, vectorStroke)
})
if (vectorStroke) {
for (const path of vectorStroke) path.delete()
}
r.renderEffects(canvas, node, rect, hasRadius, 'front', shadowChild)
}
function isGradientFill(fill?: Fill): boolean {
return fill?.type.startsWith('GRADIENT') === true
}
function drawGradientText(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
paragraphY: number
): boolean {
if (!r.fontsLoaded || !r.fontProvider) return false
const paragraph = r.buildParagraph(node, r.ck.Color4f(0, 0, 0, 1))
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
canvas.saveLayer(r.effectLayerPaint)
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcIn)
canvas.saveLayer(r.effectLayerPaint)
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.fillPaint)
canvas.restore()
canvas.restore()
r.effectLayerPaint.setImageFilter(null)
r.effectLayerPaint.setColorFilter(null)
r.effectLayerPaint.setBlendMode(r.ck.BlendMode.SrcOver)
return true
}
export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode, fill?: Fill): void {
const text = node.text
if (!text) return
canvas.save()
const shouldClipText = node.textAutoResize === 'NONE' || node.textAutoResize === 'TRUNCATE'
if (shouldClipText) {
canvas.clipRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.ck.ClipOp.Intersect, false)
}
const paragraphY = -1
if (isGradientFill(fill) && drawGradientText(r, canvas, node, paragraphY)) {
canvas.restore()
return
}
if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture)
if (pic) {
canvas.drawPicture(pic)
pic.delete()
canvas.restore()
return
}
}
if (r.fontsLoaded && r.fontProvider) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor())
canvas.drawParagraph(paragraph, 0, paragraphY)
paragraph.delete()
} else if (r.textFont) {
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
}
canvas.restore()
}