489 lines
19 KiB
Swift
489 lines
19 KiB
Swift
// 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)
|
|
func canvasDidPickColor(_ canvas: EditorCanvasView, color: NSColor)
|
|
}
|
|
|
|
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
|
|
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
|
|
case .dropper: cursor = .crosshair
|
|
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 .dropper:
|
|
// Sample the flattened image so annotation colours can be picked up too.
|
|
if let color = ImageExporter.color(at: point, in: surface.render(), scale: surface.scale) {
|
|
delegate?.canvasDidPickColor(self, color: color)
|
|
}
|
|
interaction = .none
|
|
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)
|
|
element.creationAnchor = 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(start: point, end: point, style: style)
|
|
case .arrow: return ArrowElement(start: point, end: point, 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)
|
|
crop.creationAnchor = point
|
|
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):
|
|
if let line = element as? LineElement {
|
|
let target = constrain ? constrainedLineEnd(from: line.start, to: point) : point
|
|
line.setEnd(target)
|
|
} else {
|
|
let anchorPoint = element.creationAnchor ?? element.frame.origin
|
|
var target = point
|
|
if constrain {
|
|
let side = max(abs(point.x - anchorPoint.x), abs(point.y - anchorPoint.y))
|
|
target = CGPoint(x: anchorPoint.x + side * (point.x < anchorPoint.x ? -1 : 1),
|
|
y: anchorPoint.y + side * (point.y < anchorPoint.y ? -1 : 1))
|
|
}
|
|
// Build the rect from the two corners so dragging up or left works.
|
|
element.frame = CGRect(x: min(anchorPoint.x, target.x),
|
|
y: min(anchorPoint.y, target.y),
|
|
width: abs(target.x - anchorPoint.x),
|
|
height: abs(target.y - anchorPoint.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 as? LineElement).map { $0.length < 4 }
|
|
?? (rect.width < 4 || rect.height < 4)
|
|
element.creationAnchor = nil
|
|
if tooSmall {
|
|
surface.remove(element)
|
|
} else if !(element is LineElement) {
|
|
element.frame = rect
|
|
if element is CropElement { surface.cropElement = element as? CropElement }
|
|
}
|
|
// The tool stays active so several shapes can be drawn in a row.
|
|
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
|
|
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|