PhotoPreviewView.swift 47 KB

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