// 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 } }