| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284 |
- import Cocoa
- // MARK: - Photo Preview Service
- enum PhotoPreviewService {
- static func present(urls: [URL], from window: NSWindow?) {
- guard !urls.isEmpty else { return }
- let hostWindow = window ?? NSApp.keyWindow
- guard PremiumAccess.require(feature: .photoPreview, from: hostWindow) else { return }
- guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
- viewController.presentPhotoPreview(urls: urls)
- }
- }
- // MARK: - Overlay
- final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
- var onDismiss: (() -> Void)?
- override var mouseDownCanMoveWindow: Bool { false }
- private let urls: [URL]
- private var currentIndex = 0
- 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 titleLabel = NSTextField(labelWithString: "Print a photo")
- private let counterLabel = NSTextField(labelWithString: "")
- private let previewSection = NSView()
- private let editControlsArea = NSView()
- private let imageContainer = NSView()
- 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 var imageContainerTopToPreviewConstraint: NSLayoutConstraint?
- private var imageContainerTopToControlsConstraint: NSLayoutConstraint?
- private var imageContainerMinHeightConstraint: NSLayoutConstraint?
- private var editControlsBottomConstraint: NSLayoutConstraint?
- private var whiteningPanelHeightConstraint: NSLayoutConstraint?
- private var saturationPanelHeightConstraint: NSLayoutConstraint?
- private var contrastPanelHeightConstraint: NSLayoutConstraint?
- private static let adjustmentPanelHeight: CGFloat = 56
- private static let editingImageMinHeight: CGFloat = 320
- private var isEditPanelVisible = false
- private var isCropMode = false
- init(urls: [URL]) {
- self.urls = urls
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- showPhoto(at: 0)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(appearanceDidChange),
- name: .appearanceDidChange,
- object: nil
- )
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @objc private func appearanceDidChange() {
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- counterLabel.textColor = AppTheme.textSecondary
- imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
- imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
- backButton.refreshAppearance()
- editButton.refreshAppearance()
- editPanel.refreshAppearance()
- whiteningPanel.refreshAppearance()
- saturationPanel.refreshAppearance()
- contrastPanel.refreshAppearance()
- cropOverlay?.refreshAppearance()
- printButton.refreshAppearance()
- }
- func present(in parent: NSView) {
- if superview == nil {
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- alphaValue = 1
- } else {
- parent.addSubview(self, positioned: .above, relativeTo: nil)
- }
- }
- override func hitTest(_ point: NSPoint) -> NSView? {
- let hit = super.hitTest(point)
- if hit != nil { return hit }
- return bounds.contains(point) ? self : nil
- }
- func dismiss(animated: Bool = true) {
- stopSecurityAccess()
- let remove = { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- }
- guard animated else {
- remove()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: remove)
- }
- private func setup() {
- wantsLayer = true
- titleLabel.font = AppTheme.semiboldFont(size: 18)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- counterLabel.font = AppTheme.regularFont(size: 13)
- counterLabel.alignment = .center
- counterLabel.translatesAutoresizingMaskIntoConstraints = false
- counterLabel.isHidden = urls.count <= 1
- imageContainer.wantsLayer = true
- imageContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- imageContainer.layer?.borderWidth = 1.5
- imageContainer.applyCardShadow()
- imageContainer.translatesAutoresizingMaskIntoConstraints = false
- imageContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
- imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- imageView.imageScaling = .scaleProportionallyUpOrDown
- imageView.imageAlignment = .alignCenter
- imageView.translatesAutoresizingMaskIntoConstraints = false
- imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
- imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
- imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- previewSection.translatesAutoresizingMaskIntoConstraints = false
- editControlsArea.translatesAutoresizingMaskIntoConstraints = false
- backButton.translatesAutoresizingMaskIntoConstraints = false
- editButton.translatesAutoresizingMaskIntoConstraints = false
- editPanel.translatesAutoresizingMaskIntoConstraints = false
- whiteningPanel.translatesAutoresizingMaskIntoConstraints = false
- saturationPanel.translatesAutoresizingMaskIntoConstraints = false
- contrastPanel.translatesAutoresizingMaskIntoConstraints = false
- printButton.translatesAutoresizingMaskIntoConstraints = false
- editPanel.isHidden = true
- whiteningPanel.isHidden = true
- saturationPanel.isHidden = true
- contrastPanel.isHidden = true
- backButton.onClick = { [weak self] in self?.dismiss() }
- editButton.onClick = { [weak self] in self?.toggleEditPanel() }
- 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(titleLabel)
- addSubview(counterLabel)
- addSubview(previewSection)
- previewSection.addSubview(imageContainer)
- previewSection.addSubview(editButton)
- previewSection.addSubview(editControlsArea)
- editControlsArea.addSubview(editPanel)
- editControlsArea.addSubview(whiteningPanel)
- editControlsArea.addSubview(saturationPanel)
- editControlsArea.addSubview(contrastPanel)
- imageContainer.addSubview(imageView)
- addSubview(printButton)
- imageContainerTopToPreviewConstraint = imageContainer.topAnchor.constraint(
- equalTo: editButton.bottomAnchor,
- constant: 12
- )
- imageContainerTopToControlsConstraint = imageContainer.topAnchor.constraint(
- equalTo: editControlsArea.bottomAnchor,
- constant: 8
- )
- imageContainerMinHeightConstraint = imageContainer.heightAnchor.constraint(
- greaterThanOrEqualToConstant: Self.editingImageMinHeight
- )
- imageContainerMinHeightConstraint?.priority = .defaultHigh
- whiteningPanelHeightConstraint = whiteningPanel.heightAnchor.constraint(equalToConstant: 0)
- saturationPanelHeightConstraint = saturationPanel.heightAnchor.constraint(equalToConstant: 0)
- contrastPanelHeightConstraint = contrastPanel.heightAnchor.constraint(equalToConstant: 0)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
- backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
- backButton.widthAnchor.constraint(equalToConstant: 40),
- backButton.heightAnchor.constraint(equalToConstant: 40),
- titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
- counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
- previewSection.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
- previewSection.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
- previewSection.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 24),
- previewSection.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -24),
- editButton.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
- editButton.topAnchor.constraint(equalTo: previewSection.topAnchor),
- editButton.widthAnchor.constraint(equalToConstant: 40),
- editButton.heightAnchor.constraint(equalToConstant: 40),
- editControlsArea.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
- editControlsArea.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
- editControlsArea.topAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 8),
- editPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
- editPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
- editPanel.topAnchor.constraint(equalTo: editControlsArea.topAnchor),
- 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),
- imageContainer.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
- imageContainer.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
- imageContainer.bottomAnchor.constraint(equalTo: previewSection.bottomAnchor),
- imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
- imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
- imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 12),
- imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -12),
- printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
- printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
- printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
- whiteningPanelHeightConstraint!,
- saturationPanelHeightConstraint!,
- contrastPanelHeightConstraint!,
- ])
- editControlsArea.isHidden = true
- updatePreviewLayout()
- }
- private func updatePreviewLayout() {
- let editing = isEditPanelVisible
- editControlsArea.isHidden = !editing
- imageContainerTopToPreviewConstraint?.isActive = !editing
- imageContainerTopToControlsConstraint?.isActive = editing
- imageContainerMinHeightConstraint?.isActive = editing
- if editing {
- updateEditControlsBottomConstraint()
- } else {
- editControlsBottomConstraint?.isActive = false
- }
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- layoutSubtreeIfNeeded()
- }
- }
- private func updateEditControlsBottomConstraint() {
- editControlsBottomConstraint?.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 {
- anchor = editPanel.bottomAnchor
- }
- let bottomConstraint = editControlsArea.bottomAnchor.constraint(equalTo: anchor)
- bottomConstraint.isActive = true
- editControlsBottomConstraint = bottomConstraint
- }
- private func hideAllAdjustmentPanels() {
- setAdjustmentPanel(whiteningPanel, heightConstraint: whiteningPanelHeightConstraint, visible: false)
- setAdjustmentPanel(saturationPanel, heightConstraint: saturationPanelHeightConstraint, visible: false)
- setAdjustmentPanel(contrastPanel, heightConstraint: contrastPanelHeightConstraint, visible: false)
- }
- private func setAdjustmentPanel(
- _ panel: PhotoAdjustSliderPanelView,
- heightConstraint: NSLayoutConstraint?,
- visible: Bool
- ) {
- panel.isHidden = !visible
- heightConstraint?.constant = visible ? Self.adjustmentPanelHeight : 0
- }
- 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 {
- let heightConstraint: NSLayoutConstraint?
- switch action {
- case .whitening:
- heightConstraint = whiteningPanelHeightConstraint
- case .saturation:
- heightConstraint = saturationPanelHeightConstraint
- case .contrast:
- heightConstraint = contrastPanelHeightConstraint
- default:
- heightConstraint = whiteningPanelHeightConstraint
- }
- setAdjustmentPanel(panel, heightConstraint: heightConstraint, visible: true)
- panel.setValue(currentValue)
- editPanel.setActive(action, isActive: true)
- }
- updatePreviewLayout()
- }
- private func toggleEditPanel() {
- isEditPanelVisible.toggle()
- editPanel.isHidden = !isEditPanelVisible
- hideAllAdjustmentPanels()
- updatePreviewLayout()
- 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()
- updatePreviewLayout()
- enterCropMode()
- case .flip:
- applyGeometricEdit { PhotoImageEditor.flipHorizontal($0) }
- case .rotate:
- applyGeometricEdit { PhotoImageEditor.rotateClockwise($0) }
- case .done:
- isEditPanelVisible = false
- editPanel.isHidden = true
- hideAllAdjustmentPanels()
- updatePreviewLayout()
- 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()
- updatePreviewLayout()
- 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) {
- guard urls.indices.contains(index) else { return }
- currentIndex = index
- let url = urls[index]
- if securityAccess[url] != true {
- securityAccess[url] = url.startAccessingSecurityScopedResource()
- }
- 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)
- updatePreviewLayout()
- exitCropMode()
- imageView.image = originalImage
- if urls.count > 1 {
- counterLabel.stringValue = "\(index + 1) of \(urls.count)"
- counterLabel.isHidden = false
- } else {
- counterLabel.isHidden = true
- }
- }
- private func currentURL() -> URL? {
- guard urls.indices.contains(currentIndex) else { return nil }
- return urls[currentIndex]
- }
- private func printCurrentPhoto() {
- 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 {
- showPhoto(at: currentIndex + 1)
- } else {
- dismiss()
- }
- }
- private func stopSecurityAccess() {
- for (url, accessed) in securityAccess where accessed {
- url.stopAccessingSecurityScopedResource()
- }
- securityAccess.removeAll()
- }
- }
- // 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([
- 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
- private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let symbolName: String
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- self.symbolName = symbolName
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = 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: - Print Button
- private final class PhotoPrintButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel = NSTextField(labelWithString: "Print Photo")
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 22
- titleLabel.font = AppTheme.semiboldFont(size: 16)
- titleLabel.textColor = .white
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
- let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.contentTintColor = .white
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 52),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
- iconView.widthAnchor.constraint(equalToConstant: 20),
- iconView.heightAnchor.constraint(equalToConstant: 20),
- ])
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- let color = isHovered
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- titleLabel.textColor = .white
- iconView.contentTintColor = .white
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- let color = hovering
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.03, 1.03, 1)
- : CATransform3DIdentity
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
|