🚀 First official release of ShotScreen with complete feature set: ✨ Core Features: - Advanced screenshot capture system - Multi-monitor support - Professional UI/UX design - Automated update system with Sparkle - Apple notarized & code signed 🛠 Technical Excellence: - Native Swift macOS application - Professional build & deployment pipeline - Comprehensive error handling - Memory optimized performance 📦 Distribution Ready: - Professional DMG packaging - Apple notarization complete - No security warnings for users - Ready for public distribution This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
443 lines
20 KiB
Swift
443 lines
20 KiB
Swift
import AppKit
|
||
import Vision
|
||
import SwiftUI
|
||
|
||
// MARK: - Grid Action Manager
|
||
protocol GridActionManagerDelegate: AnyObject {
|
||
func showOrUpdateStash(with imageURL: URL)
|
||
func closePreviewWithAnimation(immediate: Bool)
|
||
func ensurePreviewVisible()
|
||
func getLastImage() -> NSImage?
|
||
func getTempURL() -> URL?
|
||
func getActivePreviewWindow() -> NSWindow?
|
||
func getRenameActionHandler() -> RenameActionHandler
|
||
func flashPreviewBorder()
|
||
func disableMonitoring()
|
||
func enableMonitoring()
|
||
func gridViewManagerHideGrid(monitorForReappear: Bool)
|
||
func getGridCurrentFrame() -> NSRect?
|
||
// 🎨 NEW: Background removal thumbnail workflow with original URL
|
||
func showBackgroundRemovalThumbnail(with image: NSImage, originalURL: URL)
|
||
}
|
||
|
||
class GridActionManager {
|
||
weak var delegate: GridActionManagerDelegate?
|
||
|
||
init(delegate: GridActionManagerDelegate) {
|
||
self.delegate = delegate
|
||
}
|
||
|
||
// MARK: - Grid Action Handler
|
||
func handleGridAction(imageURL: URL, cellIndex: Int, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
print("✅ GridActionManager: Handling action for cell \(cellIndex)")
|
||
|
||
// Get the action type based on the dynamic action order
|
||
let actionOrder = SettingsManager.shared.actionOrder
|
||
guard cellIndex < actionOrder.count else {
|
||
print("ℹ️ Cell index \(cellIndex) out of range for action order.")
|
||
completion(false, false, false)
|
||
return
|
||
}
|
||
|
||
let actionType = actionOrder[cellIndex]
|
||
|
||
switch actionType {
|
||
case .rename:
|
||
print("📝 Rename action triggered for \(imageURL.path)")
|
||
handleRenameAction(imageURL: imageURL, completion: completion)
|
||
case .stash:
|
||
print("✨ Stash action triggered for \(imageURL.path)")
|
||
handleStashAction(imageURL: imageURL, completion: completion)
|
||
case .ocr:
|
||
print("📑 OCR action triggered for \(imageURL.path)")
|
||
handleOCRAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
|
||
case .clipboard:
|
||
print("📋 Clipboard action triggered for \(imageURL.path)")
|
||
handleClipboardAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
|
||
case .backgroundRemove:
|
||
print("🎨 Background Remove action triggered for \(imageURL.path)")
|
||
handleBackgroundRemoveAction(imageURL: imageURL, dropPoint: dropPoint, gridWindow: gridWindow, completion: completion)
|
||
case .cancel:
|
||
print("🚫 Cancel action triggered")
|
||
handleCancelAction(completion: completion)
|
||
case .remove:
|
||
print("🗑 Remove action triggered")
|
||
handleRemoveAction(completion: completion)
|
||
}
|
||
}
|
||
|
||
// MARK: - Individual Action Handlers
|
||
|
||
private func handleRenameAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
print("🔍 DEBUG: handleRenameAction called with URL: \(imageURL)")
|
||
print("🔍 DEBUG: File exists at URL: \(FileManager.default.fileExists(atPath: imageURL.path))")
|
||
|
||
delegate?.getRenameActionHandler().promptAndRename(originalURL: imageURL) { responseForRename in
|
||
let successfulAction = (responseForRename != .alertThirdButtonReturn)
|
||
let saveToFolderTriggered = (responseForRename == .alertSecondButtonReturn)
|
||
print("🔍 DEBUG: Rename response: \(responseForRename), successful: \(successfulAction)")
|
||
completion(successfulAction, saveToFolderTriggered, false)
|
||
}
|
||
}
|
||
|
||
private func handleStashAction(imageURL: URL, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
print("✨ Stash action triggered for \(imageURL.path)")
|
||
|
||
// CRITICALLY IMPORTANT: Close main grid immediately after stash action
|
||
print("🔶 STASH ACTION: Closing main grid and disabling proximity monitoring")
|
||
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
|
||
|
||
delegate?.showOrUpdateStash(with: imageURL)
|
||
completion(true, false, true) // Pass true for isStashAction
|
||
}
|
||
|
||
private func handleOCRAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
delegate?.disableMonitoring()
|
||
performOcrAndCopy(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) { success in
|
||
// Completion for performOcrAndCopy is now async after panel closes
|
||
// The actual success of OCR (text found vs. no text) is handled by the message in the panel.
|
||
// For the grid action itself, we consider it 'successful' if the process ran.
|
||
self.delegate?.enableMonitoring()
|
||
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
|
||
completion(true, false, false) // OCR action always 'succeeds' in terms of grid interaction
|
||
}
|
||
}
|
||
|
||
private func handleClipboardAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
delegate?.disableMonitoring()
|
||
copyImageToClipboard(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow) {
|
||
self.delegate?.enableMonitoring()
|
||
self.delegate?.gridViewManagerHideGrid(monitorForReappear: false)
|
||
completion(true, false, false)
|
||
}
|
||
}
|
||
|
||
private func handleBackgroundRemoveAction(imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
performBackgroundRemove(from: imageURL, dropPoint: dropPoint, gridWindow: gridWindow)
|
||
completion(true, false, false)
|
||
}
|
||
|
||
private func handleCancelAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
// Handle cancel action through delegate callback mechanism
|
||
NotificationCenter.default.post(name: .gridActionCancelRequested, object: nil)
|
||
completion(false, false, false)
|
||
}
|
||
|
||
private func handleRemoveAction(completion: @escaping (Bool, Bool, Bool) -> Void) {
|
||
// Handle remove action through delegate callback mechanism
|
||
NotificationCenter.default.post(name: .gridActionRemoveRequested, object: nil)
|
||
completion(false, false, false)
|
||
}
|
||
|
||
// MARK: - OCR Implementation
|
||
|
||
private func performOcrAndCopy(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping (Bool) -> Void) {
|
||
// Intel Mac compatible Vision framework OCR
|
||
guard #available(macOS 10.15, *) else {
|
||
print("❌ OCR requires macOS 10.15 or later")
|
||
if let gridFrame = delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "OCR requires macOS 10.15+", autoCloseAfter: 1.8, onAutoCloseCompletion: {
|
||
completion(false)
|
||
})
|
||
} else {
|
||
completion(false)
|
||
}
|
||
return
|
||
}
|
||
guard let nsImage = NSImage(contentsOf: imageURL) else {
|
||
print("❌ OCR: Could not load image from URL: \(imageURL.path)")
|
||
if let gridFrame = delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
|
||
completion(false)
|
||
})
|
||
} else {
|
||
completion(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
guard let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||
print("❌ OCR: Could not obtain CGImage")
|
||
if let gridFrame = delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Could not get image data for OCR", autoCloseAfter: 1.8, onAutoCloseCompletion: {
|
||
completion(false)
|
||
})
|
||
} else {
|
||
completion(false)
|
||
}
|
||
return
|
||
}
|
||
|
||
let request = VNRecognizeTextRequest { [weak self] request, error in
|
||
guard let self = self else { completion(false); return }
|
||
|
||
var message: String
|
||
var ocrDidFindText = false
|
||
|
||
if let err = error {
|
||
print("❌ OCR error: \(err.localizedDescription)")
|
||
message = "OCR error: \(err.localizedDescription)"
|
||
} else {
|
||
let observations = request.results as? [VNRecognizedTextObservation] ?? []
|
||
let recognizedText = observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n")
|
||
if recognizedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
message = "No text found"
|
||
} else {
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(recognizedText, forType: .string)
|
||
message = "Text copied to clipboard!"
|
||
ocrDidFindText = true
|
||
}
|
||
}
|
||
|
||
DispatchQueue.main.async {
|
||
if let gridFrame = self.delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Processing...")
|
||
panel.show(aroundGridFrame: gridFrame, text: message, autoCloseAfter: 1.8, onAutoCloseCompletion: {
|
||
completion(ocrDidFindText)
|
||
})
|
||
} else {
|
||
completion(ocrDidFindText) // Call completion if gridFrame is nil
|
||
}
|
||
}
|
||
}
|
||
request.recognitionLevel = VNRequestTextRecognitionLevel.accurate
|
||
request.usesLanguageCorrection = true
|
||
|
||
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
do {
|
||
try handler.perform([request])
|
||
} catch {
|
||
print("❌ OCR: Failed to perform request – \(error.localizedDescription)")
|
||
DispatchQueue.main.async {
|
||
self.showToast(message: "OCR failed", near: dropPoint, gridWindow: gridWindow)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Clipboard Implementation
|
||
|
||
private func copyImageToClipboard(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?, completion: @escaping () -> Void) {
|
||
guard let nsImage = NSImage(contentsOf: imageURL) else {
|
||
print("❌ Clipboard: Could not load image from URL: \(imageURL.path)")
|
||
// Consider showing error with FeedbackBubblePanel if gridFrame is available
|
||
if let gridFrame = delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Error")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Image load failed for Clipboard", autoCloseAfter: 2.0)
|
||
panel.closeWithAnimation { completion() }
|
||
} else {
|
||
completion()
|
||
}
|
||
return
|
||
}
|
||
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.writeObjects([nsImage])
|
||
|
||
if let gridFrame = delegate?.getGridCurrentFrame() {
|
||
let panel = FeedbackBubblePanel(contentRect: NSRect.zero, text: "Copied!")
|
||
panel.show(aroundGridFrame: gridFrame, text: "Copied to clipboard!", autoCloseAfter: nil)
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||
panel.closeWithAnimation {
|
||
completion()
|
||
}
|
||
}
|
||
} else {
|
||
print("⚠️ Could not get grid frame to show feedback bubble for clipboard.")
|
||
completion()
|
||
}
|
||
}
|
||
|
||
// MARK: - Background Remove Implementation
|
||
|
||
private func performBackgroundRemove(from imageURL: URL, dropPoint: NSPoint, gridWindow: NSWindow?) {
|
||
print("🎨 Background Remove action triggered for \(imageURL.path)")
|
||
|
||
// 🔄 ENHANCED: Better error handling and recovery for image loading
|
||
var finalImageURL = imageURL
|
||
var originalImage: NSImage? = nil
|
||
|
||
// First attempt: Direct load
|
||
originalImage = NSImage(contentsOf: finalImageURL)
|
||
|
||
// If failed, try recovery strategies
|
||
if originalImage == nil {
|
||
print("⚠️ Initial image load failed, attempting recovery...")
|
||
|
||
// Strategy 1: Check if file exists but has load issues
|
||
if FileManager.default.fileExists(atPath: imageURL.path) {
|
||
print("🔍 File exists but NSImage can't load it, trying alternative loading...")
|
||
|
||
// Try loading with different methods
|
||
if let imageData = try? Data(contentsOf: imageURL) {
|
||
originalImage = NSImage(data: imageData)
|
||
if originalImage != nil {
|
||
print("✅ RECOVERED: Successfully loaded image via Data method")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strategy 2: Check temp directory for file
|
||
if originalImage == nil {
|
||
let tempDir = FileManager.default.temporaryDirectory
|
||
let fileName = imageURL.lastPathComponent
|
||
let tempURL = tempDir.appendingPathComponent(fileName)
|
||
|
||
if FileManager.default.fileExists(atPath: tempURL.path) {
|
||
originalImage = NSImage(contentsOf: tempURL)
|
||
if originalImage != nil {
|
||
finalImageURL = tempURL
|
||
print("✅ RECOVERED: Found image in temp directory: \(tempURL.path)")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Strategy 3: Check if it's a cache file issue and try alternative cache locations
|
||
if originalImage == nil {
|
||
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||
let shotScreenDir = supportDir.appendingPathComponent("ShotScreen/Thumbnails")
|
||
let cacheName = imageURL.lastPathComponent
|
||
let cacheURL = shotScreenDir.appendingPathComponent(cacheName)
|
||
|
||
if FileManager.default.fileExists(atPath: cacheURL.path) {
|
||
originalImage = NSImage(contentsOf: cacheURL)
|
||
if originalImage != nil {
|
||
finalImageURL = cacheURL
|
||
print("✅ RECOVERED: Found image in cache: \(cacheURL.path)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Final check
|
||
guard let finalImage = originalImage else {
|
||
print("❌ Could not load image for background removal after all recovery attempts")
|
||
print("📍 Attempted URLs:")
|
||
print(" - Original: \(imageURL.path)")
|
||
print(" - File exists: \(FileManager.default.fileExists(atPath: imageURL.path))")
|
||
|
||
showToast(message: "Image load failed", near: dropPoint, gridWindow: gridWindow)
|
||
return
|
||
}
|
||
|
||
print("✅ Successfully loaded image for BGR: \(finalImageURL.lastPathComponent)")
|
||
print("🎨 Background Remove thumbnail UI starting...")
|
||
|
||
// 🎯 NEW: Use thumbnail-based BGR workflow via delegate with recovered URL
|
||
delegate?.showBackgroundRemovalThumbnail(with: finalImage, originalURL: finalImageURL)
|
||
}
|
||
|
||
// MARK: - Toast Notification System
|
||
|
||
private func showToast(message: String, near point: NSPoint, gridWindow: NSWindow?) {
|
||
let toastWidth: CGFloat = 180
|
||
let toastHeight: CGFloat = 40
|
||
|
||
// Kies vaste positie links (anders rechts) naast de grid
|
||
let margin: CGFloat = 5
|
||
var screenPoint: NSPoint
|
||
if let gw = gridWindow {
|
||
let gridFrame = gw.frame
|
||
let screenVisible = gw.screen?.visibleFrame ?? NSScreen.main!.visibleFrame
|
||
var x = gridFrame.minX - toastWidth - margin // links van grid
|
||
if x < screenVisible.minX + margin { // niet genoeg ruimte links → rechts
|
||
x = gridFrame.maxX + margin
|
||
}
|
||
let y = gridFrame.midY - toastHeight / 2
|
||
screenPoint = NSPoint(x: x, y: y)
|
||
} else {
|
||
// Fallback: gebruik muislocatie zoals voorheen
|
||
screenPoint = NSEvent.mouseLocation
|
||
screenPoint.x -= toastWidth / 2
|
||
screenPoint.y += 20
|
||
}
|
||
|
||
let toastWindow = NSWindow(contentRect: NSRect(x: screenPoint.x, y: screenPoint.y, width: toastWidth, height: toastHeight),
|
||
styleMask: [.borderless], backing: .buffered, defer: false)
|
||
toastWindow.isOpaque = false
|
||
toastWindow.backgroundColor = .clear
|
||
toastWindow.level = .floating + 5
|
||
toastWindow.hasShadow = true
|
||
|
||
// Opbouw content
|
||
let container = NSView(frame: NSRect(x: 0, y: 0, width: toastWidth, height: toastHeight))
|
||
let blur = NSVisualEffectView(frame: container.bounds)
|
||
blur.blendingMode = .behindWindow
|
||
blur.material = .hudWindow
|
||
blur.state = .active
|
||
blur.alphaValue = 0.9
|
||
blur.wantsLayer = true
|
||
blur.layer?.cornerRadius = 12
|
||
blur.layer?.masksToBounds = true
|
||
|
||
let label = NSTextField(labelWithString: message)
|
||
label.textColor = .white
|
||
label.alignment = .center
|
||
label.font = .systemFont(ofSize: 13, weight: .medium)
|
||
label.frame = NSRect(x: 0, y: (toastHeight - 20) / 2, width: toastWidth, height: 20)
|
||
|
||
container.addSubview(blur)
|
||
container.addSubview(label)
|
||
toastWindow.contentView = container
|
||
toastWindow.alphaValue = 0
|
||
toastWindow.makeKeyAndOrderFront(nil as Any?)
|
||
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 0.35
|
||
toastWindow.animator().alphaValue = 1
|
||
}, completionHandler: {
|
||
let flyUp: CGFloat = 30
|
||
let targetOrigin = NSPoint(x: toastWindow.frame.origin.x, y: toastWindow.frame.origin.y + flyUp)
|
||
NSAnimationContext.runAnimationGroup({ ctx in
|
||
ctx.duration = 2
|
||
toastWindow.animator().alphaValue = 0
|
||
toastWindow.animator().setFrameOrigin(targetOrigin)
|
||
}, completionHandler: {
|
||
toastWindow.orderOut(nil as Any?)
|
||
})
|
||
})
|
||
}
|
||
|
||
// MARK: - Action Completion Handler
|
||
|
||
func handleActionCompletion(actionCompletedSuccessfully: Bool, wasSaveToFolder: Bool, isStashAction: Bool) {
|
||
print("ℹ️ Action panel completion: Success - \(actionCompletedSuccessfully), WasSaveToFolder - \(wasSaveToFolder), IsStashAction - \(isStashAction)")
|
||
|
||
if actionCompletedSuccessfully {
|
||
if wasSaveToFolder {
|
||
// Save to folder actions handle their own preview closing via closeAfterSave setting.
|
||
print("ℹ️ Save to folder action: Preview management handled by save logic.")
|
||
} else if isStashAction {
|
||
// CRITICAL: Ensure main grid stays closed after stash action
|
||
print("🔶 STASH ACTION COMPLETION: Ensuring main grid stays closed - no proximity monitoring")
|
||
delegate?.gridViewManagerHideGrid(monitorForReappear: false)
|
||
|
||
// FIXED: For stash action, preview is already properly closed in main.swift _showOrUpdateStash
|
||
// No need to close again here or check tempURL - it's already moved to stash directory
|
||
print("ℹ️ Stash action: Preview management handled by stash logic in main.swift")
|
||
} else {
|
||
if delegate?.getTempURL() != nil {
|
||
print("ℹ️ Other successful grid action (not save/stash): Ensuring preview is visible.")
|
||
delegate?.ensurePreviewVisible()
|
||
}
|
||
}
|
||
} else {
|
||
if delegate?.getTempURL() != nil {
|
||
print("ℹ️ Action not successful or cancelled: Ensuring preview is visible.")
|
||
delegate?.ensurePreviewVisible()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Notification Names
|
||
extension Notification.Name {
|
||
static let gridActionCancelRequested = Notification.Name("gridActionCancelRequested")
|
||
static let gridActionRemoveRequested = Notification.Name("gridActionRemoveRequested")
|
||
} |