Files
SnipMC/Sources/Editor/AnnotationRenderer.swift
T
marcoandClaude Opus 4.6 8d6c2213ad Fix arrows not drawing when dragged left or upward
CGRect.maxX/maxY standardize to positive values, collapsing
the arrow endpoint onto the start point for negative width/height.
Use origin + size instead to preserve direction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-22 07:39:56 +02:00

145 lines
5.3 KiB
Swift

import AppKit
import Foundation
enum AnnotationRenderer {
static func draw(_ item: AnnotationItem, in context: CGContext) {
context.saveGState()
context.setStrokeColor(item.color.cgColor)
context.setFillColor(item.color.cgColor)
context.setLineWidth(item.lineWidth)
context.setLineCap(.round)
context.setLineJoin(.round)
switch item.kind {
case .arrow:
drawArrow(item, in: context)
case .rectangle:
drawRectangle(item, in: context)
case .ellipse:
drawEllipse(item, in: context)
case .text:
drawText(item)
}
context.restoreGState()
}
static func render(annotations: [AnnotationItem], onto image: NSImage) -> NSImage {
let size = image.size
let result = NSImage(size: size)
result.lockFocusFlipped(true)
image.draw(in: NSRect(origin: .zero, size: size),
from: .zero, operation: .sourceOver, fraction: 1,
respectFlipped: true, hints: nil)
if let context = NSGraphicsContext.current?.cgContext {
for item in annotations {
draw(item, in: context)
}
}
result.unlockFocus()
return result
}
// MARK: - Shape drawing
private static func drawArrow(_ item: AnnotationItem, in context: CGContext) {
let start = item.rect.origin
let end = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
context.move(to: start)
context.addLine(to: end)
context.strokePath()
let angle = atan2(end.y - start.y, end.x - start.x)
let headLength = max(item.lineWidth * 4, 14)
let headAngle: CGFloat = .pi / 6
let left = CGPoint(
x: end.x - headLength * cos(angle - headAngle),
y: end.y - headLength * sin(angle - headAngle)
)
let right = CGPoint(
x: end.x - headLength * cos(angle + headAngle),
y: end.y - headLength * sin(angle + headAngle)
)
context.move(to: end)
context.addLine(to: left)
context.addLine(to: right)
context.closePath()
context.fillPath()
}
private static func drawRectangle(_ item: AnnotationItem, in context: CGContext) {
context.stroke(item.rect, width: item.lineWidth)
}
private static func drawEllipse(_ item: AnnotationItem, in context: CGContext) {
context.strokeEllipse(in: item.rect)
}
private static func drawText(_ item: AnnotationItem) {
guard let text = item.text, !text.isEmpty else { return }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: item.color,
]
let string = NSAttributedString(string: text, attributes: attributes)
string.draw(at: item.rect.origin)
}
// MARK: - Hit testing
static func hitTest(point: CGPoint, item: AnnotationItem, tolerance: CGFloat = 6) -> Bool {
switch item.kind {
case .arrow:
let arrowEnd = CGPoint(x: item.rect.origin.x + item.rect.size.width,
y: item.rect.origin.y + item.rect.size.height)
return distanceToLine(point: point,
from: item.rect.origin,
to: arrowEnd) < tolerance + item.lineWidth
case .rectangle:
let outer = item.rect.insetBy(dx: -(tolerance + item.lineWidth),
dy: -(tolerance + item.lineWidth))
let inner = item.rect.insetBy(dx: tolerance + item.lineWidth,
dy: tolerance + item.lineWidth)
return outer.contains(point) && !inner.contains(point)
case .ellipse:
let center = CGPoint(x: item.rect.midX, y: item.rect.midY)
let rx = item.rect.width / 2
let ry = item.rect.height / 2
guard rx > 0, ry > 0 else { return false }
let dx = (point.x - center.x) / rx
let dy = (point.y - center.y) / ry
let dist = sqrt(dx * dx + dy * dy)
return abs(dist - 1.0) < (tolerance + item.lineWidth) / min(rx, ry)
case .text:
guard let text = item.text, !text.isEmpty else { return false }
let font = NSFont.systemFont(ofSize: item.fontSize, weight: .semibold)
let size = (text as NSString).size(withAttributes: [.font: font])
let textRect = CGRect(origin: item.rect.origin, size: size)
return textRect.insetBy(dx: -tolerance, dy: -tolerance).contains(point)
}
}
private static func distanceToLine(point: CGPoint, from a: CGPoint, to b: CGPoint) -> CGFloat {
let dx = b.x - a.x
let dy = b.y - a.y
let lengthSq = dx * dx + dy * dy
guard lengthSq > 0 else { return hypot(point.x - a.x, point.y - a.y) }
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSq
t = max(0, min(1, t))
let projX = a.x + t * dx
let projY = a.y + t * dy
return hypot(point.x - projX, point.y - projY)
}
}