// 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.. Element { FreehandElement(points: points, style: style) } }