|
@@ -20,13 +20,48 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
private var currentIndex = 0
|
|
private var currentIndex = 0
|
|
|
private var securityAccess: [URL: Bool] = [:]
|
|
private var securityAccess: [URL: Bool] = [:]
|
|
|
|
|
|
|
|
|
|
+ private var originalImage: NSImage?
|
|
|
|
|
+ private var editedImage: NSImage?
|
|
|
|
|
+ private var whiteningAmount: Float = 0
|
|
|
|
|
+ private var saturationAmount: Float = 1
|
|
|
|
|
+ private var contrastAmount: Float = 1
|
|
|
|
|
+ private var autoEnhanceApplied = false
|
|
|
|
|
+
|
|
|
private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
|
|
|
private let titleLabel = NSTextField(labelWithString: "Print a photo")
|
|
private let titleLabel = NSTextField(labelWithString: "Print a photo")
|
|
|
private let counterLabel = NSTextField(labelWithString: "")
|
|
private let counterLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private let previewSection = NSView()
|
|
|
|
|
+ private let editControlsArea = NSView()
|
|
|
private let imageContainer = NSView()
|
|
private let imageContainer = NSView()
|
|
|
private let imageView = NSImageView()
|
|
private let imageView = NSImageView()
|
|
|
|
|
+ private let editButton = PhotoToolbarButton(symbolName: "slider.horizontal.3", accessibilityLabel: "Edit")
|
|
|
|
|
+ private let editPanel = PhotoEditPanelView()
|
|
|
|
|
+ private let whiteningPanel = PhotoAdjustSliderPanelView(
|
|
|
|
|
+ title: "Whitening",
|
|
|
|
|
+ minValue: 0,
|
|
|
|
|
+ maxValue: 1,
|
|
|
|
|
+ defaultValue: 0
|
|
|
|
|
+ )
|
|
|
|
|
+ private let saturationPanel = PhotoAdjustSliderPanelView(
|
|
|
|
|
+ title: "Saturation",
|
|
|
|
|
+ minValue: 0,
|
|
|
|
|
+ maxValue: 2,
|
|
|
|
|
+ defaultValue: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ private let contrastPanel = PhotoAdjustSliderPanelView(
|
|
|
|
|
+ title: "Contrast",
|
|
|
|
|
+ minValue: 0,
|
|
|
|
|
+ maxValue: 2,
|
|
|
|
|
+ defaultValue: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ private var cropOverlay: PhotoCropOverlayView?
|
|
|
private let printButton = PhotoPrintButton()
|
|
private let printButton = PhotoPrintButton()
|
|
|
|
|
|
|
|
|
|
+ private var imageContainerTopConstraint: NSLayoutConstraint!
|
|
|
|
|
+
|
|
|
|
|
+ private var isEditPanelVisible = false
|
|
|
|
|
+ private var isCropMode = false
|
|
|
|
|
+
|
|
|
init(urls: [URL]) {
|
|
init(urls: [URL]) {
|
|
|
self.urls = urls
|
|
self.urls = urls
|
|
|
super.init(frame: .zero)
|
|
super.init(frame: .zero)
|
|
@@ -60,6 +95,12 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
|
|
imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
|
|
|
imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
backButton.refreshAppearance()
|
|
backButton.refreshAppearance()
|
|
|
|
|
+ editButton.refreshAppearance()
|
|
|
|
|
+ editPanel.refreshAppearance()
|
|
|
|
|
+ whiteningPanel.refreshAppearance()
|
|
|
|
|
+ saturationPanel.refreshAppearance()
|
|
|
|
|
+ contrastPanel.refreshAppearance()
|
|
|
|
|
+ cropOverlay?.refreshAppearance()
|
|
|
printButton.refreshAppearance()
|
|
printButton.refreshAppearance()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -125,16 +166,50 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
|
|
|
|
|
|
|
|
|
|
+ previewSection.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ editControlsArea.translatesAutoresizingMaskIntoConstraints = false
|
|
|
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
backButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ editButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ editPanel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ whiteningPanel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ saturationPanel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ contrastPanel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
printButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
|
|
|
|
|
+ editPanel.isHidden = true
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+
|
|
|
backButton.onClick = { [weak self] in self?.dismiss() }
|
|
backButton.onClick = { [weak self] in self?.dismiss() }
|
|
|
|
|
+ editButton.onClick = { [weak self] in self?.toggleEditPanel() }
|
|
|
printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
|
|
printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
|
|
|
|
|
|
|
|
|
|
+ editPanel.onAction = { [weak self] action in
|
|
|
|
|
+ self?.handleEditAction(action)
|
|
|
|
|
+ }
|
|
|
|
|
+ whiteningPanel.onValueChanged = { [weak self] value in
|
|
|
|
|
+ self?.whiteningAmount = value
|
|
|
|
|
+ self?.refreshDisplayedImage()
|
|
|
|
|
+ }
|
|
|
|
|
+ saturationPanel.onValueChanged = { [weak self] value in
|
|
|
|
|
+ self?.saturationAmount = value
|
|
|
|
|
+ self?.refreshDisplayedImage()
|
|
|
|
|
+ }
|
|
|
|
|
+ contrastPanel.onValueChanged = { [weak self] value in
|
|
|
|
|
+ self?.contrastAmount = value
|
|
|
|
|
+ self?.refreshDisplayedImage()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
addSubview(backButton)
|
|
addSubview(backButton)
|
|
|
addSubview(titleLabel)
|
|
addSubview(titleLabel)
|
|
|
addSubview(counterLabel)
|
|
addSubview(counterLabel)
|
|
|
- addSubview(imageContainer)
|
|
|
|
|
|
|
+ addSubview(previewSection)
|
|
|
|
|
+ previewSection.addSubview(editControlsArea)
|
|
|
|
|
+ previewSection.addSubview(imageContainer)
|
|
|
|
|
+ editControlsArea.addSubview(editButton)
|
|
|
|
|
+ editControlsArea.addSubview(editPanel)
|
|
|
|
|
+ editControlsArea.addSubview(whiteningPanel)
|
|
|
|
|
+ editControlsArea.addSubview(saturationPanel)
|
|
|
|
|
+ editControlsArea.addSubview(contrastPanel)
|
|
|
imageContainer.addSubview(imageView)
|
|
imageContainer.addSubview(imageView)
|
|
|
addSubview(printButton)
|
|
addSubview(printButton)
|
|
|
|
|
|
|
@@ -150,10 +225,47 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
|
|
|
|
|
|
|
|
- imageContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
|
|
- imageContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
|
|
- imageContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
|
|
|
|
|
- imageContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
|
|
|
|
|
|
|
+ previewSection.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
|
|
|
|
|
+ previewSection.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
|
|
|
|
|
+ previewSection.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
|
|
|
|
|
+ previewSection.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
|
|
|
|
|
+
|
|
|
|
|
+ editControlsArea.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
|
|
|
|
|
+ editControlsArea.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
|
|
|
|
|
+ editControlsArea.topAnchor.constraint(equalTo: previewSection.topAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ editButton.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
|
|
|
|
|
+ editButton.topAnchor.constraint(equalTo: editControlsArea.topAnchor),
|
|
|
|
|
+ editButton.widthAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+ editButton.heightAnchor.constraint(equalToConstant: 40),
|
|
|
|
|
+
|
|
|
|
|
+ editPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
|
|
|
|
|
+ editPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
|
|
|
|
|
+ editPanel.topAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 10),
|
|
|
|
|
+
|
|
|
|
|
+ whiteningPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
|
|
|
|
|
+ whiteningPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
|
|
|
|
|
+ whiteningPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
|
|
|
|
|
+
|
|
|
|
|
+ saturationPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
|
|
|
|
|
+ saturationPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
|
|
|
|
|
+ saturationPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
|
|
|
|
|
+
|
|
|
|
|
+ contrastPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
|
|
|
|
|
+ contrastPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
|
|
|
|
|
+ contrastPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
|
|
|
|
|
+
|
|
|
|
|
+ editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: whiteningPanel.bottomAnchor),
|
|
|
|
|
+ editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: saturationPanel.bottomAnchor),
|
|
|
|
|
+ editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: contrastPanel.bottomAnchor),
|
|
|
|
|
+ editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: editPanel.bottomAnchor),
|
|
|
|
|
+ editControlsArea.bottomAnchor.constraint(greaterThanOrEqualTo: editButton.bottomAnchor),
|
|
|
|
|
+
|
|
|
|
|
+ imageContainer.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
|
|
|
|
|
+ imageContainer.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
|
|
|
|
|
+ imageContainer.heightAnchor.constraint(equalToConstant: 300),
|
|
|
|
|
+ imageContainer.heightAnchor.constraint(lessThanOrEqualToConstant: 340),
|
|
|
|
|
+ imageContainer.bottomAnchor.constraint(lessThanOrEqualTo: previewSection.bottomAnchor),
|
|
|
|
|
|
|
|
imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
|
|
imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
|
|
|
imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
|
|
imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
|
|
@@ -164,6 +276,174 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
|
|
|
printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
|
|
])
|
|
])
|
|
|
|
|
+
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateImageContainerTopConstraint() {
|
|
|
|
|
+ imageContainerTopConstraint?.isActive = false
|
|
|
|
|
+
|
|
|
|
|
+ let anchor: NSLayoutYAxisAnchor
|
|
|
|
|
+ if !whiteningPanel.isHidden {
|
|
|
|
|
+ anchor = whiteningPanel.bottomAnchor
|
|
|
|
|
+ } else if !saturationPanel.isHidden {
|
|
|
|
|
+ anchor = saturationPanel.bottomAnchor
|
|
|
|
|
+ } else if !contrastPanel.isHidden {
|
|
|
|
|
+ anchor = contrastPanel.bottomAnchor
|
|
|
|
|
+ } else if !editPanel.isHidden {
|
|
|
|
|
+ anchor = editPanel.bottomAnchor
|
|
|
|
|
+ } else {
|
|
|
|
|
+ anchor = editButton.bottomAnchor
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ imageContainerTopConstraint = imageContainer.topAnchor.constraint(equalTo: anchor, constant: 14)
|
|
|
|
|
+ imageContainerTopConstraint.isActive = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func hideAllAdjustmentPanels() {
|
|
|
|
|
+ whiteningPanel.isHidden = true
|
|
|
|
|
+ saturationPanel.isHidden = true
|
|
|
|
|
+ contrastPanel.isHidden = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func toggleAdjustmentPanel(_ panel: PhotoAdjustSliderPanelView, action: PhotoEditAction, currentValue: Float) {
|
|
|
|
|
+ let willShow = panel.isHidden
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ editPanel.setActive(.whitening, isActive: false)
|
|
|
|
|
+ editPanel.setActive(.saturation, isActive: false)
|
|
|
|
|
+ editPanel.setActive(.contrast, isActive: false)
|
|
|
|
|
+
|
|
|
|
|
+ if willShow {
|
|
|
|
|
+ panel.isHidden = false
|
|
|
|
|
+ panel.setValue(currentValue)
|
|
|
|
|
+ editPanel.setActive(action, isActive: true)
|
|
|
|
|
+ }
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func toggleEditPanel() {
|
|
|
|
|
+ isEditPanelVisible.toggle()
|
|
|
|
|
+ editPanel.isHidden = !isEditPanelVisible
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ if !isEditPanelVisible {
|
|
|
|
|
+ exitCropMode()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func handleEditAction(_ action: PhotoEditAction) {
|
|
|
|
|
+ switch action {
|
|
|
|
|
+ case .whitening:
|
|
|
|
|
+ toggleAdjustmentPanel(whiteningPanel, action: .whitening, currentValue: whiteningAmount)
|
|
|
|
|
+ case .autoMode:
|
|
|
|
|
+ autoEnhanceApplied = true
|
|
|
|
|
+ editPanel.setActive(.autoMode, isActive: true)
|
|
|
|
|
+ refreshDisplayedImage()
|
|
|
|
|
+ case .saturation:
|
|
|
|
|
+ toggleAdjustmentPanel(saturationPanel, action: .saturation, currentValue: saturationAmount)
|
|
|
|
|
+ case .contrast:
|
|
|
|
|
+ toggleAdjustmentPanel(contrastPanel, action: .contrast, currentValue: contrastAmount)
|
|
|
|
|
+ case .crop:
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ enterCropMode()
|
|
|
|
|
+ case .flip:
|
|
|
|
|
+ applyGeometricEdit { PhotoImageEditor.flipHorizontal($0) }
|
|
|
|
|
+ case .rotate:
|
|
|
|
|
+ applyGeometricEdit { PhotoImageEditor.rotateClockwise($0) }
|
|
|
|
|
+ case .done:
|
|
|
|
|
+ isEditPanelVisible = false
|
|
|
|
|
+ editPanel.isHidden = true
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ exitCropMode()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyGeometricEdit(_ transform: (NSImage) -> NSImage) {
|
|
|
|
|
+ guard let base = currentBaseImage() else { return }
|
|
|
|
|
+ let transformed = transform(base)
|
|
|
|
|
+ originalImage = transformed
|
|
|
|
|
+ whiteningAmount = 0
|
|
|
|
|
+ saturationAmount = 1
|
|
|
|
|
+ contrastAmount = 1
|
|
|
|
|
+ autoEnhanceApplied = false
|
|
|
|
|
+ editedImage = nil
|
|
|
|
|
+ editPanel.resetActiveStates()
|
|
|
|
|
+ whiteningPanel.setValue(0)
|
|
|
|
|
+ saturationPanel.setValue(1)
|
|
|
|
|
+ contrastPanel.setValue(1)
|
|
|
|
|
+ refreshDisplayedImage()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func enterCropMode() {
|
|
|
|
|
+ guard !isCropMode, currentBaseImage() != nil else { return }
|
|
|
|
|
+ isCropMode = true
|
|
|
|
|
+ editPanel.setActive(.crop, isActive: true)
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+
|
|
|
|
|
+ let overlay = PhotoCropOverlayView(imageView: imageView)
|
|
|
|
|
+ overlay.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ overlay.onCancel = { [weak self] in
|
|
|
|
|
+ self?.exitCropMode()
|
|
|
|
|
+ }
|
|
|
|
|
+ overlay.onApply = { [weak self] normalizedRect in
|
|
|
|
|
+ self?.applyCrop(normalizedRect)
|
|
|
|
|
+ }
|
|
|
|
|
+ imageContainer.addSubview(overlay, positioned: .above, relativeTo: imageView)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ overlay.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
|
|
|
|
|
+ overlay.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
|
|
|
|
|
+ overlay.topAnchor.constraint(equalTo: imageView.topAnchor),
|
|
|
|
|
+ overlay.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ cropOverlay = overlay
|
|
|
|
|
+ overlay.refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func exitCropMode() {
|
|
|
|
|
+ isCropMode = false
|
|
|
|
|
+ editPanel.setActive(.crop, isActive: false)
|
|
|
|
|
+ cropOverlay?.removeFromSuperview()
|
|
|
|
|
+ cropOverlay = nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func applyCrop(_ normalizedRect: CGRect) {
|
|
|
|
|
+ guard let base = currentBaseImage() else {
|
|
|
|
|
+ exitCropMode()
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ originalImage = PhotoImageEditor.crop(base, normalizedRect: normalizedRect)
|
|
|
|
|
+ whiteningAmount = 0
|
|
|
|
|
+ saturationAmount = 1
|
|
|
|
|
+ contrastAmount = 1
|
|
|
|
|
+ autoEnhanceApplied = false
|
|
|
|
|
+ editedImage = nil
|
|
|
|
|
+ editPanel.resetActiveStates()
|
|
|
|
|
+ whiteningPanel.setValue(0)
|
|
|
|
|
+ saturationPanel.setValue(1)
|
|
|
|
|
+ contrastPanel.setValue(1)
|
|
|
|
|
+ exitCropMode()
|
|
|
|
|
+ refreshDisplayedImage()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func currentBaseImage() -> NSImage? {
|
|
|
|
|
+ originalImage ?? imageView.image
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func refreshDisplayedImage() {
|
|
|
|
|
+ guard let base = originalImage else { return }
|
|
|
|
|
+ let processed = PhotoImageEditor.apply(
|
|
|
|
|
+ to: base,
|
|
|
|
|
+ saturation: saturationAmount,
|
|
|
|
|
+ whitening: whiteningAmount,
|
|
|
|
|
+ contrast: contrastAmount,
|
|
|
|
|
+ autoEnhance: autoEnhanceApplied
|
|
|
|
|
+ )
|
|
|
|
|
+ editedImage = processed
|
|
|
|
|
+ imageView.image = processed
|
|
|
|
|
+ cropOverlay?.imageDidChange()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func showPhoto(at index: Int) {
|
|
private func showPhoto(at index: Int) {
|
|
@@ -175,7 +455,23 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
securityAccess[url] = url.startAccessingSecurityScopedResource()
|
|
securityAccess[url] = url.startAccessingSecurityScopedResource()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- imageView.image = NSImage(contentsOf: url)
|
|
|
|
|
|
|
+ originalImage = NSImage(contentsOf: url)
|
|
|
|
|
+ editedImage = nil
|
|
|
|
|
+ whiteningAmount = 0
|
|
|
|
|
+ saturationAmount = 1
|
|
|
|
|
+ contrastAmount = 1
|
|
|
|
|
+ autoEnhanceApplied = false
|
|
|
|
|
+ isEditPanelVisible = false
|
|
|
|
|
+ editPanel.isHidden = true
|
|
|
|
|
+ editPanel.resetActiveStates()
|
|
|
|
|
+ hideAllAdjustmentPanels()
|
|
|
|
|
+ whiteningPanel.setValue(0)
|
|
|
|
|
+ saturationPanel.setValue(1)
|
|
|
|
|
+ contrastPanel.setValue(1)
|
|
|
|
|
+ updateImageContainerTopConstraint()
|
|
|
|
|
+ exitCropMode()
|
|
|
|
|
+
|
|
|
|
|
+ imageView.image = originalImage
|
|
|
|
|
|
|
|
if urls.count > 1 {
|
|
if urls.count > 1 {
|
|
|
counterLabel.stringValue = "\(index + 1) of \(urls.count)"
|
|
counterLabel.stringValue = "\(index + 1) of \(urls.count)"
|
|
@@ -191,8 +487,11 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func printCurrentPhoto() {
|
|
private func printCurrentPhoto() {
|
|
|
- guard let url = currentURL() else { return }
|
|
|
|
|
- PrintService.print(urls: [url], from: window)
|
|
|
|
|
|
|
+ let image = editedImage ?? originalImage ?? currentURL().flatMap { NSImage(contentsOf: $0) }
|
|
|
|
|
+ guard let image else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let title = currentURL()?.lastPathComponent ?? "Photo"
|
|
|
|
|
+ PrintService.printPhoto(image, title: title, from: window)
|
|
|
|
|
|
|
|
if currentIndex < urls.count - 1 {
|
|
if currentIndex < urls.count - 1 {
|
|
|
showPhoto(at: currentIndex + 1)
|
|
showPhoto(at: currentIndex + 1)
|
|
@@ -209,6 +508,580 @@ final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// MARK: - Edit Actions
|
|
|
|
|
+
|
|
|
|
|
+private enum PhotoEditAction {
|
|
|
|
|
+ case whitening
|
|
|
|
|
+ case autoMode
|
|
|
|
|
+ case saturation
|
|
|
|
|
+ case contrast
|
|
|
|
|
+ case crop
|
|
|
|
|
+ case flip
|
|
|
|
|
+ case rotate
|
|
|
|
|
+ case done
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Edit Panel
|
|
|
|
|
+
|
|
|
|
|
+private final class PhotoEditPanelView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onAction: ((PhotoEditAction) -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let stack = NSStackView()
|
|
|
|
|
+ private var activeActions: Set<PhotoEditAction> = []
|
|
|
|
|
+
|
|
|
|
|
+ private struct Tool {
|
|
|
|
|
+ let action: PhotoEditAction
|
|
|
|
|
+ let title: String
|
|
|
|
|
+ let symbol: String
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private let tools: [Tool] = [
|
|
|
|
|
+ Tool(action: .whitening, title: "Whitening", symbol: "sparkles"),
|
|
|
|
|
+ Tool(action: .autoMode, title: "Auto", symbol: "wand.and.stars"),
|
|
|
|
|
+ Tool(action: .saturation, title: "Saturation", symbol: "drop.halffull"),
|
|
|
|
|
+ Tool(action: .contrast, title: "Contrast", symbol: "circle.lefthalf.filled"),
|
|
|
|
|
+ Tool(action: .crop, title: "Crop", symbol: "crop"),
|
|
|
|
|
+ Tool(action: .flip, title: "Flip", symbol: "arrow.left.and.right.righttriangle.left.righttriangle.right"),
|
|
|
|
|
+ Tool(action: .rotate, title: "Rotate", symbol: "rotate.right"),
|
|
|
|
|
+ Tool(action: .done, title: "Done", symbol: "checkmark"),
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ init() {
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 14
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ stack.orientation = .horizontal
|
|
|
|
|
+ stack.spacing = 6
|
|
|
|
|
+ stack.distribution = .fillEqually
|
|
|
|
|
+ stack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ for tool in tools {
|
|
|
|
|
+ let button = PhotoEditToolButton(title: tool.title, symbolName: tool.symbol)
|
|
|
|
|
+ button.onClick = { [weak self] in
|
|
|
|
|
+ self?.onAction?(tool.action)
|
|
|
|
|
+ }
|
|
|
|
|
+ stack.addArrangedSubview(button)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(stack)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 72),
|
|
|
|
|
+ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
|
|
|
|
|
+ stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
|
|
|
|
|
+ stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
|
|
+ stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1
|
|
|
|
|
+ stack.arrangedSubviews.compactMap { $0 as? PhotoEditToolButton }.forEach { $0.refreshAppearance() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func setActive(_ action: PhotoEditAction, isActive: Bool) {
|
|
|
|
|
+ if isActive {
|
|
|
|
|
+ if action == .whitening || action == .autoMode || action == .saturation || action == .contrast || action == .crop {
|
|
|
|
|
+ activeActions.insert(action)
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ activeActions.remove(action)
|
|
|
|
|
+ }
|
|
|
|
|
+ updateButtonStates()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func resetActiveStates() {
|
|
|
|
|
+ activeActions.removeAll()
|
|
|
|
|
+ updateButtonStates()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateButtonStates() {
|
|
|
|
|
+ for (index, tool) in tools.enumerated() {
|
|
|
|
|
+ guard let button = stack.arrangedSubviews[index] as? PhotoEditToolButton else { continue }
|
|
|
|
|
+ button.isActive = activeActions.contains(tool.action)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Edit Tool Button
|
|
|
|
|
+
|
|
|
|
|
+private final class PhotoEditToolButton: NSControl, AppearanceRefreshable {
|
|
|
|
|
+ var onClick: (() -> Void)?
|
|
|
|
|
+ var isActive = false {
|
|
|
|
|
+ didSet { refreshAppearance() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private let title: String
|
|
|
|
|
+ private let iconView = NSImageView()
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private var hoverTracker: HoverTracker?
|
|
|
|
|
+ private var isHovered = false
|
|
|
|
|
+
|
|
|
|
|
+ init(title: String, symbolName: String) {
|
|
|
|
|
+ self.title = title
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 10
|
|
|
|
|
+
|
|
|
|
|
+ if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
|
|
|
|
|
+ let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
|
|
|
|
|
+ iconView.image = image.withSymbolConfiguration(config)
|
|
|
|
|
+ }
|
|
|
|
|
+ iconView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.stringValue = title
|
|
|
|
|
+ titleLabel.font = AppTheme.mediumFont(size: 10)
|
|
|
|
|
+ titleLabel.alignment = .center
|
|
|
|
|
+ titleLabel.maximumNumberOfLines = 1
|
|
|
|
|
+ titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(iconView)
|
|
|
|
|
+ addSubview(titleLabel)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
|
|
|
|
|
+ iconView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
|
|
|
|
|
+ iconView.widthAnchor.constraint(equalToConstant: 18),
|
|
|
|
|
+ iconView.heightAnchor.constraint(equalToConstant: 18),
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
|
|
|
|
|
+ titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2),
|
|
|
|
|
+ titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 4),
|
|
|
|
|
+ titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6),
|
|
|
|
|
+ ])
|
|
|
|
|
+
|
|
|
|
|
+ hoverTracker = HoverTracker(view: self) { [weak self] hovering in
|
|
|
|
|
+ self?.isHovered = hovering
|
|
|
|
|
+ self?.refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ if isActive {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.15).cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.blue.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1.5
|
|
|
|
|
+ iconView.contentTintColor = AppTheme.blue
|
|
|
|
|
+ titleLabel.textColor = AppTheme.blue
|
|
|
|
|
+ } else if isHovered {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.border.withAlphaComponent(0.35).cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1
|
|
|
|
|
+ iconView.contentTintColor = AppTheme.textPrimary
|
|
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
|
|
+ } else {
|
|
|
|
|
+ layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
|
+ layer?.borderColor = NSColor.clear.cgColor
|
|
|
|
|
+ layer?.borderWidth = 0
|
|
|
|
|
+ iconView.contentTintColor = AppTheme.textPrimary
|
|
|
|
|
+ titleLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onClick?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Adjustment Slider Panel
|
|
|
|
|
+
|
|
|
|
|
+private final class PhotoAdjustSliderPanelView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onValueChanged: ((Float) -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+ private let valueLabel = NSTextField(labelWithString: "0%")
|
|
|
|
|
+ private let slider: NSSlider
|
|
|
|
|
+ private let defaultValue: Float
|
|
|
|
|
+
|
|
|
|
|
+ init(title: String, minValue: Float, maxValue: Float, defaultValue: Float) {
|
|
|
|
|
+ self.defaultValue = defaultValue
|
|
|
|
|
+ slider = NSSlider(value: Double(defaultValue), minValue: Double(minValue), maxValue: Double(maxValue), target: nil, action: nil)
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 12
|
|
|
|
|
+ translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.stringValue = title
|
|
|
|
|
+ titleLabel.font = AppTheme.mediumFont(size: 13)
|
|
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ valueLabel.font = AppTheme.regularFont(size: 12)
|
|
|
|
|
+ valueLabel.alignment = .right
|
|
|
|
|
+ valueLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ slider.target = self
|
|
|
|
|
+ slider.action = #selector(sliderChanged)
|
|
|
|
|
+ slider.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(titleLabel)
|
|
|
|
|
+ addSubview(valueLabel)
|
|
|
|
|
+ addSubview(slider)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 56),
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ titleLabel.widthAnchor.constraint(equalToConstant: 78),
|
|
|
|
|
+
|
|
|
|
|
+ valueLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
|
|
|
+ valueLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ valueLabel.widthAnchor.constraint(equalToConstant: 48),
|
|
|
|
|
+
|
|
|
|
|
+ slider.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
|
|
|
|
|
+ slider.trailingAnchor.constraint(equalTo: valueLabel.leadingAnchor, constant: -12),
|
|
|
|
|
+ slider.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ setValue(defaultValue)
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func setValue(_ value: Float) {
|
|
|
|
|
+ slider.floatValue = value
|
|
|
|
|
+ updateValueLabel()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1
|
|
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
|
|
+ valueLabel.textColor = AppTheme.textSecondary
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @objc private func sliderChanged() {
|
|
|
|
|
+ updateValueLabel()
|
|
|
|
|
+ onValueChanged?(slider.floatValue)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func updateValueLabel() {
|
|
|
|
|
+ valueLabel.stringValue = "\(Int(slider.floatValue * 100))%"
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// MARK: - Crop Overlay
|
|
|
|
|
+
|
|
|
|
|
+private final class PhotoCropOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
+ var onApply: ((CGRect) -> Void)?
|
|
|
|
|
+ var onCancel: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private weak var imageView: NSImageView?
|
|
|
|
|
+ private var cropRect = CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.8)
|
|
|
|
|
+ private var dragMode: CropDragMode = .none
|
|
|
|
|
+ private var dragStartPoint = NSPoint.zero
|
|
|
|
|
+ private var dragStartRect = CGRect.zero
|
|
|
|
|
+
|
|
|
|
|
+ private let applyButton = PhotoCropActionButton(title: "Apply", isPrimary: true)
|
|
|
|
|
+ private let cancelButton = PhotoCropActionButton(title: "Cancel", isPrimary: false)
|
|
|
|
|
+
|
|
|
|
|
+ private enum CropDragMode {
|
|
|
|
|
+ case none
|
|
|
|
|
+ case move
|
|
|
|
|
+ case resize(CropHandle)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private enum CropHandle {
|
|
|
|
|
+ case topLeft, topRight, bottomLeft, bottomRight
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ init(imageView: NSImageView) {
|
|
|
|
|
+ self.imageView = imageView
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+
|
|
|
|
|
+ applyButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ cancelButton.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ applyButton.onClick = { [weak self] in
|
|
|
|
|
+ guard let self else { return }
|
|
|
|
|
+ onApply?(cropRect)
|
|
|
|
|
+ }
|
|
|
|
|
+ cancelButton.onClick = { [weak self] in
|
|
|
|
|
+ self?.onCancel?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(applyButton)
|
|
|
|
|
+ addSubview(cancelButton)
|
|
|
|
|
+
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ applyButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
|
|
|
|
+ applyButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
|
|
|
|
+
|
|
|
|
|
+ cancelButton.trailingAnchor.constraint(equalTo: applyButton.leadingAnchor, constant: -8),
|
|
|
|
|
+ cancelButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func imageDidChange() {
|
|
|
|
|
+ needsDisplay = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ applyButton.refreshAppearance()
|
|
|
|
|
+ cancelButton.refreshAppearance()
|
|
|
|
|
+ needsDisplay = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func draw(_ dirtyRect: NSRect) {
|
|
|
|
|
+ guard let imageFrame = currentImageFrame() else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let selection = rectInView(from: cropRect, imageFrame: imageFrame)
|
|
|
|
|
+ let dim = NSColor.black.withAlphaComponent(0.45)
|
|
|
|
|
+ dim.setFill()
|
|
|
|
|
+ NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: selection.maxY - bounds.minY).fill()
|
|
|
|
|
+ NSRect(x: bounds.minX, y: selection.minY, width: selection.minX - bounds.minX, height: selection.height).fill()
|
|
|
|
|
+ NSRect(x: selection.maxX, y: selection.minY, width: bounds.maxX - selection.maxX, height: selection.height).fill()
|
|
|
|
|
+ NSRect(x: bounds.minX, y: selection.maxY, width: bounds.width, height: bounds.maxY - selection.maxY).fill()
|
|
|
|
|
+
|
|
|
|
|
+ NSColor.white.setStroke()
|
|
|
|
|
+ let path = NSBezierPath(rect: selection)
|
|
|
|
|
+ path.lineWidth = 2
|
|
|
|
|
+ path.stroke()
|
|
|
|
|
+
|
|
|
|
|
+ let handleSize: CGFloat = 10
|
|
|
|
|
+ for point in handlePoints(in: selection) {
|
|
|
|
|
+ let handle = NSRect(
|
|
|
|
|
+ x: point.x - handleSize / 2,
|
|
|
|
|
+ y: point.y - handleSize / 2,
|
|
|
|
|
+ width: handleSize,
|
|
|
|
|
+ height: handleSize
|
|
|
|
|
+ )
|
|
|
|
|
+ NSColor.white.setFill()
|
|
|
|
|
+ NSBezierPath(ovalIn: handle).fill()
|
|
|
|
|
+ AppTheme.blue.setStroke()
|
|
|
|
|
+ NSBezierPath(ovalIn: handle).lineWidth = 1.5
|
|
|
|
|
+ NSBezierPath(ovalIn: handle).stroke()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseDown(with event: NSEvent) {
|
|
|
|
|
+ guard let imageFrame = currentImageFrame() else { return }
|
|
|
|
|
+ let point = convert(event.locationInWindow, from: nil)
|
|
|
|
|
+ let selection = rectInView(from: cropRect, imageFrame: imageFrame)
|
|
|
|
|
+
|
|
|
|
|
+ if let handle = hitTestHandle(at: point, in: selection) {
|
|
|
|
|
+ dragMode = .resize(handle)
|
|
|
|
|
+ } else if selection.contains(point) {
|
|
|
|
|
+ dragMode = .move
|
|
|
|
|
+ } else {
|
|
|
|
|
+ dragMode = .none
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ dragStartPoint = point
|
|
|
|
|
+ dragStartRect = cropRect
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseDragged(with event: NSEvent) {
|
|
|
|
|
+ guard case .none = dragMode else {
|
|
|
|
|
+ performDrag(with: event)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func performDrag(with event: NSEvent) {
|
|
|
|
|
+ guard let imageFrame = currentImageFrame() else { return }
|
|
|
|
|
+ let point = convert(event.locationInWindow, from: nil)
|
|
|
|
|
+ let dx = (point.x - dragStartPoint.x) / imageFrame.width
|
|
|
|
|
+ let dy = (point.y - dragStartPoint.y) / imageFrame.height
|
|
|
|
|
+
|
|
|
|
|
+ switch dragMode {
|
|
|
|
|
+ case .move:
|
|
|
|
|
+ var rect = dragStartRect
|
|
|
|
|
+ rect.origin.x += dx
|
|
|
|
|
+ rect.origin.y += dy
|
|
|
|
|
+ cropRect = clamp(rect)
|
|
|
|
|
+ case .resize(let handle):
|
|
|
|
|
+ cropRect = clamp(resizeRect(from: dragStartRect, handle: handle, dx: dx, dy: dy))
|
|
|
|
|
+ case .none:
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ needsDisplay = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ dragMode = .none
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .crosshair)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func currentImageFrame() -> NSRect? {
|
|
|
|
|
+ guard let imageView, let image = imageView.image else { return nil }
|
|
|
|
|
+ let bounds = self.bounds
|
|
|
|
|
+ guard image.size.width > 0, image.size.height > 0, bounds.width > 0, bounds.height > 0 else {
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+ let imageAspect = image.size.width / image.size.height
|
|
|
|
|
+ let boundsAspect = bounds.width / bounds.height
|
|
|
|
|
+ if imageAspect > boundsAspect {
|
|
|
|
|
+ let height = bounds.width / imageAspect
|
|
|
|
|
+ let y = (bounds.height - height) / 2
|
|
|
|
|
+ return NSRect(x: 0, y: y, width: bounds.width, height: height)
|
|
|
|
|
+ }
|
|
|
|
|
+ let width = bounds.height * imageAspect
|
|
|
|
|
+ let x = (bounds.width - width) / 2
|
|
|
|
|
+ return NSRect(x: x, y: 0, width: width, height: bounds.height)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func rectInView(from normalized: CGRect, imageFrame: NSRect) -> NSRect {
|
|
|
|
|
+ NSRect(
|
|
|
|
|
+ x: imageFrame.origin.x + normalized.origin.x * imageFrame.width,
|
|
|
|
|
+ y: imageFrame.origin.y + normalized.origin.y * imageFrame.height,
|
|
|
|
|
+ width: normalized.width * imageFrame.width,
|
|
|
|
|
+ height: normalized.height * imageFrame.height
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func handlePoints(in rect: NSRect) -> [NSPoint] {
|
|
|
|
|
+ [
|
|
|
|
|
+ NSPoint(x: rect.minX, y: rect.minY),
|
|
|
|
|
+ NSPoint(x: rect.maxX, y: rect.minY),
|
|
|
|
|
+ NSPoint(x: rect.minX, y: rect.maxY),
|
|
|
|
|
+ NSPoint(x: rect.maxX, y: rect.maxY),
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func hitTestHandle(at point: NSPoint, in rect: NSRect) -> CropHandle? {
|
|
|
|
|
+ let threshold: CGFloat = 14
|
|
|
|
|
+ let handles: [(CropHandle, NSPoint)] = [
|
|
|
|
|
+ (.bottomLeft, NSPoint(x: rect.minX, y: rect.minY)),
|
|
|
|
|
+ (.bottomRight, NSPoint(x: rect.maxX, y: rect.minY)),
|
|
|
|
|
+ (.topLeft, NSPoint(x: rect.minX, y: rect.maxY)),
|
|
|
|
|
+ (.topRight, NSPoint(x: rect.maxX, y: rect.maxY)),
|
|
|
|
|
+ ]
|
|
|
|
|
+ for (handle, center) in handles {
|
|
|
|
|
+ let handleRect = NSRect(
|
|
|
|
|
+ x: center.x - threshold / 2,
|
|
|
|
|
+ y: center.y - threshold / 2,
|
|
|
|
|
+ width: threshold,
|
|
|
|
|
+ height: threshold
|
|
|
|
|
+ )
|
|
|
|
|
+ if handleRect.contains(point) {
|
|
|
|
|
+ return handle
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return nil
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func resizeRect(from start: CGRect, handle: CropHandle, dx: CGFloat, dy: CGFloat) -> CGRect {
|
|
|
|
|
+ var rect = start
|
|
|
|
|
+ switch handle {
|
|
|
|
|
+ case .bottomLeft:
|
|
|
|
|
+ rect.origin.x += dx
|
|
|
|
|
+ rect.size.width -= dx
|
|
|
|
|
+ rect.origin.y += dy
|
|
|
|
|
+ rect.size.height -= dy
|
|
|
|
|
+ case .bottomRight:
|
|
|
|
|
+ rect.size.width += dx
|
|
|
|
|
+ rect.origin.y += dy
|
|
|
|
|
+ rect.size.height -= dy
|
|
|
|
|
+ case .topLeft:
|
|
|
|
|
+ rect.origin.x += dx
|
|
|
|
|
+ rect.size.width -= dx
|
|
|
|
|
+ rect.size.height += dy
|
|
|
|
|
+ case .topRight:
|
|
|
|
|
+ rect.size.width += dx
|
|
|
|
|
+ rect.size.height += dy
|
|
|
|
|
+ }
|
|
|
|
|
+ return rect
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private func clamp(_ rect: CGRect) -> CGRect {
|
|
|
|
|
+ let minSize: CGFloat = 0.12
|
|
|
|
|
+ var result = rect
|
|
|
|
|
+ result.size.width = max(minSize, result.size.width)
|
|
|
|
|
+ result.size.height = max(minSize, result.size.height)
|
|
|
|
|
+ result.origin.x = min(max(0, result.origin.x), 1 - result.size.width)
|
|
|
|
|
+ result.origin.y = min(max(0, result.origin.y), 1 - result.size.height)
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+private final class PhotoCropActionButton: NSControl, AppearanceRefreshable {
|
|
|
|
|
+ var onClick: (() -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ private let title: String
|
|
|
|
|
+ private let isPrimary: Bool
|
|
|
|
|
+ private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
|
+
|
|
|
|
|
+ init(title: String, isPrimary: Bool) {
|
|
|
|
|
+ self.title = title
|
|
|
|
|
+ self.isPrimary = isPrimary
|
|
|
|
|
+ super.init(frame: .zero)
|
|
|
|
|
+
|
|
|
|
|
+ wantsLayer = true
|
|
|
|
|
+ layer?.cornerRadius = 8
|
|
|
|
|
+
|
|
|
|
|
+ titleLabel.stringValue = title
|
|
|
|
|
+ titleLabel.font = AppTheme.semiboldFont(size: 12)
|
|
|
|
|
+ titleLabel.alignment = .center
|
|
|
|
|
+ titleLabel.isBordered = false
|
|
|
|
|
+ titleLabel.isEditable = false
|
|
|
|
|
+ titleLabel.drawsBackground = false
|
|
|
|
|
+ titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+
|
|
|
|
|
+ addSubview(titleLabel)
|
|
|
|
|
+ NSLayoutConstraint.activate([
|
|
|
|
|
+ heightAnchor.constraint(equalToConstant: 30),
|
|
|
|
|
+ titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
|
|
|
|
|
+ titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
|
|
|
|
|
+ titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
|
|
|
+ ])
|
|
|
|
|
+ refreshAppearance()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @available(*, unavailable)
|
|
|
|
|
+ required init?(coder: NSCoder) { nil }
|
|
|
|
|
+
|
|
|
|
|
+ func refreshAppearance() {
|
|
|
|
|
+ if isPrimary {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.blue.cgColor
|
|
|
|
|
+ titleLabel.textColor = .white
|
|
|
|
|
+ } else {
|
|
|
|
|
+ layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
|
|
|
|
|
+ layer?.borderColor = AppTheme.paywallBorder.cgColor
|
|
|
|
|
+ layer?.borderWidth = 1
|
|
|
|
|
+ titleLabel.textColor = AppTheme.textPrimary
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func mouseUp(with event: NSEvent) {
|
|
|
|
|
+ guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
|
|
|
|
|
+ onClick?()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func resetCursorRects() {
|
|
|
|
|
+ addCursorRect(bounds, cursor: .pointingHand)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// MARK: - Toolbar Button
|
|
// MARK: - Toolbar Button
|
|
|
|
|
|
|
|
private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
|
|
private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
|