403 lines
14 KiB
Swift
403 lines
14 KiB
Swift
// Elements.swift
|
|
// GreenshotMac
|
|
//
|
|
// Port of Greenshot's DrawableContainer hierarchy. Every element lives in
|
|
// image point coordinates with a top-left origin, matching the flipped canvas.
|
|
|
|
import AppKit
|
|
import CoreImage
|
|
|
|
enum ToolKind: String, CaseIterable {
|
|
case select
|
|
case rectangle
|
|
case ellipse
|
|
case line
|
|
case arrow
|
|
case freehand
|
|
case text
|
|
case highlight
|
|
case obfuscate
|
|
case counter
|
|
case dropper
|
|
case crop
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .select: return "Select"
|
|
case .rectangle: return "Rectangle"
|
|
case .ellipse: return "Ellipse"
|
|
case .line: return "Line"
|
|
case .arrow: return "Arrow"
|
|
case .freehand: return "Freehand"
|
|
case .text: return "Text"
|
|
case .highlight: return "Highlight"
|
|
case .obfuscate: return "Obfuscate"
|
|
case .counter: return "Step counter"
|
|
case .dropper: return "Colour picker"
|
|
case .crop: return "Crop"
|
|
}
|
|
}
|
|
|
|
var symbolName: String {
|
|
switch self {
|
|
case .select: return "cursorarrow"
|
|
case .rectangle: return "square"
|
|
case .ellipse: return "circle"
|
|
case .line: return "line.diagonal"
|
|
case .arrow: return "arrow.up.forward"
|
|
case .freehand: return "scribble.variable"
|
|
case .text: return "character.textbox"
|
|
case .highlight: return "highlighter"
|
|
case .obfuscate: return "square.grid.3x3.fill"
|
|
case .counter: return "number.circle"
|
|
case .dropper: return "eyedropper"
|
|
case .crop: return "crop"
|
|
}
|
|
}
|
|
|
|
var keyEquivalent: String {
|
|
switch self {
|
|
case .select: return "v"
|
|
case .rectangle: return "r"
|
|
case .ellipse: return "e"
|
|
case .line: return "l"
|
|
case .arrow: return "a"
|
|
case .freehand: return "f"
|
|
case .text: return "t"
|
|
case .highlight: return "h"
|
|
case .obfuscate: return "o"
|
|
case .counter: return "c"
|
|
case .dropper: return "i"
|
|
case .crop: return "k"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ObfuscateMode: String, CaseIterable {
|
|
case pixelate
|
|
case blur
|
|
|
|
var title: String { self == .pixelate ? "Pixelate" : "Blur" }
|
|
}
|
|
|
|
/// The drawing fields Greenshot keeps per element (FieldType in the original).
|
|
struct ElementStyle {
|
|
var lineColor: NSColor = Configuration.shared.editorLineColor
|
|
var fillColor: NSColor = Configuration.shared.editorFillColor
|
|
var lineThickness: CGFloat = CGFloat(Configuration.shared.editorLineThickness)
|
|
var highlightColor: NSColor = Configuration.shared.editorHighlightColor
|
|
var fontFamily: String = Configuration.shared.editorFontFamily
|
|
var fontSize: CGFloat = CGFloat(Configuration.shared.editorFontSize)
|
|
var bold: Bool = false
|
|
var italic: Bool = false
|
|
var blurRadius: CGFloat = CGFloat(Configuration.shared.editorBlurRadius)
|
|
var pixelSize: CGFloat = CGFloat(Configuration.shared.editorPixelSize)
|
|
var obfuscateMode: ObfuscateMode = .pixelate
|
|
var dropShadow: Bool = Configuration.shared.editorDropShadow
|
|
|
|
var font: NSFont {
|
|
var traits: NSFontDescriptor.SymbolicTraits = []
|
|
if bold { traits.insert(.bold) }
|
|
if italic { traits.insert(.italic) }
|
|
let descriptor = NSFontDescriptor(name: fontFamily, size: fontSize).withSymbolicTraits(traits)
|
|
return NSFont(descriptor: descriptor, size: fontSize)
|
|
?? NSFont.systemFont(ofSize: fontSize)
|
|
}
|
|
}
|
|
|
|
/// Which style fields a tool exposes in the style bar.
|
|
struct StyleCapabilities: OptionSet, Hashable {
|
|
let rawValue: Int
|
|
static let lineColor = StyleCapabilities(rawValue: 1 << 0)
|
|
static let fillColor = StyleCapabilities(rawValue: 1 << 1)
|
|
static let lineThickness = StyleCapabilities(rawValue: 1 << 2)
|
|
static let font = StyleCapabilities(rawValue: 1 << 3)
|
|
static let obfuscation = StyleCapabilities(rawValue: 1 << 4)
|
|
static let highlightColor = StyleCapabilities(rawValue: 1 << 5)
|
|
static let shadow = StyleCapabilities(rawValue: 1 << 6)
|
|
}
|
|
|
|
// MARK: - Base element
|
|
|
|
class Element {
|
|
var frame: CGRect
|
|
var style: ElementStyle
|
|
/// Where the drag that created this element started; nil once the drag has finished.
|
|
var creationAnchor: CGPoint?
|
|
|
|
init(frame: CGRect, style: ElementStyle) {
|
|
self.frame = frame
|
|
self.style = style
|
|
}
|
|
|
|
var capabilities: StyleCapabilities { [.lineColor, .fillColor, .lineThickness, .shadow] }
|
|
|
|
/// Elements that read the pixels underneath them (obfuscate, highlight) need the base image.
|
|
var needsBaseImage: Bool { false }
|
|
|
|
var isResizable: Bool { true }
|
|
|
|
func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {}
|
|
|
|
func hitTest(_ point: CGPoint) -> Bool {
|
|
frame.insetBy(dx: -4, dy: -4).contains(point)
|
|
}
|
|
|
|
func move(by delta: CGVector) {
|
|
frame = frame.offsetBy(dx: delta.dx, dy: delta.dy)
|
|
}
|
|
|
|
func resize(to rect: CGRect) {
|
|
frame = rect
|
|
}
|
|
|
|
func copyElement() -> Element {
|
|
let copy = Element(frame: frame, style: style)
|
|
return copy
|
|
}
|
|
|
|
// MARK: Drawing helpers
|
|
|
|
func applyShadow(_ context: CGContext) {
|
|
guard style.dropShadow else { return }
|
|
context.setShadow(offset: CGSize(width: 1.5, height: 1.5),
|
|
blur: 3,
|
|
color: NSColor.black.withAlphaComponent(0.5).cgColor)
|
|
}
|
|
|
|
var normalizedFrame: CGRect {
|
|
CGRect(x: min(frame.minX, frame.maxX),
|
|
y: min(frame.minY, frame.maxY),
|
|
width: abs(frame.width),
|
|
height: abs(frame.height))
|
|
}
|
|
}
|
|
|
|
// MARK: - Shapes
|
|
|
|
final class RectangleElement: Element {
|
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
|
context.saveGState()
|
|
applyShadow(context)
|
|
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
|
|
if style.fillColor.alphaComponent > 0 {
|
|
context.setFillColor(style.fillColor.cgColor)
|
|
context.fill(rect)
|
|
}
|
|
if style.lineThickness > 0 {
|
|
context.setStrokeColor(style.lineColor.cgColor)
|
|
context.setLineWidth(style.lineThickness)
|
|
context.stroke(rect)
|
|
}
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func copyElement() -> Element { RectangleElement(frame: frame, style: style) }
|
|
}
|
|
|
|
final class EllipseElement: Element {
|
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
|
context.saveGState()
|
|
applyShadow(context)
|
|
let rect = normalizedFrame.insetBy(dx: style.lineThickness / 2, dy: style.lineThickness / 2)
|
|
if style.fillColor.alphaComponent > 0 {
|
|
context.setFillColor(style.fillColor.cgColor)
|
|
context.fillEllipse(in: rect)
|
|
}
|
|
if style.lineThickness > 0 {
|
|
context.setStrokeColor(style.lineColor.cgColor)
|
|
context.setLineWidth(style.lineThickness)
|
|
context.strokeEllipse(in: rect)
|
|
}
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint) -> Bool {
|
|
let rect = normalizedFrame.insetBy(dx: -4, dy: -4)
|
|
guard rect.width > 0, rect.height > 0 else { return false }
|
|
let dx = (point.x - rect.midX) / (rect.width / 2)
|
|
let dy = (point.y - rect.midY) / (rect.height / 2)
|
|
return dx * dx + dy * dy <= 1
|
|
}
|
|
|
|
override func copyElement() -> Element { EllipseElement(frame: frame, style: style) }
|
|
}
|
|
|
|
/// A straight line between two explicit points.
|
|
///
|
|
/// The end points are stored separately rather than being derived from `frame`: CGRect
|
|
/// reports `width` and `height` as absolute values, so a rect built with a negative size
|
|
/// cannot describe a line that runs right-to-left or bottom-to-top. `frame` is kept in
|
|
/// sync as the bounding box so selection and hit testing behave like every other element.
|
|
class LineElement: Element {
|
|
private(set) var start: CGPoint
|
|
private(set) var end: CGPoint
|
|
|
|
init(start: CGPoint, end: CGPoint, style: ElementStyle) {
|
|
self.start = start
|
|
self.end = end
|
|
super.init(frame: LineElement.bounds(start, end), style: style)
|
|
}
|
|
|
|
static func bounds(_ a: CGPoint, _ b: CGPoint) -> CGRect {
|
|
CGRect(x: min(a.x, b.x), y: min(a.y, b.y),
|
|
width: abs(b.x - a.x), height: abs(b.y - a.y))
|
|
}
|
|
|
|
/// Moves the free end while the line is being drawn.
|
|
func setEnd(_ point: CGPoint) {
|
|
end = point
|
|
frame = LineElement.bounds(start, end)
|
|
}
|
|
|
|
var length: CGFloat { hypot(end.x - start.x, end.y - start.y) }
|
|
|
|
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
|
|
|
|
override func move(by delta: CGVector) {
|
|
start = CGPoint(x: start.x + delta.dx, y: start.y + delta.dy)
|
|
end = CGPoint(x: end.x + delta.dx, y: end.y + delta.dy)
|
|
frame = LineElement.bounds(start, end)
|
|
}
|
|
|
|
/// Maps both end points into the new bounding box, preserving the line's direction.
|
|
override func resize(to rect: CGRect) {
|
|
let old = frame
|
|
let target = CGRect(x: min(rect.minX, rect.maxX), y: min(rect.minY, rect.maxY),
|
|
width: abs(rect.width), height: abs(rect.height))
|
|
func map(_ point: CGPoint) -> CGPoint {
|
|
let fractionX = old.width > 0 ? (point.x - old.minX) / old.width : 0
|
|
let fractionY = old.height > 0 ? (point.y - old.minY) / old.height : 0
|
|
return CGPoint(x: target.minX + fractionX * target.width,
|
|
y: target.minY + fractionY * target.height)
|
|
}
|
|
let newStart = map(start)
|
|
let newEnd = map(end)
|
|
start = newStart
|
|
end = newEnd
|
|
frame = LineElement.bounds(start, end)
|
|
}
|
|
|
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
|
context.saveGState()
|
|
applyShadow(context)
|
|
context.setStrokeColor(style.lineColor.cgColor)
|
|
context.setLineWidth(max(1, style.lineThickness))
|
|
context.setLineCap(.round)
|
|
context.move(to: start)
|
|
context.addLine(to: end)
|
|
context.strokePath()
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint) -> Bool {
|
|
distance(from: point, toSegment: start, end) <= max(6, style.lineThickness + 4)
|
|
}
|
|
|
|
func distance(from point: CGPoint, toSegment a: CGPoint, _ b: CGPoint) -> CGFloat {
|
|
let dx = b.x - a.x
|
|
let dy = b.y - a.y
|
|
if dx == 0 && dy == 0 { return hypot(point.x - a.x, point.y - a.y) }
|
|
var t = ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)
|
|
t = max(0, min(1, t))
|
|
return hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy))
|
|
}
|
|
|
|
override func copyElement() -> Element { LineElement(start: start, end: end, style: style) }
|
|
}
|
|
|
|
final class ArrowElement: LineElement {
|
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
|
context.saveGState()
|
|
applyShadow(context)
|
|
let width = max(1, style.lineThickness)
|
|
let headLength = max(10, width * 4)
|
|
let angle = atan2(end.y - start.y, end.x - start.x)
|
|
let length = hypot(end.x - start.x, end.y - start.y)
|
|
let shaftEnd = CGPoint(x: end.x - cos(angle) * min(headLength * 0.8, length),
|
|
y: end.y - sin(angle) * min(headLength * 0.8, length))
|
|
|
|
context.setStrokeColor(style.lineColor.cgColor)
|
|
context.setFillColor(style.lineColor.cgColor)
|
|
context.setLineWidth(width)
|
|
context.setLineCap(.round)
|
|
context.move(to: start)
|
|
context.addLine(to: shaftEnd)
|
|
context.strokePath()
|
|
|
|
let spread = CGFloat.pi / 7
|
|
context.move(to: end)
|
|
context.addLine(to: CGPoint(x: end.x - cos(angle - spread) * headLength,
|
|
y: end.y - sin(angle - spread) * headLength))
|
|
context.addLine(to: CGPoint(x: end.x - cos(angle + spread) * headLength,
|
|
y: end.y - sin(angle + spread) * headLength))
|
|
context.closePath()
|
|
context.fillPath()
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func copyElement() -> Element { ArrowElement(start: start, end: end, style: style) }
|
|
}
|
|
|
|
final class FreehandElement: Element {
|
|
private(set) var points: [CGPoint]
|
|
|
|
init(points: [CGPoint], style: ElementStyle) {
|
|
self.points = points
|
|
super.init(frame: FreehandElement.bounds(of: points), style: style)
|
|
}
|
|
|
|
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
|
|
override var isResizable: Bool { false }
|
|
|
|
func append(_ point: CGPoint) {
|
|
points.append(point)
|
|
frame = FreehandElement.bounds(of: points)
|
|
}
|
|
|
|
static func bounds(of points: [CGPoint]) -> CGRect {
|
|
guard let first = points.first else { return .zero }
|
|
var rect = CGRect(origin: first, size: .zero)
|
|
for point in points.dropFirst() {
|
|
rect = rect.union(CGRect(origin: point, size: .zero))
|
|
}
|
|
return rect
|
|
}
|
|
|
|
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
|
guard points.count > 1 else { return }
|
|
context.saveGState()
|
|
applyShadow(context)
|
|
context.setStrokeColor(style.lineColor.cgColor)
|
|
context.setLineWidth(max(1, style.lineThickness))
|
|
context.setLineCap(.round)
|
|
context.setLineJoin(.round)
|
|
context.move(to: points[0])
|
|
for point in points.dropFirst() { context.addLine(to: point) }
|
|
context.strokePath()
|
|
context.restoreGState()
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint) -> Bool {
|
|
let tolerance = max(6, style.lineThickness + 4)
|
|
for index in 1..<max(1, points.count) {
|
|
let a = points[index - 1]
|
|
let b = points[index]
|
|
let dx = b.x - a.x, dy = b.y - a.y
|
|
var t: CGFloat = 0
|
|
if dx != 0 || dy != 0 {
|
|
t = max(0, min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / (dx * dx + dy * dy)))
|
|
}
|
|
if hypot(point.x - (a.x + t * dx), point.y - (a.y + t * dy)) <= tolerance { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
override func move(by delta: CGVector) {
|
|
points = points.map { CGPoint(x: $0.x + delta.dx, y: $0.y + delta.dy) }
|
|
frame = FreehandElement.bounds(of: points)
|
|
}
|
|
|
|
override func copyElement() -> Element { FreehandElement(points: points, style: style) }
|
|
}
|