Initial commit
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
// 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 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 .crop: return "Crop"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .select: return "cursorarrow"
|
||||
case .rectangle: return "rectangle"
|
||||
case .ellipse: return "circle"
|
||||
case .line: return "line.diagonal"
|
||||
case .arrow: return "arrow.up.right"
|
||||
case .freehand: return "scribble"
|
||||
case .text: return "textformat"
|
||||
case .highlight: return "highlighter"
|
||||
case .obfuscate: return "square.grid.3x3.fill"
|
||||
case .counter: return "1.circle"
|
||||
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 .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
|
||||
|
||||
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; `frame` stores start in origin and end in origin + size, so the size may be negative.
|
||||
class LineElement: Element {
|
||||
var start: CGPoint { frame.origin }
|
||||
var end: CGPoint { CGPoint(x: frame.origin.x + frame.width, y: frame.origin.y + frame.height) }
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .lineThickness, .shadow] }
|
||||
|
||||
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(frame: frame, 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(frame: frame, 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) }
|
||||
}
|
||||
Reference in New Issue
Block a user