Bez popisu

PhotoPreviewView.swift 13KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  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 let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  18. private let editButton = PhotoToolbarButton(symbolName: "pencil", accessibilityLabel: "Edit")
  19. private let titleLabel = NSTextField(labelWithString: "Print a photo")
  20. private let counterLabel = NSTextField(labelWithString: "")
  21. private let imageContainer = NSView()
  22. private let imageView = NSImageView()
  23. private let printButton = PhotoPrintButton()
  24. init(urls: [URL]) {
  25. self.urls = urls
  26. super.init(frame: .zero)
  27. translatesAutoresizingMaskIntoConstraints = false
  28. setup()
  29. refreshAppearance()
  30. showPhoto(at: 0)
  31. NotificationCenter.default.addObserver(
  32. self,
  33. selector: #selector(appearanceDidChange),
  34. name: .appearanceDidChange,
  35. object: nil
  36. )
  37. }
  38. deinit {
  39. NotificationCenter.default.removeObserver(self)
  40. }
  41. @objc private func appearanceDidChange() {
  42. refreshAppearance()
  43. }
  44. @available(*, unavailable)
  45. required init?(coder: NSCoder) { nil }
  46. func refreshAppearance() {
  47. layer?.backgroundColor = AppTheme.background.cgColor
  48. titleLabel.textColor = AppTheme.textPrimary
  49. counterLabel.textColor = AppTheme.textSecondary
  50. imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  51. imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  52. backButton.refreshAppearance()
  53. editButton.refreshAppearance()
  54. printButton.refreshAppearance()
  55. }
  56. func present(in parent: NSView) {
  57. guard superview == nil else { return }
  58. parent.addSubview(self)
  59. NSLayoutConstraint.activate([
  60. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  61. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  62. topAnchor.constraint(equalTo: parent.topAnchor),
  63. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  64. ])
  65. alphaValue = 0
  66. NSAnimationContext.runAnimationGroup { context in
  67. context.duration = 0.2
  68. animator().alphaValue = 1
  69. }
  70. }
  71. func dismiss(animated: Bool = true) {
  72. stopSecurityAccess()
  73. let remove = { [weak self] in
  74. self?.removeFromSuperview()
  75. self?.onDismiss?()
  76. }
  77. guard animated else {
  78. remove()
  79. return
  80. }
  81. NSAnimationContext.runAnimationGroup({ context in
  82. context.duration = 0.15
  83. animator().alphaValue = 0
  84. }, completionHandler: remove)
  85. }
  86. private func setup() {
  87. wantsLayer = true
  88. titleLabel.font = AppTheme.semiboldFont(size: 18)
  89. titleLabel.alignment = .center
  90. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  91. counterLabel.font = AppTheme.regularFont(size: 13)
  92. counterLabel.alignment = .center
  93. counterLabel.translatesAutoresizingMaskIntoConstraints = false
  94. counterLabel.isHidden = urls.count <= 1
  95. imageContainer.wantsLayer = true
  96. imageContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  97. imageContainer.layer?.borderWidth = 1.5
  98. imageContainer.applyCardShadow()
  99. imageContainer.translatesAutoresizingMaskIntoConstraints = false
  100. imageContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  101. imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  102. imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  103. imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  104. imageView.imageScaling = .scaleProportionallyUpOrDown
  105. imageView.imageAlignment = .alignCenter
  106. imageView.translatesAutoresizingMaskIntoConstraints = false
  107. imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  108. imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
  109. imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  110. imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  111. backButton.translatesAutoresizingMaskIntoConstraints = false
  112. editButton.translatesAutoresizingMaskIntoConstraints = false
  113. printButton.translatesAutoresizingMaskIntoConstraints = false
  114. backButton.onClick = { [weak self] in self?.dismiss() }
  115. editButton.onClick = { [weak self] in self?.editCurrentPhoto() }
  116. printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
  117. addSubview(backButton)
  118. addSubview(titleLabel)
  119. addSubview(editButton)
  120. addSubview(counterLabel)
  121. addSubview(imageContainer)
  122. imageContainer.addSubview(imageView)
  123. addSubview(printButton)
  124. NSLayoutConstraint.activate([
  125. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  126. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  127. backButton.widthAnchor.constraint(equalToConstant: 40),
  128. backButton.heightAnchor.constraint(equalToConstant: 40),
  129. editButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
  130. editButton.topAnchor.constraint(equalTo: backButton.topAnchor),
  131. editButton.widthAnchor.constraint(equalToConstant: 40),
  132. editButton.heightAnchor.constraint(equalToConstant: 40),
  133. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  134. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  135. counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  136. counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  137. imageContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  138. imageContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
  139. imageContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
  140. imageContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  141. imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
  142. imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
  143. imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 12),
  144. imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -12),
  145. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  146. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  147. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  148. ])
  149. }
  150. private func showPhoto(at index: Int) {
  151. guard urls.indices.contains(index) else { return }
  152. currentIndex = index
  153. let url = urls[index]
  154. if securityAccess[url] != true {
  155. securityAccess[url] = url.startAccessingSecurityScopedResource()
  156. }
  157. imageView.image = NSImage(contentsOf: url)
  158. if urls.count > 1 {
  159. counterLabel.stringValue = "\(index + 1) of \(urls.count)"
  160. counterLabel.isHidden = false
  161. } else {
  162. counterLabel.isHidden = true
  163. }
  164. }
  165. private func currentURL() -> URL? {
  166. guard urls.indices.contains(currentIndex) else { return nil }
  167. return urls[currentIndex]
  168. }
  169. private func editCurrentPhoto() {
  170. guard let url = currentURL() else { return }
  171. NSWorkspace.shared.open(url)
  172. }
  173. private func printCurrentPhoto() {
  174. guard let url = currentURL() else { return }
  175. PrintService.print(urls: [url])
  176. if currentIndex < urls.count - 1 {
  177. showPhoto(at: currentIndex + 1)
  178. } else {
  179. dismiss()
  180. }
  181. }
  182. private func stopSecurityAccess() {
  183. for (url, accessed) in securityAccess where accessed {
  184. url.stopAccessingSecurityScopedResource()
  185. }
  186. securityAccess.removeAll()
  187. }
  188. }
  189. // MARK: - Toolbar Button
  190. private final class PhotoToolbarButton: NSControl, AppearanceRefreshable {
  191. var onClick: (() -> Void)?
  192. private let symbolName: String
  193. private let iconView = NSImageView()
  194. init(symbolName: String, accessibilityLabel: String) {
  195. self.symbolName = symbolName
  196. super.init(frame: .zero)
  197. toolTip = accessibilityLabel
  198. wantsLayer = true
  199. layer?.cornerRadius = 12
  200. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  201. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  202. iconView.image = image.withSymbolConfiguration(config)
  203. }
  204. iconView.translatesAutoresizingMaskIntoConstraints = false
  205. addSubview(iconView)
  206. NSLayoutConstraint.activate([
  207. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  208. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  209. ])
  210. }
  211. @available(*, unavailable)
  212. required init?(coder: NSCoder) { nil }
  213. func refreshAppearance() {
  214. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  215. layer?.borderColor = AppTheme.paywallBorder.cgColor
  216. layer?.borderWidth = 1.5
  217. iconView.contentTintColor = AppTheme.textPrimary
  218. }
  219. override func mouseUp(with event: NSEvent) {
  220. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  221. onClick?()
  222. }
  223. override func resetCursorRects() {
  224. addCursorRect(bounds, cursor: .pointingHand)
  225. }
  226. }
  227. // MARK: - Print Button
  228. private final class PhotoPrintButton: NSControl, AppearanceRefreshable {
  229. var onClick: (() -> Void)?
  230. private let titleLabel = NSTextField(labelWithString: "Print Photo")
  231. private let iconView = NSImageView()
  232. private var hoverTracker: HoverTracker?
  233. private var isHovered = false
  234. init() {
  235. super.init(frame: .zero)
  236. wantsLayer = true
  237. layer?.cornerRadius = 22
  238. titleLabel.font = AppTheme.semiboldFont(size: 16)
  239. titleLabel.textColor = .white
  240. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  241. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  242. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  243. iconView.image = image.withSymbolConfiguration(config)
  244. }
  245. iconView.contentTintColor = .white
  246. iconView.translatesAutoresizingMaskIntoConstraints = false
  247. addSubview(titleLabel)
  248. addSubview(iconView)
  249. NSLayoutConstraint.activate([
  250. heightAnchor.constraint(equalToConstant: 52),
  251. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  252. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  253. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  254. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  255. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  256. iconView.widthAnchor.constraint(equalToConstant: 20),
  257. iconView.heightAnchor.constraint(equalToConstant: 20),
  258. ])
  259. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  260. self?.setHovered(hovering)
  261. }
  262. refreshAppearance()
  263. }
  264. @available(*, unavailable)
  265. required init?(coder: NSCoder) { nil }
  266. func refreshAppearance() {
  267. let color = isHovered
  268. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  269. : AppTheme.blue
  270. layer?.backgroundColor = color.cgColor
  271. titleLabel.textColor = .white
  272. iconView.contentTintColor = .white
  273. }
  274. private func setHovered(_ hovering: Bool) {
  275. isHovered = hovering
  276. animateHover {
  277. let color = hovering
  278. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  279. : AppTheme.blue
  280. layer?.backgroundColor = color.cgColor
  281. layer?.transform = hovering
  282. ? CATransform3DMakeScale(1.03, 1.03, 1)
  283. : CATransform3DIdentity
  284. }
  285. }
  286. override func mouseUp(with event: NSEvent) {
  287. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  288. onClick?()
  289. }
  290. override func resetCursorRects() {
  291. addCursorRect(bounds, cursor: .pointingHand)
  292. }
  293. }