openpencil/src/engine/renderer.ts
Danila Poyarkov 2152a9b2d6 Fix vertical ruler text clipping
Clip both ruler strips to their bounds, reposition vertical text
to stay centered within the 20px ruler width.
2026-02-28 09:39:08 +03:00

1049 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { SELECTION_COLOR, SNAP_COLOR, CANVAS_BG_COLOR, HANDLE_SIZE, ROTATION_HANDLE_OFFSET } from '../constants'
import type { SceneNode, SceneGraph, Fill } from './scene-graph'
import type { SnapGuide } from './snap'
import { vectorNetworkToPath } from './vector'
import type {
CanvasKit,
Surface,
Canvas,
Paint,
Font,
FontMgr,
TypefaceFontProvider
} from 'canvaskit-wasm'
export interface RenderOverlays {
editingTextId?: string | null
marquee?: { x: number; y: number; width: number; height: number } | null
snapGuides?: SnapGuide[]
rotationPreview?: { nodeId: string; angle: number } | null
dropTargetId?: string | null
layoutInsertIndicator?: {
x: number
y: number
length: number
direction: 'HORIZONTAL' | 'VERTICAL'
} | null
penState?: {
vertices: Array<{ x: number; y: number }>
segments: Array<{ start: number; end: number; tangentStart: { x: number; y: number }; tangentEnd: { x: number; y: number } }>
dragTangent: { x: number; y: number } | null
closingToFirst: boolean
cursorX?: number
cursorY?: number
} | null
}
export class SkiaRenderer {
private ck: CanvasKit
private surface: Surface
private fillPaint: Paint
private strokePaint: Paint
private selectionPaint: Paint
private parentOutlinePaint: Paint
private snapPaint: Paint
private textFont: Font | null = null
private labelFont: Font | null = null
private sizeFont: Font | null = null
private fontMgr: FontMgr | null = null
private fontProvider: TypefaceFontProvider | null = null
private fontsLoaded = false
panX = 0
panY = 0
zoom = 1
dpr = 1
viewportWidth = 0
viewportHeight = 0
showRulers = true
private selColor(alpha = 1) {
return this.ck.Color4f(SELECTION_COLOR.r, SELECTION_COLOR.g, SELECTION_COLOR.b, alpha)
}
constructor(ck: CanvasKit, surface: Surface) {
this.ck = ck
this.surface = surface
this.fillPaint = new ck.Paint()
this.fillPaint.setStyle(ck.PaintStyle.Fill)
this.fillPaint.setAntiAlias(true)
this.strokePaint = new ck.Paint()
this.strokePaint.setStyle(ck.PaintStyle.Stroke)
this.strokePaint.setAntiAlias(true)
this.selectionPaint = new ck.Paint()
this.selectionPaint.setStyle(ck.PaintStyle.Stroke)
this.selectionPaint.setStrokeWidth(1)
this.selectionPaint.setColor(this.selColor())
this.selectionPaint.setAntiAlias(true)
this.parentOutlinePaint = new ck.Paint()
this.parentOutlinePaint.setStyle(ck.PaintStyle.Stroke)
this.parentOutlinePaint.setStrokeWidth(1)
this.parentOutlinePaint.setColor(this.selColor(0.5))
this.parentOutlinePaint.setAntiAlias(true)
this.parentOutlinePaint.setPathEffect(ck.PathEffect.MakeDash([4, 4], 0))
this.snapPaint = new ck.Paint()
this.snapPaint.setStyle(ck.PaintStyle.Stroke)
this.snapPaint.setStrokeWidth(1)
this.snapPaint.setColor(this.ck.Color4f(SNAP_COLOR.r, SNAP_COLOR.g, SNAP_COLOR.b, 1))
this.snapPaint.setAntiAlias(true)
this.textFont = new ck.Font(null, 14)
}
async loadFonts(): Promise<void> {
this.fontProvider = this.ck.TypefaceFontProvider.Make()
const { initFontService, loadFont } = await import('./fonts')
initFontService(this.ck, this.fontProvider)
const fontData = await loadFont('Inter', 'Regular')
if (fontData) {
const typeface = this.ck.Typeface.MakeFreeTypeFaceFromData(fontData)
if (typeface) {
this.textFont?.delete()
this.labelFont?.delete()
this.sizeFont?.delete()
this.textFont = new this.ck.Font(typeface, 14)
this.labelFont = new this.ck.Font(typeface, 11)
this.sizeFont = new this.ck.Font(typeface, 10)
}
this.fontMgr = this.ck.FontMgr.FromData(fontData) ?? null
}
this.fontsLoaded = true
}
render(graph: SceneGraph, selectedIds: Set<string>, overlays: RenderOverlays = {}): void {
const canvas = this.surface.getCanvas()
canvas.clear(this.ck.Color4f(CANVAS_BG_COLOR.r, CANVAS_BG_COLOR.g, CANVAS_BG_COLOR.b, 1))
// Scene layer (world coordinates)
canvas.save()
canvas.scale(this.dpr, this.dpr)
canvas.translate(this.panX, this.panY)
canvas.scale(this.zoom, this.zoom)
const root = graph.getNode(graph.rootId)
if (root) {
for (const childId of root.childIds) {
this.renderNode(canvas, graph, childId, overlays)
}
}
canvas.restore()
// UI overlay layer (screen coordinates, zoom-independent)
canvas.save()
canvas.scale(this.dpr, this.dpr)
this.drawSelection(canvas, graph, selectedIds, overlays)
this.drawSnapGuides(canvas, overlays.snapGuides)
this.drawMarquee(canvas, overlays.marquee)
this.drawLayoutInsertIndicator(canvas, overlays.layoutInsertIndicator)
this.drawPenOverlay(canvas, overlays.penState)
if (this.showRulers) this.drawRulers(canvas, graph, selectedIds)
canvas.restore()
this.surface.flush()
}
// --- Selection UI ---
private drawSelection(
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>,
overlays: RenderOverlays
): void {
if (selectedIds.size === 0) return
this.selectionPaint.setStrokeWidth(1)
this.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 rotation =
overlays.rotationPreview?.nodeId === id ? overlays.rotationPreview.angle : node.rotation
this.drawNodeSelection(canvas, node, rotation, graph)
this.drawSelectionLabels(canvas, graph, selectedIds)
return
}
for (const id of selectedIds) {
const node = graph.getNode(id)
if (!node) continue
const rotation =
overlays.rotationPreview?.nodeId === id ? overlays.rotationPreview.angle : node.rotation
this.drawNodeOutline(canvas, node, rotation, graph)
}
const nodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
this.drawGroupBounds(canvas, nodes, graph)
this.drawSelectionLabels(canvas, graph, selectedIds)
}
private drawNodeSelection(
canvas: Canvas,
node: SceneNode,
rotation: number,
graph: SceneGraph
): void {
const abs = graph.getAbsolutePosition(node.id)
const cx = (abs.x + node.width / 2) * this.zoom + this.panX
const cy = (abs.y + node.height / 2) * this.zoom + this.panY
const hw = (node.width / 2) * this.zoom
const hh = (node.height / 2) * this.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(this.ck.LTRBRect(x1, y1, x2, y2), this.selectionPaint)
// Corner handles
this.drawHandle(canvas, x1, y1)
this.drawHandle(canvas, x2, y1)
this.drawHandle(canvas, x1, y2)
this.drawHandle(canvas, x2, y2)
// Edge handles
const mx = (x1 + x2) / 2
const my = (y1 + y2) / 2
this.drawHandle(canvas, mx, y1)
this.drawHandle(canvas, mx, y2)
this.drawHandle(canvas, x1, my)
this.drawHandle(canvas, x2, my)
// Rotation handle (line extending above + circle)
const rotHandleY = y1 - 24
const rotLinePaint = new this.ck.Paint()
rotLinePaint.setStyle(this.ck.PaintStyle.Stroke)
rotLinePaint.setStrokeWidth(1)
rotLinePaint.setColor(this.selColor())
rotLinePaint.setAntiAlias(true)
canvas.drawLine(mx, y1, mx, rotHandleY, rotLinePaint)
const rotFill = new this.ck.Paint()
rotFill.setStyle(this.ck.PaintStyle.Fill)
rotFill.setColor(this.ck.WHITE)
rotFill.setAntiAlias(true)
canvas.drawCircle(mx, rotHandleY, 4, rotFill)
canvas.drawCircle(mx, rotHandleY, 4, rotLinePaint)
rotLinePaint.delete()
rotFill.delete()
canvas.restore()
}
private drawSelectionLabels(
canvas: Canvas,
graph: SceneGraph,
selectedIds: Set<string>
): void {
if (!this.labelFont || !this.sizeFont) return
// Compute bounding box of all selected nodes
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 * this.zoom + this.panX
const sy1 = minY * this.zoom + this.panY
const sx2 = maxX * this.zoom + this.panX
const sy2 = maxY * this.zoom + this.panY
const smx = (sx1 + sx2) / 2
// Frame name label — only for top-level frames (direct children of root)
if (nodes.length === 1) {
const node = nodes[0]
if (node.type === 'FRAME' && node.parentId === graph.rootId) {
const labelPaint = new this.ck.Paint()
labelPaint.setStyle(this.ck.PaintStyle.Fill)
labelPaint.setColor(this.selColor())
labelPaint.setAntiAlias(true)
canvas.drawText(node.name, sx1, sy1 - 8, labelPaint, this.labelFont)
labelPaint.delete()
}
}
// Size widget — blue pill below selection
const w = Math.round(maxX - minX)
const h = Math.round(maxY - minY)
const sizeText = `${w} × ${h}`
const glyphIds = this.sizeFont.getGlyphIDs(sizeText)
const widths = this.sizeFont.getGlyphWidths(glyphIds)
let textWidth = 0
for (let i = 0; i < widths.length; i++) textWidth += widths[i]
const pillW = textWidth + 12
const pillH = 18
const pillX = smx - pillW / 2
const pillY = sy2 + 6
const pillPaint = new this.ck.Paint()
pillPaint.setStyle(this.ck.PaintStyle.Fill)
pillPaint.setColor(this.selColor())
pillPaint.setAntiAlias(true)
const rrect = this.ck.RRectXY(this.ck.LTRBRect(pillX, pillY, pillX + pillW, pillY + pillH), 4, 4)
canvas.drawRRect(rrect, pillPaint)
const textPaint = new this.ck.Paint()
textPaint.setStyle(this.ck.PaintStyle.Fill)
textPaint.setColor(this.ck.WHITE)
textPaint.setAntiAlias(true)
canvas.drawText(sizeText, pillX + 6, pillY + 13, textPaint, this.sizeFont)
pillPaint.delete()
textPaint.delete()
}
private drawParentFrameOutlines(
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 || node.parentId === graph.rootId) continue
if (drawn.has(node.parentId) || selectedIds.has(node.parentId)) continue
const parent = graph.getNode(node.parentId)
if (!parent) continue
// Skip dashed outline for top-level frames (direct children of root)
if (parent.parentId === graph.rootId) continue
drawn.add(node.parentId)
const abs = graph.getAbsolutePosition(parent.id)
const x = abs.x * this.zoom + this.panX
const y = abs.y * this.zoom + this.panY
const w = parent.width * this.zoom
const h = parent.height * this.zoom
canvas.save()
if (parent.rotation !== 0) {
canvas.rotate(parent.rotation, x + w / 2, y + h / 2)
}
canvas.drawRect(this.ck.LTRBRect(x, y, x + w, y + h), this.parentOutlinePaint)
canvas.restore()
}
}
private drawNodeOutline(
canvas: Canvas,
node: SceneNode,
rotation: number,
graph: SceneGraph
): void {
const abs = graph.getAbsolutePosition(node.id)
const cx = (abs.x + node.width / 2) * this.zoom + this.panX
const cy = (abs.y + node.height / 2) * this.zoom + this.panY
const hw = (node.width / 2) * this.zoom
const hh = (node.height / 2) * this.zoom
canvas.save()
if (rotation !== 0) {
canvas.rotate(rotation, cx, cy)
}
canvas.drawRect(this.ck.LTRBRect(cx - hw, cy - hh, cx + hw, cy + hh), this.selectionPaint)
canvas.restore()
}
private drawGroupBounds(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 = this.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 * this.zoom + this.panX
const y1 = abs.y * this.zoom + this.panY
const x2 = (abs.x + n.width) * this.zoom + this.panX
const y2 = (abs.y + n.height) * this.zoom + this.panY
minX = Math.min(minX, x1)
minY = Math.min(minY, y1)
maxX = Math.max(maxX, x2)
maxY = Math.max(maxY, y2)
}
}
// Dashed bounding box
const dashPaint = new this.ck.Paint()
dashPaint.setStyle(this.ck.PaintStyle.Stroke)
dashPaint.setStrokeWidth(1)
dashPaint.setColor(this.selColor(0.6))
dashPaint.setAntiAlias(true)
canvas.drawRect(this.ck.LTRBRect(minX, minY, maxX, maxY), dashPaint)
// Group resize handles
this.drawHandle(canvas, minX, minY)
this.drawHandle(canvas, maxX, minY)
this.drawHandle(canvas, minX, maxY)
this.drawHandle(canvas, maxX, maxY)
const gmx = (minX + maxX) / 2
const gmy = (minY + maxY) / 2
this.drawHandle(canvas, gmx, minY)
this.drawHandle(canvas, gmx, maxY)
this.drawHandle(canvas, minX, gmy)
this.drawHandle(canvas, maxX, gmy)
dashPaint.delete()
}
private getRotatedCorners(n: SceneNode, abs: { x: number; y: number }) {
const cx = (abs.x + n.width / 2) * this.zoom + this.panX
const cy = (abs.y + n.height / 2) * this.zoom + this.panY
const hw = (n.width / 2) * this.zoom
const hh = (n.height / 2) * this.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 }
]
}
private drawHandle(canvas: Canvas, x: number, y: number): void {
const S = 3
const handleFill = new this.ck.Paint()
handleFill.setStyle(this.ck.PaintStyle.Fill)
handleFill.setColor(this.ck.WHITE)
const rect = this.ck.LTRBRect(x - S, y - S, x + S, y + S)
canvas.drawRect(rect, handleFill)
canvas.drawRect(rect, this.selectionPaint)
handleFill.delete()
}
// --- Snap guides ---
private drawSnapGuides(canvas: Canvas, guides?: SnapGuide[]): void {
if (!guides || guides.length === 0) return
for (const guide of guides) {
if (guide.axis === 'x') {
const x = guide.position * this.zoom + this.panX
const y1 = guide.from * this.zoom + this.panY
const y2 = guide.to * this.zoom + this.panY
canvas.drawLine(x, y1, x, y2, this.snapPaint)
} else {
const y = guide.position * this.zoom + this.panY
const x1 = guide.from * this.zoom + this.panX
const x2 = guide.to * this.zoom + this.panX
canvas.drawLine(x1, y, x2, y, this.snapPaint)
}
}
}
// --- Marquee ---
private drawMarquee(
canvas: Canvas,
marquee?: { x: number; y: number; width: number; height: number } | null
): void {
if (!marquee || marquee.width <= 0 || marquee.height <= 0) return
const x1 = marquee.x * this.zoom + this.panX
const y1 = marquee.y * this.zoom + this.panY
const x2 = (marquee.x + marquee.width) * this.zoom + this.panX
const y2 = (marquee.y + marquee.height) * this.zoom + this.panY
const rect = this.ck.LTRBRect(x1, y1, x2, y2)
const fill = new this.ck.Paint()
fill.setStyle(this.ck.PaintStyle.Fill)
fill.setColor(this.selColor(0.08))
canvas.drawRect(rect, fill)
canvas.drawRect(rect, this.selectionPaint)
fill.delete()
}
// --- Layout insert indicator ---
private drawLayoutInsertIndicator(
canvas: Canvas,
indicator?: RenderOverlays['layoutInsertIndicator']
): void {
if (!indicator) return
const paint = new this.ck.Paint()
paint.setStyle(this.ck.PaintStyle.Stroke)
paint.setStrokeWidth(2)
paint.setColor(this.selColor())
paint.setAntiAlias(true)
if (indicator.direction === 'HORIZONTAL') {
const y = indicator.y * this.zoom + this.panY
const x1 = indicator.x * this.zoom + this.panX
const x2 = (indicator.x + indicator.length) * this.zoom + this.panX
canvas.drawLine(x1, y, x2, y, paint)
} else {
const x = indicator.x * this.zoom + this.panX
const y1 = indicator.y * this.zoom + this.panY
const y2 = (indicator.y + indicator.length) * this.zoom + this.panY
canvas.drawLine(x, y1, x, y2, paint)
}
paint.delete()
}
// --- Scene rendering ---
private renderNode(
canvas: Canvas,
graph: SceneGraph,
nodeId: string,
overlays: RenderOverlays
): void {
const node = graph.getNode(nodeId)
if (!node || !node.visible) return
canvas.save()
canvas.translate(node.x, node.y)
if (node.opacity < 1) {
const layerPaint = new this.ck.Paint()
layerPaint.setAlphaf(node.opacity)
canvas.saveLayer(layerPaint)
layerPaint.delete()
}
const rotation =
overlays.rotationPreview?.nodeId === nodeId ? overlays.rotationPreview.angle : node.rotation
if (rotation !== 0) {
canvas.rotate(rotation, node.width / 2, node.height / 2)
}
if (overlays.editingTextId !== nodeId) {
this.renderShape(canvas, node)
}
// Drop target highlight
if (overlays.dropTargetId === nodeId) {
const highlight = new this.ck.Paint()
highlight.setStyle(this.ck.PaintStyle.Stroke)
highlight.setStrokeWidth(2 / this.zoom)
highlight.setColor(this.selColor(0.8))
highlight.setAntiAlias(true)
canvas.drawRect(this.ck.LTRBRect(0, 0, node.width, node.height), highlight)
highlight.delete()
}
// Clip + render children for containers
if (node.type === 'FRAME' && node.childIds.length > 0) {
canvas.save()
canvas.clipRect(
this.ck.LTRBRect(0, 0, node.width, node.height),
this.ck.ClipOp.Intersect,
true
)
for (const childId of node.childIds) {
this.renderNode(canvas, graph, childId, overlays)
}
canvas.restore()
} else {
for (const childId of node.childIds) {
this.renderNode(canvas, graph, childId, overlays)
}
}
if (node.opacity < 1) {
canvas.restore()
}
canvas.restore()
}
private renderShape(canvas: Canvas, node: SceneNode): void {
const rect = this.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))
// Fills
for (const fill of node.fills) {
if (!fill.visible) continue
this.applyFill(fill)
this.fillPaint.setAlphaf(fill.opacity)
switch (node.type) {
case 'VECTOR':
if (node.vectorNetwork) {
const vp = vectorNetworkToPath(this.ck, node.vectorNetwork)
canvas.drawPath(vp, this.fillPaint)
vp.delete()
}
break
case 'ELLIPSE':
canvas.drawOval(rect, this.fillPaint)
break
case 'TEXT':
this.renderText(canvas, node)
break
case 'LINE':
canvas.drawLine(0, 0, node.width, node.height, this.fillPaint)
break
default:
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, this.fillPaint)
} else {
const rrect = this.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius)
canvas.drawRRect(rrect, this.fillPaint)
}
} else {
canvas.drawRect(rect, this.fillPaint)
}
}
}
// Strokes
for (const stroke of node.strokes) {
if (!stroke.visible) continue
this.strokePaint.setColor(
this.ck.Color4f(stroke.color.r, stroke.color.g, stroke.color.b, stroke.color.a)
)
this.strokePaint.setStrokeWidth(stroke.weight)
this.strokePaint.setAlphaf(stroke.opacity)
switch (node.type) {
case 'VECTOR':
if (node.vectorNetwork) {
const vp = vectorNetworkToPath(this.ck, node.vectorNetwork)
canvas.drawPath(vp, this.strokePaint)
vp.delete()
}
break
case 'ELLIPSE':
canvas.drawOval(rect, this.strokePaint)
break
default:
if (hasRadius && !node.independentCorners) {
const rrect = this.ck.RRectXY(rect, node.cornerRadius, node.cornerRadius)
canvas.drawRRect(rrect, this.strokePaint)
} else {
canvas.drawRect(rect, this.strokePaint)
}
}
}
}
private renderText(canvas: Canvas, node: SceneNode): void {
const text = node.text
if (!text) return
if (this.fontsLoaded && this.fontProvider) {
const paraStyle = new this.ck.ParagraphStyle({
textAlign: this.getTextAlign(node.textAlignHorizontal),
textStyle: {
color: this.fillPaint.getColor(),
fontFamilies: [node.fontFamily || 'Inter'],
fontSize: node.fontSize || 14,
fontStyle: { weight: node.fontWeight || 400 },
letterSpacing: node.letterSpacing || 0,
heightMultiplier: node.lineHeight ? node.lineHeight / (node.fontSize || 14) : undefined
}
})
const builder = this.ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, this.fontProvider)
builder.addText(text)
const paragraph = builder.build()
paragraph.layout(node.width || 1e6)
canvas.drawParagraph(paragraph, 0, 0)
paragraph.delete()
builder.delete()
} else if (this.textFont) {
canvas.drawText(text, 0, node.fontSize || 14, this.fillPaint, this.textFont)
}
}
private getTextAlign(align: string) {
switch (align) {
case 'CENTER':
return this.ck.TextAlign.Center
case 'RIGHT':
return this.ck.TextAlign.Right
case 'JUSTIFIED':
return this.ck.TextAlign.Justify
default:
return this.ck.TextAlign.Left
}
}
private applyFill(fill: Fill): void {
if (fill.type === 'SOLID') {
this.fillPaint.setColor(
this.ck.Color4f(fill.color.r, fill.color.g, fill.color.b, fill.color.a)
)
}
}
screenToCanvas(sx: number, sy: number): { x: number; y: number } {
return {
x: (sx - this.panX) / this.zoom,
y: (sy - this.panY) / this.zoom
}
}
private drawPenOverlay(canvas: Canvas, penState: RenderOverlays['penState']): void {
if (!penState || penState.vertices.length === 0) return
const { vertices, segments, dragTangent, cursorX, cursorY } = penState
const VERTEX_RADIUS = 4
const pathPaint = new this.ck.Paint()
pathPaint.setStyle(this.ck.PaintStyle.Stroke)
pathPaint.setStrokeWidth(2)
pathPaint.setColor(this.selColor())
pathPaint.setAntiAlias(true)
const handlePaint = new this.ck.Paint()
handlePaint.setStyle(this.ck.PaintStyle.Stroke)
handlePaint.setStrokeWidth(1)
handlePaint.setColor(this.selColor(0.5))
handlePaint.setAntiAlias(true)
const vertexFill = new this.ck.Paint()
vertexFill.setStyle(this.ck.PaintStyle.Fill)
vertexFill.setColor(this.ck.WHITE)
vertexFill.setAntiAlias(true)
const vertexStroke = new this.ck.Paint()
vertexStroke.setStyle(this.ck.PaintStyle.Stroke)
vertexStroke.setStrokeWidth(2)
vertexStroke.setColor(this.selColor())
vertexStroke.setAntiAlias(true)
const toScreen = (x: number, y: number) => ({
x: x * this.zoom + this.panX,
y: y * this.zoom + this.panY
})
// Draw existing segments
const path = new this.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)
}
}
// Preview line from last vertex to cursor
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()
// Draw tangent handle lines
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, 3, vertexFill)
canvas.drawCircle(cp.x, cp.y, 3, 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, 3, vertexFill)
canvas.drawCircle(cp.x, cp.y, 3, handlePaint)
}
}
// Draw current drag tangent handles
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, 3, vertexFill)
canvas.drawCircle(cp1.x, cp1.y, 3, handlePaint)
canvas.drawCircle(cp2.x, cp2.y, 3, vertexFill)
canvas.drawCircle(cp2.x, cp2.y, 3, handlePaint)
}
// Draw vertices
for (let i = 0; i < vertices.length; i++) {
const v = toScreen(vertices[i].x, vertices[i].y)
const radius = i === 0 && penState.closingToFirst ? VERTEX_RADIUS + 2 : VERTEX_RADIUS
canvas.drawCircle(v.x, v.y, radius, vertexFill)
canvas.drawCircle(v.x, v.y, radius, vertexStroke)
}
pathPaint.delete()
handlePaint.delete()
vertexFill.delete()
vertexStroke.delete()
}
// --- Rulers ---
private static readonly RULER_SIZE = 20
private static readonly RULER_BG = { r: 0.14, g: 0.14, b: 0.14 }
private static readonly RULER_TICK = { r: 0.4, g: 0.4, b: 0.4 }
private static readonly RULER_TEXT = { r: 0.55, g: 0.55, b: 0.55 }
private drawRulers(canvas: Canvas, graph: SceneGraph, selectedIds: Set<string>): void {
const R = SkiaRenderer.RULER_SIZE
const vw = this.viewportWidth
const vh = this.viewportHeight
if (vw === 0 || vh === 0) return
const bg = SkiaRenderer.RULER_BG
const bgPaint = new this.ck.Paint()
bgPaint.setColor(this.ck.Color4f(bg.r, bg.g, bg.b, 1))
canvas.drawRect(this.ck.LTRBRect(0, 0, vw, R), bgPaint)
canvas.drawRect(this.ck.LTRBRect(0, R, R, vh), bgPaint)
// Corner square
canvas.drawRect(this.ck.LTRBRect(0, 0, R, R), bgPaint)
const tickPaint = new this.ck.Paint()
tickPaint.setColor(this.ck.Color4f(SkiaRenderer.RULER_TICK.r, SkiaRenderer.RULER_TICK.g, SkiaRenderer.RULER_TICK.b, 1))
tickPaint.setStrokeWidth(1)
tickPaint.setAntiAlias(true)
const textColor = SkiaRenderer.RULER_TEXT
const textPaint = new this.ck.Paint()
textPaint.setColor(this.ck.Color4f(textColor.r, textColor.g, textColor.b, 1))
textPaint.setAntiAlias(true)
const font = this.sizeFont ?? this.textFont
if (!font) { bgPaint.delete(); tickPaint.delete(); textPaint.delete(); return }
const step = this.rulerStep()
const minorStep = step / 5
// Horizontal ruler (clipped)
canvas.save()
canvas.clipRect(this.ck.LTRBRect(R, 0, vw, R), this.ck.ClipOp.Intersect, false)
const worldLeft = -this.panX / this.zoom
const worldRight = (vw - this.panX) / this.zoom
const startX = Math.floor(worldLeft / step) * step
for (let wx = startX; wx <= worldRight; wx += minorStep) {
const sx = wx * this.zoom + this.panX
if (sx < R) continue
const isMajor = Math.abs(wx % step) < 0.01
const tickLen = isMajor ? R * 0.5 : R * 0.25
canvas.drawLine(sx, R - tickLen, sx, R, tickPaint)
if (isMajor) {
const label = this.rulerLabel(wx)
canvas.drawText(label, sx + 2, R - tickLen - 2, textPaint, font)
}
}
canvas.restore()
// Vertical ruler (clipped)
canvas.save()
canvas.clipRect(this.ck.LTRBRect(0, R, R, vh), this.ck.ClipOp.Intersect, false)
const worldTop = -this.panY / this.zoom
const worldBottom = (vh - this.panY) / this.zoom
const startY = Math.floor(worldTop / step) * step
for (let wy = startY; wy <= worldBottom; wy += minorStep) {
const sy = wy * this.zoom + this.panY
if (sy < R) continue
const isMajor = Math.abs(wy % step) < 0.01
const tickLen = isMajor ? R * 0.5 : R * 0.25
canvas.drawLine(R - tickLen, sy, R, sy, tickPaint)
if (isMajor) {
const label = this.rulerLabel(wy)
canvas.save()
canvas.translate(R * 0.5, sy - 3)
canvas.rotate(-90, 0, 0)
canvas.drawText(label, 0, 3, textPaint, font)
canvas.restore()
}
}
canvas.restore()
// Selection highlight on rulers
if (selectedIds.size > 0) {
const hlPaint = new this.ck.Paint()
hlPaint.setColor(this.selColor())
const labelPaint = new this.ck.Paint()
labelPaint.setColor(this.ck.Color4f(1, 1, 1, 1))
labelPaint.setAntiAlias(true)
const nodes = [...selectedIds]
.map((id) => graph.getNode(id))
.filter((n): n is SceneNode => n !== undefined)
if (nodes.length > 0) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const n of nodes) {
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)
}
const sx1 = minX * this.zoom + this.panX
const sx2 = maxX * this.zoom + this.panX
const sy1 = minY * this.zoom + this.panY
const sy2 = maxY * this.zoom + this.panY
// Horizontal highlight
canvas.drawRect(this.ck.LTRBRect(Math.max(R, sx1), 0, sx2, R), hlPaint)
// Vertical highlight
canvas.drawRect(this.ck.LTRBRect(0, Math.max(R, sy1), R, sy2), hlPaint)
// Coordinate labels on highlights
const x1Label = Math.round(minX).toString()
const y1Label = Math.round(minY).toString()
canvas.drawText(x1Label, Math.max(R + 1, sx1 + 2), R - 4, labelPaint, font)
canvas.drawText(y1Label, 2, Math.max(R + 10, sy1 + 10), labelPaint, font)
}
hlPaint.delete()
labelPaint.delete()
}
bgPaint.delete()
tickPaint.delete()
textPaint.delete()
}
private rulerStep(): number {
const pixelsPerUnit = this.zoom
const targetPixelSpacing = 100
const rawStep = targetPixelSpacing / 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
}
private rulerLabel(value: number): string {
const abs = Math.abs(value)
if (abs >= 1000) return Math.round(value).toString()
if (abs >= 1) return Math.round(value).toString()
return value.toFixed(1)
}
destroy(): void {
this.fillPaint.delete()
this.strokePaint.delete()
this.selectionPaint.delete()
this.snapPaint.delete()
this.textFont?.delete()
this.labelFont?.delete()
this.sizeFont?.delete()
this.fontMgr?.delete()
this.fontProvider?.delete()
this.surface.delete()
}
}