Split renderer.ts into renderer/ directory

This commit is contained in:
Danila Poyarkov 2026-03-06 22:25:59 +03:00
parent 31326e8e5e
commit 859143149e
12 changed files with 3555 additions and 3207 deletions

View file

@ -1,4 +1,5 @@
export type { GUID, Color, Vector, Matrix, Rect } from './types'
export { degToRad, radToDeg, rotatePoint, rotatedCorners, rotatedBBox } from './geometry'
export * from './constants'
@ -48,7 +49,7 @@ export {
export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { SkiaRenderer, type RenderOverlays } from './renderer'
export { SkiaRenderer, type RenderOverlays } from './renderer/index'
export { RenderProfiler } from './profiler'
export type { FrameCapture, NodeProfile } from './profiler'
export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout'

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
import type { SceneNode } from '../scene-graph'
import type { Canvas, ImageFilter, MaskFilter } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function getCachedDropShadow(
r: SkiaRenderer,
dx: number,
dy: number,
sigma: number,
color: Float32Array
): ImageFilter {
const key = `ds:${dx},${dy},${sigma},${color[0]},${color[1]},${color[2]},${color[3]}`
let filter = r.imageFilterCache.get(key)
if (!filter) {
filter = r.ck.ImageFilter.MakeDropShadowOnly(dx, dy, sigma, sigma, color, null)
r.imageFilterCache.set(key, filter)
}
return filter
}
export function getCachedBlur(r: SkiaRenderer, sigma: number): ImageFilter {
const key = `blur:${sigma}`
let filter = r.imageFilterCache.get(key)
if (!filter) {
filter = r.ck.ImageFilter.MakeBlur(sigma, sigma, r.ck.TileMode.Clamp, null)
r.imageFilterCache.set(key, filter)
}
return filter
}
export function getCachedDecalBlur(r: SkiaRenderer, sigma: number): ImageFilter {
const key = `dblur:${sigma}`
let filter = r.imageFilterCache.get(key)
if (!filter) {
filter = r.ck.ImageFilter.MakeBlur(sigma, sigma, r.ck.TileMode.Decal, null)
r.imageFilterCache.set(key, filter)
}
return filter
}
export function getCachedMaskBlur(r: SkiaRenderer, sigma: number): MaskFilter {
let filter = r.maskFilterCache.get(sigma)
if (!filter) {
filter = r.ck.MaskFilter.MakeBlur(r.ck.BlurStyle.Normal, sigma, true)
r.maskFilterCache.set(sigma, filter)
}
return filter
}
export function applyClippedBlur(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
sigma: number
): void {
canvas.save()
r.clipNodeShape(canvas, node, rect, hasRadius)
r.effectLayerPaint.setImageFilter(r.getCachedBlur(sigma))
canvas.saveLayer(r.effectLayerPaint)
canvas.restore()
canvas.restore()
}

View file

@ -0,0 +1,233 @@
import type { SceneNode, SceneGraph, Fill } from '../scene-graph'
import type { Canvas, Paint } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawNodeFill(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
): void {
switch (node.type) {
case 'VECTOR': {
const fg = r.getFillGeometry(node)
if (fg) {
for (const p of fg) canvas.drawPath(p, r.fillPaint)
} else {
const vps = r.getVectorPaths(node)
if (vps) {
for (const vp of vps) canvas.drawPath(vp, r.fillPaint)
}
}
break
}
case 'ELLIPSE':
if (node.arcData) {
r.drawArc(canvas, node, r.fillPaint)
} else {
canvas.drawOval(rect, r.fillPaint)
}
break
case 'TEXT':
r.renderText(canvas, node)
break
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, r.fillPaint)
break
case 'POLYGON':
case 'STAR': {
const path = r.makePolygonPath(node)
canvas.drawPath(path, r.fillPaint)
path.delete()
break
}
default:
if (hasRadius) {
canvas.drawRRect(r.makeRRect(node), r.fillPaint)
} else {
canvas.drawRect(rect, r.fillPaint)
}
}
}
export function applyFill(
r: SkiaRenderer,
fill: Fill,
node: SceneNode,
graph: SceneGraph,
fillIndex = 0
): void {
r.fillPaint.setShader(null)
if (fill.type === 'SOLID') {
const c = r.resolveFillColor(fill, fillIndex, node, graph)
r.fillPaint.setColor(r.ck.Color4f(c.r, c.g, c.b, c.a))
return
}
if (fill.type.startsWith('GRADIENT') && fill.gradientStops && fill.gradientTransform) {
r.applyGradientFill(fill, node)
return
}
if (fill.type === 'IMAGE' && fill.imageHash) {
r.applyImageFill(fill, node, graph)
return
}
}
export function applyGradientFill(r: SkiaRenderer, fill: Fill, node: SceneNode): void {
const stops = fill.gradientStops
const t = fill.gradientTransform
if (!stops || !t) return
const colors = stops.map((s) => r.ck.Color4f(s.color.r, s.color.g, s.color.b, s.color.a))
const positions = stops.map((s) => s.position)
const w = node.width
const h = node.height
if (fill.type === 'GRADIENT_LINEAR') {
const startX = t.m02 * w
const startY = t.m12 * h
const endX = (t.m00 + t.m02) * w
const endY = (t.m10 + t.m12) * h
const shader = r.ck.Shader.MakeLinearGradient(
[startX, startY],
[endX, endY],
colors,
positions,
r.ck.TileMode.Clamp
)
r.fillPaint.setShader(shader)
} else if (fill.type === 'GRADIENT_RADIAL') {
const cx = t.m02 * w
const cy = t.m12 * h
const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h)
const shader = r.ck.Shader.MakeRadialGradient(
[cx, cy],
radius,
colors,
positions,
r.ck.TileMode.Clamp
)
r.fillPaint.setShader(shader)
} else if (fill.type === 'GRADIENT_ANGULAR') {
const cx = t.m02 * w
const cy = t.m12 * h
const shader = r.ck.Shader.MakeSweepGradient(
cx,
cy,
colors,
positions,
r.ck.TileMode.Clamp,
undefined
)
r.fillPaint.setShader(shader)
} else if (fill.type === 'GRADIENT_DIAMOND') {
const cx = t.m02 * w
const cy = t.m12 * h
const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h)
const shader = r.ck.Shader.MakeRadialGradient(
[cx, cy],
radius,
colors,
positions,
r.ck.TileMode.Clamp
)
r.fillPaint.setShader(shader)
}
}
export function applyImageFill(
r: SkiaRenderer,
fill: Fill,
node: SceneNode,
graph: SceneGraph
): void {
const hash = fill.imageHash
if (!hash) return
let img = r.imageCache.get(hash)
if (!img) {
const data = graph.images.get(hash)
if (!data) return
img = r.ck.MakeImageFromEncoded(data) ?? undefined
if (img) r.imageCache.set(hash, img)
else return
}
const imgW = img.width()
const imgH = img.height()
const scaleMode = fill.imageScaleMode ?? 'FILL'
let sx: number, sy: number, sw: number, sh: number
if (scaleMode === 'FILL') {
const scale = Math.max(node.width / imgW, node.height / imgH)
sw = node.width / scale
sh = node.height / scale
sx = (imgW - sw) / 2
sy = (imgH - sh) / 2
} else if (scaleMode === 'FIT') {
sw = imgW
sh = imgH
sx = 0
sy = 0
} else {
sx = 0
sy = 0
sw = imgW
sh = imgH
}
const shader = img.makeShaderCubic(
r.ck.TileMode.Clamp,
r.ck.TileMode.Clamp,
1 / 3,
1 / 3,
r.ck.Matrix.multiply(
r.ck.Matrix.scaled(node.width / sw, node.height / sh),
r.ck.Matrix.translated(-sx, -sy)
)
)
r.fillPaint.setShader(shader)
}
export function drawArc(r: SkiaRenderer, canvas: Canvas, node: SceneNode, paint: Paint): void {
const arc = node.arcData
if (!arc) return
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const innerRx = rx * arc.innerRadius
const innerRy = ry * arc.innerRadius
const startDeg = arc.startingAngle * (180 / Math.PI)
const endDeg = arc.endingAngle * (180 / Math.PI)
const sweepDeg = endDeg - startDeg
const path = new r.ck.Path()
const oval = r.ck.LTRBRect(0, 0, node.width, node.height)
if (arc.innerRadius > 0) {
path.addArc(oval, startDeg, sweepDeg)
const innerOval = r.ck.LTRBRect(cx - innerRx, cy - innerRy, cx + innerRx, cy + innerRy)
const innerPath = new r.ck.Path()
innerPath.addArc(innerOval, startDeg + sweepDeg, -sweepDeg)
path.addPath(innerPath)
path.close()
innerPath.delete()
} else {
const isFullCircle = Math.abs(sweepDeg) >= 359.99
if (isFullCircle) {
path.addOval(oval)
} else {
path.moveTo(cx, cy)
path.addArc(oval, startDeg, sweepDeg)
path.close()
}
}
canvas.drawPath(path, paint)
path.delete()
}

View file

@ -0,0 +1 @@
export { SkiaRenderer, type RenderOverlays } from './renderer'

View file

@ -0,0 +1,212 @@
import {
SECTION_TITLE_HEIGHT,
SECTION_TITLE_PADDING_X,
SECTION_TITLE_RADIUS,
SECTION_TITLE_GAP,
COMPONENT_LABEL_FONT_SIZE,
COMPONENT_LABEL_GAP,
COMPONENT_LABEL_ICON_SIZE,
COMPONENT_LABEL_ICON_GAP
} from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.sectionTitleFont) return
const pageNode = graph.getNode(r.pageId ?? graph.rootId)
if (!pageNode) return
const sections: { node: SceneNode; absX: number; absY: number; nested: boolean }[] = []
const collectSections = (parentId: string, ox: number, oy: number, insideSection: boolean) => {
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const ax = ox + child.x
const ay = oy + child.y
if (child.type === 'SECTION') {
const vp = r.worldViewport
if (
ax + child.width >= vp.x &&
ay + child.height >= vp.y &&
ax <= vp.x + vp.w &&
ay <= vp.y + vp.h
) {
sections.push({ node: child, absX: ax, absY: ay, nested: insideSection })
}
collectSections(childId, ax, ay, true)
} else if (child.childIds.length > 0) {
collectSections(childId, ax, ay, insideSection)
}
}
}
collectSections(pageNode.id, 0, 0, false)
const font = r.sectionTitleFont
const ellipsis = '…'
const ellipsisGlyphs = font.getGlyphIDs(ellipsis)
const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0]
for (const { node, absX, absY, nested } of sections) {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY
const screenW = node.width * r.zoom
const maxPillW = Math.max(screenW, 0)
const glyphIds = font.getGlyphIDs(node.name)
const widths = font.getGlyphWidths(glyphIds)
let fullTextWidth = 0
for (const w of widths) fullTextWidth += w
const maxTextW = maxPillW - SECTION_TITLE_PADDING_X * 2
let displayText = node.name
let textWidth = fullTextWidth
if (textWidth > maxTextW && maxTextW > ellipsisWidth) {
let truncW = 0
let truncIdx = 0
for (let i = 0; i < widths.length; i++) {
if (truncW + widths[i] + ellipsisWidth > maxTextW) break
truncW += widths[i]
truncIdx = i + 1
}
displayText = node.name.slice(0, truncIdx) + ellipsis
textWidth = truncW + ellipsisWidth
} else if (maxTextW <= ellipsisWidth) {
displayText = ellipsis
textWidth = ellipsisWidth
}
const pillW = Math.min(textWidth + SECTION_TITLE_PADDING_X * 2, maxPillW)
const pillH = SECTION_TITLE_HEIGHT
const pillX = screenX
const pillY = nested ? screenY + SECTION_TITLE_GAP : screenY - pillH - SECTION_TITLE_GAP
if (node.fills.length > 0 && node.fills[0].visible) {
const c = node.fills[0].color
r.auxFill.setColor(r.ck.Color4f(c.r, c.g, c.b, node.fills[0].opacity))
} else {
r.auxFill.setColor(r.ck.Color4f(0.37, 0.37, 0.37, 1))
}
const pillRect = r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH)
canvas.drawRRect(
r.ck.RRectXY(pillRect, SECTION_TITLE_RADIUS, SECTION_TITLE_RADIUS),
r.auxFill
)
const pillColor =
node.fills.length > 0 && node.fills[0].visible
? node.fills[0].color
: { r: 0.37, g: 0.37, b: 0.37 }
const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b
r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE)
const textY = pillY + pillH * 0.7
canvas.drawText(displayText, pillX + SECTION_TITLE_PADDING_X, textY, r.auxFill, font)
}
}
export function drawComponentLabels(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (!r.componentLabelFont) return
const pageNode = graph.getNode(r.pageId ?? graph.rootId)
if (!pageNode) return
const font = r.componentLabelFont
const LABEL_TYPES = new Set(['COMPONENT', 'COMPONENT_SET'])
const nodes: { node: SceneNode; absX: number; absY: number; inside: boolean }[] = []
const collect = (parentId: string, ox: number, oy: number) => {
const parent = graph.getNode(parentId)
if (!parent) return
for (const childId of parent.childIds) {
const child = graph.getNode(childId)
if (!child || !child.visible) continue
const ax = ox + child.x
const ay = oy + child.y
if (LABEL_TYPES.has(child.type)) {
const vp = r.worldViewport
if (
ax + child.width >= vp.x &&
ay + child.height >= vp.y &&
ax <= vp.x + vp.w &&
ay <= vp.y + vp.h
) {
const isInsideSet = parent.type === 'COMPONENT_SET'
nodes.push({ node: child, absX: ax, absY: ay, inside: isInsideSet })
}
}
if (child.childIds.length > 0) {
collect(childId, ax, ay)
}
}
}
collect(pageNode.id, 0, 0)
const compColor = r.compColor()
const iconS = COMPONENT_LABEL_ICON_SIZE
for (const { node, absX, absY, inside } of nodes) {
const screenX = absX * r.zoom + r.panX
const screenY = absY * r.zoom + r.panY
const labelX = screenX
let labelY: number
if (inside) {
labelY = screenY + COMPONENT_LABEL_GAP + COMPONENT_LABEL_FONT_SIZE
} else {
labelY = screenY - COMPONENT_LABEL_GAP
}
const iconX = labelX
const iconY = labelY - COMPONENT_LABEL_FONT_SIZE * 0.75
const iconCx = iconX + iconS / 2
const iconCy = iconY + iconS / 2
const iconR = iconS / 2
r.auxFill.setColor(compColor)
if (node.type === 'COMPONENT_SET') {
const s = iconR * 0.45
const gap = iconR * 0.2
const path = new r.ck.Path()
for (const [dx, dy] of [
[-1, -1],
[1, -1],
[-1, 1],
[1, 1]
]) {
const cx = iconCx + dx * (s + gap)
const cy = iconCy + dy * (s + gap)
path.moveTo(cx, cy - s)
path.lineTo(cx + s, cy)
path.lineTo(cx, cy + s)
path.lineTo(cx - s, cy)
path.close()
}
canvas.drawPath(path, r.auxFill)
path.delete()
} else {
const path = new r.ck.Path()
path.moveTo(iconCx, iconCy - iconR)
path.lineTo(iconCx + iconR, iconCy)
path.lineTo(iconCx, iconCy + iconR)
path.lineTo(iconCx - iconR, iconCy)
path.close()
canvas.drawPath(path, r.auxFill)
path.delete()
}
canvas.drawText(
node.name,
labelX + iconS + COMPONENT_LABEL_ICON_GAP,
labelY,
r.auxFill,
font
)
}
}

View file

@ -0,0 +1,760 @@
import {
ROTATION_HANDLE_OFFSET,
ROTATION_HANDLE_RADIUS,
HANDLE_HALF_SIZE,
LABEL_OFFSET_Y,
SIZE_PILL_PADDING_X,
SIZE_PILL_PADDING_Y,
SIZE_PILL_HEIGHT,
SIZE_PILL_RADIUS,
SIZE_PILL_TEXT_OFFSET_Y,
MARQUEE_FILL_ALPHA,
SELECTION_DASH_ALPHA,
LAYOUT_INDICATOR_STROKE,
PEN_HANDLE_RADIUS,
PEN_VERTEX_RADIUS,
PEN_CLOSE_RADIUS_BOOST,
TEXT_SELECTION_COLOR,
TEXT_CARET_COLOR,
TEXT_CARET_WIDTH,
FLASH_COLOR,
FLASH_ATTACK_MS,
FLASH_HOLD_MS,
FLASH_RELEASE_MS,
FLASH_STROKE_WIDTH,
FLASH_PADDING,
FLASH_OVERSHOOT,
FLASH_RADIUS
} from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Rect } from '../types'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import type { RenderOverlays } from './renderer'
export function drawHoverHighlight(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
hoveredNodeId?: string | null
): void {
if (!hoveredNodeId) return
const node = graph.getNode(hoveredNodeId)
if (!node) return
const abs = graph.getAbsolutePosition(node.id)
const sx = abs.x * r.zoom + r.panX
const sy = abs.y * r.zoom + r.panY
r.auxStroke.setStrokeWidth(1 / r.zoom)
r.auxStroke.setColor(
r.isComponentType(node.type) ? r.compColor() : r.selColor()
)
r.auxStroke.setPathEffect(null)
canvas.save()
canvas.translate(sx, sy)
if (node.rotation !== 0) {
const cx = (node.width / 2) * r.zoom
const cy = (node.height / 2) * r.zoom
canvas.rotate(node.rotation, cx, cy)
}
canvas.scale(r.zoom, r.zoom)
r.strokeNodeShape(canvas, node, r.auxStroke)
canvas.restore()
}
export function drawSelection(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays: RenderOverlays
): void {
if (selectedIds.size === 0) return
r.drawParentFrameOutlines(canvas, graph, selectedIds)
if (selectedIds.size === 1) {
const id = [...selectedIds][0]
if (overlays.editingTextId === id) return
const node = graph.getNode(id)
if (!node) return
const useComponentColor = r.isComponentType(node.type)
r.selectionPaint.setColor(useComponentColor ? r.compColor() : r.selColor())
r.selectionPaint.setStrokeWidth(1)
const rotation =
overlays.rotationPreview?.nodeId === id ? overlays.rotationPreview.angle : node.rotation
r.drawNodeSelection(canvas, node, rotation, graph)
r.drawSelectionLabels(canvas, graph, selectedIds)
r.selectionPaint.setColor(r.selColor())
return
}
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node) continue
const useComponentColor = r.isComponentType(node.type)
r.selectionPaint.setColor(useComponentColor ? r.compColor() : r.selColor())
r.selectionPaint.setStrokeWidth(1)
const rotation =
overlays.rotationPreview?.nodeId === id ? overlays.rotationPreview.angle : node.rotation
r.drawNodeOutline(canvas, node, rotation, graph)
}
r.selectionPaint.setColor(r.selColor())
const nodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
r.drawGroupBounds(canvas, nodes, graph)
r.drawSelectionLabels(canvas, graph, selectedIds)
}
export function drawNodeSelection(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rotation: number,
graph: SceneGraph
): void {
const abs = graph.getAbsolutePosition(node.id)
const cx = (abs.x + node.width / 2) * r.zoom + r.panX
const cy = (abs.y + node.height / 2) * r.zoom + r.panY
const hw = (node.width / 2) * r.zoom
const hh = (node.height / 2) * r.zoom
canvas.save()
if (rotation !== 0) {
canvas.rotate(rotation, cx, cy)
}
const x1 = cx - hw
const y1 = cy - hh
const x2 = cx + hw
const y2 = cy + hh
canvas.drawRect(r.ck.LTRBRect(x1, y1, x2, y2), r.selectionPaint)
r.drawHandle(canvas, x1, y1)
r.drawHandle(canvas, x2, y1)
r.drawHandle(canvas, x1, y2)
r.drawHandle(canvas, x2, y2)
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
r.drawHandle(canvas, mx, y1)
r.drawHandle(canvas, mx, y2)
r.drawHandle(canvas, x1, my)
r.drawHandle(canvas, x2, my)
const rotHandleY = y1 - ROTATION_HANDLE_OFFSET - ROTATION_HANDLE_RADIUS
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setColor(r.selColor())
r.auxStroke.setPathEffect(null)
canvas.drawLine(mx, y1, mx, rotHandleY, r.auxStroke)
r.auxFill.setColor(r.ck.WHITE)
canvas.drawCircle(mx, rotHandleY, ROTATION_HANDLE_RADIUS, r.auxFill)
canvas.drawCircle(mx, rotHandleY, ROTATION_HANDLE_RADIUS, r.auxStroke)
canvas.restore()
}
export function drawSelectionLabels(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
if (!r.labelFont || !r.sizeFont) return
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const nodes: SceneNode[] = []
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node) continue
nodes.push(node)
const abs = graph.getAbsolutePosition(id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + node.width)
maxY = Math.max(maxY, abs.y + node.height)
}
if (nodes.length === 0) return
const sx1 = minX * r.zoom + r.panX
const sy1 = minY * r.zoom + r.panY
const sx2 = maxX * r.zoom + r.panX
const sy2 = maxY * r.zoom + r.panY
const smx = (sx1 + sx2) / 2
if (nodes.length === 1) {
const node = nodes[0]
const parentNode = node.parentId ? graph.getNode(node.parentId) : null
const isTopLevel =
!parentNode || parentNode.type === 'CANVAS' || parentNode.type === 'SECTION'
if (node.type === 'FRAME' && isTopLevel) {
r.auxFill.setColor(r.selColor())
canvas.drawText(node.name, sx1, sy1 - LABEL_OFFSET_Y, r.auxFill, r.labelFont)
}
}
const w = Math.round(maxX - minX)
const h = Math.round(maxY - minY)
const sizeText = `${w} × ${h}`
const glyphIds = r.sizeFont.getGlyphIDs(sizeText)
const widths = r.sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
const pillW = textWidth + SIZE_PILL_PADDING_X * 2
const pillH = SIZE_PILL_HEIGHT
const pillX = smx - pillW / 2
const pillY = sy2 + SIZE_PILL_PADDING_Y
const allComponents = nodes.length > 0 && nodes.every((n) => r.isComponentType(n.type))
const pillColor = allComponents ? r.compColor() : r.selColor()
r.auxFill.setColor(pillColor)
const rrect = r.ck.RRectXY(
r.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH),
SIZE_PILL_RADIUS,
SIZE_PILL_RADIUS
)
canvas.drawRRect(rrect, r.auxFill)
r.auxFill.setColor(r.ck.WHITE)
canvas.drawText(
sizeText,
pillX + SIZE_PILL_PADDING_X,
pillY + SIZE_PILL_TEXT_OFFSET_Y,
r.auxFill,
r.sizeFont
)
}
export function drawParentFrameOutlines(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
const drawn = new Set<string>()
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node?.parentId) continue
const nodeParent = graph.getNode(node.parentId)
if (!nodeParent || nodeParent.type === 'CANVAS') continue
if (drawn.has(node.parentId) || selectedIds.has(node.parentId)) continue
const parent = nodeParent
const grandparent = parent.parentId ? graph.getNode(parent.parentId) : null
if (!grandparent || grandparent.type === 'CANVAS') continue
drawn.add(node.parentId)
const abs = graph.getAbsolutePosition(parent.id)
const x = abs.x * r.zoom + r.panX
const y = abs.y * r.zoom + r.panY
const w = parent.width * r.zoom
const h = parent.height * r.zoom
canvas.save()
if (parent.rotation !== 0) {
canvas.rotate(parent.rotation, x + w / 2, y + h / 2)
}
canvas.drawRect(r.ck.LTRBRect(x, y, x + w, y + h), r.parentOutlinePaint)
canvas.restore()
}
}
export function drawNodeOutline(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rotation: number,
graph: SceneGraph
): void {
const abs = graph.getAbsolutePosition(node.id)
const cx = (abs.x + node.width / 2) * r.zoom + r.panX
const cy = (abs.y + node.height / 2) * r.zoom + r.panY
const hw = (node.width / 2) * r.zoom
const hh = (node.height / 2) * r.zoom
canvas.save()
if (rotation !== 0) {
canvas.rotate(rotation, cx, cy)
}
canvas.drawRect(r.ck.LTRBRect(cx - hw, cy - hh, cx + hw, cy + hh), r.selectionPaint)
canvas.restore()
}
export function drawGroupBounds(
r: SkiaRenderer,
canvas: Canvas,
nodes: SceneNode[],
graph: SceneGraph
): void {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const n of nodes) {
const abs = graph.getAbsolutePosition(n.id)
if (n.rotation !== 0) {
const corners = r.getRotatedCorners(n, abs)
for (const c of corners) {
minX = Math.min(minX, c.x)
minY = Math.min(minY, c.y)
maxX = Math.max(maxX, c.x)
maxY = Math.max(maxY, c.y)
}
} else {
const x1 = abs.x * r.zoom + r.panX
const y1 = abs.y * r.zoom + r.panY
const x2 = (abs.x + n.width) * r.zoom + r.panX
const y2 = (abs.y + n.height) * r.zoom + r.panY
minX = Math.min(minX, x1)
minY = Math.min(minY, y1)
maxX = Math.max(maxX, x2)
maxY = Math.max(maxY, y2)
}
}
r.auxStroke.setStrokeWidth(1)
r.auxStroke.setColor(r.selColor(SELECTION_DASH_ALPHA))
r.auxStroke.setPathEffect(null)
canvas.drawRect(r.ck.LTRBRect(minX, minY, maxX, maxY), r.auxStroke)
r.drawHandle(canvas, minX, minY)
r.drawHandle(canvas, maxX, minY)
r.drawHandle(canvas, minX, maxY)
r.drawHandle(canvas, maxX, maxY)
const gmx = (minX + maxX) / 2
const gmy = (minY + maxY) / 2
r.drawHandle(canvas, gmx, minY)
r.drawHandle(canvas, gmx, maxY)
r.drawHandle(canvas, minX, gmy)
r.drawHandle(canvas, maxX, gmy)
}
export function getRotatedCorners(
r: SkiaRenderer,
n: SceneNode,
abs: { x: number; y: number }
): Array<{ x: number; y: number }> {
const cx = (abs.x + n.width / 2) * r.zoom + r.panX
const cy = (abs.y + n.height / 2) * r.zoom + r.panY
const hw = (n.width / 2) * r.zoom
const hh = (n.height / 2) * r.zoom
const rad = (n.rotation * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return [
{ x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos },
{ x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos },
{ x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos },
{ x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos }
]
}
export function drawHandle(r: SkiaRenderer, canvas: Canvas, x: number, y: number): void {
r.auxFill.setColor(r.ck.WHITE)
const rect = r.ck.LTRBRect(
x - HANDLE_HALF_SIZE,
y - HANDLE_HALF_SIZE,
x + HANDLE_HALF_SIZE,
y + HANDLE_HALF_SIZE
)
canvas.drawRect(rect, r.auxFill)
canvas.drawRect(rect, r.selectionPaint)
}
export function drawSnapGuides(
r: SkiaRenderer,
canvas: Canvas,
guides?: SnapGuide[]
): void {
if (!guides || guides.length === 0) return
for (const guide of guides) {
if (guide.axis === 'x') {
const x = guide.position * r.zoom + r.panX
const y1 = guide.from * r.zoom + r.panY
const y2 = guide.to * r.zoom + r.panY
canvas.drawLine(x, y1, x, y2, r.snapPaint)
} else {
const y = guide.position * r.zoom + r.panY
const x1 = guide.from * r.zoom + r.panX
const x2 = guide.to * r.zoom + r.panX
canvas.drawLine(x1, y, x2, y, r.snapPaint)
}
}
}
export function drawMarquee(r: SkiaRenderer, canvas: Canvas, marquee?: Rect | null): void {
if (!marquee || marquee.width <= 0 || marquee.height <= 0) return
const x1 = marquee.x * r.zoom + r.panX
const y1 = marquee.y * r.zoom + r.panY
const x2 = (marquee.x + marquee.width) * r.zoom + r.panX
const y2 = (marquee.y + marquee.height) * r.zoom + r.panY
const rect = r.ck.LTRBRect(x1, y1, x2, y2)
r.auxFill.setColor(r.selColor(MARQUEE_FILL_ALPHA))
canvas.drawRect(rect, r.auxFill)
canvas.drawRect(rect, r.selectionPaint)
}
export function drawFlashes(r: SkiaRenderer, canvas: Canvas, graph: SceneGraph): void {
if (r._flashes.length === 0) return
const now = performance.now()
const ck = r.ck
const totalMs = FLASH_ATTACK_MS + FLASH_HOLD_MS + FLASH_RELEASE_MS
if (!r._flashPaint) {
r._flashPaint = new ck.Paint()
r._flashPaint.setStyle(ck.PaintStyle.Stroke)
r._flashPaint.setAntiAlias(true)
}
const paint = r._flashPaint
const zoom = r.zoom
for (let i = r._flashes.length - 1; i >= 0; i--) {
const flash = r._flashes[i]
const elapsed = now - flash.startTime
if (elapsed > totalMs) {
r._flashes.splice(i, 1)
continue
}
const node = graph.getNode(flash.nodeId)
if (!node) {
r._flashes.splice(i, 1)
continue
}
const abs = graph.getAbsolutePosition(flash.nodeId)
const cx = (abs.x + node.width / 2) * zoom + r.panX
const cy = (abs.y + node.height / 2) * zoom + r.panY
const hw = (node.width / 2) * zoom
const hh = (node.height / 2) * zoom
let opacity: number
let extraPad: number
if (elapsed < FLASH_ATTACK_MS) {
const t = elapsed / FLASH_ATTACK_MS
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
opacity = ease
extraPad = (1 - ease) * FLASH_OVERSHOOT
} else if (elapsed < FLASH_ATTACK_MS + FLASH_HOLD_MS) {
opacity = 1
extraPad = 0
} else {
const t = (elapsed - FLASH_ATTACK_MS - FLASH_HOLD_MS) / FLASH_RELEASE_MS
opacity = 1 - t * t
extraPad = 0
}
const pad = FLASH_PADDING + extraPad
const rad = FLASH_RADIUS
paint.setColor(ck.Color4f(FLASH_COLOR.r, FLASH_COLOR.g, FLASH_COLOR.b, opacity))
paint.setStrokeWidth(FLASH_STROKE_WIDTH)
canvas.save()
if (node.rotation !== 0) canvas.rotate(node.rotation, cx, cy)
const rect = ck.RRectXY(
ck.LTRBRect(cx - hw - pad, cy - hh - pad, cx + hw + pad, cy + hh + pad),
rad,
rad
)
canvas.drawRRect(rect, paint)
canvas.restore()
}
}
export function drawLayoutInsertIndicator(
r: SkiaRenderer,
canvas: Canvas,
indicator?: RenderOverlays['layoutInsertIndicator']
): void {
if (!indicator) return
r.auxStroke.setStrokeWidth(LAYOUT_INDICATOR_STROKE)
r.auxStroke.setColor(r.selColor())
r.auxStroke.setPathEffect(null)
if (indicator.direction === 'HORIZONTAL') {
const y = indicator.y * r.zoom + r.panY
const x1 = indicator.x * r.zoom + r.panX
const x2 = (indicator.x + indicator.length) * r.zoom + r.panX
canvas.drawLine(x1, y, x2, y, r.auxStroke)
} else {
const x = indicator.x * r.zoom + r.panX
const y1 = indicator.y * r.zoom + r.panY
const y2 = (indicator.y + indicator.length) * r.zoom + r.panY
canvas.drawLine(x, y1, x, y2, r.auxStroke)
}
}
export function drawTextEditOverlay(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
editor: TextEditor
): void {
r.auxStroke.setStrokeWidth(1 / r.zoom)
r.auxStroke.setColor(r.selColor())
r.auxStroke.setPathEffect(null)
canvas.drawRect(r.ck.LTRBRect(0, 0, node.width, node.height), r.auxStroke)
const selRects = editor.getSelectionRects()
if (selRects.length > 0) {
r.auxFill.setColor(
r.ck.Color4f(
TEXT_SELECTION_COLOR.r,
TEXT_SELECTION_COLOR.g,
TEXT_SELECTION_COLOR.b,
TEXT_SELECTION_COLOR.a
)
)
for (const sel of selRects) {
canvas.drawRect(
r.ck.LTRBRect(sel.x, sel.y, sel.x + sel.width, sel.y + sel.height),
r.auxFill
)
}
}
if (editor.caretVisible && !editor.hasSelection()) {
const caret = editor.getCaretRect()
if (caret) {
r.auxFill.setColor(
r.ck.Color4f(
TEXT_CARET_COLOR.r,
TEXT_CARET_COLOR.g,
TEXT_CARET_COLOR.b,
TEXT_CARET_COLOR.a
)
)
const w = TEXT_CARET_WIDTH / r.zoom
canvas.drawRect(
r.ck.LTRBRect(caret.x - w / 2, caret.y0, caret.x + w / 2, caret.y1),
r.auxFill
)
}
}
}
export function drawPenOverlay(
r: SkiaRenderer,
canvas: Canvas,
penState: RenderOverlays['penState']
): void {
if (!penState || penState.vertices.length === 0) return
const { vertices, segments, dragTangent, cursorX, cursorY } = penState
const pathPaint = r.penPathPaint
const handlePaint = r.penHandlePaint
const vertexFill = r.penVertexFill
const vertexStroke = r.penVertexStroke
const toScreen = (x: number, y: number) => ({
x: x * r.zoom + r.panX,
y: y * r.zoom + r.panY
})
const path = new r.ck.Path()
for (const seg of segments) {
const s = toScreen(vertices[seg.start].x, vertices[seg.start].y)
const e = toScreen(vertices[seg.end].x, vertices[seg.end].y)
path.moveTo(s.x, s.y)
const isLine =
seg.tangentStart.x === 0 &&
seg.tangentStart.y === 0 &&
seg.tangentEnd.x === 0 &&
seg.tangentEnd.y === 0
if (isLine) {
path.lineTo(e.x, e.y)
} else {
const cp1 = toScreen(
vertices[seg.start].x + seg.tangentStart.x,
vertices[seg.start].y + seg.tangentStart.y
)
const cp2 = toScreen(
vertices[seg.end].x + seg.tangentEnd.x,
vertices[seg.end].y + seg.tangentEnd.y
)
path.cubicTo(cp1.x, cp1.y, cp2.x, cp2.y, e.x, e.y)
}
}
if (vertices.length > 0 && cursorX != null && cursorY != null) {
const last = toScreen(vertices[vertices.length - 1].x, vertices[vertices.length - 1].y)
const cursor = toScreen(cursorX, cursorY)
path.moveTo(last.x, last.y)
if (dragTangent) {
const cp1 = toScreen(
vertices[vertices.length - 1].x + dragTangent.x,
vertices[vertices.length - 1].y + dragTangent.y
)
path.cubicTo(cp1.x, cp1.y, cursor.x, cursor.y, cursor.x, cursor.y)
} else {
path.lineTo(cursor.x, cursor.y)
}
}
canvas.drawPath(path, pathPaint)
path.delete()
for (const seg of segments) {
const ts = seg.tangentStart
const te = seg.tangentEnd
if (ts.x !== 0 || ts.y !== 0) {
const s = toScreen(vertices[seg.start].x, vertices[seg.start].y)
const cp = toScreen(vertices[seg.start].x + ts.x, vertices[seg.start].y + ts.y)
canvas.drawLine(s.x, s.y, cp.x, cp.y, handlePaint)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint)
}
if (te.x !== 0 || te.y !== 0) {
const e = toScreen(vertices[seg.end].x, vertices[seg.end].y)
const cp = toScreen(vertices[seg.end].x + te.x, vertices[seg.end].y + te.y)
canvas.drawLine(e.x, e.y, cp.x, cp.y, handlePaint)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp.x, cp.y, PEN_HANDLE_RADIUS, handlePaint)
}
}
if (dragTangent && vertices.length > 0) {
const last = vertices[vertices.length - 1]
const cp1 = toScreen(last.x + dragTangent.x, last.y + dragTangent.y)
const cp2 = toScreen(last.x - dragTangent.x, last.y - dragTangent.y)
canvas.drawLine(cp2.x, cp2.y, cp1.x, cp1.y, handlePaint)
canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp1.x, cp1.y, PEN_HANDLE_RADIUS, handlePaint)
canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, vertexFill)
canvas.drawCircle(cp2.x, cp2.y, PEN_HANDLE_RADIUS, handlePaint)
}
for (let i = 0; i < vertices.length; i++) {
const v = toScreen(vertices[i].x, vertices[i].y)
const radius =
i === 0 && penState.closingToFirst
? PEN_VERTEX_RADIUS + PEN_CLOSE_RADIUS_BOOST
: PEN_VERTEX_RADIUS
canvas.drawCircle(v.x, v.y, radius, vertexFill)
canvas.drawCircle(v.x, v.y, radius, vertexStroke)
}
}
export function drawRemoteCursors(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
cursors?: RenderOverlays['remoteCursors']
): void {
if (!cursors || cursors.length === 0) return
const CURSOR_SIZE = 9
const LABEL_PADDING_X = 4
const LABEL_PADDING_Y = 2
const LABEL_FONT_SIZE = 10
const LABEL_OFFSET_X = 12
const LABEL_OFFSET_Y = 20
for (const cursor of cursors) {
const screenX = cursor.x * r.zoom + r.panX
const screenY = cursor.y * r.zoom + r.panY
const { r: cr, g, b } = cursor.color
if (cursor.selection?.length) {
r.auxStroke.setColor(r.ck.Color4f(cr, g, b, 0.6))
r.auxStroke.setStrokeWidth(1.5)
r.auxStroke.setPathEffect(null)
for (const nodeId of cursor.selection) {
const node = graph.getNode(nodeId)
if (!node) continue
const abs = graph.getAbsolutePosition(nodeId)
const sx = abs.x * r.zoom + r.panX
const sy = abs.y * r.zoom + r.panY
const sw = node.width * r.zoom
const sh = node.height * r.zoom
canvas.drawRect(r.ck.XYWHRect(sx, sy, sw, sh), r.auxStroke)
}
}
const S = CURSOR_SIZE
const path = new r.ck.Path()
path.moveTo(screenX, screenY)
path.lineTo(screenX, screenY + S * 1.35)
path.lineTo(screenX + S * 0.38, screenY + S * 1.0)
path.lineTo(screenX + S * 0.72, screenY + S * 1.5)
path.lineTo(screenX + S * 0.92, screenY + S * 1.38)
path.lineTo(screenX + S * 0.58, screenY + S * 0.88)
path.lineTo(screenX + S * 1.0, screenY + S * 0.82)
path.close()
r.auxStroke.setColor(r.ck.Color4f(1, 1, 1, 1))
r.auxStroke.setStrokeWidth(2)
r.auxStroke.setPathEffect(null)
canvas.drawPath(path, r.auxStroke)
r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1))
canvas.drawPath(path, r.auxFill)
path.delete()
if (cursor.name) {
const font = r.labelFont
if (font) {
font.setSize(LABEL_FONT_SIZE)
const labelX = screenX + LABEL_OFFSET_X
const labelY = screenY + LABEL_OFFSET_Y
const glyphIds = font.getGlyphIDs(cursor.name)
const widths = font.getGlyphWidths(glyphIds)
let textWidth = 0
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
r.auxFill.setColor(r.ck.Color4f(cr, g, b, 1))
const bgRect = r.ck.RRectXY(
r.ck.XYWHRect(
labelX - LABEL_PADDING_X,
labelY - LABEL_FONT_SIZE - LABEL_PADDING_Y + 2,
textWidth + LABEL_PADDING_X * 2,
LABEL_FONT_SIZE + LABEL_PADDING_Y * 2
),
4,
4
)
canvas.drawRRect(bgRect, r.auxFill)
r.auxFill.setColor(r.ck.Color4f(1, 1, 1, 1))
canvas.drawText(cursor.name, labelX, labelY, r.auxFill, font)
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,235 @@
import {
RULER_SIZE,
RULER_BADGE_HEIGHT,
RULER_BADGE_PADDING,
RULER_BADGE_RADIUS,
RULER_BADGE_EXCLUSION,
RULER_TEXT_BASELINE,
RULER_MAJOR_TICK,
RULER_MINOR_TICK,
RULER_HIGHLIGHT_ALPHA,
RULER_TARGET_PIXEL_SPACING,
RULER_MAJOR_TOLERANCE
} from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas, CanvasKit } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawRulers(
r: SkiaRenderer,
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
const R = RULER_SIZE
const vw = r.viewportWidth
const vh = r.viewportHeight
if (vw === 0 || vh === 0) return
const bgPaint = r.rulerBgPaint
const tickPaint = r.rulerTickPaint
const textPaint = r.rulerTextPaint
canvas.drawRect(r.ck.LTRBRect(0, 0, vw, R), bgPaint)
canvas.drawRect(r.ck.LTRBRect(0, R, R, vh), bgPaint)
canvas.drawRect(r.ck.LTRBRect(0, 0, R, R), bgPaint)
const font = r.sizeFont ?? r.textFont
if (!font) return
const step = rulerStep(r)
const minorStep = step / 5
let sx1 = -Infinity,
sx2 = -Infinity,
sy1 = -Infinity,
sy2 = -Infinity
const selNodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
if (selNodes.length > 0) {
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity
for (const n of selNodes) {
const abs = graph.getAbsolutePosition(n.id)
minX = Math.min(minX, abs.x)
minY = Math.min(minY, abs.y)
maxX = Math.max(maxX, abs.x + n.width)
maxY = Math.max(maxY, abs.y + n.height)
}
sx1 = minX * r.zoom + r.panX
sx2 = maxX * r.zoom + r.panX
sy1 = minY * r.zoom + r.panY
sy2 = maxY * r.zoom + r.panY
}
const badgeW = RULER_BADGE_EXCLUSION
canvas.save()
canvas.clipRect(r.ck.LTRBRect(R, 0, vw, R), r.ck.ClipOp.Intersect, false)
const worldLeft = -r.panX / r.zoom
const worldRight = (vw - r.panX) / r.zoom
const startX = Math.floor(worldLeft / step) * step
for (let wx = startX; wx <= worldRight; wx += minorStep) {
const sx = wx * r.zoom + r.panX
if (sx < R) continue
const isMajor = Math.abs(wx % step) < RULER_MAJOR_TOLERANCE
const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK
canvas.drawLine(sx, R - tickLen, sx, R, tickPaint)
if (isMajor && selNodes.length > 0) {
const tooClose = Math.abs(sx - sx1) < badgeW || Math.abs(sx - sx2) < badgeW
if (!tooClose) {
canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font)
}
} else if (isMajor) {
canvas.drawText(rulerLabel(wx), sx + 2, R * RULER_TEXT_BASELINE, textPaint, font)
}
}
canvas.restore()
canvas.save()
canvas.clipRect(r.ck.LTRBRect(0, R, R, vh), r.ck.ClipOp.Intersect, false)
const worldTop = -r.panY / r.zoom
const worldBottom = (vh - r.panY) / r.zoom
const startY = Math.floor(worldTop / step) * step
for (let wy = startY; wy <= worldBottom; wy += minorStep) {
const sy = wy * r.zoom + r.panY
if (sy < R) continue
const isMajor = Math.abs(wy % step) < RULER_MAJOR_TOLERANCE
const tickLen = isMajor ? R * RULER_MAJOR_TICK : R * RULER_MINOR_TICK
canvas.drawLine(R - tickLen, sy, R, sy, tickPaint)
if (isMajor && selNodes.length > 0) {
const tooClose = Math.abs(sy - sy1) < badgeW || Math.abs(sy - sy2) < badgeW
if (!tooClose) {
canvas.save()
canvas.translate(R * RULER_TEXT_BASELINE, sy - 2)
canvas.rotate(-90, 0, 0)
canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font)
canvas.restore()
}
} else if (isMajor) {
canvas.save()
canvas.translate(R * RULER_TEXT_BASELINE, sy - 2)
canvas.rotate(-90, 0, 0)
canvas.drawText(rulerLabel(wy), 0, 3, textPaint, font)
canvas.restore()
}
}
canvas.restore()
if (selNodes.length > 0) {
r.rulerHlPaint.setColor(r.selColor(RULER_HIGHLIGHT_ALPHA))
canvas.drawRect(r.ck.LTRBRect(Math.max(R, sx1), 0, sx2, R), r.rulerHlPaint)
canvas.drawRect(r.ck.LTRBRect(0, Math.max(R, sy1), R, sy2), r.rulerHlPaint)
drawRulerBadge(
r,
canvas,
font,
Math.round((sx1 - r.panX) / r.zoom).toString(),
Math.max(R, sx1),
0,
'horizontal'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sx2 - r.panX) / r.zoom).toString(),
sx2,
0,
'horizontal'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sy1 - r.panY) / r.zoom).toString(),
0,
Math.max(R, sy1),
'vertical'
)
drawRulerBadge(
r,
canvas,
font,
Math.round((sy2 - r.panY) / r.zoom).toString(),
0,
sy2,
'vertical'
)
}
}
export function drawRulerBadge(
r: SkiaRenderer,
canvas: Canvas,
font: InstanceType<CanvasKit['Font']>,
label: string,
x: number,
y: number,
axis: 'horizontal' | 'vertical'
): void {
const R = RULER_SIZE
const glyphIds = font.getGlyphIDs(label)
const widths = font.getGlyphWidths(glyphIds)
const textW = widths.reduce((s, w) => s + w, 0)
const pad = RULER_BADGE_PADDING
const h = RULER_BADGE_HEIGHT
r.rulerBadgePaint.setColor(r.selColor())
if (axis === 'horizontal') {
const bx = x - (textW + pad * 2) / 2
const by = (R - h) / 2
canvas.drawRRect(
r.ck.RRectXY(
r.ck.LTRBRect(bx, by, bx + textW + pad * 2, by + h),
RULER_BADGE_RADIUS,
RULER_BADGE_RADIUS
),
r.rulerBadgePaint
)
canvas.drawText(label, bx + pad, R * RULER_TEXT_BASELINE, r.rulerLabelPaint, font)
} else {
const bw = textW + pad * 2
const bx = (R - h) / 2
const by = y - bw / 2
canvas.save()
canvas.translate(bx + h / 2, by + bw / 2)
canvas.rotate(-90, 0, 0)
canvas.drawRRect(
r.ck.RRectXY(
r.ck.LTRBRect(-bw / 2, -h / 2, bw / 2, h / 2),
RULER_BADGE_RADIUS,
RULER_BADGE_RADIUS
),
r.rulerBadgePaint
)
canvas.drawText(label, -bw / 2 + pad, h / 2 - 3, r.rulerLabelPaint, font)
canvas.restore()
}
}
export function rulerStep(r: SkiaRenderer): number {
const pixelsPerUnit = r.zoom
const rawStep = RULER_TARGET_PIXEL_SPACING / pixelsPerUnit
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)))
const normalized = rawStep / magnitude
if (normalized <= 1) return magnitude
if (normalized <= 2) return 2 * magnitude
if (normalized <= 5) return 5 * magnitude
return 10 * magnitude
}
export function rulerLabel(value: number): string {
return Math.round(value).toString()
}

View file

@ -0,0 +1,490 @@
import { DROP_HIGHLIGHT_ALPHA, DROP_HIGHLIGHT_STROKE, SECTION_CORNER_RADIUS } from '../constants'
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import type { RenderOverlays } from './renderer'
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
r._nodeCount++
const absX = parentAbsX + node.x
const absY = parentAbsY + node.y
const canCull =
node.childIds.length === 0 ||
((node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE') &&
node.clipsContent)
if (canCull) {
const vp = r.worldViewport
let bw = node.width
let 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
if (
cx - diag / 2 > vp.x + vp.w ||
cy - diag / 2 > vp.y + vp.h ||
cx + diag / 2 < vp.x ||
cy + diag / 2 < vp.y
) {
r._culledCount++
return
}
} else if (absX > vp.x + vp.w || absY > vp.y + vp.h || absX + bw < vp.x || absY + bh < vp.y) {
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')
if (layerBlur) {
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
canvas.saveLayer(r.effectLayerPaint)
}
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)
}
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)
}
const isClippableContainer =
node.type === 'FRAME' || node.type === 'COMPONENT' || node.type === 'INSTANCE'
if (isClippableContainer && node.clipsContent && node.childIds.length > 0) {
canvas.save()
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)
}
canvas.restore()
} else {
for (const childId of node.childIds) {
r.renderNode(canvas, graph, childId, overlays, absX, absY)
}
}
if (layerBlur) {
canvas.restore()
}
if (node.opacity < 1) {
canvas.restore()
}
canvas.restore()
}
export function renderSection(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
graph: SceneGraph
): void {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
const rrect = r.ck.RRectXY(rect, SECTION_CORNER_RADIUS, SECTION_CORNER_RADIUS)
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
canvas.drawRRect(rrect, r.fillPaint)
r.fillPaint.setShader(null)
}
for (let si = 0; si < node.strokes.length; si++) {
const stroke = node.strokes[si]!
if (!stroke.visible) continue
const sc = r.resolveStrokeColor(stroke, si, node, graph)
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 (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 rect = r.ck.LTRBRect(0, 0, node.width, node.height)
const rrect = r.ck.RRectXY(rect, 5, 5)
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
canvas.drawRRect(rrect, r.fillPaint)
r.fillPaint.setShader(null)
}
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)
}
}
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 =
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
r.renderEffects(canvas, node, rect, hasRadius, 'behind')
for (let fi = 0; fi < node.fills.length; fi++) {
const fill = node.fills[fi]!
if (!fill.visible) continue
r.applyFill(fill, node, graph, fi)
r.fillPaint.setAlphaf(fill.opacity)
r.drawNodeFill(canvas, node, rect, hasRadius)
r.fillPaint.setShader(null)
}
const sg = node.type === 'VECTOR' ? r.getStrokeGeometry(node) : null
const vectorPaths = !sg && node.type === 'VECTOR' ? r.getVectorPaths(node) : null
for (let si = 0; si < node.strokes.length; si++) {
const stroke = node.strokes[si]!
if (!stroke.visible) continue
const sc = r.resolveStrokeColor(stroke, si, node, graph)
if (sg) {
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 p of sg) canvas.drawPath(p, r.fillPaint)
continue
}
if (vectorPaths) {
const capMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
}
const joinMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel
}
const strokeOpts = {
width: stroke.weight,
miter_limit: 4,
cap: capMap[stroke.cap ?? 'NONE'] ?? r.ck.StrokeCap.Butt,
join: joinMap[stroke.join ?? 'MITER'] ?? r.ck.StrokeJoin.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()
}
}
continue
}
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) {
const capMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
NONE: r.ck.StrokeCap.Butt,
ROUND: r.ck.StrokeCap.Round,
SQUARE: r.ck.StrokeCap.Square
}
r.strokePaint.setStrokeCap(capMap[stroke.cap] ?? r.ck.StrokeCap.Butt)
}
if (stroke.join) {
const joinMap: Record<string, import('canvaskit-wasm').EmbindEnumEntity> = {
MITER: r.ck.StrokeJoin.Miter,
ROUND: r.ck.StrokeJoin.Round,
BEVEL: r.ck.StrokeJoin.Bevel
}
r.strokePaint.setStrokeJoin(joinMap[stroke.join] ?? r.ck.StrokeJoin.Miter)
}
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)
}
}
r.renderEffects(canvas, node, rect, hasRadius, 'front')
}
export function renderEffects(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
pass: 'behind' | 'front'
): void {
for (const effect of node.effects) {
if (!effect.visible) continue
if (pass === 'behind' && effect.type === 'DROP_SHADOW') {
const sp = effect.spread
const sigma = effect.radius / 2
if (node.type === 'TEXT') {
const shadowColor = r.ck.Color4f(
effect.color.r,
effect.color.g,
effect.color.b,
effect.color.a
)
const dropFilter = r.getCachedDropShadow(
effect.offset.x,
effect.offset.y,
sigma,
shadowColor
)
r.effectLayerPaint.setImageFilter(dropFilter)
canvas.saveLayer(r.effectLayerPaint)
r.renderText(canvas, node)
canvas.restore()
} else {
r.auxFill.setColor(
r.color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a)
)
r.auxFill.setMaskFilter(r.getCachedMaskBlur(sigma))
r.auxFill.setImageFilter(null)
canvas.save()
canvas.translate(effect.offset.x, effect.offset.y)
if (node.type === 'ELLIPSE') {
canvas.drawOval(r.ltrb(-sp, -sp, node.width + sp, node.height + sp), r.auxFill)
} else if (hasRadius) {
canvas.drawRRect(r.makeRRectWithSpread(node, sp), r.auxFill)
} else {
canvas.drawRect(r.ltrb(-sp, -sp, node.width + sp, node.height + sp), r.auxFill)
}
canvas.restore()
r.auxFill.setMaskFilter(null)
}
}
if (
(pass === 'behind' && effect.type === 'BACKGROUND_BLUR') ||
(pass === 'front' && effect.type === 'FOREGROUND_BLUR')
) {
r.applyClippedBlur(canvas, node, rect, hasRadius, effect.radius / 2)
}
if (pass === 'front' && effect.type === 'INNER_SHADOW') {
if (node.type === 'TEXT') {
r.effectLayerPaint.setImageFilter(r.getCachedDecalBlur(effect.radius))
r.effectLayerPaint.setColorFilter(
r.ck.ColorFilter.MakeBlend(
r.ck.Color4f(effect.color.r, effect.color.g, effect.color.b, effect.color.a),
r.ck.BlendMode.SrcIn
)
)
canvas.saveLayer(r.effectLayerPaint)
canvas.save()
canvas.translate(effect.offset.x, effect.offset.y)
r.renderText(canvas, node)
canvas.restore()
canvas.restore()
r.effectLayerPaint.setColorFilter(null)
continue
}
const sp = effect.spread
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))
canvas.save()
if (node.type === 'ELLIPSE') {
const path = new r.ck.Path()
path.addOval(rect)
canvas.clipPath(path, r.ck.ClipOp.Intersect, true)
path.delete()
} else if (hasRadius) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else {
canvas.clipRect(rect, r.ck.ClipOp.Intersect, true)
}
const expand = effect.radius * 2
const big = r.ck.LTRBRect(
-expand + effect.offset.x,
-expand + effect.offset.y,
node.width + expand + effect.offset.x,
node.height + expand + effect.offset.y
)
const bigPath = new r.ck.Path()
bigPath.addRect(big)
if (node.type === 'ELLIPSE') {
const innerPath = new r.ck.Path()
const offsetRect = r.ck.LTRBRect(
effect.offset.x + sp,
effect.offset.y + sp,
node.width + effect.offset.x - sp,
node.height + effect.offset.y - sp
)
innerPath.addOval(offsetRect)
bigPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
} else if (hasRadius) {
const innerPath = new r.ck.Path()
innerPath.addRRect(r.makeRRectWithOffset(node, effect.offset.x + sp, effect.offset.y + sp, -sp))
bigPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
} else {
const innerPath = new r.ck.Path()
innerPath.addRect(
r.ck.LTRBRect(
effect.offset.x + sp,
effect.offset.y + sp,
node.width + effect.offset.x - sp,
node.height + effect.offset.y - sp
)
)
bigPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
}
canvas.drawPath(bigPath, r.auxFill)
bigPath.delete()
canvas.restore()
r.auxFill.setImageFilter(null)
}
}
}
export function renderText(r: SkiaRenderer, canvas: Canvas, node: SceneNode): void {
const text = node.text
if (!text) return
if (r.fontsLoaded && r.fontProvider) {
if (r.isNodeFontLoaded(node)) {
const paragraph = r.buildParagraph(node, r.fillPaint.getColor(), { halfLeading: true })
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
} else if (node.textPicture) {
const pic = r.ck.MakePicture(node.textPicture)
if (pic) {
canvas.drawPicture(pic)
pic.delete()
}
} else if (r.textFont) {
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
}
} else if (r.textFont) {
canvas.drawText(text, 0, node.fontSize || r.DEFAULT_FONT_SIZE, r.fillPaint, r.textFont)
}
}

View file

@ -0,0 +1,193 @@
import type { SceneNode } from '../scene-graph'
import type { Canvas, Path } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import { vectorNetworkToPath, geometryBlobToPath } from '../vector'
export function makeNodeShapePath(
r: SkiaRenderer,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
): Path {
const path = new r.ck.Path()
switch (node.type) {
case 'ELLIPSE':
path.addOval(rect)
break
case 'VECTOR': {
const vps = r.getVectorPaths(node)
if (vps) {
for (const vp of vps) path.addPath(vp)
}
break
}
case 'POLYGON':
case 'STAR': {
const polyPath = r.makePolygonPath(node)
path.addPath(polyPath)
polyPath.delete()
break
}
default:
if (hasRadius) {
path.addRRect(r.makeRRect(node))
} else {
path.addRect(rect)
}
}
return path
}
export function makePolygonPath(r: SkiaRenderer, node: SceneNode): Path {
const path = new r.ck.Path()
const cx = node.width / 2
const cy = node.height / 2
const rx = node.width / 2
const ry = node.height / 2
const n = Math.max(3, node.pointCount)
const isStar = node.type === 'STAR'
const innerRatio = isStar ? node.starInnerRadius : 1
const totalPoints = isStar ? n * 2 : n
const angleOffset = -Math.PI / 2
for (let i = 0; i < totalPoints; i++) {
const angle = angleOffset + (2 * Math.PI * i) / totalPoints
const isInner = isStar && i % 2 === 1
const rad = isInner ? innerRatio : 1
const px = cx + rx * rad * Math.cos(angle)
const py = cy + ry * rad * Math.sin(angle)
if (i === 0) path.moveTo(px, py)
else path.lineTo(px, py)
}
path.close()
return path
}
export function makeRRect(r: SkiaRenderer, node: SceneNode): Float32Array {
if (node.independentCorners) {
return new Float32Array([
0,
0,
node.width,
node.height,
node.topLeftRadius,
node.topLeftRadius,
node.topRightRadius,
node.topRightRadius,
node.bottomRightRadius,
node.bottomRightRadius,
node.bottomLeftRadius,
node.bottomLeftRadius
])
}
return r.ck.RRectXY(
r.ck.LTRBRect(0, 0, node.width, node.height),
node.cornerRadius,
node.cornerRadius
)
}
export function makeRRectWithSpread(r: SkiaRenderer, node: SceneNode, spread: number): Float32Array {
if (node.independentCorners) {
return new Float32Array([
-spread,
-spread,
node.width + spread,
node.height + spread,
Math.max(0, node.topLeftRadius + spread),
Math.max(0, node.topLeftRadius + spread),
Math.max(0, node.topRightRadius + spread),
Math.max(0, node.topRightRadius + spread),
Math.max(0, node.bottomRightRadius + spread),
Math.max(0, node.bottomRightRadius + spread),
Math.max(0, node.bottomLeftRadius + spread),
Math.max(0, node.bottomLeftRadius + spread)
])
}
return r.ck.RRectXY(
r.ck.LTRBRect(-spread, -spread, node.width + spread, node.height + spread),
Math.max(0, node.cornerRadius + spread),
Math.max(0, node.cornerRadius + spread)
)
}
export function makeRRectWithOffset(
r: SkiaRenderer,
node: SceneNode,
ox: number,
oy: number,
spread: number
): Float32Array {
const s = spread
if (node.independentCorners) {
return new Float32Array([
ox + s,
oy + s,
node.width + ox - s,
node.height + oy - s,
Math.max(0, node.topLeftRadius - s),
Math.max(0, node.topLeftRadius - s),
Math.max(0, node.topRightRadius - s),
Math.max(0, node.topRightRadius - s),
Math.max(0, node.bottomRightRadius - s),
Math.max(0, node.bottomRightRadius - s),
Math.max(0, node.bottomLeftRadius - s),
Math.max(0, node.bottomLeftRadius - s)
])
}
return r.ck.RRectXY(
r.ck.LTRBRect(ox + s, oy + s, node.width + ox - s, node.height + oy - s),
Math.max(0, node.cornerRadius - s),
Math.max(0, node.cornerRadius - s)
)
}
export function clipNodeShape(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
): void {
if (node.type === 'ELLIPSE') {
const clipPath = new r.ck.Path()
clipPath.addOval(rect)
canvas.clipPath(clipPath, r.ck.ClipOp.Intersect, true)
clipPath.delete()
} else if (hasRadius) {
canvas.clipRRect(r.makeRRect(node), r.ck.ClipOp.Intersect, true)
} else {
canvas.clipRect(rect, r.ck.ClipOp.Intersect, true)
}
}
export function getVectorPaths(r: SkiaRenderer, node: SceneNode): Path[] | null {
if (!node.vectorNetwork) return null
const cached = r.vectorPathCache.get(node.id)
if (cached) return cached
const paths = vectorNetworkToPath(r.ck, node.vectorNetwork)
r.vectorPathCache.set(node.id, paths)
return paths
}
export function getFillGeometry(r: SkiaRenderer, node: SceneNode): Path[] | null {
if (node.fillGeometry.length === 0) return null
const cached = r.fillGeometryCache.get(node.id)
if (cached) return cached
const paths = node.fillGeometry.map((g) =>
geometryBlobToPath(r.ck, g.commandsBlob, g.windingRule)
)
r.fillGeometryCache.set(node.id, paths)
return paths
}
export function getStrokeGeometry(r: SkiaRenderer, node: SceneNode): Path[] | null {
if (node.strokeGeometry.length === 0) return null
const cached = r.strokeGeometryCache.get(node.id)
if (cached) return cached
const paths = node.strokeGeometry.map((g) =>
geometryBlobToPath(r.ck, g.commandsBlob, g.windingRule)
)
r.strokeGeometryCache.set(node.id, paths)
return paths
}

View file

@ -0,0 +1,221 @@
import type { SceneNode, Stroke } from '../scene-graph'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
export function drawNodeStroke(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean
): void {
switch (node.type) {
case 'VECTOR': {
const vps = r.getVectorPaths(node)
if (vps) {
for (const vp of vps) canvas.drawPath(vp, r.strokePaint)
}
break
}
case 'ELLIPSE':
if (node.arcData) {
r.drawArc(canvas, node, r.strokePaint)
} else {
canvas.drawOval(rect, r.strokePaint)
}
break
case 'POLYGON':
case 'STAR': {
const path = r.makePolygonPath(node)
canvas.drawPath(path, r.strokePaint)
path.delete()
break
}
default:
if (hasRadius) {
canvas.drawRRect(r.makeRRect(node), r.strokePaint)
} else {
canvas.drawRect(rect, r.strokePaint)
}
}
}
export function drawStrokeWithAlign(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
rect: Float32Array,
hasRadius: boolean,
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
): void {
if (align === 'INSIDE') {
canvas.save()
r.clipNodeShape(canvas, node, rect, hasRadius)
const origWidth = r.strokePaint.getStrokeWidth()
r.strokePaint.setStrokeWidth(origWidth * 2)
r.drawNodeStroke(canvas, node, rect, hasRadius)
r.strokePaint.setStrokeWidth(origWidth)
canvas.restore()
} else if (align === 'OUTSIDE') {
canvas.save()
const bigRect = r.ck.LTRBRect(
-node.width,
-node.height,
node.width * 2,
node.height * 2
)
const outerPath = new r.ck.Path()
outerPath.addRect(bigRect)
const innerPath = r.makeNodeShapePath(node, rect, hasRadius)
outerPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
canvas.clipPath(outerPath, r.ck.ClipOp.Intersect, true)
outerPath.delete()
const origWidth = r.strokePaint.getStrokeWidth()
r.strokePaint.setStrokeWidth(origWidth * 2)
r.drawNodeStroke(canvas, node, rect, hasRadius)
r.strokePaint.setStrokeWidth(origWidth)
canvas.restore()
} else {
r.drawNodeStroke(canvas, node, rect, hasRadius)
}
}
export function drawRRectStrokeWithAlign(
r: SkiaRenderer,
canvas: Canvas,
rrect: Float32Array,
node: SceneNode,
stroke: Stroke
): void {
if (stroke.align === 'INSIDE') {
canvas.save()
canvas.clipRRect(rrect, r.ck.ClipOp.Intersect, true)
r.strokePaint.setStrokeWidth(stroke.weight * 2)
canvas.drawRRect(rrect, r.strokePaint)
r.strokePaint.setStrokeWidth(stroke.weight)
canvas.restore()
} else if (stroke.align === 'OUTSIDE') {
canvas.save()
const outerPath = new r.ck.Path()
outerPath.addRect(
r.ck.LTRBRect(-node.width, -node.height, node.width * 2, node.height * 2)
)
const innerPath = new r.ck.Path()
innerPath.addRRect(rrect)
outerPath.op(innerPath, r.ck.PathOp.Difference)
innerPath.delete()
canvas.clipPath(outerPath, r.ck.ClipOp.Intersect, true)
outerPath.delete()
r.strokePaint.setStrokeWidth(stroke.weight * 2)
canvas.drawRRect(rrect, r.strokePaint)
r.strokePaint.setStrokeWidth(stroke.weight)
canvas.restore()
} else {
canvas.drawRRect(rrect, r.strokePaint)
}
}
export function drawIndividualSideStrokes(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
align: 'INSIDE' | 'CENTER' | 'OUTSIDE'
): void {
const w = node.width
const h = node.height
const inside = align === 'INSIDE'
const outside = align === 'OUTSIDE'
const tw = node.borderTopWeight
if (tw > 0) {
const y = inside ? tw / 2 : outside ? -tw / 2 : 0
r.strokePaint.setStrokeWidth(tw)
canvas.drawLine(0, y, w, y, r.strokePaint)
}
const rw = node.borderRightWeight
if (rw > 0) {
const x = inside ? w - rw / 2 : outside ? w + rw / 2 : w
r.strokePaint.setStrokeWidth(rw)
canvas.drawLine(x, 0, x, h, r.strokePaint)
}
const bw = node.borderBottomWeight
if (bw > 0) {
const y = inside ? h - bw / 2 : outside ? h + bw / 2 : h
r.strokePaint.setStrokeWidth(bw)
canvas.drawLine(0, y, w, y, r.strokePaint)
}
const lw = node.borderLeftWeight
if (lw > 0) {
const x = inside ? lw / 2 : outside ? -lw / 2 : 0
r.strokePaint.setStrokeWidth(lw)
canvas.drawLine(x, 0, x, h, r.strokePaint)
}
}
export function strokeNodeShape(
r: SkiaRenderer,
canvas: Canvas,
node: SceneNode,
paint: import('canvaskit-wasm').Paint
): void {
const rect = r.ck.LTRBRect(0, 0, node.width, node.height)
switch (node.type) {
case 'ELLIPSE':
canvas.drawOval(rect, paint)
return
case 'VECTOR': {
const vps = r.getVectorPaths(node)
if (vps) {
for (const vp of vps) canvas.drawPath(vp, paint)
}
return
}
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, paint)
return
case 'POLYGON':
case 'STAR': {
const path = r.makePolygonPath(node)
canvas.drawPath(path, paint)
path.delete()
return
}
}
const hasRadius =
node.cornerRadius > 0 ||
(node.independentCorners &&
(node.topLeftRadius > 0 ||
node.topRightRadius > 0 ||
node.bottomRightRadius > 0 ||
node.bottomLeftRadius > 0))
if (hasRadius) {
if (node.independentCorners) {
const rrect = new Float32Array([
0,
0,
node.width,
node.height,
node.topLeftRadius,
node.topLeftRadius,
node.topRightRadius,
node.topRightRadius,
node.bottomRightRadius,
node.bottomRightRadius,
node.bottomLeftRadius,
node.bottomLeftRadius
])
canvas.drawRRect(rrect, paint)
} else {
canvas.drawRRect(r.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius), paint)
}
} else {
canvas.drawRect(rect, paint)
}
}