| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- import Cocoa
- import PDFKit
- import UniformTypeIdentifiers
- // MARK: - File Preview Service
- enum FilePreviewService {
- static func present(urls: [URL], from window: NSWindow?) {
- guard !urls.isEmpty else { return }
- let hostWindow = window ?? NSApp.keyWindow
- guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
- viewController.presentFilePreview(urls: urls)
- }
- }
- // MARK: - Overlay
- final class FilePreviewOverlayView: NSView, AppearanceRefreshable {
- var onDismiss: (() -> Void)?
- private let urls: [URL]
- private var currentIndex = 0
- private var securityAccess: [URL: Bool] = [:]
- private let backButton = FileToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
- private let titleLabel = NSTextField(labelWithString: "Print a file")
- private let counterLabel = NSTextField(labelWithString: "")
- private let previewContainer = NSView()
- private var previewSubview: NSView?
- private let printButton = FilePrintButton()
- init(urls: [URL]) {
- self.urls = urls
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- showFile(at: 0)
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(appearanceDidChange),
- name: .appearanceDidChange,
- object: nil
- )
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @objc private func appearanceDidChange() {
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- counterLabel.textColor = AppTheme.textSecondary
- previewContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
- previewContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
- backButton.refreshAppearance()
- printButton.refreshAppearance()
- }
- func present(in parent: NSView) {
- guard superview == nil else { return }
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- alphaValue = 0
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- animator().alphaValue = 1
- }
- }
- func dismiss(animated: Bool = true) {
- stopSecurityAccess()
- let remove = { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- }
- guard animated else {
- remove()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: remove)
- }
- private func setup() {
- wantsLayer = true
- titleLabel.font = AppTheme.semiboldFont(size: 18)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- counterLabel.font = AppTheme.regularFont(size: 13)
- counterLabel.alignment = .center
- counterLabel.translatesAutoresizingMaskIntoConstraints = false
- counterLabel.isHidden = urls.count <= 1
- previewContainer.wantsLayer = true
- previewContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- previewContainer.layer?.borderWidth = 1.5
- previewContainer.applyCardShadow()
- previewContainer.translatesAutoresizingMaskIntoConstraints = false
- previewContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- previewContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
- previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- previewContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- backButton.translatesAutoresizingMaskIntoConstraints = false
- printButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.onClick = { [weak self] in self?.dismiss() }
- printButton.onClick = { [weak self] in self?.printCurrentFile() }
- addSubview(backButton)
- addSubview(titleLabel)
- addSubview(counterLabel)
- addSubview(previewContainer)
- addSubview(printButton)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
- backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
- backButton.widthAnchor.constraint(equalToConstant: 40),
- backButton.heightAnchor.constraint(equalToConstant: 40),
- titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
- counterLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- counterLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
- previewContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
- previewContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
- previewContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
- previewContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
- printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
- printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
- printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
- ])
- }
- private func showFile(at index: Int) {
- guard urls.indices.contains(index) else { return }
- currentIndex = index
- let url = urls[index]
- if securityAccess[url] != true {
- securityAccess[url] = url.startAccessingSecurityScopedResource()
- }
- previewSubview?.removeFromSuperview()
- let preview = makePreview(for: url)
- preview.translatesAutoresizingMaskIntoConstraints = false
- previewContainer.addSubview(preview)
- NSLayoutConstraint.activate([
- preview.leadingAnchor.constraint(equalTo: previewContainer.leadingAnchor, constant: 12),
- preview.trailingAnchor.constraint(equalTo: previewContainer.trailingAnchor, constant: -12),
- preview.topAnchor.constraint(equalTo: previewContainer.topAnchor, constant: 12),
- preview.bottomAnchor.constraint(equalTo: previewContainer.bottomAnchor, constant: -12),
- ])
- previewSubview = preview
- if urls.count > 1 {
- counterLabel.stringValue = "\(index + 1) of \(urls.count) — \(url.lastPathComponent)"
- counterLabel.isHidden = false
- } else {
- counterLabel.stringValue = url.lastPathComponent
- counterLabel.isHidden = false
- }
- }
- private func makePreview(for url: URL) -> NSView {
- let ext = url.pathExtension.lowercased()
- if ext == "pdf", let document = PDFDocument(url: url) {
- let pdfView = PDFView()
- pdfView.document = document
- pdfView.autoScales = true
- pdfView.displayMode = .singlePageContinuous
- pdfView.backgroundColor = .clear
- return pdfView
- }
- if ext == "rtf",
- let data = try? Data(contentsOf: url),
- let attributed = NSAttributedString(rtf: data, documentAttributes: nil) {
- return makeTextPreview(attributed)
- }
- if ["txt", "md", "csv", "json", "xml", "html", "htm"].contains(ext),
- let text = try? String(contentsOf: url, encoding: .utf8) {
- return makeTextPreview(NSAttributedString(string: text))
- }
- return makePlaceholderPreview(for: url)
- }
- private func makeTextPreview(_ text: NSAttributedString) -> NSView {
- let scrollView = NSScrollView()
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.borderType = .noBorder
- scrollView.drawsBackground = false
- let textView = NSTextView()
- textView.isEditable = false
- textView.isSelectable = true
- textView.drawsBackground = false
- textView.textStorage?.setAttributedString(text)
- if textView.font == nil {
- textView.font = AppTheme.regularFont(size: 13)
- }
- textView.textColor = AppTheme.textPrimary
- scrollView.documentView = textView
- return scrollView
- }
- private func makePlaceholderPreview(for url: URL) -> NSView {
- let container = NSView()
- let iconView = NSImageView()
- iconView.image = NSWorkspace.shared.icon(forFile: url.path)
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.translatesAutoresizingMaskIntoConstraints = false
- let nameLabel = NSTextField(labelWithString: url.lastPathComponent)
- nameLabel.font = AppTheme.semiboldFont(size: 16)
- nameLabel.textColor = AppTheme.textPrimary
- nameLabel.alignment = .center
- nameLabel.lineBreakMode = .byTruncatingMiddle
- nameLabel.maximumNumberOfLines = 2
- nameLabel.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(iconView)
- container.addSubview(nameLabel)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: container.centerYAnchor, constant: -20),
- iconView.widthAnchor.constraint(equalToConstant: 72),
- iconView.heightAnchor.constraint(equalToConstant: 72),
- nameLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 16),
- nameLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- nameLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- ])
- return container
- }
- private func currentURL() -> URL? {
- guard urls.indices.contains(currentIndex) else { return nil }
- return urls[currentIndex]
- }
- private func printCurrentFile() {
- guard let url = currentURL() else { return }
- PrintService.print(urls: [url], from: window)
- if currentIndex < urls.count - 1 {
- showFile(at: currentIndex + 1)
- } else {
- dismiss()
- }
- }
- private func stopSecurityAccess() {
- for (url, accessed) in securityAccess where accessed {
- url.stopAccessingSecurityScopedResource()
- }
- securityAccess.removeAll()
- }
- }
- // MARK: - Toolbar Button
- private final class FileToolbarButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- // MARK: - Print Button
- private final class FilePrintButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel = NSTextField(labelWithString: "Print File")
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 22
- titleLabel.font = AppTheme.semiboldFont(size: 16)
- titleLabel.textColor = .white
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
- let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.contentTintColor = .white
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 52),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
- iconView.widthAnchor.constraint(equalToConstant: 20),
- iconView.heightAnchor.constraint(equalToConstant: 20),
- ])
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- let color = isHovered
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- titleLabel.textColor = .white
- iconView.contentTintColor = .white
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- let color = hovering
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.03, 1.03, 1)
- : CATransform3DIdentity
- }
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
|