PhotoPreviewView.swift 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276
  1. import Cocoa
  2. // MARK: - Photo Preview Service
  3. enum PhotoPreviewService {
  4. static func present(urls: [URL], from window: NSWindow?) {
  5. guard !urls.isEmpty else { return }
  6. let hostWindow = window ?? NSApp.keyWindow
  7. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  8. viewController.presentPhotoPreview(urls: urls)
  9. }
  10. }
  11. // MARK: - Overlay
  12. final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
  13. var onDismiss: (() -> Void)?
  14. private let urls: [URL]
  15. private var currentIndex = 0
  16. private var securityAccess: [URL: Bool] = [:]
  17. private var originalImage: NSImage?
  18. private var editedImage: NSImage?
  19. private var whiteningAmount: Float = 0
  20. private var saturationAmount: Float = 1
  21. private var contrastAmount: Float = 1
  22. private var autoEnhanceApplied = false
  23. private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  24. private let titleLabel = NSTextField(labelWithString: "Print a photo")
  25. private let counterLabel = NSTextField(labelWithString: "")
  26. private let previewSection = NSView()
  27. private let editControlsArea = NSView()
  28. private let imageContainer = NSView()
  29. private let imageView = NSImageView()
  30. private let editButton = PhotoToolbarButton(symbolName: "slider.horizontal.3", accessibilityLabel: "Edit")
  31. private let editPanel = PhotoEditPanelView()
  32. private let whiteningPanel = PhotoAdjustSliderPanelView(
  33. title: "Whitening",
  34. minValue: 0,
  35. maxValue: 1,
  36. defaultValue: 0
  37. )
  38. private let saturationPanel = PhotoAdjustSliderPanelView(
  39. title: "Saturation",
  40. minValue: 0,
  41. maxValue: 2,
  42. defaultValue: 1
  43. )
  44. private let contrastPanel = PhotoAdjustSliderPanelView(
  45. title: "Contrast",
  46. minValue: 0,
  47. maxValue: 2,
  48. defaultValue: 1
  49. )
  50. private var cropOverlay: PhotoCropOverlayView?
  51. private let printButton = PhotoPrintButton()
  52. private var imageContainerTopToPreviewConstraint: NSLayoutConstraint?
  53. private var imageContainerTopToControlsConstraint: NSLayoutConstraint?
  54. private var imageContainerMinHeightConstraint: NSLayoutConstraint?
  55. private var editControlsBottomConstraint: NSLayoutConstraint?
  56. private var whiteningPanelHeightConstraint: NSLayoutConstraint?
  57. private var saturationPanelHeightConstraint: NSLayoutConstraint?
  58. private var contrastPanelHeightConstraint: NSLayoutConstraint?
  59. private static let adjustmentPanelHeight: CGFloat = 56
  60. private static let editingImageMinHeight: CGFloat = 320
  61. private var isEditPanelVisible = false
  62. private var isCropMode = false
  63. init(urls: [URL]) {
  64. self.urls = urls
  65. super.init(frame: .zero)
  66. translatesAutoresizingMaskIntoConstraints = false
  67. setup()
  68. refreshAppearance()
  69. showPhoto(at: 0)
  70. NotificationCenter.default.addObserver(
  71. self,
  72. selector: #selector(appearanceDidChange),
  73. name: .appearanceDidChange,
  74. object: nil
  75. )
  76. }
  77. deinit {
  78. NotificationCenter.default.removeObserver(self)
  79. }
  80. @objc private func appearanceDidChange() {
  81. refreshAppearance()
  82. }
  83. @available(*, unavailable)
  84. required init?(coder: NSCoder) { nil }
  85. func refreshAppearance() {
  86. layer?.backgroundColor = AppTheme.background.cgColor
  87. titleLabel.textColor = AppTheme.textPrimary
  88. counterLabel.textColor = AppTheme.textSecondary
  89. imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  90. imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  91. backButton.refreshAppearance()
  92. editButton.refreshAppearance()
  93. editPanel.refreshAppearance()
  94. whiteningPanel.refreshAppearance()
  95. saturationPanel.refreshAppearance()
  96. contrastPanel.refreshAppearance()
  97. cropOverlay?.refreshAppearance()
  98. printButton.refreshAppearance()
  99. }
  100. func present(in parent: NSView) {
  101. guard superview == nil else { return }
  102. parent.addSubview(self)
  103. NSLayoutConstraint.activate([
  104. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  105. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  106. topAnchor.constraint(equalTo: parent.topAnchor),
  107. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  108. ])
  109. alphaValue = 0
  110. NSAnimationContext.runAnimationGroup { context in
  111. context.duration = 0.2
  112. animator().alphaValue = 1
  113. }
  114. }
  115. func dismiss(animated: Bool = true) {
  116. stopSecurityAccess()
  117. let remove = { [weak self] in
  118. self?.removeFromSuperview()
  119. self?.onDismiss?()
  120. }
  121. guard animated else {
  122. remove()
  123. return
  124. }
  125. NSAnimationContext.runAnimationGroup({ context in
  126. context.duration = 0.15
  127. animator().alphaValue = 0
  128. }, completionHandler: remove)
  129. }
  130. private func setup() {
  131. wantsLayer = true
  132. titleLabel.font = AppTheme.semiboldFont(size: 18)
  133. titleLabel.alignment = .center
  134. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  135. counterLabel.font = AppTheme.regularFont(size: 13)
  136. counterLabel.alignment = .center
  137. counterLabel.translatesAutoresizingMaskIntoConstraints = false
  138. counterLabel.isHidden = urls.count <= 1
  139. imageContainer.wantsLayer = true
  140. imageContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  141. imageContainer.layer?.borderWidth = 1.5
  142. imageContainer.applyCardShadow()
  143. imageContainer.translatesAutoresizingMaskIntoConstraints = false
  144. imageContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  145. imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  146. imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  147. imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  148. imageView.imageScaling = .scaleProportionallyUpOrDown
  149. imageView.imageAlignment = .alignCenter
  150. imageView.translatesAutoresizingMaskIntoConstraints = false
  151. imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  152. imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
  153. imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  154. imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  155. previewSection.translatesAutoresizingMaskIntoConstraints = false
  156. editControlsArea.translatesAutoresizingMaskIntoConstraints = false
  157. backButton.translatesAutoresizingMaskIntoConstraints = false
  158. editButton.translatesAutoresizingMaskIntoConstraints = false
  159. editPanel.translatesAutoresizingMaskIntoConstraints = false
  160. whiteningPanel.translatesAutoresizingMaskIntoConstraints = false
  161. saturationPanel.translatesAutoresizingMaskIntoConstraints = false
  162. contrastPanel.translatesAutoresizingMaskIntoConstraints = false
  163. printButton.translatesAutoresizingMaskIntoConstraints = false
  164. editPanel.isHidden = true
  165. whiteningPanel.isHidden = true
  166. saturationPanel.isHidden = true
  167. contrastPanel.isHidden = true
  168. backButton.onClick = { [weak self] in self?.dismiss() }
  169. editButton.onClick = { [weak self] in self?.toggleEditPanel() }
  170. printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
  171. editPanel.onAction = { [weak self] action in
  172. self?.handleEditAction(action)
  173. }
  174. whiteningPanel.onValueChanged = { [weak self] value in
  175. self?.whiteningAmount = value
  176. self?.refreshDisplayedImage()
  177. }
  178. saturationPanel.onValueChanged = { [weak self] value in
  179. self?.saturationAmount = value
  180. self?.refreshDisplayedImage()
  181. }
  182. contrastPanel.onValueChanged = { [weak self] value in
  183. self?.contrastAmount = value
  184. self?.refreshDisplayedImage()
  185. }
  186. addSubview(backButton)
  187. addSubview(titleLabel)
  188. addSubview(counterLabel)
  189. addSubview(previewSection)
  190. previewSection.addSubview(imageContainer)
  191. previewSection.addSubview(editButton)
  192. previewSection.addSubview(editControlsArea)
  193. editControlsArea.addSubview(editPanel)
  194. editControlsArea.addSubview(whiteningPanel)
  195. editControlsArea.addSubview(saturationPanel)
  196. editControlsArea.addSubview(contrastPanel)
  197. imageContainer.addSubview(imageView)
  198. addSubview(printButton)
  199. imageContainerTopToPreviewConstraint = imageContainer.topAnchor.constraint(
  200. equalTo: editButton.bottomAnchor,
  201. constant: 12
  202. )
  203. imageContainerTopToControlsConstraint = imageContainer.topAnchor.constraint(
  204. equalTo: editControlsArea.bottomAnchor,
  205. constant: 8
  206. )
  207. imageContainerMinHeightConstraint = imageContainer.heightAnchor.constraint(
  208. greaterThanOrEqualToConstant: Self.editingImageMinHeight
  209. )
  210. imageContainerMinHeightConstraint?.priority = .defaultHigh
  211. whiteningPanelHeightConstraint = whiteningPanel.heightAnchor.constraint(equalToConstant: 0)
  212. saturationPanelHeightConstraint = saturationPanel.heightAnchor.constraint(equalToConstant: 0)
  213. contrastPanelHeightConstraint = contrastPanel.heightAnchor.constraint(equalToConstant: 0)
  214. NSLayoutConstraint.activate([
  215. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  216. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  217. backButton.widthAnchor.constraint(equalToConstant: 40),
  218. backButton.heightAnchor.constraint(equalToConstant: 40),
  219. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  220. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  221. counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  222. counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  223. previewSection.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  224. previewSection.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
  225. previewSection.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 24),
  226. previewSection.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -24),
  227. editButton.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
  228. editButton.topAnchor.constraint(equalTo: previewSection.topAnchor),
  229. editButton.widthAnchor.constraint(equalToConstant: 40),
  230. editButton.heightAnchor.constraint(equalToConstant: 40),
  231. editControlsArea.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
  232. editControlsArea.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
  233. editControlsArea.topAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 8),
  234. editPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
  235. editPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
  236. editPanel.topAnchor.constraint(equalTo: editControlsArea.topAnchor),
  237. whiteningPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
  238. whiteningPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
  239. whiteningPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
  240. saturationPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
  241. saturationPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
  242. saturationPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
  243. contrastPanel.leadingAnchor.constraint(equalTo: editControlsArea.leadingAnchor),
  244. contrastPanel.trailingAnchor.constraint(equalTo: editControlsArea.trailingAnchor),
  245. contrastPanel.topAnchor.constraint(equalTo: editPanel.bottomAnchor, constant: 8),
  246. imageContainer.leadingAnchor.constraint(equalTo: previewSection.leadingAnchor),
  247. imageContainer.trailingAnchor.constraint(equalTo: previewSection.trailingAnchor),
  248. imageContainer.bottomAnchor.constraint(equalTo: previewSection.bottomAnchor),
  249. imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
  250. imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
  251. imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 12),
  252. imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -12),
  253. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  254. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  255. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  256. whiteningPanelHeightConstraint!,
  257. saturationPanelHeightConstraint!,
  258. contrastPanelHeightConstraint!,
  259. ])
  260. editControlsArea.isHidden = true
  261. updatePreviewLayout()
  262. }
  263. private func updatePreviewLayout() {
  264. let editing = isEditPanelVisible
  265. editControlsArea.isHidden = !editing
  266. imageContainerTopToPreviewConstraint?.isActive = !editing
  267. imageContainerTopToControlsConstraint?.isActive = editing
  268. imageContainerMinHeightConstraint?.isActive = editing
  269. if editing {
  270. updateEditControlsBottomConstraint()
  271. } else {
  272. editControlsBottomConstraint?.isActive = false
  273. }
  274. NSAnimationContext.runAnimationGroup { context in
  275. context.duration = 0.2
  276. layoutSubtreeIfNeeded()
  277. }
  278. }
  279. private func updateEditControlsBottomConstraint() {
  280. editControlsBottomConstraint?.isActive = false
  281. let anchor: NSLayoutYAxisAnchor
  282. if !whiteningPanel.isHidden {
  283. anchor = whiteningPanel.bottomAnchor
  284. } else if !saturationPanel.isHidden {
  285. anchor = saturationPanel.bottomAnchor
  286. } else if !contrastPanel.isHidden {
  287. anchor = contrastPanel.bottomAnchor
  288. } else {
  289. anchor = editPanel.bottomAnchor
  290. }
  291. let bottomConstraint = editControlsArea.bottomAnchor.constraint(equalTo: anchor)
  292. bottomConstraint.isActive = true
  293. editControlsBottomConstraint = bottomConstraint
  294. }
  295. private func hideAllAdjustmentPanels() {
  296. setAdjustmentPanel(whiteningPanel, heightConstraint: whiteningPanelHeightConstraint, visible: false)
  297. setAdjustmentPanel(saturationPanel, heightConstraint: saturationPanelHeightConstraint, visible: false)
  298. setAdjustmentPanel(contrastPanel, heightConstraint: contrastPanelHeightConstraint, visible: false)
  299. }
  300. private func setAdjustmentPanel(
  301. _ panel: PhotoAdjustSliderPanelView,
  302. heightConstraint: NSLayoutConstraint?,
  303. visible: Bool
  304. ) {
  305. panel.isHidden = !visible
  306. heightConstraint?.constant = visible ? Self.adjustmentPanelHeight : 0
  307. }
  308. private func toggleAdjustmentPanel(_ panel: PhotoAdjustSliderPanelView, action: PhotoEditAction, currentValue: Float) {
  309. let willShow = panel.isHidden
  310. hideAllAdjustmentPanels()
  311. editPanel.setActive(.whitening, isActive: false)
  312. editPanel.setActive(.saturation, isActive: false)
  313. editPanel.setActive(.contrast, isActive: false)
  314. if willShow {
  315. let heightConstraint: NSLayoutConstraint?
  316. switch action {
  317. case .whitening:
  318. heightConstraint = whiteningPanelHeightConstraint
  319. case .saturation:
  320. heightConstraint = saturationPanelHeightConstraint
  321. case .contrast:
  322. heightConstraint = contrastPanelHeightConstraint
  323. default:
  324. heightConstraint = whiteningPanelHeightConstraint
  325. }
  326. setAdjustmentPanel(panel, heightConstraint: heightConstraint, visible: true)
  327. panel.setValue(currentValue)
  328. editPanel.setActive(action, isActive: true)
  329. }
  330. updatePreviewLayout()
  331. }
  332. private func toggleEditPanel() {
  333. isEditPanelVisible.toggle()
  334. editPanel.isHidden = !isEditPanelVisible
  335. hideAllAdjustmentPanels()
  336. updatePreviewLayout()
  337. if !isEditPanelVisible {
  338. exitCropMode()
  339. }
  340. }
  341. private func handleEditAction(_ action: PhotoEditAction) {
  342. switch action {
  343. case .whitening:
  344. toggleAdjustmentPanel(whiteningPanel, action: .whitening, currentValue: whiteningAmount)
  345. case .autoMode:
  346. autoEnhanceApplied = true
  347. editPanel.setActive(.autoMode, isActive: true)
  348. refreshDisplayedImage()
  349. case .saturation:
  350. toggleAdjustmentPanel(saturationPanel, action: .saturation, currentValue: saturationAmount)
  351. case .contrast:
  352. toggleAdjustmentPanel(contrastPanel, action: .contrast, currentValue: contrastAmount)
  353. case .crop:
  354. hideAllAdjustmentPanels()
  355. updatePreviewLayout()
  356. enterCropMode()
  357. case .flip:
  358. applyGeometricEdit { PhotoImageEditor.flipHorizontal($0) }
  359. case .rotate:
  360. applyGeometricEdit { PhotoImageEditor.rotateClockwise($0) }
  361. case .done:
  362. isEditPanelVisible = false
  363. editPanel.isHidden = true
  364. hideAllAdjustmentPanels()
  365. updatePreviewLayout()
  366. exitCropMode()
  367. }
  368. }
  369. private func applyGeometricEdit(_ transform: (NSImage) -> NSImage) {
  370. guard let base = currentBaseImage() else { return }
  371. let transformed = transform(base)
  372. originalImage = transformed
  373. whiteningAmount = 0
  374. saturationAmount = 1
  375. contrastAmount = 1
  376. autoEnhanceApplied = false
  377. editedImage = nil
  378. editPanel.resetActiveStates()
  379. whiteningPanel.setValue(0)
  380. saturationPanel.setValue(1)
  381. contrastPanel.setValue(1)
  382. refreshDisplayedImage()
  383. }
  384. private func enterCropMode() {
  385. guard !isCropMode, currentBaseImage() != nil else { return }
  386. isCropMode = true
  387. editPanel.setActive(.crop, isActive: true)
  388. hideAllAdjustmentPanels()
  389. updatePreviewLayout()
  390. let overlay = PhotoCropOverlayView(imageView: imageView)
  391. overlay.translatesAutoresizingMaskIntoConstraints = false
  392. overlay.onCancel = { [weak self] in
  393. self?.exitCropMode()
  394. }
  395. overlay.onApply = { [weak self] normalizedRect in
  396. self?.applyCrop(normalizedRect)
  397. }
  398. imageContainer.addSubview(overlay, positioned: .above, relativeTo: imageView)
  399. NSLayoutConstraint.activate([
  400. overlay.leadingAnchor.constraint(equalTo: imageView.leadingAnchor),
  401. overlay.trailingAnchor.constraint(equalTo: imageView.trailingAnchor),
  402. overlay.topAnchor.constraint(equalTo: imageView.topAnchor),
  403. overlay.bottomAnchor.constraint(equalTo: imageView.bottomAnchor),
  404. ])
  405. cropOverlay = overlay
  406. overlay.refreshAppearance()
  407. }
  408. private func exitCropMode() {
  409. isCropMode = false
  410. editPanel.setActive(.crop, isActive: false)
  411. cropOverlay?.removeFromSuperview()
  412. cropOverlay = nil
  413. }
  414. private func applyCrop(_ normalizedRect: CGRect) {
  415. guard let base = currentBaseImage() else {
  416. exitCropMode()
  417. return
  418. }
  419. originalImage = PhotoImageEditor.crop(base, normalizedRect: normalizedRect)
  420. whiteningAmount = 0
  421. saturationAmount = 1
  422. contrastAmount = 1
  423. autoEnhanceApplied = false
  424. editedImage = nil
  425. editPanel.resetActiveStates()
  426. whiteningPanel.setValue(0)
  427. saturationPanel.setValue(1)
  428. contrastPanel.setValue(1)
  429. exitCropMode()
  430. refreshDisplayedImage()
  431. }
  432. private func currentBaseImage() -> NSImage? {
  433. originalImage ?? imageView.image
  434. }
  435. private func refreshDisplayedImage() {
  436. guard let base = originalImage else { return }
  437. let processed = PhotoImageEditor.apply(
  438. to: base,
  439. saturation: saturationAmount,
  440. whitening: whiteningAmount,
  441. contrast: contrastAmount,
  442. autoEnhance: autoEnhanceApplied
  443. )
  444. editedImage = processed
  445. imageView.image = processed
  446. cropOverlay?.imageDidChange()
  447. }
  448. private func showPhoto(at index: Int) {
  449. guard urls.indices.contains(index) else { return }
  450. currentIndex = index
  451. let url = urls[index]
  452. if securityAccess[url] != true {
  453. securityAccess[url] = url.startAccessingSecurityScopedResource()
  454. }
  455. originalImage = NSImage(contentsOf: url)
  456. editedImage = nil
  457. whiteningAmount = 0
  458. saturationAmount = 1
  459. contrastAmount = 1
  460. autoEnhanceApplied = false
  461. isEditPanelVisible = false
  462. editPanel.isHidden = true
  463. editPanel.resetActiveStates()
  464. hideAllAdjustmentPanels()
  465. whiteningPanel.setValue(0)
  466. saturationPanel.setValue(1)
  467. contrastPanel.setValue(1)
  468. updatePreviewLayout()
  469. exitCropMode()
  470. imageView.image = originalImage
  471. if urls.count > 1 {
  472. counterLabel.stringValue = "\(index + 1) of \(urls.count)"
  473. counterLabel.isHidden = false
  474. } else {
  475. counterLabel.isHidden = true
  476. }
  477. }
  478. private func currentURL() -> URL? {
  479. guard urls.indices.contains(currentIndex) else { return nil }
  480. return urls[currentIndex]
  481. }
  482. private func printCurrentPhoto() {
  483. let image = editedImage ?? originalImage ?? currentURL().flatMap { NSImage(contentsOf: $0) }
  484. guard let image else { return }
  485. let title = currentURL()?.lastPathComponent ?? "Photo"
  486. PrintService.printPhoto(image, title: title, from: window)
  487. if currentIndex < urls.count - 1 {
  488. showPhoto(at: currentIndex + 1)
  489. } else {
  490. dismiss()
  491. }
  492. }
  493. private func stopSecurityAccess() {
  494. for (url, accessed) in securityAccess where accessed {
  495. url.stopAccessingSecurityScopedResource()
  496. }
  497. securityAccess.removeAll()
  498. }
  499. }
  500. // MARK: - Edit Actions
  501. private enum PhotoEditAction {
  502. case whitening
  503. case autoMode
  504. case saturation
  505. case contrast
  506. case crop
  507. case flip
  508. case rotate
  509. case done
  510. }
  511. // MARK: - Edit Panel
  512. private final class PhotoEditPanelView: NSView, AppearanceRefreshable {
  513. var onAction: ((PhotoEditAction) -> Void)?
  514. private let stack = NSStackView()
  515. private var activeActions: Set<PhotoEditAction> = []
  516. private struct Tool {
  517. let action: PhotoEditAction
  518. let title: String
  519. let symbol: String
  520. }
  521. private let tools: [Tool] = [
  522. Tool(action: .whitening, title: "Whitening", symbol: "sparkles"),
  523. Tool(action: .autoMode, title: "Auto", symbol: "wand.and.stars"),
  524. Tool(action: .saturation, title: "Saturation", symbol: "drop.halffull"),
  525. Tool(action: .contrast, title: "Contrast", symbol: "circle.lefthalf.filled"),
  526. Tool(action: .crop, title: "Crop", symbol: "crop"),
  527. Tool(action: .flip, title: "Flip", symbol: "arrow.left.and.right.righttriangle.left.righttriangle.right"),
  528. Tool(action: .rotate, title: "Rotate", symbol: "rotate.right"),
  529. Tool(action: .done, title: "Done", symbol: "checkmark"),
  530. ]
  531. init() {
  532. super.init(frame: .zero)
  533. wantsLayer = true
  534. layer?.cornerRadius = 14
  535. translatesAutoresizingMaskIntoConstraints = false
  536. stack.orientation = .horizontal
  537. stack.spacing = 6
  538. stack.distribution = .fillEqually
  539. stack.translatesAutoresizingMaskIntoConstraints = false
  540. for tool in tools {
  541. let button = PhotoEditToolButton(title: tool.title, symbolName: tool.symbol)
  542. button.onClick = { [weak self] in
  543. self?.onAction?(tool.action)
  544. }
  545. stack.addArrangedSubview(button)
  546. }
  547. addSubview(stack)
  548. NSLayoutConstraint.activate([
  549. heightAnchor.constraint(equalToConstant: 72),
  550. stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10),
  551. stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10),
  552. stack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  553. stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
  554. ])
  555. refreshAppearance()
  556. }
  557. @available(*, unavailable)
  558. required init?(coder: NSCoder) { nil }
  559. func refreshAppearance() {
  560. layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
  561. layer?.borderColor = AppTheme.paywallBorder.cgColor
  562. layer?.borderWidth = 1
  563. stack.arrangedSubviews.compactMap { $0 as? PhotoEditToolButton }.forEach { $0.refreshAppearance() }
  564. }
  565. func setActive(_ action: PhotoEditAction, isActive: Bool) {
  566. if isActive {
  567. if action == .whitening || action == .autoMode || action == .saturation || action == .contrast || action == .crop {
  568. activeActions.insert(action)
  569. }
  570. } else {
  571. activeActions.remove(action)
  572. }
  573. updateButtonStates()
  574. }
  575. func resetActiveStates() {
  576. activeActions.removeAll()
  577. updateButtonStates()
  578. }
  579. private func updateButtonStates() {
  580. for (index, tool) in tools.enumerated() {
  581. guard let button = stack.arrangedSubviews[index] as? PhotoEditToolButton else { continue }
  582. button.isActive = activeActions.contains(tool.action)
  583. }
  584. }
  585. }
  586. // MARK: - Edit Tool Button
  587. private final class PhotoEditToolButton: NSControl, AppearanceRefreshable {
  588. var onClick: (() -> Void)?
  589. var isActive = false {
  590. didSet { refreshAppearance() }
  591. }
  592. private let title: String
  593. private let iconView = NSImageView()
  594. private let titleLabel = NSTextField(labelWithString: "")
  595. private var hoverTracker: HoverTracker?
  596. private var isHovered = false
  597. init(title: String, symbolName: String) {
  598. self.title = title
  599. super.init(frame: .zero)
  600. wantsLayer = true
  601. layer?.cornerRadius = 10
  602. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  603. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  604. iconView.image = image.withSymbolConfiguration(config)
  605. }
  606. iconView.translatesAutoresizingMaskIntoConstraints = false
  607. titleLabel.stringValue = title
  608. titleLabel.font = AppTheme.mediumFont(size: 10)
  609. titleLabel.alignment = .center
  610. titleLabel.maximumNumberOfLines = 1
  611. titleLabel.lineBreakMode = .byTruncatingTail
  612. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  613. addSubview(iconView)
  614. addSubview(titleLabel)
  615. NSLayoutConstraint.activate([
  616. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  617. iconView.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  618. iconView.widthAnchor.constraint(equalToConstant: 18),
  619. iconView.heightAnchor.constraint(equalToConstant: 18),
  620. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2),
  621. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2),
  622. titleLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 4),
  623. titleLabel.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -6),
  624. ])
  625. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  626. self?.isHovered = hovering
  627. self?.refreshAppearance()
  628. }
  629. refreshAppearance()
  630. }
  631. @available(*, unavailable)
  632. required init?(coder: NSCoder) { nil }
  633. func refreshAppearance() {
  634. if isActive {
  635. layer?.backgroundColor = AppTheme.blue.withAlphaComponent(0.15).cgColor
  636. layer?.borderColor = AppTheme.blue.cgColor
  637. layer?.borderWidth = 1.5
  638. iconView.contentTintColor = AppTheme.blue
  639. titleLabel.textColor = AppTheme.blue
  640. } else if isHovered {
  641. layer?.backgroundColor = AppTheme.border.withAlphaComponent(0.35).cgColor
  642. layer?.borderColor = AppTheme.paywallBorder.cgColor
  643. layer?.borderWidth = 1
  644. iconView.contentTintColor = AppTheme.textPrimary
  645. titleLabel.textColor = AppTheme.textPrimary
  646. } else {
  647. layer?.backgroundColor = NSColor.clear.cgColor
  648. layer?.borderColor = NSColor.clear.cgColor
  649. layer?.borderWidth = 0
  650. iconView.contentTintColor = AppTheme.textPrimary
  651. titleLabel.textColor = AppTheme.textSecondary
  652. }
  653. }
  654. override func mouseUp(with event: NSEvent) {
  655. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  656. onClick?()
  657. }
  658. override func resetCursorRects() {
  659. addCursorRect(bounds, cursor: .pointingHand)
  660. }
  661. }
  662. // MARK: - Adjustment Slider Panel
  663. private final class PhotoAdjustSliderPanelView: NSView, AppearanceRefreshable {
  664. var onValueChanged: ((Float) -> Void)?
  665. private let titleLabel = NSTextField(labelWithString: "")
  666. private let valueLabel = NSTextField(labelWithString: "0%")
  667. private let slider: NSSlider
  668. private let defaultValue: Float
  669. init(title: String, minValue: Float, maxValue: Float, defaultValue: Float) {
  670. self.defaultValue = defaultValue
  671. slider = NSSlider(value: Double(defaultValue), minValue: Double(minValue), maxValue: Double(maxValue), target: nil, action: nil)
  672. super.init(frame: .zero)
  673. wantsLayer = true
  674. layer?.cornerRadius = 12
  675. translatesAutoresizingMaskIntoConstraints = false
  676. titleLabel.stringValue = title
  677. titleLabel.font = AppTheme.mediumFont(size: 13)
  678. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  679. valueLabel.font = AppTheme.regularFont(size: 12)
  680. valueLabel.alignment = .right
  681. valueLabel.translatesAutoresizingMaskIntoConstraints = false
  682. slider.target = self
  683. slider.action = #selector(sliderChanged)
  684. slider.translatesAutoresizingMaskIntoConstraints = false
  685. addSubview(titleLabel)
  686. addSubview(valueLabel)
  687. addSubview(slider)
  688. NSLayoutConstraint.activate([
  689. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  690. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  691. titleLabel.widthAnchor.constraint(equalToConstant: 78),
  692. valueLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  693. valueLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  694. valueLabel.widthAnchor.constraint(equalToConstant: 48),
  695. slider.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
  696. slider.trailingAnchor.constraint(equalTo: valueLabel.leadingAnchor, constant: -12),
  697. slider.centerYAnchor.constraint(equalTo: centerYAnchor),
  698. ])
  699. setValue(defaultValue)
  700. refreshAppearance()
  701. }
  702. @available(*, unavailable)
  703. required init?(coder: NSCoder) { nil }
  704. func setValue(_ value: Float) {
  705. slider.floatValue = value
  706. updateValueLabel()
  707. }
  708. func refreshAppearance() {
  709. layer?.backgroundColor = AppTheme.elevatedBackground.withAlphaComponent(0.96).cgColor
  710. layer?.borderColor = AppTheme.paywallBorder.cgColor
  711. layer?.borderWidth = 1
  712. titleLabel.textColor = AppTheme.textPrimary
  713. valueLabel.textColor = AppTheme.textSecondary
  714. }
  715. @objc private func sliderChanged() {
  716. updateValueLabel()
  717. onValueChanged?(slider.floatValue)
  718. }
  719. private func updateValueLabel() {
  720. valueLabel.stringValue = "\(Int(slider.floatValue * 100))%"
  721. }
  722. }
  723. // MARK: - Crop Overlay
  724. private final class PhotoCropOverlayView: NSView, AppearanceRefreshable {
  725. var onApply: ((CGRect) -> Void)?
  726. var onCancel: (() -> Void)?
  727. private weak var imageView: NSImageView?
  728. private var cropRect = CGRect(x: 0.1, y: 0.1, width: 0.8, height: 0.8)
  729. private var dragMode: CropDragMode = .none
  730. private var dragStartPoint = NSPoint.zero
  731. private var dragStartRect = CGRect.zero
  732. private let applyButton = PhotoCropActionButton(title: "Apply", isPrimary: true)
  733. private let cancelButton = PhotoCropActionButton(title: "Cancel", isPrimary: false)
  734. private enum CropDragMode {
  735. case none
  736. case move
  737. case resize(CropHandle)
  738. }
  739. private enum CropHandle {
  740. case topLeft, topRight, bottomLeft, bottomRight
  741. }
  742. init(imageView: NSImageView) {
  743. self.imageView = imageView
  744. super.init(frame: .zero)
  745. wantsLayer = true
  746. applyButton.translatesAutoresizingMaskIntoConstraints = false
  747. cancelButton.translatesAutoresizingMaskIntoConstraints = false
  748. applyButton.onClick = { [weak self] in
  749. guard let self else { return }
  750. onApply?(cropRect)
  751. }
  752. cancelButton.onClick = { [weak self] in
  753. self?.onCancel?()
  754. }
  755. addSubview(applyButton)
  756. addSubview(cancelButton)
  757. NSLayoutConstraint.activate([
  758. applyButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
  759. applyButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
  760. cancelButton.trailingAnchor.constraint(equalTo: applyButton.leadingAnchor, constant: -8),
  761. cancelButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
  762. ])
  763. refreshAppearance()
  764. }
  765. @available(*, unavailable)
  766. required init?(coder: NSCoder) { nil }
  767. func imageDidChange() {
  768. needsDisplay = true
  769. }
  770. func refreshAppearance() {
  771. applyButton.refreshAppearance()
  772. cancelButton.refreshAppearance()
  773. needsDisplay = true
  774. }
  775. override func draw(_ dirtyRect: NSRect) {
  776. guard let imageFrame = currentImageFrame() else { return }
  777. let selection = rectInView(from: cropRect, imageFrame: imageFrame)
  778. let dim = NSColor.black.withAlphaComponent(0.45)
  779. dim.setFill()
  780. NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: selection.maxY - bounds.minY).fill()
  781. NSRect(x: bounds.minX, y: selection.minY, width: selection.minX - bounds.minX, height: selection.height).fill()
  782. NSRect(x: selection.maxX, y: selection.minY, width: bounds.maxX - selection.maxX, height: selection.height).fill()
  783. NSRect(x: bounds.minX, y: selection.maxY, width: bounds.width, height: bounds.maxY - selection.maxY).fill()
  784. NSColor.white.setStroke()
  785. let path = NSBezierPath(rect: selection)
  786. path.lineWidth = 2
  787. path.stroke()
  788. let handleSize: CGFloat = 10
  789. for point in handlePoints(in: selection) {
  790. let handle = NSRect(
  791. x: point.x - handleSize / 2,
  792. y: point.y - handleSize / 2,
  793. width: handleSize,
  794. height: handleSize
  795. )
  796. NSColor.white.setFill()
  797. NSBezierPath(ovalIn: handle).fill()
  798. AppTheme.blue.setStroke()
  799. NSBezierPath(ovalIn: handle).lineWidth = 1.5
  800. NSBezierPath(ovalIn: handle).stroke()
  801. }
  802. }
  803. override func mouseDown(with event: NSEvent) {
  804. guard let imageFrame = currentImageFrame() else { return }
  805. let point = convert(event.locationInWindow, from: nil)
  806. let selection = rectInView(from: cropRect, imageFrame: imageFrame)
  807. if let handle = hitTestHandle(at: point, in: selection) {
  808. dragMode = .resize(handle)
  809. } else if selection.contains(point) {
  810. dragMode = .move
  811. } else {
  812. dragMode = .none
  813. return
  814. }
  815. dragStartPoint = point
  816. dragStartRect = cropRect
  817. }
  818. override func mouseDragged(with event: NSEvent) {
  819. guard case .none = dragMode else {
  820. performDrag(with: event)
  821. return
  822. }
  823. }
  824. private func performDrag(with event: NSEvent) {
  825. guard let imageFrame = currentImageFrame() else { return }
  826. let point = convert(event.locationInWindow, from: nil)
  827. let dx = (point.x - dragStartPoint.x) / imageFrame.width
  828. let dy = (point.y - dragStartPoint.y) / imageFrame.height
  829. switch dragMode {
  830. case .move:
  831. var rect = dragStartRect
  832. rect.origin.x += dx
  833. rect.origin.y += dy
  834. cropRect = clamp(rect)
  835. case .resize(let handle):
  836. cropRect = clamp(resizeRect(from: dragStartRect, handle: handle, dx: dx, dy: dy))
  837. case .none:
  838. return
  839. }
  840. needsDisplay = true
  841. }
  842. override func mouseUp(with event: NSEvent) {
  843. dragMode = .none
  844. }
  845. override func resetCursorRects() {
  846. addCursorRect(bounds, cursor: .crosshair)
  847. }
  848. private func currentImageFrame() -> NSRect? {
  849. guard let imageView, let image = imageView.image else { return nil }
  850. let bounds = self.bounds
  851. guard image.size.width > 0, image.size.height > 0, bounds.width > 0, bounds.height > 0 else {
  852. return nil
  853. }
  854. let imageAspect = image.size.width / image.size.height
  855. let boundsAspect = bounds.width / bounds.height
  856. if imageAspect > boundsAspect {
  857. let height = bounds.width / imageAspect
  858. let y = (bounds.height - height) / 2
  859. return NSRect(x: 0, y: y, width: bounds.width, height: height)
  860. }
  861. let width = bounds.height * imageAspect
  862. let x = (bounds.width - width) / 2
  863. return NSRect(x: x, y: 0, width: width, height: bounds.height)
  864. }
  865. private func rectInView(from normalized: CGRect, imageFrame: NSRect) -> NSRect {
  866. NSRect(
  867. x: imageFrame.origin.x + normalized.origin.x * imageFrame.width,
  868. y: imageFrame.origin.y + normalized.origin.y * imageFrame.height,
  869. width: normalized.width * imageFrame.width,
  870. height: normalized.height * imageFrame.height
  871. )
  872. }
  873. private func handlePoints(in rect: NSRect) -> [NSPoint] {
  874. [
  875. NSPoint(x: rect.minX, y: rect.minY),
  876. NSPoint(x: rect.maxX, y: rect.minY),
  877. NSPoint(x: rect.minX, y: rect.maxY),
  878. NSPoint(x: rect.maxX, y: rect.maxY),
  879. ]
  880. }
  881. private func hitTestHandle(at point: NSPoint, in rect: NSRect) -> CropHandle? {
  882. let threshold: CGFloat = 14
  883. let handles: [(CropHandle, NSPoint)] = [
  884. (.bottomLeft, NSPoint(x: rect.minX, y: rect.minY)),
  885. (.bottomRight, NSPoint(x: rect.maxX, y: rect.minY)),
  886. (.topLeft, NSPoint(x: rect.minX, y: rect.maxY)),
  887. (.topRight, NSPoint(x: rect.maxX, y: rect.maxY)),
  888. ]
  889. for (handle, center) in handles {
  890. let handleRect = NSRect(
  891. x: center.x - threshold / 2,
  892. y: center.y - threshold / 2,
  893. width: threshold,
  894. height: threshold
  895. )
  896. if handleRect.contains(point) {
  897. return handle
  898. }
  899. }
  900. return nil
  901. }
  902. private func resizeRect(from start: CGRect, handle: CropHandle, dx: CGFloat, dy: CGFloat) -> CGRect {
  903. var rect = start
  904. switch handle {
  905. case .bottomLeft:
  906. rect.origin.x += dx
  907. rect.size.width -= dx
  908. rect.origin.y += dy
  909. rect.size.height -= dy
  910. case .bottomRight:
  911. rect.size.width += dx
  912. rect.origin.y += dy
  913. rect.size.height -= dy
  914. case .topLeft:
  915. rect.origin.x += dx
  916. rect.size.width -= dx
  917. rect.size.height += dy
  918. case .topRight:
  919. rect.size.width += dx
  920. rect.size.height += dy
  921. }
  922. return rect
  923. }
  924. private func clamp(_ rect: CGRect) -> CGRect {
  925. let minSize: CGFloat = 0.12
  926. var result = rect
  927. result.size.width = max(minSize, result.size.width)
  928. result.size.height = max(minSize, result.size.height)
  929. result.origin.x = min(max(0, result.origin.x), 1 - result.size.width)
  930. result.origin.y = min(max(0, result.origin.y), 1 - result.size.height)
  931. return result
  932. }
  933. }
  934. private final class PhotoCropActionButton: NSControl, AppearanceRefreshable {
  935. var onClick: (() -> Void)?
  936. private let title: String
  937. private let isPrimary: Bool
  938. private let titleLabel = NSTextField(labelWithString: "")
  939. init(title: String, isPrimary: Bool) {
  940. self.title = title
  941. self.isPrimary = isPrimary
  942. super.init(frame: .zero)
  943. wantsLayer = true
  944. layer?.cornerRadius = 8
  945. titleLabel.stringValue = title
  946. titleLabel.font = AppTheme.semiboldFont(size: 12)
  947. titleLabel.alignment = .center
  948. titleLabel.isBordered = false
  949. titleLabel.isEditable = false
  950. titleLabel.drawsBackground = false
  951. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  952. addSubview(titleLabel)
  953. NSLayoutConstraint.activate([
  954. heightAnchor.constraint(equalToConstant: 30),
  955. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  956. titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  957. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  958. ])
  959. refreshAppearance()
  960. }
  961. @available(*, unavailable)
  962. required init?(coder: NSCoder) { nil }
  963. func refreshAppearance() {
  964. if isPrimary {
  965. layer?.backgroundColor = AppTheme.blue.cgColor
  966. titleLabel.textColor = .white
  967. } else {
  968. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  969. layer?.borderColor = AppTheme.paywallBorder.cgColor
  970. layer?.borderWidth = 1
  971. titleLabel.textColor = AppTheme.textPrimary
  972. }
  973. }
  974. override func mouseUp(with event: NSEvent) {
  975. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  976. onClick?()
  977. }
  978. override func resetCursorRects() {
  979. addCursorRect(bounds, cursor: .pointingHand)
  980. }
  981. }
  982. // MARK: - Toolbar Button
  983. private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
  984. var onClick: (() -> Void)?
  985. private let symbolName: String
  986. private let iconView = NSImageView()
  987. init(symbolName: String, accessibilityLabel: String) {
  988. self.symbolName = symbolName
  989. super.init(frame: .zero)
  990. toolTip = accessibilityLabel
  991. wantsLayer = true
  992. layer?.cornerRadius = 12
  993. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  994. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  995. iconView.image = image.withSymbolConfiguration(config)
  996. }
  997. iconView.translatesAutoresizingMaskIntoConstraints = false
  998. addSubview(iconView)
  999. NSLayoutConstraint.activate([
  1000. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1001. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1002. ])
  1003. }
  1004. @available(*, unavailable)
  1005. required init?(coder: NSCoder) { nil }
  1006. func refreshAppearance() {
  1007. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  1008. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1009. layer?.borderWidth = 1.5
  1010. iconView.contentTintColor = AppTheme.textPrimary
  1011. }
  1012. override func mouseUp(with event: NSEvent) {
  1013. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1014. onClick?()
  1015. }
  1016. override func resetCursorRects() {
  1017. addCursorRect(bounds, cursor: .pointingHand)
  1018. }
  1019. }
  1020. // MARK: - Print Button
  1021. private final class PhotoPrintButton: NSControl, AppearanceRefreshable {
  1022. var onClick: (() -> Void)?
  1023. private let titleLabel = NSTextField(labelWithString: "Print Photo")
  1024. private let iconView = NSImageView()
  1025. private var hoverTracker: HoverTracker?
  1026. private var isHovered = false
  1027. init() {
  1028. super.init(frame: .zero)
  1029. wantsLayer = true
  1030. layer?.cornerRadius = 22
  1031. titleLabel.font = AppTheme.semiboldFont(size: 16)
  1032. titleLabel.textColor = .white
  1033. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1034. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  1035. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  1036. iconView.image = image.withSymbolConfiguration(config)
  1037. }
  1038. iconView.contentTintColor = .white
  1039. iconView.translatesAutoresizingMaskIntoConstraints = false
  1040. addSubview(titleLabel)
  1041. addSubview(iconView)
  1042. NSLayoutConstraint.activate([
  1043. heightAnchor.constraint(equalToConstant: 52),
  1044. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  1045. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  1046. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  1047. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1048. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  1049. iconView.widthAnchor.constraint(equalToConstant: 20),
  1050. iconView.heightAnchor.constraint(equalToConstant: 20),
  1051. ])
  1052. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1053. self?.setHovered(hovering)
  1054. }
  1055. refreshAppearance()
  1056. }
  1057. @available(*, unavailable)
  1058. required init?(coder: NSCoder) { nil }
  1059. func refreshAppearance() {
  1060. let color = isHovered
  1061. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  1062. : AppTheme.blue
  1063. layer?.backgroundColor = color.cgColor
  1064. titleLabel.textColor = .white
  1065. iconView.contentTintColor = .white
  1066. }
  1067. private func setHovered(_ hovering: Bool) {
  1068. isHovered = hovering
  1069. animateHover {
  1070. let color = hovering
  1071. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  1072. : AppTheme.blue
  1073. layer?.backgroundColor = color.cgColor
  1074. layer?.transform = hovering
  1075. ? CATransform3DMakeScale(1.03, 1.03, 1)
  1076. : CATransform3DIdentity
  1077. }
  1078. }
  1079. override func mouseUp(with event: NSEvent) {
  1080. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1081. onClick?()
  1082. }
  1083. override func resetCursorRects() {
  1084. addCursorRect(bounds, cursor: .pointingHand)
  1085. }
  1086. }