Procházet zdrojové kódy

Add photo preview screen with back, edit, and print actions.

Show selected photos in the main content area before printing so users can review, edit in Preview, or go back without changing the app window size.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 před 1 měsícem
rodič
revize
c62cf5ec49

+ 13 - 4
smart_printer/DocumentPickerService.swift

@@ -56,7 +56,7 @@ enum DocumentPickerService {
         let completion: (NSApplication.ModalResponse) -> Void = { response in
             defer { activeDelegate = nil }
             guard response == .OK else { return }
-            handleSelection(panel.urls, kind: kind)
+            handleSelection(panel.urls, kind: kind, from: window ?? NSApp.keyWindow)
         }
 
         if let window = window ?? NSApp.keyWindow {
@@ -105,7 +105,7 @@ enum DocumentPickerService {
         return UTType(filenameExtension: ext)
     }
 
-    private static func handleSelection(_ urls: [URL], kind: DocumentPickerKind) {
+    private static func handleSelection(_ urls: [URL], kind: DocumentPickerKind, from window: NSWindow?) {
         let matched: [URL]
         switch kind {
         case .photos:
@@ -122,8 +122,17 @@ enum DocumentPickerService {
         }
 
         switch kind {
-        case .photos, .files:
-            PrintService.print(urls: matched)
+        case .photos:
+            PhotoPreviewService.present(urls: matched, from: window)
+        case .files:
+            let photos = matched.filter { isPhoto($0) }
+            let documents = matched.filter { isDocument($0) }
+            if !photos.isEmpty {
+                PhotoPreviewService.present(urls: photos, from: window)
+            }
+            if !documents.isEmpty {
+                PrintService.print(urls: documents)
+            }
         case .importFile:
             let added = ImportedFilesStore.add(urls: matched)
             showImportSuccessAlert(count: added > 0 ? added : matched.count)

+ 357 - 0
smart_printer/PhotoPreviewView.swift

@@ -0,0 +1,357 @@
+import Cocoa
+
+// MARK: - Photo Preview Service
+
+enum PhotoPreviewService {
+    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.presentPhotoPreview(urls: urls)
+    }
+}
+
+// MARK: - Overlay
+
+final class PhotoPreviewOverlayView: NSView, AppearanceRefreshable {
+    var onDismiss: (() -> Void)?
+
+    private let urls: [URL]
+    private var currentIndex = 0
+    private var securityAccess: [URL: Bool] = [:]
+
+    private let backButton = PhotoToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
+    private let editButton = PhotoToolbarButton(symbolName: "pencil", accessibilityLabel: "Edit")
+    private let titleLabel = NSTextField(labelWithString: "Print a photo")
+    private let counterLabel = NSTextField(labelWithString: "")
+    private let imageContainer = NSView()
+    private let imageView = NSImageView()
+    private let printButton = PhotoPrintButton()
+
+    init(urls: [URL]) {
+        self.urls = urls
+        super.init(frame: .zero)
+        translatesAutoresizingMaskIntoConstraints = false
+        setup()
+        refreshAppearance()
+        showPhoto(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
+        imageContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
+        imageContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
+        backButton.refreshAppearance()
+        editButton.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
+
+        imageContainer.wantsLayer = true
+        imageContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
+        imageContainer.layer?.borderWidth = 1.5
+        imageContainer.applyCardShadow()
+        imageContainer.translatesAutoresizingMaskIntoConstraints = false
+        imageContainer.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        imageContainer.setContentHuggingPriority(.defaultLow, for: .vertical)
+        imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        imageContainer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        imageView.imageScaling = .scaleProportionallyUpOrDown
+        imageView.imageAlignment = .alignCenter
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
+        imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+        imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
+
+        backButton.translatesAutoresizingMaskIntoConstraints = false
+        editButton.translatesAutoresizingMaskIntoConstraints = false
+        printButton.translatesAutoresizingMaskIntoConstraints = false
+
+        backButton.onClick = { [weak self] in self?.dismiss() }
+        editButton.onClick = { [weak self] in self?.editCurrentPhoto() }
+        printButton.onClick = { [weak self] in self?.printCurrentPhoto() }
+
+        addSubview(backButton)
+        addSubview(titleLabel)
+        addSubview(editButton)
+        addSubview(counterLabel)
+        addSubview(imageContainer)
+        imageContainer.addSubview(imageView)
+        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),
+
+            editButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
+            editButton.topAnchor.constraint(equalTo: backButton.topAnchor),
+            editButton.widthAnchor.constraint(equalToConstant: 40),
+            editButton.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),
+
+            imageContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
+            imageContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -AppTheme.contentPanelInset),
+            imageContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
+            imageContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
+
+            imageView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor, constant: 12),
+            imageView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor, constant: -12),
+            imageView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 12),
+            imageView.bottomAnchor.constraint(equalTo: imageContainer.bottomAnchor, constant: -12),
+
+            printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
+            printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
+            printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
+        ])
+    }
+
+    private func showPhoto(at index: Int) {
+        guard urls.indices.contains(index) else { return }
+        currentIndex = index
+        let url = urls[index]
+
+        if securityAccess[url] != true {
+            securityAccess[url] = url.startAccessingSecurityScopedResource()
+        }
+
+        imageView.image = NSImage(contentsOf: url)
+
+        if urls.count > 1 {
+            counterLabel.stringValue = "\(index + 1) of \(urls.count)"
+            counterLabel.isHidden = false
+        } else {
+            counterLabel.isHidden = true
+        }
+    }
+
+    private func currentURL() -> URL? {
+        guard urls.indices.contains(currentIndex) else { return nil }
+        return urls[currentIndex]
+    }
+
+    private func editCurrentPhoto() {
+        guard let url = currentURL() else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    private func printCurrentPhoto() {
+        guard let url = currentURL() else { return }
+        PrintService.print(urls: [url])
+
+        if currentIndex < urls.count - 1 {
+            showPhoto(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 PhotoToolbarButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let symbolName: String
+    private let iconView = NSImageView()
+
+    init(symbolName: String, accessibilityLabel: String) {
+        self.symbolName = symbolName
+        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 PhotoPrintButton: NSControl, AppearanceRefreshable {
+    var onClick: (() -> Void)?
+
+    private let titleLabel = NSTextField(labelWithString: "Print Photo")
+    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)
+    }
+}

+ 13 - 0
smart_printer/ViewController.swift

@@ -15,6 +15,7 @@ class ViewController: NSViewController {
     private var settingsContentView: NSView!
     private var contentContainer: NSView!
     private var paywallOverlay: PaywallOverlayView?
+    private var photoPreviewOverlay: PhotoPreviewOverlayView?
 
     private var headerView: NSView!
     private var contentTopBelowHeader: NSLayoutConstraint!
@@ -76,6 +77,18 @@ class ViewController: NSViewController {
         sidebar?.refreshAppearance()
         view.refreshAppearanceRecursively()
         paywallOverlay?.refreshAppearance()
+        photoPreviewOverlay?.refreshAppearance()
+    }
+
+    func presentPhotoPreview(urls: [URL]) {
+        photoPreviewOverlay?.dismiss(animated: false)
+
+        let overlay = PhotoPreviewOverlayView(urls: urls)
+        overlay.onDismiss = { [weak self] in
+            self?.photoPreviewOverlay = nil
+        }
+        photoPreviewOverlay = overlay
+        overlay.present(in: mainContentView)
     }
 
     private func setupLayout() {