FilePreviewView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import Cocoa
  2. import PDFKit
  3. import UniformTypeIdentifiers
  4. // MARK: - File Preview Service
  5. enum FilePreviewService {
  6. static func present(urls: [URL], from window: NSWindow?) {
  7. guard !urls.isEmpty else { return }
  8. let hostWindow = window ?? NSApp.keyWindow
  9. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  10. viewController.presentFilePreview(urls: urls)
  11. }
  12. }
  13. // MARK: - Overlay
  14. final class FilePreviewOverlayView: NSView, AppearanceRefreshable {
  15. var onDismiss: (() -> Void)?
  16. private let urls: [URL]
  17. private var currentIndex = 0
  18. private var securityAccess: [URL: Bool] = [:]
  19. private let backButton = FileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  20. private let titleLabel = NSTextField(labelWithString: "Print a file")
  21. private let counterLabel = NSTextField(labelWithString: "")
  22. private let previewContainer = NSView()
  23. private var previewSubview: NSView?
  24. private let printButton = FilePrintButton()
  25. init(urls: [URL]) {
  26. self.urls = urls
  27. super.init(frame: .zero)
  28. translatesAutoresizingMaskIntoConstraints = false
  29. setup()
  30. refreshAppearance()
  31. showFile(at: 0)
  32. NotificationCenter.default.addObserver(
  33. self,
  34. selector: #selector(appearanceDidChange),
  35. name: .appearanceDidChange,
  36. object: nil
  37. )
  38. }
  39. deinit {
  40. NotificationCenter.default.removeObserver(self)
  41. }
  42. @objc private func appearanceDidChange() {
  43. refreshAppearance()
  44. }
  45. @available(*, unavailable)
  46. required init?(coder: NSCoder) { nil }
  47. func refreshAppearance() {
  48. layer?.backgroundColor = AppTheme.background.cgColor
  49. titleLabel.textColor = AppTheme.textPrimary
  50. counterLabel.textColor = AppTheme.textSecondary
  51. previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  52. previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  53. backButton.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. previewContainer.wantsLayer = true
  96. previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  97. previewContainer.layer?.borderWidth = 1.5
  98. previewContainer.applyCardShadow()
  99. previewContainer.translatesAutoresizingMaskIntoConstraints = false
  100. previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  101. previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
  102. previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  103. previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  104. backButton.translatesAutoresizingMaskIntoConstraints = false
  105. printButton.translatesAutoresizingMaskIntoConstraints = false
  106. backButton.onClick = { [weak self] in self?.dismiss() }
  107. printButton.onClick = { [weak self] in self?.printCurrentFile() }
  108. addSubview(backButton)
  109. addSubview(titleLabel)
  110. addSubview(counterLabel)
  111. addSubview(previewContainer)
  112. addSubview(printButton)
  113. NSLayoutConstraint.activate([
  114. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  115. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  116. backButton.widthAnchor.constraint(equalToConstant: 40),
  117. backButton.heightAnchor.constraint(equalToConstant: 40),
  118. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  119. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  120. counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  121. counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  122. previewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  123. previewContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
  124. previewContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
  125. previewContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  126. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  127. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  128. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  129. ])
  130. }
  131. private func showFile(at index: Int) {
  132. guard urls.indices.contains(index) else { return }
  133. currentIndex = index
  134. let url = urls[index]
  135. if securityAccess[url] != true {
  136. securityAccess[url] = url.startAccessingSecurityScopedResource()
  137. }
  138. previewSubview?.removeFromSuperview()
  139. let preview = makePreview(for: url)
  140. preview.translatesAutoresizingMaskIntoConstraints = false
  141. previewContainer.addSubview(preview)
  142. NSLayoutConstraint.activate([
  143. preview.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
  144. preview.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
  145. preview.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
  146. preview.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
  147. ])
  148. previewSubview = preview
  149. if urls.count > 1 {
  150. counterLabel.stringValue = "\(index + 1) of \(urls.count) — \(url.lastPathComponent)"
  151. counterLabel.isHidden = false
  152. } else {
  153. counterLabel.stringValue = url.lastPathComponent
  154. counterLabel.isHidden = false
  155. }
  156. }
  157. private func makePreview(for url: URL) -> NSView {
  158. let ext = url.pathExtension.lowercased()
  159. if ext == "pdf", let document = PDFDocument(url: url) {
  160. let pdfView = PDFView()
  161. pdfView.document = document
  162. pdfView.autoScales = true
  163. pdfView.displayMode = .singlePageContinuous
  164. pdfView.backgroundColor = .clear
  165. return pdfView
  166. }
  167. if ext == "rtf",
  168. let data = try? Data(contentsOf: url),
  169. let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
  170. return makeTextPreview(attributed)
  171. }
  172. if ["txt", "md", "csv", "json", "xml", "html", "htm"].contains(ext),
  173. let text = try? String(contentsOf: url, encoding: .utf8) {
  174. return makeTextPreview(NSAttributedString(string: text))
  175. }
  176. return makePlaceholderPreview(for: url)
  177. }
  178. private func makeTextPreview(_ text: NSAttributedString) -> NSView {
  179. let scrollView = NSScrollView()
  180. scrollView.hasVerticalScroller = true
  181. scrollView.hasHorizontalScroller = false
  182. scrollView.autohidesScrollers = true
  183. scrollView.borderType = .noBorder
  184. scrollView.drawsBackground = false
  185. let textView = NSTextView()
  186. textView.isEditable = false
  187. textView.isSelectable = true
  188. textView.drawsBackground = false
  189. textView.textStorage?.setAttributedString(text)
  190. if textView.font == nil {
  191. textView.font = AppTheme.regularFont(size: 13)
  192. }
  193. textView.textColor = AppTheme.textPrimary
  194. scrollView.documentView = textView
  195. return scrollView
  196. }
  197. private func makePlaceholderPreview(for url: URL) -> NSView {
  198. let container = NSView()
  199. let iconView = NSImageView()
  200. iconView.image = NSWorkspace.shared.icon(forFile: url.path)
  201. iconView.imageScaling = .scaleProportionallyUpOrDown
  202. iconView.translatesAutoresizingMaskIntoConstraints = false
  203. let nameLabel = NSTextField(labelWithString: url.lastPathComponent)
  204. nameLabel.font = AppTheme.semiboldFont(size: 16)
  205. nameLabel.textColor = AppTheme.textPrimary
  206. nameLabel.alignment = .center
  207. nameLabel.lineBreakMode = .byTruncatingMiddle
  208. nameLabel.maximumNumberOfLines = 2
  209. nameLabel.translatesAutoresizingMaskIntoConstraints = false
  210. container.addSubview(iconView)
  211. container.addSubview(nameLabel)
  212. NSLayoutConstraint.activate([
  213. iconView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  214. iconView.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -20),
  215. iconView.widthAnchor.constraint(equalToConstant: 72),
  216. iconView.heightAnchor.constraint(equalToConstant: 72),
  217. nameLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 16),
  218. nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  219. nameLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  220. ])
  221. return container
  222. }
  223. private func currentURL() -> URL? {
  224. guard urls.indices.contains(currentIndex) else { return nil }
  225. return urls[currentIndex]
  226. }
  227. private func printCurrentFile() {
  228. guard let url = currentURL() else { return }
  229. PrintService.print(urls: [url], from: window)
  230. if currentIndex < urls.count - 1 {
  231. showFile(at: currentIndex + 1)
  232. } else {
  233. dismiss()
  234. }
  235. }
  236. private func stopSecurityAccess() {
  237. for (url, accessed) in securityAccess where accessed {
  238. url.stopAccessingSecurityScopedResource()
  239. }
  240. securityAccess.removeAll()
  241. }
  242. }
  243. // MARK: - Toolbar Button
  244. private final class FileToolbarButton: NSControl, AppearanceRefreshable {
  245. var onClick: (() -> Void)?
  246. private let iconView = NSImageView()
  247. init(symbolName: String, accessibilityLabel: String) {
  248. super.init(frame: .zero)
  249. toolTip = accessibilityLabel
  250. wantsLayer = true
  251. layer?.cornerRadius = 12
  252. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  253. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  254. iconView.image = image.withSymbolConfiguration(config)
  255. }
  256. iconView.translatesAutoresizingMaskIntoConstraints = false
  257. addSubview(iconView)
  258. NSLayoutConstraint.activate([
  259. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  260. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  261. ])
  262. }
  263. @available(*, unavailable)
  264. required init?(coder: NSCoder) { nil }
  265. func refreshAppearance() {
  266. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  267. layer?.borderColor = AppTheme.paywallBorder.cgColor
  268. layer?.borderWidth = 1.5
  269. iconView.contentTintColor = AppTheme.textPrimary
  270. }
  271. override func mouseUp(with event: NSEvent) {
  272. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  273. onClick?()
  274. }
  275. override func resetCursorRects() {
  276. addCursorRect(bounds, cursor: .pointingHand)
  277. }
  278. }
  279. // MARK: - Print Button
  280. private final class FilePrintButton: NSControl, AppearanceRefreshable {
  281. var onClick: (() -> Void)?
  282. private let titleLabel = NSTextField(labelWithString: "Print File")
  283. private let iconView = NSImageView()
  284. private var hoverTracker: HoverTracker?
  285. private var isHovered = false
  286. init() {
  287. super.init(frame: .zero)
  288. wantsLayer = true
  289. layer?.cornerRadius = 22
  290. titleLabel.font = AppTheme.semiboldFont(size: 16)
  291. titleLabel.textColor = .white
  292. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  293. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  294. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  295. iconView.image = image.withSymbolConfiguration(config)
  296. }
  297. iconView.contentTintColor = .white
  298. iconView.translatesAutoresizingMaskIntoConstraints = false
  299. addSubview(titleLabel)
  300. addSubview(iconView)
  301. NSLayoutConstraint.activate([
  302. heightAnchor.constraint(equalToConstant: 52),
  303. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  304. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  305. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  306. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  307. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  308. iconView.widthAnchor.constraint(equalToConstant: 20),
  309. iconView.heightAnchor.constraint(equalToConstant: 20),
  310. ])
  311. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  312. self?.setHovered(hovering)
  313. }
  314. refreshAppearance()
  315. }
  316. @available(*, unavailable)
  317. required init?(coder: NSCoder) { nil }
  318. func refreshAppearance() {
  319. let color = isHovered
  320. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  321. : AppTheme.blue
  322. layer?.backgroundColor = color.cgColor
  323. titleLabel.textColor = .white
  324. iconView.contentTintColor = .white
  325. }
  326. private func setHovered(_ hovering: Bool) {
  327. isHovered = hovering
  328. animateHover {
  329. let color = hovering
  330. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  331. : AppTheme.blue
  332. layer?.backgroundColor = color.cgColor
  333. layer?.transform = hovering
  334. ? CATransform3DMakeScale(1.03, 1.03, 1)
  335. : CATransform3DIdentity
  336. }
  337. }
  338. override func mouseUp(with event: NSEvent) {
  339. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  340. onClick?()
  341. }
  342. override func resetCursorRects() {
  343. addCursorRect(bounds, cursor: .pointingHand)
  344. }
  345. }