Files
2026-09-18 07:17:34 -05:00

181 lines
6.0 KiB
Swift

// Surface.swift
// Snaggle
//
// 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
}
}