Split renderer.ts into renderer/ directory
This commit is contained in:
parent
31326e8e5e
commit
859143149e
|
|
@ -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
64
packages/core/src/renderer/effects.ts
Normal file
64
packages/core/src/renderer/effects.ts
Normal 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()
|
||||
}
|
||||
233
packages/core/src/renderer/fills.ts
Normal file
233
packages/core/src/renderer/fills.ts
Normal 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()
|
||||
}
|
||||
1
packages/core/src/renderer/index.ts
Normal file
1
packages/core/src/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SkiaRenderer, type RenderOverlays } from './renderer'
|
||||
212
packages/core/src/renderer/labels.ts
Normal file
212
packages/core/src/renderer/labels.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
760
packages/core/src/renderer/overlays.ts
Normal file
760
packages/core/src/renderer/overlays.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1144
packages/core/src/renderer/renderer.ts
Normal file
1144
packages/core/src/renderer/renderer.ts
Normal file
File diff suppressed because it is too large
Load diff
235
packages/core/src/renderer/rulers.ts
Normal file
235
packages/core/src/renderer/rulers.ts
Normal 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()
|
||||
}
|
||||
490
packages/core/src/renderer/scene.ts
Normal file
490
packages/core/src/renderer/scene.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
193
packages/core/src/renderer/shapes.ts
Normal file
193
packages/core/src/renderer/shapes.ts
Normal 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
|
||||
}
|
||||
221
packages/core/src/renderer/strokes.ts
Normal file
221
packages/core/src/renderer/strokes.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue