Initial commit
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
// EditorCanvasView.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// The drawing canvas: hit testing, creation, moving, resizing and inline text editing.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
protocol EditorCanvasDelegate: AnyObject {
|
||||
func canvasDidChangeSelection(_ canvas: EditorCanvasView)
|
||||
func canvasDidChangeContent(_ canvas: EditorCanvasView)
|
||||
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind)
|
||||
}
|
||||
|
||||
enum ResizeHandle: Int, CaseIterable {
|
||||
case topLeft, top, topRight, right, bottomRight, bottom, bottomLeft, left
|
||||
|
||||
func point(in rect: CGRect) -> CGPoint {
|
||||
switch self {
|
||||
case .topLeft: return CGPoint(x: rect.minX, y: rect.minY)
|
||||
case .top: return CGPoint(x: rect.midX, y: rect.minY)
|
||||
case .topRight: return CGPoint(x: rect.maxX, y: rect.minY)
|
||||
case .right: return CGPoint(x: rect.maxX, y: rect.midY)
|
||||
case .bottomRight: return CGPoint(x: rect.maxX, y: rect.maxY)
|
||||
case .bottom: return CGPoint(x: rect.midX, y: rect.maxY)
|
||||
case .bottomLeft: return CGPoint(x: rect.minX, y: rect.maxY)
|
||||
case .left: return CGPoint(x: rect.minX, y: rect.midY)
|
||||
}
|
||||
}
|
||||
|
||||
func resized(_ rect: CGRect, to point: CGPoint) -> CGRect {
|
||||
var result = rect
|
||||
switch self {
|
||||
case .topLeft:
|
||||
result = CGRect(x: point.x, y: point.y, width: rect.maxX - point.x, height: rect.maxY - point.y)
|
||||
case .top:
|
||||
result = CGRect(x: rect.minX, y: point.y, width: rect.width, height: rect.maxY - point.y)
|
||||
case .topRight:
|
||||
result = CGRect(x: rect.minX, y: point.y, width: point.x - rect.minX, height: rect.maxY - point.y)
|
||||
case .right:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: point.x - rect.minX, height: rect.height)
|
||||
case .bottomRight:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: point.x - rect.minX, height: point.y - rect.minY)
|
||||
case .bottom:
|
||||
result = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: point.y - rect.minY)
|
||||
case .bottomLeft:
|
||||
result = CGRect(x: point.x, y: rect.minY, width: rect.maxX - point.x, height: point.y - rect.minY)
|
||||
case .left:
|
||||
result = CGRect(x: point.x, y: rect.minY, width: rect.maxX - point.x, height: rect.height)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
final class EditorCanvasView: NSView, NSTextViewDelegate {
|
||||
|
||||
let surface: Surface
|
||||
weak var delegate: EditorCanvasDelegate?
|
||||
|
||||
var tool: ToolKind = .select {
|
||||
didSet {
|
||||
endTextEditing()
|
||||
if tool != .select { surface.selection = nil }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Style applied to newly created elements.
|
||||
var style = ElementStyle()
|
||||
|
||||
var zoom: CGFloat = 1 {
|
||||
didSet { refreshFrame() }
|
||||
}
|
||||
|
||||
/// Resizes the view to the image size at the current zoom. Called after a zoom change
|
||||
/// and whenever the surface itself changes size, such as after a crop.
|
||||
func refreshFrame() {
|
||||
frame = CGRect(origin: frame.origin,
|
||||
size: CGSize(width: surface.imageSize.width * zoom,
|
||||
height: surface.imageSize.height * zoom))
|
||||
invalidateIntrinsicContentSize()
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private enum Interaction {
|
||||
case none
|
||||
case creating(Element)
|
||||
case moving(Element, CGPoint)
|
||||
case resizing(Element, ResizeHandle)
|
||||
case drawingFreehand(FreehandElement)
|
||||
}
|
||||
|
||||
private var interaction: Interaction = .none
|
||||
private var textEditor: NSTextView?
|
||||
private var editingElement: TextElement?
|
||||
|
||||
init(surface: Surface) {
|
||||
self.surface = surface
|
||||
super.init(frame: CGRect(origin: .zero, size: surface.imageSize))
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
surface.onChange = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.needsDisplay = true
|
||||
self.delegate?.canvasDidChangeContent(self)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
override var isFlipped: Bool { true }
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override var intrinsicContentSize: NSSize {
|
||||
CGSize(width: surface.imageSize.width * zoom, height: surface.imageSize.height * zoom)
|
||||
}
|
||||
|
||||
func imagePoint(from viewPoint: CGPoint) -> CGPoint {
|
||||
CGPoint(x: viewPoint.x / zoom, y: viewPoint.y / zoom)
|
||||
}
|
||||
|
||||
func viewRect(from imageRect: CGRect) -> CGRect {
|
||||
CGRect(x: imageRect.minX * zoom, y: imageRect.minY * zoom,
|
||||
width: imageRect.width * zoom, height: imageRect.height * zoom)
|
||||
}
|
||||
|
||||
// MARK: - Drawing
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
guard let context = NSGraphicsContext.current?.cgContext else { return }
|
||||
|
||||
context.saveGState()
|
||||
context.scaleBy(x: zoom, y: zoom)
|
||||
context.interpolationQuality = zoom > 1 ? .none : .high
|
||||
drawFlipped(surface.baseImage, in: CGRect(origin: .zero, size: surface.imageSize), context: context)
|
||||
for element in surface.elements where !(element is CropElement) {
|
||||
element.draw(in: context, baseImage: surface.baseImage, imageSize: surface.imageSize)
|
||||
}
|
||||
context.restoreGState()
|
||||
|
||||
if let crop = surface.cropElement {
|
||||
drawCropOverlay(crop)
|
||||
}
|
||||
if let selection = surface.selection, !(selection is CropElement) {
|
||||
drawSelection(selection)
|
||||
}
|
||||
}
|
||||
|
||||
private func drawSelection(_ element: Element) {
|
||||
let rect = viewRect(from: element.normalizedFrame).insetBy(dx: -2, dy: -2)
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
let path = NSBezierPath(rect: rect)
|
||||
path.lineWidth = 1
|
||||
path.setLineDash([4, 3], count: 2, phase: 0)
|
||||
path.stroke()
|
||||
|
||||
guard element.isResizable else { return }
|
||||
for handle in ResizeHandle.allCases {
|
||||
let point = handle.point(in: rect)
|
||||
let box = CGRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)
|
||||
NSColor.white.setFill()
|
||||
NSColor.controlAccentColor.setStroke()
|
||||
let handlePath = NSBezierPath(rect: box)
|
||||
handlePath.fill()
|
||||
handlePath.lineWidth = 1
|
||||
handlePath.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private func drawCropOverlay(_ crop: CropElement) {
|
||||
let rect = viewRect(from: crop.normalizedFrame)
|
||||
NSColor(calibratedWhite: 0, alpha: 0.45).setFill()
|
||||
let path = NSBezierPath(rect: bounds)
|
||||
path.append(NSBezierPath(rect: rect))
|
||||
path.windingRule = .evenOdd
|
||||
path.fill()
|
||||
|
||||
NSColor.systemGreen.setStroke()
|
||||
let border = NSBezierPath(rect: rect)
|
||||
border.lineWidth = 1.5
|
||||
border.stroke()
|
||||
|
||||
for handle in ResizeHandle.allCases {
|
||||
let point = handle.point(in: rect)
|
||||
NSColor.systemGreen.setFill()
|
||||
NSBezierPath(rect: CGRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)).fill()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cursor
|
||||
|
||||
override func resetCursorRects() {
|
||||
let cursor: NSCursor
|
||||
switch tool {
|
||||
case .select: cursor = .arrow
|
||||
case .text: cursor = .iBeam
|
||||
default: cursor = .crosshair
|
||||
}
|
||||
addCursorRect(bounds, cursor: cursor)
|
||||
}
|
||||
|
||||
// MARK: - Mouse
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
endTextEditing()
|
||||
let point = imagePoint(from: convert(event.locationInWindow, from: nil))
|
||||
|
||||
switch tool {
|
||||
case .select:
|
||||
handleSelectMouseDown(at: point, event: event)
|
||||
case .crop:
|
||||
beginCrop(at: point)
|
||||
case .freehand:
|
||||
let element = FreehandElement(points: [point], style: style)
|
||||
surface.add(element)
|
||||
interaction = .drawingFreehand(element)
|
||||
case .text:
|
||||
let element = TextElement(frame: CGRect(x: point.x, y: point.y, width: 180, height: style.fontSize + 12),
|
||||
style: style)
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .none
|
||||
// The tool must stay on .text until editing finishes: switching tools calls
|
||||
// endTextEditing(), which would discard the element before anything is typed.
|
||||
beginTextEditing(element)
|
||||
case .counter:
|
||||
let element = CounterElement(center: point, number: surface.nextCounterNumber, style: style)
|
||||
surface.nextCounterNumber += 1
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .none
|
||||
case .rectangle, .ellipse, .line, .arrow, .highlight, .obfuscate:
|
||||
let element = makeElement(for: tool, at: point)
|
||||
surface.add(element)
|
||||
surface.selection = element
|
||||
interaction = .creating(element)
|
||||
}
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
|
||||
private func makeElement(for tool: ToolKind, at point: CGPoint) -> Element {
|
||||
let frame = CGRect(origin: point, size: .zero)
|
||||
switch tool {
|
||||
case .rectangle: return RectangleElement(frame: frame, style: style)
|
||||
case .ellipse: return EllipseElement(frame: frame, style: style)
|
||||
case .line: return LineElement(frame: frame, style: style)
|
||||
case .arrow: return ArrowElement(frame: frame, style: style)
|
||||
case .highlight: return HighlightElement(frame: frame, style: style)
|
||||
case .obfuscate: return ObfuscateElement(frame: frame, style: style)
|
||||
default: return RectangleElement(frame: frame, style: style)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSelectMouseDown(at point: CGPoint, event: NSEvent) {
|
||||
if let selection = surface.selection, selection.isResizable {
|
||||
let rect = selection.normalizedFrame
|
||||
for handle in ResizeHandle.allCases {
|
||||
let handlePoint = handle.point(in: rect)
|
||||
if hypot(handlePoint.x - point.x, handlePoint.y - point.y) <= 6 / zoom {
|
||||
surface.registerGeometryUndo(for: selection)
|
||||
interaction = .resizing(selection, handle)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let element = surface.element(at: point) {
|
||||
surface.selection = element
|
||||
if event.clickCount == 2, let text = element as? TextElement {
|
||||
beginTextEditing(text)
|
||||
interaction = .none
|
||||
return
|
||||
}
|
||||
surface.registerGeometryUndo(for: element)
|
||||
interaction = .moving(element, point)
|
||||
} else {
|
||||
surface.selection = nil
|
||||
interaction = .none
|
||||
}
|
||||
}
|
||||
|
||||
private func beginCrop(at point: CGPoint) {
|
||||
if let existing = surface.cropElement {
|
||||
surface.remove(existing)
|
||||
}
|
||||
let crop = CropElement(frame: CGRect(origin: point, size: .zero), style: style)
|
||||
surface.add(crop)
|
||||
surface.selection = crop
|
||||
interaction = .creating(crop)
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
let point = imagePoint(from: convert(event.locationInWindow, from: nil))
|
||||
let constrain = event.modifierFlags.contains(.shift)
|
||||
|
||||
switch interaction {
|
||||
case .creating(let element):
|
||||
var target = point
|
||||
if constrain {
|
||||
if element is LineElement {
|
||||
target = constrainedLineEnd(from: element.frame.origin, to: point)
|
||||
} else {
|
||||
let side = max(abs(point.x - element.frame.origin.x), abs(point.y - element.frame.origin.y))
|
||||
target = CGPoint(x: element.frame.origin.x + side * (point.x < element.frame.origin.x ? -1 : 1),
|
||||
y: element.frame.origin.y + side * (point.y < element.frame.origin.y ? -1 : 1))
|
||||
}
|
||||
}
|
||||
element.frame = CGRect(x: element.frame.origin.x,
|
||||
y: element.frame.origin.y,
|
||||
width: target.x - element.frame.origin.x,
|
||||
height: target.y - element.frame.origin.y)
|
||||
surface.changed()
|
||||
case .moving(let element, let origin):
|
||||
element.move(by: CGVector(dx: point.x - origin.x, dy: point.y - origin.y))
|
||||
interaction = .moving(element, point)
|
||||
surface.changed()
|
||||
case .resizing(let element, let handle):
|
||||
element.resize(to: handle.resized(element.normalizedFrame, to: point))
|
||||
surface.changed()
|
||||
case .drawingFreehand(let element):
|
||||
element.append(point)
|
||||
surface.changed()
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
private func constrainedLineEnd(from start: CGPoint, to point: CGPoint) -> CGPoint {
|
||||
let dx = point.x - start.x
|
||||
let dy = point.y - start.y
|
||||
let angle = (atan2(dy, dx) / (.pi / 4)).rounded() * (.pi / 4)
|
||||
let length = hypot(dx, dy)
|
||||
return CGPoint(x: start.x + cos(angle) * length, y: start.y + sin(angle) * length)
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
switch interaction {
|
||||
case .creating(let element):
|
||||
let rect = element.normalizedFrame
|
||||
let tooSmall = element is LineElement
|
||||
? hypot(element.frame.width, element.frame.height) < 4
|
||||
: (rect.width < 4 || rect.height < 4)
|
||||
if tooSmall {
|
||||
surface.remove(element)
|
||||
} else if !(element is LineElement) {
|
||||
element.frame = rect
|
||||
if element is CropElement { surface.cropElement = element as? CropElement }
|
||||
}
|
||||
if !(element is CropElement) {
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
}
|
||||
case .moving, .resizing, .drawingFreehand, .none:
|
||||
break
|
||||
}
|
||||
interaction = .none
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
}
|
||||
|
||||
// MARK: - Keyboard
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
switch event.keyCode {
|
||||
case 51, 117: // Delete, forward delete
|
||||
if surface.selection != nil {
|
||||
surface.removeSelection()
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
return
|
||||
}
|
||||
case 53: // Escape
|
||||
if let crop = surface.cropElement {
|
||||
surface.remove(crop)
|
||||
}
|
||||
surface.selection = nil
|
||||
needsDisplay = true
|
||||
delegate?.canvasDidChangeSelection(self)
|
||||
return
|
||||
case 36: // Return applies a pending crop
|
||||
if let crop = surface.cropElement {
|
||||
surface.applyCrop(to: crop.normalizedFrame)
|
||||
refreshFrame()
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
return
|
||||
}
|
||||
case 123, 124, 125, 126: // Arrow keys nudge the selection
|
||||
if let selection = surface.selection {
|
||||
let step: CGFloat = event.modifierFlags.contains(.shift) ? 10 : 1
|
||||
let delta: CGVector
|
||||
switch event.keyCode {
|
||||
case 123: delta = CGVector(dx: -step, dy: 0)
|
||||
case 124: delta = CGVector(dx: step, dy: 0)
|
||||
case 125: delta = CGVector(dx: 0, dy: step)
|
||||
default: delta = CGVector(dx: 0, dy: -step)
|
||||
}
|
||||
surface.registerGeometryUndo(for: selection)
|
||||
selection.move(by: delta)
|
||||
surface.changed()
|
||||
return
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
|
||||
// MARK: - Inline text editing
|
||||
|
||||
func beginTextEditing(_ element: TextElement) {
|
||||
endTextEditing()
|
||||
let rect = viewRect(from: element.normalizedFrame)
|
||||
let textView = NSTextView(frame: rect)
|
||||
textView.delegate = self
|
||||
textView.isRichText = false
|
||||
textView.font = element.style.font
|
||||
textView.textColor = element.style.lineColor
|
||||
textView.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.85)
|
||||
textView.drawsBackground = true
|
||||
textView.string = element.text
|
||||
textView.isVerticallyResizable = true
|
||||
textView.textContainerInset = CGSize(width: 2, height: 2)
|
||||
addSubview(textView)
|
||||
window?.makeFirstResponder(textView)
|
||||
|
||||
textEditor = textView
|
||||
editingElement = element
|
||||
}
|
||||
|
||||
func endTextEditing() {
|
||||
guard let textEditor, let editingElement else { return }
|
||||
editingElement.text = textEditor.string
|
||||
if editingElement.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
surface.remove(editingElement)
|
||||
} else {
|
||||
let size = editingElement.fittingSize(maxWidth: max(80, editingElement.normalizedFrame.width))
|
||||
editingElement.frame = CGRect(origin: editingElement.normalizedFrame.origin,
|
||||
size: CGSize(width: max(size.width, editingElement.normalizedFrame.width),
|
||||
height: max(size.height, editingElement.normalizedFrame.height)))
|
||||
}
|
||||
textEditor.removeFromSuperview()
|
||||
self.textEditor = nil
|
||||
self.editingElement = nil
|
||||
window?.makeFirstResponder(self)
|
||||
surface.changed()
|
||||
needsDisplay = true
|
||||
|
||||
if tool == .text {
|
||||
delegate?.canvasDidRequestToolChange(self, to: .select)
|
||||
}
|
||||
}
|
||||
|
||||
func textDidEndEditing(_ notification: Notification) {
|
||||
endTextEditing()
|
||||
}
|
||||
|
||||
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
||||
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
|
||||
endTextEditing()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Style application
|
||||
|
||||
/// Applies the current style to the selected element, or stores it for the next one.
|
||||
func applyStyleToSelection(_ mutate: (inout ElementStyle) -> Void) {
|
||||
mutate(&style)
|
||||
if let selection = surface.selection {
|
||||
var elementStyle = selection.style
|
||||
mutate(&elementStyle)
|
||||
selection.style = elementStyle
|
||||
surface.changed()
|
||||
}
|
||||
needsDisplay = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
// EditorWindowController.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's ImageEditorForm.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class EditorWindowController: NSWindowController, NSWindowDelegate,
|
||||
EditorCanvasDelegate, StyleBarDelegate {
|
||||
|
||||
private static var openEditors: [EditorWindowController] = []
|
||||
|
||||
/// The one-click palette in the toolbar, matching Greenshot's swatch row.
|
||||
private static let palette: [NSColor] = [
|
||||
.systemGreen, .systemRed, .systemYellow, .systemBlue,
|
||||
.systemPurple, .cyan, .black, .white
|
||||
]
|
||||
|
||||
let surface: Surface
|
||||
private let canvas: EditorCanvasView
|
||||
private let scrollView = NSScrollView()
|
||||
private let toolBar = NSStackView()
|
||||
private let styleBar = StyleBarView()
|
||||
private var toolButtons: [ToolKind: NSButton] = [:]
|
||||
private var deleteButton: NSButton?
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@discardableResult
|
||||
static func open(with capture: Capture) -> EditorWindowController {
|
||||
let controller = EditorWindowController(capture: capture)
|
||||
openEditors.append(controller)
|
||||
controller.showWindow(nil)
|
||||
controller.window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return controller
|
||||
}
|
||||
|
||||
init(capture: Capture) {
|
||||
surface = Surface(capture: capture)
|
||||
canvas = EditorCanvasView(surface: surface)
|
||||
|
||||
let size = surface.imageSize
|
||||
let maxSize = (NSScreen.main?.visibleFrame.size ?? CGSize(width: 1400, height: 900))
|
||||
let contentWidth = min(max(720, size.width), maxSize.width - 80)
|
||||
let contentHeight = min(max(400, size.height + 96), maxSize.height - 80)
|
||||
|
||||
let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.title = capture.details.title.isEmpty
|
||||
? "Screenshot"
|
||||
: "Screenshot — \(capture.details.title)"
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
super.init(window: window)
|
||||
|
||||
window.delegate = self
|
||||
buildInterface()
|
||||
canvas.delegate = self
|
||||
select(tool: .select)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
||||
surface.undoManager
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
canvas.endTextEditing()
|
||||
EditorWindowController.openEditors.removeAll { $0 === self }
|
||||
}
|
||||
|
||||
func windowDidResize(_ notification: Notification) {
|
||||
// Nothing to do; the canvas keeps its own size. Fit-to-window is explicit.
|
||||
}
|
||||
|
||||
// MARK: - Interface
|
||||
|
||||
private func buildInterface() {
|
||||
guard let window, let contentView = window.contentView else { return }
|
||||
|
||||
toolBar.orientation = .horizontal
|
||||
toolBar.spacing = 4
|
||||
toolBar.alignment = .centerY
|
||||
toolBar.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
|
||||
// Destinations first, as in the Greenshot toolbar.
|
||||
for destination in [DestinationID.file, .email, .clipboard, .fileWithDialog, .printer, .share] {
|
||||
toolBar.addArrangedSubview(destinationButton(for: destination))
|
||||
}
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Drawing tools.
|
||||
for tool in ToolKind.allCases {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: tool.symbolName, fallbackTitle: tool.title)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.toolTip = "\(tool.title) (\(tool.keyEquivalent.uppercased()))"
|
||||
button.target = self
|
||||
button.action = #selector(toolButtonClicked(_:))
|
||||
button.tag = ToolKind.allCases.firstIndex(of: tool) ?? 0
|
||||
toolButtons[tool] = button
|
||||
toolBar.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Delete the selected element.
|
||||
let delete = NSButton()
|
||||
EditorWindowController.configure(delete, symbol: "minus.circle", fallbackTitle: "Delete")
|
||||
delete.contentTintColor = .systemRed
|
||||
delete.bezelStyle = .texturedRounded
|
||||
delete.toolTip = "Delete the selected element (⌫)"
|
||||
delete.target = self
|
||||
delete.action = #selector(deleteSelection(_:))
|
||||
delete.isEnabled = false
|
||||
deleteButton = delete
|
||||
toolBar.addArrangedSubview(delete)
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// One-click colour palette.
|
||||
for (index, color) in EditorWindowController.palette.enumerated() {
|
||||
let swatch = NSButton()
|
||||
swatch.image = EditorWindowController.swatchImage(color)
|
||||
swatch.imagePosition = .imageOnly
|
||||
swatch.isBordered = false
|
||||
swatch.toolTip = "Set colour"
|
||||
swatch.target = self
|
||||
swatch.action = #selector(colorSwatchClicked(_:))
|
||||
swatch.tag = index
|
||||
swatch.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
||||
toolBar.addArrangedSubview(swatch)
|
||||
}
|
||||
|
||||
toolBar.addArrangedSubview(NSBox.separator())
|
||||
|
||||
// Zoom controls.
|
||||
let zoomControls: [(String, String, Selector)] = [
|
||||
("minus.magnifyingglass", "Zoom out (⌘−)", #selector(zoomOut(_:))),
|
||||
("plus.magnifyingglass", "Zoom in (⌘+)", #selector(zoomIn(_:))),
|
||||
("1.magnifyingglass", "Actual size (⌘0)", #selector(actualSize(_:))),
|
||||
("arrow.up.left.and.down.right.magnifyingglass", "Fit to window", #selector(fitToWindow(_:)))
|
||||
]
|
||||
for (symbol, tip, action) in zoomControls {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: symbol, fallbackTitle: tip)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.toolTip = tip
|
||||
button.target = self
|
||||
button.action = action
|
||||
toolBar.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
styleBar.delegate = self
|
||||
|
||||
scrollView.documentView = canvas
|
||||
scrollView.hasVerticalScroller = true
|
||||
scrollView.hasHorizontalScroller = true
|
||||
scrollView.allowsMagnification = false
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.backgroundColor = .underPageBackgroundColor
|
||||
scrollView.drawsBackground = true
|
||||
|
||||
let stack = NSStackView(views: [toolBar, styleBar, scrollView])
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 0
|
||||
stack.alignment = .leading
|
||||
stack.distribution = .fill
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
contentView.addSubview(stack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
||||
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
||||
toolBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
toolBar.trailingAnchor.constraint(lessThanOrEqualTo: stack.trailingAnchor),
|
||||
styleBar.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
styleBar.trailingAnchor.constraint(equalTo: stack.trailingAnchor),
|
||||
scrollView.leadingAnchor.constraint(equalTo: stack.leadingAnchor),
|
||||
scrollView.trailingAnchor.constraint(equalTo: stack.trailingAnchor)
|
||||
])
|
||||
|
||||
window.makeFirstResponder(canvas)
|
||||
}
|
||||
|
||||
private func destinationButton(for destination: DestinationID) -> NSButton {
|
||||
let button = NSButton()
|
||||
EditorWindowController.configure(button, symbol: destination.symbolName, fallbackTitle: destination.pickerTitle)
|
||||
button.bezelStyle = .texturedRounded
|
||||
button.toolTip = destination.title
|
||||
button.target = self
|
||||
button.action = #selector(destinationButtonClicked(_:))
|
||||
button.tag = DestinationID.allCases.firstIndex(of: destination) ?? 0
|
||||
return button
|
||||
}
|
||||
|
||||
/// Uses the SF Symbol when the running system has it, and a short text label otherwise,
|
||||
/// so a missing symbol never leaves an invisible toolbar button.
|
||||
private static func configure(_ button: NSButton, symbol: String, fallbackTitle: String) {
|
||||
if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: fallbackTitle) {
|
||||
button.image = image
|
||||
button.imagePosition = .imageOnly
|
||||
} else {
|
||||
button.title = fallbackTitle
|
||||
button.imagePosition = .noImage
|
||||
button.font = NSFont.systemFont(ofSize: 11)
|
||||
}
|
||||
}
|
||||
|
||||
private static func swatchImage(_ color: NSColor) -> NSImage {
|
||||
let size = NSSize(width: 14, height: 14)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
let path = NSBezierPath(ovalIn: NSRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5))
|
||||
color.setFill()
|
||||
path.fill()
|
||||
NSColor.separatorColor.setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
image.unlockFocus()
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Tools
|
||||
|
||||
func select(tool: ToolKind) {
|
||||
canvas.tool = tool
|
||||
for (kind, button) in toolButtons {
|
||||
button.state = kind == tool ? .on : .off
|
||||
}
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
@objc private func toolButtonClicked(_ sender: NSButton) {
|
||||
let tool = ToolKind.allCases[min(sender.tag, ToolKind.allCases.count - 1)]
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
@objc private func destinationButtonClicked(_ sender: NSButton) {
|
||||
let destination = DestinationID.allCases[min(sender.tag, DestinationID.allCases.count - 1)]
|
||||
export(to: destination, anchor: sender)
|
||||
}
|
||||
|
||||
@objc private func colorSwatchClicked(_ sender: NSButton) {
|
||||
guard sender.tag >= 0, sender.tag < EditorWindowController.palette.count else { return }
|
||||
let color = EditorWindowController.palette[sender.tag]
|
||||
|
||||
// The highlighter has its own colour field; everything else uses the line colour.
|
||||
let isHighlight = canvas.tool == .highlight || surface.selection is HighlightElement
|
||||
canvas.applyStyleToSelection { style in
|
||||
if isHighlight {
|
||||
style.highlightColor = color
|
||||
} else {
|
||||
style.lineColor = color
|
||||
}
|
||||
}
|
||||
if isHighlight {
|
||||
Configuration.shared.editorHighlightColor = color
|
||||
} else {
|
||||
Configuration.shared.editorLineColor = color
|
||||
}
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
private func updateStyleBar() {
|
||||
let capabilities: StyleCapabilities
|
||||
let style: ElementStyle
|
||||
if let selection = surface.selection, !(selection is CropElement) {
|
||||
capabilities = selection.capabilities
|
||||
style = selection.style
|
||||
} else {
|
||||
capabilities = capabilitiesForTool(canvas.tool)
|
||||
style = canvas.style
|
||||
}
|
||||
styleBar.update(capabilities: capabilities,
|
||||
style: style,
|
||||
showCrop: surface.cropElement != nil)
|
||||
deleteButton?.isEnabled = surface.selection != nil
|
||||
}
|
||||
|
||||
private func capabilitiesForTool(_ tool: ToolKind) -> StyleCapabilities {
|
||||
switch tool {
|
||||
case .select: return []
|
||||
case .rectangle, .ellipse: return [.lineColor, .fillColor, .lineThickness, .shadow]
|
||||
case .line, .arrow, .freehand: return [.lineColor, .lineThickness, .shadow]
|
||||
case .text: return [.lineColor, .fillColor, .font, .shadow]
|
||||
case .counter: return [.lineColor, .fillColor, .font, .shadow]
|
||||
case .highlight: return [.highlightColor]
|
||||
case .obfuscate: return [.obfuscation]
|
||||
case .crop: return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canvas delegate
|
||||
|
||||
func canvasDidChangeSelection(_ canvas: EditorCanvasView) {
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
func canvasDidChangeContent(_ canvas: EditorCanvasView) {
|
||||
canvas.invalidateIntrinsicContentSize()
|
||||
window?.isDocumentEdited = surface.hasUnsavedChanges
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
func canvasDidRequestToolChange(_ canvas: EditorCanvasView, to tool: ToolKind) {
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
// MARK: - Style bar delegate
|
||||
|
||||
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void) {
|
||||
canvas.applyStyleToSelection(mutate)
|
||||
}
|
||||
|
||||
func styleBarDidApplyCrop(_ bar: StyleBarView) {
|
||||
applyCrop(nil)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@IBAction func applyCrop(_ sender: Any?) {
|
||||
guard let crop = surface.cropElement else { return }
|
||||
surface.applyCrop(to: crop.normalizedFrame)
|
||||
canvas.refreshFrame()
|
||||
select(tool: .select)
|
||||
}
|
||||
|
||||
func export(to destination: DestinationID, anchor: NSView? = nil) {
|
||||
canvas.endTextEditing()
|
||||
let capture = surface.renderedCapture()
|
||||
Destinations.export(capture, to: destination, anchor: anchor ?? canvas)
|
||||
if destination == .file || destination == .fileWithDialog {
|
||||
surface.hasUnsavedChanges = false
|
||||
window?.isDocumentEdited = false
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func saveDocument(_ sender: Any?) { export(to: .file) }
|
||||
@IBAction func saveDocumentAs(_ sender: Any?) { export(to: .fileWithDialog) }
|
||||
@IBAction func copy(_ sender: Any?) { export(to: .clipboard) }
|
||||
@IBAction func printDocument(_ sender: Any?) { export(to: .printer) }
|
||||
@IBAction func shareDocument(_ sender: Any?) { export(to: .share, anchor: canvas) }
|
||||
@IBAction func sendAsEmail(_ sender: Any?) { export(to: .email) }
|
||||
|
||||
@IBAction func zoomIn(_ sender: Any?) { canvas.zoom = min(8, canvas.zoom * 1.25) }
|
||||
@IBAction func zoomOut(_ sender: Any?) { canvas.zoom = max(0.1, canvas.zoom / 1.25) }
|
||||
@IBAction func actualSize(_ sender: Any?) { canvas.zoom = 1 }
|
||||
|
||||
@IBAction func fitToWindow(_ sender: Any?) {
|
||||
let available = scrollView.contentView.bounds.size
|
||||
guard available.width > 1, available.height > 1,
|
||||
surface.imageSize.width > 1, surface.imageSize.height > 1 else { return }
|
||||
let fit = min(available.width / surface.imageSize.width,
|
||||
available.height / surface.imageSize.height)
|
||||
canvas.zoom = min(1, max(0.1, fit))
|
||||
}
|
||||
|
||||
@IBAction func selectTool(_ sender: NSMenuItem) {
|
||||
guard let raw = sender.representedObject as? String, let tool = ToolKind(rawValue: raw) else { return }
|
||||
select(tool: tool)
|
||||
}
|
||||
|
||||
@IBAction func deleteSelection(_ sender: Any?) {
|
||||
surface.removeSelection()
|
||||
canvas.needsDisplay = true
|
||||
updateStyleBar()
|
||||
}
|
||||
|
||||
@IBAction func duplicateSelection(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.duplicate(selection)
|
||||
canvas.needsDisplay = true
|
||||
}
|
||||
|
||||
@IBAction func bringToFront(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.bringToFront(selection)
|
||||
}
|
||||
|
||||
@IBAction func sendToBack(_ sender: Any?) {
|
||||
guard let selection = surface.selection else { return }
|
||||
surface.sendToBack(selection)
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSBox {
|
||||
static func separator() -> NSBox {
|
||||
let box = NSBox()
|
||||
box.translatesAutoresizingMaskIntoConstraints = false
|
||||
box.boxType = .separator
|
||||
box.widthAnchor.constraint(equalToConstant: 12).isActive = true
|
||||
return box
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// ElementsFilters.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Text, highlight, obfuscation, step counter and crop elements.
|
||||
|
||||
import AppKit
|
||||
import CoreImage
|
||||
|
||||
/// Draws a CGImage the right way up inside a top-left-origin (flipped) context.
|
||||
func drawFlipped(_ image: CGImage, in rect: CGRect, context: CGContext) {
|
||||
context.saveGState()
|
||||
context.translateBy(x: rect.minX, y: rect.minY + rect.height)
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
context.draw(image, in: CGRect(origin: .zero, size: rect.size))
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - Text
|
||||
|
||||
final class TextElement: Element {
|
||||
var text: String
|
||||
|
||||
init(frame: CGRect, style: ElementStyle, text: String = "") {
|
||||
self.text = text
|
||||
super.init(frame: frame, style: style)
|
||||
}
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .fillColor, .font, .shadow] }
|
||||
|
||||
var attributes: [NSAttributedString.Key: Any] {
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .left
|
||||
paragraph.lineBreakMode = .byWordWrapping
|
||||
return [
|
||||
.font: style.font,
|
||||
.foregroundColor: style.lineColor,
|
||||
.paragraphStyle: paragraph
|
||||
]
|
||||
}
|
||||
|
||||
/// Size the text needs for the current width.
|
||||
func fittingSize(maxWidth: CGFloat) -> CGSize {
|
||||
let bounding = (text as NSString).boundingRect(
|
||||
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||
attributes: attributes)
|
||||
return CGSize(width: ceil(bounding.width) + 8, height: ceil(bounding.height) + 6)
|
||||
}
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
|
||||
context.saveGState()
|
||||
if style.fillColor.alphaComponent > 0 {
|
||||
context.setFillColor(style.fillColor.cgColor)
|
||||
context.fill(rect)
|
||||
}
|
||||
applyShadow(context)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
(text as NSString).draw(in: rect.insetBy(dx: 4, dy: 3), withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { TextElement(frame: frame, style: style, text: text) }
|
||||
}
|
||||
|
||||
// MARK: - Highlight
|
||||
|
||||
final class HighlightElement: Element {
|
||||
override var capabilities: StyleCapabilities { [.highlightColor] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 0, rect.height > 0 else { return }
|
||||
context.saveGState()
|
||||
context.setBlendMode(.multiply)
|
||||
context.setFillColor(style.highlightColor.withAlphaComponent(1).cgColor)
|
||||
context.fill(rect)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { HighlightElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
// MARK: - Obfuscate
|
||||
|
||||
final class ObfuscateElement: Element {
|
||||
private static let ciContext = CIContext(options: [.useSoftwareRenderer: false])
|
||||
|
||||
override var capabilities: StyleCapabilities { [.obfuscation] }
|
||||
override var needsBaseImage: Bool { true }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
guard rect.width > 1, rect.height > 1 else { return }
|
||||
guard let baseImage, imageSize.width > 0 else {
|
||||
context.setFillColor(NSColor.black.cgColor)
|
||||
context.fill(rect)
|
||||
return
|
||||
}
|
||||
|
||||
let scale = CGFloat(baseImage.width) / imageSize.width
|
||||
let pixelRect = CGRect(x: rect.minX * scale,
|
||||
y: rect.minY * scale,
|
||||
width: rect.width * scale,
|
||||
height: rect.height * scale)
|
||||
.intersection(CGRect(x: 0, y: 0, width: CGFloat(baseImage.width), height: CGFloat(baseImage.height)))
|
||||
guard pixelRect.width >= 1, pixelRect.height >= 1,
|
||||
let region = baseImage.cropping(to: pixelRect) else { return }
|
||||
|
||||
let input = CIImage(cgImage: region)
|
||||
let output: CIImage?
|
||||
|
||||
switch style.obfuscateMode {
|
||||
case .pixelate:
|
||||
let filter = CIFilter(name: "CIPixellate")
|
||||
filter?.setValue(input, forKey: kCIInputImageKey)
|
||||
filter?.setValue(max(2, style.pixelSize * scale), forKey: kCIInputScaleKey)
|
||||
filter?.setValue(CIVector(x: input.extent.midX, y: input.extent.midY), forKey: kCIInputCenterKey)
|
||||
output = filter?.outputImage
|
||||
case .blur:
|
||||
let filter = CIFilter(name: "CIGaussianBlur")
|
||||
// Clamp first so the blur does not fade out at the edges of the region.
|
||||
filter?.setValue(input.clampedToExtent(), forKey: kCIInputImageKey)
|
||||
filter?.setValue(max(1, style.blurRadius * scale / 2), forKey: kCIInputRadiusKey)
|
||||
output = filter?.outputImage?.cropped(to: input.extent)
|
||||
}
|
||||
|
||||
guard let output,
|
||||
let filtered = ObfuscateElement.ciContext.createCGImage(output, from: input.extent) else { return }
|
||||
drawFlipped(filtered, in: rect, context: context)
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { ObfuscateElement(frame: frame, style: style) }
|
||||
}
|
||||
|
||||
// MARK: - Step counter
|
||||
|
||||
final class CounterElement: Element {
|
||||
var number: Int
|
||||
|
||||
init(center: CGPoint, number: Int, style: ElementStyle) {
|
||||
self.number = number
|
||||
let diameter = max(24, style.fontSize * 2)
|
||||
super.init(frame: CGRect(x: center.x - diameter / 2,
|
||||
y: center.y - diameter / 2,
|
||||
width: diameter,
|
||||
height: diameter),
|
||||
style: style)
|
||||
}
|
||||
|
||||
override var capabilities: StyleCapabilities { [.lineColor, .fillColor, .font, .shadow] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
let rect = normalizedFrame
|
||||
context.saveGState()
|
||||
applyShadow(context)
|
||||
let fill = style.fillColor.alphaComponent > 0 ? style.fillColor : style.lineColor
|
||||
context.setFillColor(fill.cgColor)
|
||||
context.fillEllipse(in: rect)
|
||||
context.restoreGState()
|
||||
|
||||
let textColor = fill.isDark ? NSColor.white : NSColor.black
|
||||
let font = NSFont.boldSystemFont(ofSize: max(10, rect.height * 0.55))
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor]
|
||||
let label = String(number) as NSString
|
||||
let size = label.size(withAttributes: attributes)
|
||||
let origin = CGPoint(x: rect.midX - size.width / 2, y: rect.midY - size.height / 2)
|
||||
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
|
||||
label.draw(at: origin, withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: CGPoint) -> Bool {
|
||||
let rect = normalizedFrame
|
||||
let dx = (point.x - rect.midX) / max(1, rect.width / 2)
|
||||
let dy = (point.y - rect.midY) / max(1, rect.height / 2)
|
||||
return dx * dx + dy * dy <= 1.2
|
||||
}
|
||||
|
||||
override func copyElement() -> Element {
|
||||
CounterElement(center: CGPoint(x: normalizedFrame.midX, y: normalizedFrame.midY),
|
||||
number: number,
|
||||
style: style)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSColor {
|
||||
var isDark: Bool {
|
||||
guard let rgb = usingColorSpace(.sRGB) else { return true }
|
||||
let luminance = 0.299 * rgb.redComponent + 0.587 * rgb.greenComponent + 0.114 * rgb.blueComponent
|
||||
return luminance < 0.6
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
/// Not rendered into the output: it marks the area the surface will be cropped to.
|
||||
final class CropElement: Element {
|
||||
override var capabilities: StyleCapabilities { [] }
|
||||
|
||||
override func draw(in context: CGContext, baseImage: CGImage?, imageSize: CGSize) {
|
||||
// Drawn by the canvas as an overlay, never baked into the image.
|
||||
}
|
||||
|
||||
override func copyElement() -> Element { CropElement(frame: frame, style: style) }
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// StyleBarView.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// The context sensitive style controls, equivalent to Greenshot's editor field toolbar.
|
||||
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
protocol StyleBarDelegate: AnyObject {
|
||||
func styleBar(_ bar: StyleBarView, didChange mutate: @escaping (inout ElementStyle) -> Void)
|
||||
func styleBarDidApplyCrop(_ bar: StyleBarView)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class StyleBarView: NSView {
|
||||
|
||||
weak var delegate: StyleBarDelegate?
|
||||
|
||||
private let stack = NSStackView()
|
||||
private let lineColorWell = NSColorWell()
|
||||
private let fillColorWell = NSColorWell()
|
||||
private let highlightColorWell = NSColorWell()
|
||||
private let thicknessSlider = NSSlider()
|
||||
private let thicknessLabel = NSTextField(labelWithString: "2 px")
|
||||
private let fontPopUp = NSPopUpButton()
|
||||
private let fontSizeField = NSTextField()
|
||||
private let boldButton = NSButton()
|
||||
private let italicButton = NSButton()
|
||||
private let obfuscatePopUp = NSPopUpButton()
|
||||
private let obfuscateSlider = NSSlider()
|
||||
private let shadowButton = NSButton()
|
||||
private let cropButton = NSButton()
|
||||
|
||||
private var labelledPairs: [StyleCapabilities: [NSView]] = [:]
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
build()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
|
||||
|
||||
private func build() {
|
||||
wantsLayer = true
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
stack.edgeInsets = NSEdgeInsets(top: 6, left: 12, bottom: 6, right: 12)
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||
stack.topAnchor.constraint(equalTo: topAnchor),
|
||||
stack.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
lineColorWell.color = Configuration.shared.editorLineColor
|
||||
lineColorWell.target = self
|
||||
lineColorWell.action = #selector(lineColorChanged)
|
||||
let lineLabel = NSTextField(labelWithString: "Line")
|
||||
labelledPairs[.lineColor] = [lineLabel, lineColorWell]
|
||||
|
||||
fillColorWell.color = Configuration.shared.editorFillColor
|
||||
fillColorWell.target = self
|
||||
fillColorWell.action = #selector(fillColorChanged)
|
||||
let fillLabel = NSTextField(labelWithString: "Fill")
|
||||
labelledPairs[.fillColor] = [fillLabel, fillColorWell]
|
||||
|
||||
highlightColorWell.color = Configuration.shared.editorHighlightColor
|
||||
highlightColorWell.target = self
|
||||
highlightColorWell.action = #selector(highlightColorChanged)
|
||||
let highlightLabel = NSTextField(labelWithString: "Highlight")
|
||||
labelledPairs[.highlightColor] = [highlightLabel, highlightColorWell]
|
||||
|
||||
thicknessSlider.minValue = 0
|
||||
thicknessSlider.maxValue = 20
|
||||
thicknessSlider.doubleValue = Double(Configuration.shared.editorLineThickness)
|
||||
thicknessSlider.target = self
|
||||
thicknessSlider.action = #selector(thicknessChanged)
|
||||
thicknessSlider.widthAnchor.constraint(equalToConstant: 90).isActive = true
|
||||
labelledPairs[.lineThickness] = [NSTextField(labelWithString: "Width"), thicknessSlider, thicknessLabel]
|
||||
|
||||
for family in ["Helvetica", "Helvetica Neue", "Menlo", "SF Pro", "Times New Roman", "Arial", "Georgia"] {
|
||||
fontPopUp.addItem(withTitle: family)
|
||||
}
|
||||
fontPopUp.selectItem(withTitle: Configuration.shared.editorFontFamily)
|
||||
fontPopUp.target = self
|
||||
fontPopUp.action = #selector(fontChanged)
|
||||
|
||||
fontSizeField.stringValue = String(Configuration.shared.editorFontSize)
|
||||
fontSizeField.target = self
|
||||
fontSizeField.action = #selector(fontChanged)
|
||||
fontSizeField.widthAnchor.constraint(equalToConstant: 46).isActive = true
|
||||
|
||||
configureToggle(boldButton, title: "B", action: #selector(fontChanged))
|
||||
boldButton.font = NSFont.boldSystemFont(ofSize: 12)
|
||||
configureToggle(italicButton, title: "I", action: #selector(fontChanged))
|
||||
labelledPairs[.font] = [fontPopUp, fontSizeField, boldButton, italicButton]
|
||||
|
||||
obfuscatePopUp.addItems(withTitles: ObfuscateMode.allCases.map(\.title))
|
||||
obfuscatePopUp.target = self
|
||||
obfuscatePopUp.action = #selector(obfuscationChanged)
|
||||
obfuscateSlider.minValue = 2
|
||||
obfuscateSlider.maxValue = 40
|
||||
obfuscateSlider.doubleValue = Double(Configuration.shared.editorPixelSize)
|
||||
obfuscateSlider.target = self
|
||||
obfuscateSlider.action = #selector(obfuscationChanged)
|
||||
obfuscateSlider.widthAnchor.constraint(equalToConstant: 90).isActive = true
|
||||
labelledPairs[.obfuscation] = [obfuscatePopUp, NSTextField(labelWithString: "Strength"), obfuscateSlider]
|
||||
|
||||
configureToggle(shadowButton, title: "Shadow", action: #selector(shadowChanged))
|
||||
shadowButton.state = Configuration.shared.editorDropShadow ? .on : .off
|
||||
labelledPairs[.shadow] = [shadowButton]
|
||||
|
||||
cropButton.title = "Apply crop"
|
||||
cropButton.bezelStyle = .rounded
|
||||
cropButton.target = self
|
||||
cropButton.action = #selector(applyCrop)
|
||||
}
|
||||
|
||||
private func configureToggle(_ button: NSButton, title: String, action: Selector) {
|
||||
button.title = title
|
||||
button.setButtonType(.pushOnPushOff)
|
||||
button.bezelStyle = .rounded
|
||||
button.target = self
|
||||
button.action = action
|
||||
}
|
||||
|
||||
/// Rebuilds the bar so it shows only the fields the current tool or selection supports.
|
||||
func update(capabilities: StyleCapabilities, style: ElementStyle, showCrop: Bool) {
|
||||
for view in stack.arrangedSubviews {
|
||||
stack.removeArrangedSubview(view)
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
lineColorWell.color = style.lineColor
|
||||
fillColorWell.color = style.fillColor
|
||||
highlightColorWell.color = style.highlightColor
|
||||
thicknessSlider.doubleValue = Double(style.lineThickness)
|
||||
thicknessLabel.stringValue = "\(Int(style.lineThickness)) px"
|
||||
fontPopUp.selectItem(withTitle: style.fontFamily)
|
||||
fontSizeField.stringValue = String(Int(style.fontSize))
|
||||
boldButton.state = style.bold ? .on : .off
|
||||
italicButton.state = style.italic ? .on : .off
|
||||
obfuscatePopUp.selectItem(withTitle: style.obfuscateMode.title)
|
||||
obfuscateSlider.doubleValue = Double(style.obfuscateMode == .pixelate ? style.pixelSize : style.blurRadius)
|
||||
shadowButton.state = style.dropShadow ? .on : .off
|
||||
|
||||
for capability in [StyleCapabilities.lineColor, .fillColor, .highlightColor,
|
||||
.lineThickness, .font, .obfuscation, .shadow] {
|
||||
guard capabilities.contains(capability), let views = labelledPairs[capability] else { continue }
|
||||
for view in views { stack.addArrangedSubview(view) }
|
||||
}
|
||||
if showCrop {
|
||||
stack.addArrangedSubview(cropButton)
|
||||
}
|
||||
isHidden = stack.arrangedSubviews.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
@objc private func lineColorChanged() {
|
||||
let color = lineColorWell.color
|
||||
Configuration.shared.editorLineColor = color
|
||||
delegate?.styleBar(self) { $0.lineColor = color }
|
||||
}
|
||||
|
||||
@objc private func fillColorChanged() {
|
||||
let color = fillColorWell.color
|
||||
Configuration.shared.editorFillColor = color
|
||||
delegate?.styleBar(self) { $0.fillColor = color }
|
||||
}
|
||||
|
||||
@objc private func highlightColorChanged() {
|
||||
let color = highlightColorWell.color
|
||||
Configuration.shared.editorHighlightColor = color
|
||||
delegate?.styleBar(self) { $0.highlightColor = color }
|
||||
}
|
||||
|
||||
@objc private func thicknessChanged() {
|
||||
let thickness = CGFloat(thicknessSlider.doubleValue.rounded())
|
||||
thicknessLabel.stringValue = "\(Int(thickness)) px"
|
||||
Configuration.shared.editorLineThickness = Int(thickness)
|
||||
delegate?.styleBar(self) { $0.lineThickness = thickness }
|
||||
}
|
||||
|
||||
@objc private func fontChanged() {
|
||||
let family = fontPopUp.titleOfSelectedItem ?? "Helvetica"
|
||||
let size = CGFloat(max(6, Int(fontSizeField.stringValue) ?? 14))
|
||||
let bold = boldButton.state == .on
|
||||
let italic = italicButton.state == .on
|
||||
Configuration.shared.editorFontFamily = family
|
||||
Configuration.shared.editorFontSize = Int(size)
|
||||
delegate?.styleBar(self) { style in
|
||||
style.fontFamily = family
|
||||
style.fontSize = size
|
||||
style.bold = bold
|
||||
style.italic = italic
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func obfuscationChanged() {
|
||||
let mode = ObfuscateMode.allCases[max(0, obfuscatePopUp.indexOfSelectedItem)]
|
||||
let value = CGFloat(obfuscateSlider.doubleValue.rounded())
|
||||
if mode == .pixelate {
|
||||
Configuration.shared.editorPixelSize = Int(value)
|
||||
} else {
|
||||
Configuration.shared.editorBlurRadius = Int(value)
|
||||
}
|
||||
delegate?.styleBar(self) { style in
|
||||
style.obfuscateMode = mode
|
||||
if mode == .pixelate { style.pixelSize = value } else { style.blurRadius = value }
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func shadowChanged() {
|
||||
let enabled = shadowButton.state == .on
|
||||
Configuration.shared.editorDropShadow = enabled
|
||||
delegate?.styleBar(self) { $0.dropShadow = enabled }
|
||||
}
|
||||
|
||||
@objc private func applyCrop() {
|
||||
delegate?.styleBarDidApplyCrop(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// Surface.swift
|
||||
// GreenshotMac
|
||||
//
|
||||
// Port of Greenshot's Surface: the image plus its drawable elements, undo and rendering.
|
||||
|
||||
import AppKit
|
||||
|
||||
final class Surface {
|
||||
|
||||
private(set) var baseImage: CGImage
|
||||
/// Size of the image in points; elements live in this coordinate space.
|
||||
private(set) var imageSize: CGSize
|
||||
private(set) var elements: [Element] = []
|
||||
|
||||
var details: CaptureDetails
|
||||
var selection: Element?
|
||||
var cropElement: CropElement?
|
||||
var nextCounterNumber: Int = 1
|
||||
var hasUnsavedChanges = false
|
||||
|
||||
let undoManager = UndoManager()
|
||||
var onChange: (() -> Void)?
|
||||
|
||||
init(capture: Capture) {
|
||||
self.baseImage = capture.image
|
||||
self.imageSize = capture.pointSize
|
||||
self.details = capture.details
|
||||
}
|
||||
|
||||
var scale: CGFloat { CGFloat(baseImage.width) / max(1, imageSize.width) }
|
||||
|
||||
// MARK: - Element management
|
||||
|
||||
func add(_ element: Element) {
|
||||
elements.append(element)
|
||||
if let crop = element as? CropElement { cropElement = crop }
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.remove(element)
|
||||
}
|
||||
undoManager.setActionName("Add \(String(describing: type(of: element)))")
|
||||
changed()
|
||||
}
|
||||
|
||||
func remove(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.remove(at: index)
|
||||
if element === cropElement { cropElement = nil }
|
||||
if element === selection { selection = nil }
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.add(element)
|
||||
}
|
||||
changed()
|
||||
}
|
||||
|
||||
func removeSelection() {
|
||||
guard let selection else { return }
|
||||
remove(selection)
|
||||
}
|
||||
|
||||
func bringToFront(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.append(elements.remove(at: index))
|
||||
changed()
|
||||
}
|
||||
|
||||
func sendToBack(_ element: Element) {
|
||||
guard let index = elements.firstIndex(where: { $0 === element }) else { return }
|
||||
elements.insert(elements.remove(at: index), at: 0)
|
||||
changed()
|
||||
}
|
||||
|
||||
func duplicate(_ element: Element) {
|
||||
let copy = element.copyElement()
|
||||
copy.move(by: CGVector(dx: 12, dy: 12))
|
||||
add(copy)
|
||||
selection = copy
|
||||
}
|
||||
|
||||
/// Records the state of an element before an interactive change so undo can restore it.
|
||||
func registerGeometryUndo(for element: Element) {
|
||||
let previousFrame = element.frame
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.registerGeometryUndo(for: element)
|
||||
element.frame = previousFrame
|
||||
surface.changed()
|
||||
}
|
||||
}
|
||||
|
||||
func element(at point: CGPoint) -> Element? {
|
||||
elements.reversed().first { !($0 is CropElement) && $0.hitTest(point) }
|
||||
}
|
||||
|
||||
func changed() {
|
||||
hasUnsavedChanges = true
|
||||
onChange?()
|
||||
}
|
||||
|
||||
// MARK: - Crop
|
||||
|
||||
func applyCrop(to rect: CGRect) {
|
||||
let clamped = rect.intersection(CGRect(origin: .zero, size: imageSize))
|
||||
guard clamped.width >= 1, clamped.height >= 1 else { return }
|
||||
|
||||
let previousImage = baseImage
|
||||
let previousSize = imageSize
|
||||
let previousElements = elements
|
||||
|
||||
let pixelRect = CGRect(x: (clamped.minX * scale).rounded(.down),
|
||||
y: (clamped.minY * scale).rounded(.down),
|
||||
width: (clamped.width * scale).rounded(),
|
||||
height: (clamped.height * scale).rounded())
|
||||
guard let cropped = baseImage.cropping(to: pixelRect) else { return }
|
||||
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.restore(image: previousImage, size: previousSize, elements: previousElements)
|
||||
}
|
||||
undoManager.setActionName("Crop")
|
||||
|
||||
baseImage = cropped
|
||||
imageSize = clamped.size
|
||||
elements.removeAll { $0 is CropElement }
|
||||
cropElement = nil
|
||||
for element in elements {
|
||||
element.move(by: CGVector(dx: -clamped.minX, dy: -clamped.minY))
|
||||
}
|
||||
selection = nil
|
||||
changed()
|
||||
}
|
||||
|
||||
private func restore(image: CGImage, size: CGSize, elements restored: [Element]) {
|
||||
let currentImage = baseImage
|
||||
let currentSize = imageSize
|
||||
let currentElements = elements
|
||||
undoManager.registerUndo(withTarget: self) { surface in
|
||||
surface.restore(image: currentImage, size: currentSize, elements: currentElements)
|
||||
}
|
||||
baseImage = image
|
||||
imageSize = size
|
||||
elements = restored
|
||||
cropElement = nil
|
||||
selection = nil
|
||||
changed()
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
/// Renders the base image with every element baked in, at full pixel resolution.
|
||||
func render() -> CGImage {
|
||||
let width = baseImage.width
|
||||
let height = baseImage.height
|
||||
guard width > 0, height > 0,
|
||||
let context = CGContext(data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else { return baseImage }
|
||||
|
||||
// Flip to a top-left origin and work in points.
|
||||
context.translateBy(x: 0, y: CGFloat(height))
|
||||
context.scaleBy(x: 1, y: -1)
|
||||
context.scaleBy(x: scale, y: scale)
|
||||
context.interpolationQuality = .high
|
||||
|
||||
drawFlipped(baseImage, in: CGRect(origin: .zero, size: imageSize), context: context)
|
||||
|
||||
for element in elements where !(element is CropElement) {
|
||||
element.draw(in: context, baseImage: baseImage, imageSize: imageSize)
|
||||
}
|
||||
return context.makeImage() ?? baseImage
|
||||
}
|
||||
|
||||
func renderedCapture() -> Capture {
|
||||
let capture = Capture(image: render(), details: details)
|
||||
capture.details.scaleFactor = scale
|
||||
return capture
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user